Update manifest.json with new file metadata and add pytest configuration

- Updated timestamps and AST hashes for multiple files in the manifest.json.
- Added new test file backend/tests/test_expected_income.py.
- Added documentation files: перево́д_между_своими.md and 0002-fixes.md.
- Introduced pytest.ini to configure pytest settings, including test paths and warning filters.
This commit is contained in:
k1nq
2026-09-24 08:34:13 +05:00
parent 970fe9dacf
commit 023612ce0a
40 changed files with 23855 additions and 16804 deletions
+8
View File
@@ -17,6 +17,8 @@ import type {
CreateCategoryPayload,
CreateOperationPayload,
ExportInfo,
ExpectedIncome,
ExpectedIncomePayload,
ImportDetail,
ImportSummary,
LoginPayload,
@@ -94,6 +96,12 @@ export const api = {
apiClient.post<CategoryDefinition>('/budget/categories', { json: payload }),
updateCategoryDefinition: (id: string, payload: PatchCategoryDefinitionPayload) =>
apiClient.patch<CategoryDefinition>(`/budget/category-definitions/${id}`, { json: payload }),
createExpectedIncome: (month: string, payload: ExpectedIncomePayload) =>
apiClient.post<ExpectedIncome>(`/budget/${month.replace('-', '/')}/expected-incomes`, { json: payload }),
updateExpectedIncome: (month: string, id: string, payload: ExpectedIncomePayload) =>
apiClient.patch<ExpectedIncome>(`/budget/${month.replace('-', '/')}/expected-incomes/${id}`, { json: payload }),
deleteExpectedIncome: (month: string, id: string) =>
apiClient.delete<void>(`/budget/${month.replace('-', '/')}/expected-incomes/${id}`),
},
imports: {
list: (month?: string) =>
+8
View File
@@ -190,10 +190,18 @@ export interface ExpectedIncome {
expectedOn: string
amountKopecks: number
sourceName: string | null
note?: string | null
matchedKopecks: number
status: ExpectedIncomeStatus
}
export interface ExpectedIncomePayload {
expectedOn: string
amountKopecks: number
sourceName: string | null
note: string | null
}
export interface BudgetSummary {
month: string
status: BudgetStatus
@@ -59,18 +59,20 @@ function CreateCategoryForm() {
return (
<form className="manager-form" onSubmit={submit} noValidate>
<TextField
label={t('common.name')}
error={errors.name?.message}
{...register('name')}
/>
<SelectField label={t('accounts.type')} error={errors.kind?.message} {...register('kind')}>
{CREATE_KINDS.map((kind) => (
<option key={kind} value={kind}>
{t(`budget.category.kind.${kind}`)}
</option>
))}
</SelectField>
<div className="manager-form-fields">
<TextField
label={t('common.name')}
error={errors.name?.message}
{...register('name')}
/>
<SelectField label={t('accounts.type')} error={errors.kind?.message} {...register('kind')}>
{CREATE_KINDS.map((kind) => (
<option key={kind} value={kind}>
{t(`budget.category.kind.${kind}`)}
</option>
))}
</SelectField>
</div>
{watch('kind') === 'monthly' ? (
<TextField
label={t('budget.manager.mccCodes')}
+2 -2
View File
@@ -111,7 +111,7 @@ const MOBILE_NAV_ROUTES: readonly string[] = ['/', '/operations', '/budget', '/i
const MOBILE_NAV_ITEMS = NAV_ITEMS.filter((item) => MOBILE_NAV_ROUTES.includes(item.to))
export function AppLayout() {
const { t } = useTranslation()
const { t, i18n } = useTranslation()
const me = useMe()
const logout = useLogout()
const navigate = useNavigate()
@@ -124,7 +124,7 @@ export function AppLayout() {
})
return (
<div className="app-shell">
<div className="app-shell" lang={i18n.language}>
<a className="skip-link" href="#main">
{t('common.skipToContent')}
</a>
+13
View File
@@ -21,6 +21,19 @@ export function Modal({ open, onClose, title, children }: ModalProps) {
if (!open && dialog.open) dialog.close()
}, [open])
useEffect(() => {
const dialog = dialogRef.current
if (!dialog) return
const closeOnBackdrop = (event: MouseEvent) => {
if (event.target !== dialog) return
const bounds = dialog.getBoundingClientRect()
if (event.clientX < bounds.left || event.clientX > bounds.right ||
event.clientY < bounds.top || event.clientY > bounds.bottom) onClose()
}
dialog.addEventListener('click', closeOnBackdrop)
return () => dialog.removeEventListener('click', closeOnBackdrop)
}, [onClose])
return (
<dialog
ref={dialogRef}
+24 -1
View File
@@ -1,7 +1,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from '@/api/endpoints'
import { invalidationRoots, queryKeys } from '@/api/queryKeys'
import type { CreateCategoryPayload, PatchCategoryDefinitionPayload } from '@/api/types'
import type { CreateCategoryPayload, ExpectedIncomePayload, PatchCategoryDefinitionPayload } from '@/api/types'
import { useToast } from '@/components/ui/Toaster'
import { useTranslation } from 'react-i18next'
import { describeApiError } from '@/lib/apiErrors'
@@ -17,6 +17,29 @@ export function useBudgetCategories(month: string) {
})
}
export function useExpectedIncomeMutation(month: string) {
const queryClient = useQueryClient()
const onError = useBudgetErrorToaster()
const invalidate = () => void queryClient.invalidateQueries({ queryKey: queryKeys.budgetSummary(month) })
const create = useMutation({
mutationFn: (payload: ExpectedIncomePayload) => api.budget.createExpectedIncome(month, payload),
onSuccess: invalidate,
onError,
})
const update = useMutation({
mutationFn: ({ id, payload }: { id: string; payload: ExpectedIncomePayload }) =>
api.budget.updateExpectedIncome(month, id, payload),
onSuccess: invalidate,
onError,
})
const remove = useMutation({
mutationFn: (id: string) => api.budget.deleteExpectedIncome(month, id),
onSuccess: invalidate,
onError,
})
return { create, update, remove }
}
export function useCategoryDefinitions() {
return useQuery({
queryKey: queryKeys.budgetCategoryDefinitions(),
+10 -1
View File
@@ -37,7 +37,14 @@
"readyToClose": "No blockers. This month is ready to close.",
"importsNeedReview": "{{count}} statement needs review",
"reviewBlockers": "Review blockers",
"categoryPulse": "Category pulse"
"categoryPulse": "Category pulse",
"noExpectedIncome": "No expected income yet. Add an estimate for this month.",
"addIncome": "Add income",
"editIncome": "Edit expected income",
"incomeNote": "Note (optional)",
"incomeInvalid": "Enter a positive amount and a date in this month.",
"deleteIncome": "Delete expected income",
"deleteIncomeConfirm": "Remove this expected income from the monthly forecast?"
},
"authShell": {
"statement": "Your household ledger, sealed by design.",
@@ -65,6 +72,8 @@
"create": "Create",
"dash": "—",
"date": "Date",
"delete": "Delete",
"edit": "Edit",
"empty": "Nothing here yet",
"loading": "Loading…",
"name": "Name",
+10 -1
View File
@@ -37,7 +37,14 @@
"readyToClose": "Блокировок нет. Месяц можно закрывать.",
"importsNeedReview": "Выписок на проверке: {{count}}",
"reviewBlockers": "Разобрать блокировки",
"categoryPulse": "Состояние категорий"
"categoryPulse": "Состояние категорий",
"noExpectedIncome": "Ожидаемых доходов пока нет. Добавьте прогноз на этот месяц.",
"addIncome": "Добавить доход",
"editIncome": "Изменить ожидаемый доход",
"incomeNote": "Примечание (необязательно)",
"incomeInvalid": "Укажите положительную сумму и дату в этом месяце.",
"deleteIncome": "Удалить ожидаемый доход",
"deleteIncomeConfirm": "Удалить этот доход из прогноза месяца?"
},
"authShell": {
"statement": "Семейный гроссбух, закрытый по замыслу.",
@@ -65,6 +72,8 @@
"create": "Создать",
"dash": "—",
"date": "Дата",
"delete": "Удалить",
"edit": "Изменить",
"empty": "Пока ничего нет",
"loading": "Загрузка…",
"name": "Название",
+8
View File
@@ -4,6 +4,14 @@ import '@fontsource/funnel-sans/400.css'
import '@fontsource/funnel-sans/500.css'
import '@fontsource/funnel-sans/600.css'
import '@fontsource/funnel-sans/700.css'
import '@fontsource/onest/cyrillic-400.css'
import '@fontsource/onest/cyrillic-500.css'
import '@fontsource/onest/cyrillic-600.css'
import '@fontsource/onest/cyrillic-700.css'
import '@fontsource/onest/latin-400.css'
import '@fontsource/onest/latin-500.css'
import '@fontsource/onest/latin-600.css'
import '@fontsource/onest/latin-700.css'
import '@fontsource/geist-mono/400.css'
import '@fontsource/geist-mono/500.css'
import '@fontsource/geist-mono/600.css'
+17 -42
View File
@@ -293,47 +293,8 @@ export function BudgetPage() {
</dl>
<QueryGate query={categories}>
{() => (
<>
{summary.data && summary.data.expectedIncomes.length > 0 ? (
<section aria-label={t('budget.expectedIncome')}>
<h2 className="section-title">{t('budget.expectedIncome')}</h2>
<table className="data-table">
<caption className="visually-hidden">{t('budget.expectedIncome')}</caption>
<thead>
<tr>
<th scope="col">{t('common.date')}</th>
<th scope="col">{t('budget.incomeSource')}</th>
<th scope="col" className="num">{t('common.amount')}</th>
<th scope="col" className="num">{t('budget.matched')}</th>
<th scope="col">{t('common.status')}</th>
</tr>
</thead>
<tbody>
{summary.data.expectedIncomes.map((income) => (
<tr key={income.id}>
<th scope="row" data-label={t('common.date')}>
{formatDate(income.expectedOn, locale)}
</th>
<td data-label={t('budget.incomeSource')}>
{income.sourceName ?? t('budget.noSource')}
</td>
<td data-label={t('common.amount')} className="num">
{formatMoney(income.amountKopecks, locale)}
</td>
<td data-label={t('budget.matched')} className="num">
{formatMoney(income.matchedKopecks, locale)}
</td>
<td data-label={t('common.status')}>
<Badge tone={income.status === 'matched' ? 'accent' : 'warn'}>
{t(`budget.income.status.${income.status}`)}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</section>
) : null}
<div className="budget-workspace">
<div className="budget-category-column">
<section aria-label={t('budget.categories')} id="categories">
<div className="section-heading categories-heading">
<h2 className="section-title">{t('budget.categories')}</h2>
@@ -377,7 +338,21 @@ export function BudgetPage() {
</table>
</section>
) : null}
</>
</div>
<aside className="budget-income-aside" aria-label={t('budget.expectedIncome')}>
<div className="section-heading"><h2>{t('budget.expectedIncome')}</h2></div>
{summary.data?.expectedIncomes.length ? (
<ul className="expected-income-list">
{summary.data.expectedIncomes.map((income) => (
<li key={income.id}>
<div><strong>{income.sourceName ?? t('budget.noSource')}</strong><small>{formatDate(income.expectedOn, locale)} · {t(`budget.income.status.${income.status}`)}</small></div>
<span className="num">{formatMoney(income.amountKopecks, locale)}</span>
</li>
))}
</ul>
) : <p className="muted">{t('overview.noExpectedIncome')}</p>}
</aside>
</div>
)}
</QueryGate>
{planTarget ? (
+114 -42
View File
@@ -1,14 +1,21 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import type { FormEvent } from 'react'
import type { ExpectedIncome } from '@/api/types'
import { BudgetBlockers } from '@/components/budget/BudgetBlockers'
import { Button } from '@/components/ui/Button'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { MoneyField, TextField } from '@/components/ui/fields'
import { Modal } from '@/components/ui/Modal'
import { PageHeader } from '@/components/ui/PageHeader'
import { QueryGate } from '@/components/ui/QueryGate'
import { useBudgetCategories, useBudgetSummary } from '@/hooks/useBudget'
import { useBudgetCategories, useBudgetSummary, useExpectedIncomeMutation } from '@/hooks/useBudget'
import { useImports } from '@/hooks/useImports'
import { categoryLabel } from '@/lib/categoryLabel'
import { currentMonth, formatMonth } from '@/lib/dates'
import { currentMonth, formatDate, formatMonth, monthBounds, todayISO } from '@/lib/dates'
import { currentLocale } from '@/lib/locale'
import { formatMoney } from '@/lib/money'
import { formatMoney, kopecksToInput, parseAmountToKopecks } from '@/lib/money'
export function OverviewPage() {
const { t, i18n } = useTranslation()
@@ -16,10 +23,40 @@ export function OverviewPage() {
const month = currentMonth()
const summary = useBudgetSummary(month)
const categories = useBudgetCategories(month)
const incomeMutation = useExpectedIncomeMutation(month)
const imports = useImports()
const [incomeEditor, setIncomeEditor] = useState<ExpectedIncome | 'new' | null>(null)
const [deleteTarget, setDeleteTarget] = useState<ExpectedIncome | null>(null)
const [expectedOn, setExpectedOn] = useState(todayISO())
const [sourceName, setSourceName] = useState('')
const [amount, setAmount] = useState('')
const [note, setNote] = useState('')
const [formError, setFormError] = useState('')
const money = (value: number) => formatMoney(value, locale)
const attentionImports = (imports.data ?? []).filter((item) => item.status === 'review_required')
const openIncomeEditor = (income?: ExpectedIncome) => {
setExpectedOn(income?.expectedOn ?? todayISO())
setSourceName(income?.sourceName ?? '')
setAmount(income ? kopecksToInput(income.amountKopecks) : '')
setNote(income?.note ?? '')
setFormError('')
setIncomeEditor(income ?? 'new')
}
const saveIncome = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
const amountKopecks = parseAmountToKopecks(amount)
if (!amountKopecks || amountKopecks <= 0 || !expectedOn.startsWith(`${month}-`)) {
setFormError(t('overview.incomeInvalid'))
return
}
const payload = { expectedOn, amountKopecks, sourceName: sourceName.trim() || null, note: note.trim() || null }
const onSuccess = () => setIncomeEditor(null)
if (incomeEditor === 'new') incomeMutation.create.mutate(payload, { onSuccess })
else if (incomeEditor) incomeMutation.update.mutate({ id: incomeEditor.id, payload }, { onSuccess })
}
return (
<div className="overview-page">
<PageHeader
@@ -47,51 +84,86 @@ export function OverviewPage() {
<div className="stat"><dt>{t('budget.spentTotal')}</dt><dd className="num">{money(data.spentTotalKopecks)}</dd><small>{t('overview.ofFunded')}</small></div>
</dl>
<div className="overview-workbench">
<section className="month-story">
<div className="section-heading">
<h2>{t('overview.monthStory')}</h2>
<span>{t('overview.liveState')}</span>
</div>
<ol className="story-list">
<li><span>{t('overview.poolAndIncome')}</span><strong className="num">{money(data.actualPoolKopecks + data.fundedTotalKopecks)}</strong></li>
<li><span>{t('overview.funded')}</span><strong className="num neg">−{money(data.fundedTotalKopecks)}</strong></li>
<li><span>{t('overview.spent')}</span><strong className="num neg">−{money(data.spentTotalKopecks)}</strong></li>
<li><span>{t('overview.poolRemaining')}</span><strong className="num">{money(data.actualPoolKopecks)}</strong></li>
</ol>
<section className="overview-categories" aria-label={t('budget.categories')}>
<div className="section-heading"><h2>{t('budget.categories')}</h2><Link to="/budget">{t('overview.openBudget')}</Link></div>
<QueryGate query={categories}>
{(items) => items.filter((item) => !item.archived).length > 0 ? (
<table className="data-table">
<caption className="visually-hidden">{t('budget.categories')}</caption>
<thead><tr>
<th scope="col">{t('common.name')}</th>
<th scope="col" className="num">{t('budget.planned')}</th>
<th scope="col" className="num">{t('budget.funded')}</th>
<th scope="col" className="num">{t('budget.spent')}</th>
<th scope="col" className="num">{t('budget.remaining')}</th>
</tr></thead>
<tbody>{items.filter((item) => !item.archived).map((item) => (
<tr key={item.id}>
<th scope="row">{categoryLabel(item, t)}</th>
<td className="num" data-label={t('budget.planned')}>{money(item.plannedKopecks)}</td>
<td className="num" data-label={t('budget.funded')}>{money(item.fundedKopecks)}</td>
<td className="num" data-label={t('budget.spent')}>{money(item.spentKopecks)}</td>
<td className={`num ${item.availableKopecks < 0 ? 'neg' : ''}`} data-label={t('budget.remaining')}>{money(item.availableKopecks)}</td>
</tr>
))}</tbody>
</table>
) : <p className="muted">{t('budget.noCategories')}</p>}
</QueryGate>
</section>
<aside className="decision-panel">
<div className="section-heading"><h2>{t('overview.closeReadiness')}</h2><span>{data.blockers.length} {t('overview.blockers')}</span></div>
{data.blockers.length > 0 ? (
<BudgetBlockers blockers={data.blockers} month={month} />
) : <p>{t('overview.readyToClose')}</p>}
{attentionImports.length > 0 ? <p>{t('overview.importsNeedReview', { count: attentionImports.length })}</p> : null}
<Link className="btn btn-subtle" to="/review">{t('overview.reviewBlockers')}</Link>
</aside>
<div className="overview-aside">
<section className="expected-income" aria-label={t('budget.expectedIncome')}>
<div className="section-heading">
<h2>{t('budget.expectedIncome')}</h2>
{data.status === 'open' ? <Button variant="ghost" onClick={() => openIncomeEditor()}>{t('overview.addIncome')}</Button> : null}
</div>
{data.expectedIncomes.length ? (
<ul className="expected-income-list">
{data.expectedIncomes.map((income) => (
<li key={income.id}>
<div><strong>{income.sourceName ?? t('budget.noSource')}</strong><small>{formatDate(income.expectedOn, locale)} · {t(`budget.income.status.${income.status}`)}</small></div>
<span className="num">{money(income.amountKopecks)}</span>
{data.status === 'open' ? <div className="income-actions">
<Button variant="ghost" onClick={() => openIncomeEditor(income)}>{t('common.edit')}</Button>
<Button variant="ghost" onClick={() => setDeleteTarget(income)}>{t('common.delete')}</Button>
</div> : null}
</li>
))}
</ul>
) : <p className="muted">{t('overview.noExpectedIncome')}</p>}
</section>
<aside className="decision-panel">
<div className="section-heading"><h2>{t('overview.closeReadiness')}</h2><span>{data.blockers.length} {t('overview.blockers')}</span></div>
{data.blockers.length > 0 ? <BudgetBlockers blockers={data.blockers} month={month} /> : <p>{t('overview.readyToClose')}</p>}
{attentionImports.length > 0 ? <p>{t('overview.importsNeedReview', { count: attentionImports.length })}</p> : null}
<Link className="btn btn-subtle" to="/review">{t('overview.reviewBlockers')}</Link>
</aside>
</div>
</div>
</>
)}
</QueryGate>
<section className="category-pulse" aria-label={t('overview.categoryPulse')}>
<div className="section-heading"><h2>{t('overview.categoryPulse')}</h2><Link to="/budget">{t('overview.openBudget')}</Link></div>
<QueryGate query={categories}>
{(items) => (
<div className="pulse-grid">
{items.filter((item) => !item.archived).slice(0, 4).map((item) => {
const ratio = item.fundedKopecks > 0 ? Math.min(item.spentKopecks / item.fundedKopecks, 1) : 0
return (
<article className="pulse-item" key={item.id}>
<span>{categoryLabel(item, t)}</span>
<strong className="num">{money(item.availableKopecks)}</strong>
<small>{t('budget.available')}</small>
<span className="pulse-track"><span style={{ width: `${ratio * 100}%` }} /></span>
</article>
)
})}
</div>
)}
</QueryGate>
</section>
<div className="mobile-primary-action"><Link className="btn btn-primary" to="/operations">{t('overview.newOperation')}</Link></div>
<Modal open={incomeEditor !== null} onClose={() => setIncomeEditor(null)} title={t(incomeEditor === 'new' ? 'overview.addIncome' : 'overview.editIncome')}>
<form className="form-grid" onSubmit={saveIncome}>
<TextField label={t('budget.incomeSource')} value={sourceName} onChange={(event) => setSourceName(event.target.value)} />
<MoneyField label={t('common.amount')} value={amount} onChange={(event) => setAmount(event.target.value)} required />
<TextField label={t('common.date')} type="date" min={monthBounds(month).start} max={monthBounds(month).end} value={expectedOn} onChange={(event) => setExpectedOn(event.target.value)} required />
<TextField label={t('overview.incomeNote')} value={note} onChange={(event) => setNote(event.target.value)} />
{formError ? <p className="field-error" role="alert">{formError}</p> : null}
<Button type="submit" disabled={incomeMutation.create.isPending || incomeMutation.update.isPending}>{t('common.save')}</Button>
</form>
</Modal>
<ConfirmDialog
open={deleteTarget !== null}
danger
title={t('overview.deleteIncome')}
body={t('overview.deleteIncomeConfirm')}
confirmLabel={t('common.delete')}
onClose={() => setDeleteTarget(null)}
onConfirm={() => {
if (deleteTarget) incomeMutation.remove.mutate(deleteTarget.id, { onSuccess: () => setDeleteTarget(null) })
}}
/>
</div>
)
}
+46 -10
View File
@@ -17,7 +17,7 @@
--amber-soft: #fff0c7;
--red: #b42f25;
--red-soft: #fbe2df;
--font-ui: 'Funnel Sans', 'Arial Narrow', Arial, sans-serif;
--font-ui: 'Funnel Sans', 'Onest', Arial, sans-serif;
--font-data: 'Geist Mono', 'SFMono-Regular', Consolas, monospace;
--sidebar: 238px;
}
@@ -206,6 +206,11 @@ a {
text-decoration: none;
}
.app-shell:lang(ru) .nav-pill,
.app-shell:lang(ru) .mobile-nav a {
font-family: 'Onest', sans-serif;
}
.nav-pill:hover { color: var(--white); background: #202020; }
.nav-pill[aria-current='page'] {
@@ -657,19 +662,29 @@ input[type='file']::file-selector-button { min-height: 30px; margin-right: 10px;
.categories-heading { margin: 34px 0 14px; }
.categories-heading .section-title { margin: 0; }
.budget-workspace { display: grid; grid-template-columns: minmax(0, 1fr) minmax(275px, 350px); align-items: start; gap: 20px; }
.budget-category-column { min-width: 0; }
.budget-category-column .categories-heading { margin-top: 0; }
.budget-income-aside { min-width: 0; background: var(--white); border: 1px solid var(--line); }
.budget-income-aside .section-heading { margin: 0; padding: 14px; background: var(--paper-deep); }
.budget-income-aside > .muted { padding: 0 14px 14px; font-size: 12px; }
.manager-form { display: grid; grid-template-columns: minmax(0, 1fr) minmax(130px, auto) auto; gap: 10px; align-items: end; margin-bottom: 18px; }
.manager-form { display: grid; min-width: 0; gap: 14px; margin-bottom: 24px; padding-bottom: 24px; border-bottom: 1px solid #3d3d3a; }
.manager-form-fields { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr); gap: 10px; }
.manager-form-fields .field { min-width: 0; }
.manager-form-fields .input { min-width: 0; }
.manager-form .btn { min-height: 44px; }
.manager-tabs { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0; margin-bottom: 14px; border: 1px solid #3d3d3a; }
.manager-tabs .btn { min-width: 0; color: #858580; border: 0; border-right: 1px solid #3d3d3a; font-family: var(--font-data); font-size: 10px; }
.manager-tabs .btn:last-child { border-right: 0; }
.manager-tabs .btn-primary { color: var(--white); background: var(--blue); }
.manager-list { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; border-top: 1px solid #3d3d3a; }
.manager-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; padding: 10px 0; border-bottom: 1px solid #3d3d3a; }
.manager-row { display: grid; gap: 10px; min-width: 0; padding: 13px 0; border-bottom: 1px solid #3d3d3a; }
.manager-row-main { display: grid; gap: 3px; min-width: 0; }
.manager-row-name { font-weight: 500; }
.manager-row-meta { color: #858580; font-family: var(--font-data); font-size: 10px; }
.manager-row-actions .btn { min-height: 30px; padding: 5px 8px; font-size: 11px; }
.manager-row-name { overflow-wrap: anywhere; font-weight: 500; }
.manager-row-meta { color: #aaa9a5; font-family: var(--font-data); font-size: 10px; overflow-wrap: anywhere; }
.manager-row-actions { width: 100%; }
.manager-row-actions .btn { min-height: 32px; padding: 6px 9px; font-size: 11px; line-height: 1.25; white-space: normal; }
.steps {
display: grid;
@@ -744,7 +759,26 @@ input[type='file']::file-selector-button { min-height: 30px; margin-right: 10px;
.overview-page .page-header { margin-bottom: 20px; }
.mobile-pool { display: none; }
.overview-workbench { display: grid; grid-template-columns: minmax(0, 1.75fr) minmax(260px, 0.75fr); gap: 16px; margin-bottom: 28px; }
.overview-workbench { display: grid; grid-template-columns: minmax(0, 1fr) minmax(275px, 350px); align-items: start; gap: 20px; margin-bottom: 28px; }
.overview-categories { min-width: 0; }
.overview-categories .data-table { table-layout: fixed; }
.overview-categories .data-table th:first-child { width: 26%; }
.overview-categories .data-table td { white-space: nowrap; }
.overview-aside { display: grid; gap: 16px; min-width: 0; }
.expected-income { background: var(--white); border: 1px solid var(--line); }
.expected-income .section-heading { margin: 0; padding: 14px; background: var(--paper-deep); }
.expected-income-list { margin: 0; padding: 0; list-style: none; }
.expected-income-list li { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 14px; border-top: 1px solid var(--line); }
.expected-income-list li { flex-wrap: wrap; }
.expected-income-list li > div { display: grid; gap: 4px; min-width: 0; }
.expected-income-list .income-actions { display: flex; width: 100%; gap: 4px; }
.income-actions .btn { min-height: 28px; padding: 4px 8px; color: var(--blue-dark); font-size: 11px; }
.income-actions .btn:hover { color: var(--white); background: var(--blue-dark); }
.expected-income .section-heading .btn { min-height: 30px; padding: 5px 8px; color: var(--blue-dark); white-space: nowrap; }
.expected-income-list strong { overflow-wrap: anywhere; font-size: 13px; font-weight: 600; }
.expected-income-list small { color: var(--muted); font-size: 11px; }
.expected-income-list .num { flex-shrink: 0; font-size: 12px; }
.expected-income > .muted { padding: 0 14px 14px; font-size: 12px; }
.month-story { padding: 18px 0 0; }
.story-list { margin: 0; padding: 0; list-style: none; border-top: 1px solid var(--line); }
.story-list li { display: flex; justify-content: space-between; gap: 20px; padding: 14px 4px; border-bottom: 1px solid var(--line); }
@@ -852,9 +886,10 @@ input[type='file']::file-selector-button { min-height: 30px; margin-right: 10px;
.mobile-pool strong { margin: 4px 0; font-size: 28px; }
.overview-stats .stat:first-child { display: none; }
.overview-stats .stat:nth-child(4) { display: block; }
.overview-workbench { display: block; margin-top: 20px; }
.overview-workbench { display: grid; grid-template-columns: minmax(0, 1fr); margin-top: 20px; }
.overview-categories .data-table { table-layout: auto; }
.month-story { display: none; }
.decision-panel { margin-bottom: 24px; padding: 14px; color: var(--ink); background: var(--amber-soft); }
.decision-panel { padding: 14px; color: var(--ink); background: var(--amber-soft); }
.decision-panel .section-heading { border-color: #d4ad52; }
.decision-panel .section-heading h2,
.decision-panel p,
@@ -862,7 +897,8 @@ input[type='file']::file-selector-button { min-height: 30px; margin-right: 10px;
.decision-panel li { border-color: #e0c57f; }
.decision-panel .btn { color: var(--ink); background: transparent; border: 1px solid var(--ink); }
.decision-panel .blocker-link { color: var(--blue); }
.manager-form { grid-template-columns: 1fr; }
.manager-form-fields { grid-template-columns: 1fr; }
.budget-workspace { grid-template-columns: minmax(0, 1fr); }
.manager-row-actions { width: 100%; }
.manager-row-actions .btn { flex: 1; }
.pulse-grid { display: block; border: 0; }
+7 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import userEvent from '@testing-library/user-event'
import { within } from '@testing-library/react'
import { fireEvent, within } from '@testing-library/react'
import { api } from '@/api/endpoints'
import type { BudgetCategory, BudgetSummary, CategoryDefinition } from '@/api/types'
import { BudgetPage } from '@/pages/BudgetPage'
@@ -142,6 +142,12 @@ describe('BudgetPage category manager lifecycle', () => {
expect(await screen.findByText('Category “Dining” created')).toBeInTheDocument()
})
it('closes the drawer when the backdrop is clicked', async () => {
const { screen, dialog } = await openManager()
fireEvent.click(dialog, { clientX: -10 })
expect(screen.queryByRole('dialog', { name: 'Manage categories' })).not.toBeInTheDocument()
})
it('creates a category with multiple MCC codes', async () => {
createCategoryMock.mockResolvedValue(definition({ id: 'def-new', mccCodes: [5999, 5994] }))
const { dialog } = await openManager()
@@ -0,0 +1,82 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import userEvent from '@testing-library/user-event'
import { within } from '@testing-library/react'
import { api } from '@/api/endpoints'
import type { BudgetSummary, ExpectedIncome } from '@/api/types'
import { OverviewPage } from '@/pages/OverviewPage'
import { currentMonth, todayISO } from '@/lib/dates'
import { renderWithProviders } from './helpers'
vi.mock('@/api/endpoints', () => ({
api: {
budget: {
summary: vi.fn(), categories: vi.fn(),
createExpectedIncome: vi.fn(), updateExpectedIncome: vi.fn(), deleteExpectedIncome: vi.fn(),
},
imports: { list: vi.fn() },
},
}))
const month = currentMonth()
const income: ExpectedIncome = {
id: 'income-1', expectedOn: todayISO(), amountKopecks: 250000,
sourceName: 'Salary', note: null, matchedKopecks: 0, status: 'expected',
}
const summary: BudgetSummary = {
month, status: 'open', projectedPoolKopecks: 250000, actualPoolKopecks: 0,
plannedTotalKopecks: 0, fundedTotalKopecks: 0, spentTotalKopecks: 0,
availableKopecks: 0, warnings: [], blockers: [], expectedIncomes: [income],
}
describe('This month expected income', () => {
beforeEach(() => {
vi.mocked(api.budget.summary).mockResolvedValue(summary)
vi.mocked(api.budget.categories).mockResolvedValue([{
id: 'groceries', name: 'Groceries', nameKey: null, kind: 'monthly', system: false,
archived: false, creditAccountId: null, plannedKopecks: 500000,
fundedKopecks: 300000, spentKopecks: 100000, availableKopecks: 200000,
}])
vi.mocked(api.imports.list).mockResolvedValue([])
})
afterEach(() => vi.clearAllMocks())
it('shows income alongside the category table without category management', async () => {
const screen = renderWithProviders(<OverviewPage />)
expect(await screen.findByText('Salary')).toBeInTheDocument()
expect(screen.getByRole('table', { name: 'Categories' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Manage categories' })).not.toBeInTheDocument()
})
it('creates an expected income for the current month', async () => {
vi.mocked(api.budget.createExpectedIncome).mockResolvedValue(income)
const screen = renderWithProviders(<OverviewPage />)
await screen.findByText('Salary')
await userEvent.click(screen.getByRole('button', { name: 'Add income' }))
const dialog = screen.getByRole('dialog', { name: 'Add income' })
await userEvent.type(within(dialog).getByLabelText('Source'), 'Side job')
await userEvent.type(within(dialog).getByLabelText('Amount'), '1234.50')
await userEvent.click(within(dialog).getByRole('button', { name: 'Save' }))
expect(api.budget.createExpectedIncome).toHaveBeenCalledWith(month, {
expectedOn: todayISO(), amountKopecks: 123450, sourceName: 'Side job', note: null,
})
})
it('edits and deletes an existing expected income', async () => {
vi.mocked(api.budget.updateExpectedIncome).mockResolvedValue(income)
vi.mocked(api.budget.deleteExpectedIncome).mockResolvedValue(undefined)
const screen = renderWithProviders(<OverviewPage />)
await screen.findByText('Salary')
await userEvent.click(screen.getByRole('button', { name: 'Edit' }))
const editor = screen.getByRole('dialog', { name: 'Edit expected income' })
await userEvent.clear(within(editor).getByLabelText('Amount'))
await userEvent.type(within(editor).getByLabelText('Amount'), '3500')
await userEvent.click(within(editor).getByRole('button', { name: 'Save' }))
expect(api.budget.updateExpectedIncome).toHaveBeenCalledWith(month, 'income-1', {
expectedOn: todayISO(), amountKopecks: 350000, sourceName: 'Salary', note: null,
})
await userEvent.click(screen.getByRole('button', { name: 'Delete' }))
const confirm = screen.getByRole('dialog', { name: 'Delete expected income' })
await userEvent.click(within(confirm).getByRole('button', { name: 'Delete' }))
expect(api.budget.deleteExpectedIncome).toHaveBeenCalledWith(month, 'income-1')
})
})