feat(money/web): UI раздела «Деньги» — счета, платежи, смены, долги, отчёт (п.7)
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions

- Раздел «Деньги» в sidebar: Отчёт/Счета и кассы/Платежи и ордера/Смены/
  Долги поставщикам. Админ — всё; кассир — только смены; кладовщик — ничего.
- Страницы (src/pages/money/): MoneyAccountsPage, ExpenseItemsPage,
  MoneyTransactionsPage (+ переводы), CashShiftsPage (open/close/внесения/
  выплаты/Z-отчёт), SupplierDebtsPage (+ корректировки), MoneyReportPage
  (остатки + движение за период).
- Приёмка: SupplyPaymentSection (статус/оплачено/остаток + «Оплатить»
  наличными/с банка, частичная оплата); в списке приёмок — колонка и фильтр
  статуса оплаты.
- src/lib/money.ts (enum'ы/DTO/лейблы/lookups), типы SupplyDto/SupplyListRow
  +payedSum/+paymentStatus, роуты в App.tsx, i18n ru/en.

Без AdvancedModeGuard — модуль для простого режима. tsc -b + eslint зелёные.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-07 21:59:32 +05:00
parent a25b5b3277
commit 830cd73ee0
16 changed files with 1579 additions and 6 deletions

View file

@ -27,8 +27,11 @@
в `/shifts/{id}/report`, проверка в `RetailSalesController.PostCoreAsync` в `/shifts/{id}/report`, проверка в `RetailSalesController.PostCoreAsync`
- [x] **6. Отчёт «Деньги»** (остатки по счетам, движение за период, - [x] **6. Отчёт «Деньги»** (остатки по счетам, движение за период,
инвариант остатков) — `MoneyReportsController.Balances`/`Movement` инвариант остатков) — `MoneyReportsController.Balances`/`Movement`
- [ ] **7. UI и меню** (раздел «Деньги», блок оплаты в приёмке, - [x] **7. UI и меню** (раздел «Деньги»: Отчёт/Счета/Платежи/Смены/Долги;
mobile-first, права кассир/кладовщик/админ) блок оплаты в приёмке `SupplyPaymentSection`; колонка+фильтр оплаты в списке
приёмок; mobile-first через ListPageShell/Modal; меню: админ — всё, кассир —
только смены, кладовщик — ничего (оплаченность видит в приёмке).
`src/pages/money/*`, `src/lib/money.ts`, роуты в App.tsx, i18n ru/en)
- [ ] **8. Тесты** (unit + integration на реальном Postgres + e2e на стенде - [ ] **8. Тесты** (unit + integration на реальном Postgres + e2e на стенде
изнутри сети + меню-прогон watchdog step 9 зелёный) изнутри сети + меню-прогон watchdog step 9 зелёный)
- [ ] **9. Деплой + документация** (`~/deploy-stage.sh`, `docs/money.md`) - [ ] **9. Деплой + документация** (`~/deploy-stage.sh`, `docs/money.md`)

View file

@ -78,6 +78,13 @@ const PriceTypesPage = lazy(() => import('@/pages/PriceTypesPage').then(m => ({
const StoresPage = lazy(() => import('@/pages/StoresPage').then(m => ({ default: m.StoresPage }))) const StoresPage = lazy(() => import('@/pages/StoresPage').then(m => ({ default: m.StoresPage })))
const RetailPointsPage = lazy(() => import('@/pages/RetailPointsPage').then(m => ({ default: m.RetailPointsPage }))) const RetailPointsPage = lazy(() => import('@/pages/RetailPointsPage').then(m => ({ default: m.RetailPointsPage })))
const CountriesPage = lazy(() => import('@/pages/CountriesPage').then(m => ({ default: m.CountriesPage }))) const CountriesPage = lazy(() => import('@/pages/CountriesPage').then(m => ({ default: m.CountriesPage })))
// Sprint 33: модуль «Деньги» (простой режим).
const MoneyAccountsPage = lazy(() => import('@/pages/money/MoneyAccountsPage').then(m => ({ default: m.MoneyAccountsPage })))
const ExpenseItemsPage = lazy(() => import('@/pages/money/ExpenseItemsPage').then(m => ({ default: m.ExpenseItemsPage })))
const MoneyTransactionsPage = lazy(() => import('@/pages/money/MoneyTransactionsPage').then(m => ({ default: m.MoneyTransactionsPage })))
const CashShiftsPage = lazy(() => import('@/pages/money/CashShiftsPage').then(m => ({ default: m.CashShiftsPage })))
const SupplierDebtsPage = lazy(() => import('@/pages/money/SupplierDebtsPage').then(m => ({ default: m.SupplierDebtsPage })))
const MoneyReportPage = lazy(() => import('@/pages/money/MoneyReportPage').then(m => ({ default: m.MoneyReportPage })))
// SuperAdmin консоль — почти всегда редко открываемая (доступна только // SuperAdmin консоль — почти всегда редко открываемая (доступна только
// супер-админу платформы, обычные владельцы её никогда не видят). // супер-админу платформы, обычные владельцы её никогда не видят).
@ -196,6 +203,14 @@ export default function App() {
<Route path="/reports/stock" element={lz(StockReportPage)} /> <Route path="/reports/stock" element={lz(StockReportPage)} />
<Route path="/reports/profit" element={lz(ProfitReportPage)} /> <Route path="/reports/profit" element={lz(ProfitReportPage)} />
<Route path="/reports/abc" element={<AdvancedModeGuard>{lz(AbcReportPage)}</AdvancedModeGuard>} /> <Route path="/reports/abc" element={<AdvancedModeGuard>{lz(AbcReportPage)}</AdvancedModeGuard>} />
{/* Sprint 33: раздел «Деньги» (простой режим, без AdvancedModeGuard).
* Смены — админ + кассир; остальное — админ (MoneyEdit). */}
<Route path="/money/accounts" element={<RoleGuard roles={['Admin']}>{lz(MoneyAccountsPage)}</RoleGuard>} />
<Route path="/money/expense-items" element={<RoleGuard roles={['Admin']}>{lz(ExpenseItemsPage)}</RoleGuard>} />
<Route path="/money/transactions" element={<RoleGuard roles={['Admin']}>{lz(MoneyTransactionsPage)}</RoleGuard>} />
<Route path="/money/shifts" element={<RoleGuard roles={['Admin', 'Cashier']}>{lz(CashShiftsPage)}</RoleGuard>} />
<Route path="/money/supplier-debts" element={<RoleGuard roles={['Admin']}>{lz(SupplierDebtsPage)}</RoleGuard>} />
<Route path="/money/report" element={<RoleGuard roles={['Admin']}>{lz(MoneyReportPage)}</RoleGuard>} />
<Route path="/sales/retail" element={<RetailSalesPage />} /> <Route path="/sales/retail" element={<RetailSalesPage />} />
<Route path="/sales/retail/new" element={<RetailSaleEditPage />} /> <Route path="/sales/retail/new" element={<RetailSaleEditPage />} />
<Route path="/sales/retail/:id" element={<RetailSaleEditPage />} /> <Route path="/sales/retail/:id" element={<RetailSaleEditPage />} />

View file

@ -9,6 +9,7 @@ import {
Users, Warehouse, Store as StoreIcon, LogOut, Download, UserCog, Shield, ShieldCheck, Users, Warehouse, Store as StoreIcon, LogOut, Download, UserCog, Shield, ShieldCheck,
Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X, PackagePlus, PackageMinus, ArrowRightLeft, ClipboardCheck, Undo2, BarChart3, TrendingUp, Target, Send, FileText, Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X, PackagePlus, PackageMinus, ArrowRightLeft, ClipboardCheck, Undo2, BarChart3, TrendingUp, Target, Send, FileText,
Camera, Smartphone, Stethoscope, Camera, Smartphone, Stethoscope,
Wallet, Landmark, Receipt, Scale, DoorOpen,
} from 'lucide-react' } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Logo } from './Logo' import { Logo } from './Logo'
@ -145,6 +146,23 @@ function buildNav(roles: string[], advancedMode: boolean): NavSection[] {
]}) ]})
} }
// Деньги (Sprint 33) — простой режим, показываем всегда (не advanced-only).
// Админ видит весь контур; кассир — только свои смены; кладовщик — ничего
// (оплаченность приёмки он видит прямо в приёмке, отдельного пункта нет).
if (isAdmin) {
sections.push({ group: 'nav.section_money', items: [
{ to: '/money/report', icon: Wallet, label: 'nav.moneyReport' },
{ to: '/money/accounts', icon: Landmark, label: 'nav.moneyAccounts' },
{ to: '/money/transactions', icon: Receipt, label: 'nav.moneyPayments' },
{ to: '/money/shifts', icon: DoorOpen, label: 'nav.cashShifts' },
{ to: '/money/supplier-debts', icon: Scale, label: 'nav.supplierDebts' },
]})
} else if (isCashier) {
sections.push({ group: 'nav.section_money', items: [
{ to: '/money/shifts', icon: DoorOpen, label: 'nav.cashShifts' },
]})
}
// Отчёты — Admin и Storekeeper. ABC — только расширенный режим. // Отчёты — Admin и Storekeeper. ABC — только расширенный режим.
if (isAdmin || isStorekeeper) { if (isAdmin || isStorekeeper) {
const reports: NavItem[] = [ const reports: NavItem[] = [

View file

@ -0,0 +1,106 @@
import { useState } from 'react'
import { Wallet, Plus } from 'lucide-react'
import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal'
import { Field, Select, MoneyInput } from '@/components/Field'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '@/lib/api'
import { useFormatCurrency } from '@/lib/useFormatCurrency'
import {
MoneyAccountKind, MoneyTxKind, PAYMENT_STATUS_LABEL, PAYMENT_STATUS_BADGE,
useMoneyAccounts,
} from '@/lib/money'
/** Блок оплаты приёмки — показывается у проведённой приёмки. Статус оплаты,
* оплачено/остаток и кнопка «Оплатить» (наличными из кассы или с банковского
* счёта). Создаёт расходный документ (CashOut/PaymentOut), привязанный к этой
* приёмке, и сразу проводит его — PayedSum пересчитывается на бэке. */
export function SupplyPaymentSection({ supplyId, total, payedSum, paymentStatus, currencyId, onPaid }: {
supplyId: string
total: number
payedSum: number
paymentStatus: string
currencyId: string
onPaid: () => void
}) {
const fmt = useFormatCurrency()
const qc = useQueryClient()
const accounts = useMoneyAccounts(true)
const remaining = Math.max(0, total - payedSum)
const [pay, setPay] = useState<{ accountId: string; amount: number | null; purpose: string } | null>(null)
const [err, setErr] = useState<string | null>(null)
const payMut = useMutation({
mutationFn: async (input: { accountId: string; amount: number }) => {
const acc = (accounts.data ?? []).find((a) => a.id === input.accountId)
const isCash = acc?.kind === MoneyAccountKind.Cash || acc?.kind === MoneyAccountKind.Safe
const kind = isCash ? MoneyTxKind.CashOut : MoneyTxKind.PaymentOut
const created = await api.post('/api/money/transactions', {
date: new Date().toISOString().slice(0, 10),
kind, accountId: input.accountId, currencyId,
amount: input.amount, purpose: 'Оплата приёмки',
links: [{ supplyId, linkedSum: input.amount }],
})
await api.post(`/api/money/transactions/${created.data.id}/post`)
return created.data
},
onSuccess: async () => {
await Promise.all([
qc.invalidateQueries({ queryKey: ['/api/purchases/supplies', supplyId] }),
qc.invalidateQueries({ queryKey: ['lookup:money-accounts'] }),
])
onPaid()
},
meta: { successMessage: 'Оплата проведена' },
})
const submit = async () => {
if (!pay) return
if (!pay.accountId) { setErr('Выберите счёт/кассу'); return }
if (!pay.amount || pay.amount <= 0) { setErr('Укажите сумму'); return }
try { await payMut.mutateAsync({ accountId: pay.accountId, amount: pay.amount }); setPay(null); setErr(null) }
catch { /* тост (напр. переплата) */ }
}
return (
<section className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden">
<header className="flex items-center justify-between px-5 py-3 border-b border-slate-100 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-800/30">
<h2 className="text-sm font-semibold text-slate-900 dark:text-slate-100 flex items-center gap-1.5"><Wallet className="w-4 h-4" /> Оплата</h2>
<span className={`text-xs px-2 py-0.5 rounded ${PAYMENT_STATUS_BADGE[paymentStatus] ?? ''}`}>{PAYMENT_STATUS_LABEL[paymentStatus] ?? paymentStatus}</span>
</header>
<div className="p-5">
<div className="flex flex-wrap items-center gap-x-8 gap-y-2">
<div><div className="text-xs text-slate-500">Сумма приёмки</div><div className="font-mono">{fmt(total)}</div></div>
<div><div className="text-xs text-slate-500">Оплачено</div><div className="font-mono text-green-700 dark:text-green-400">{fmt(payedSum)}</div></div>
<div><div className="text-xs text-slate-500">Остаток</div><div className="font-mono text-red-700 dark:text-red-400">{fmt(remaining)}</div></div>
<div className="ml-auto">
{remaining > 0.0001 && (
<Button onClick={() => { setErr(null); setPay({ accountId: (accounts.data ?? [])[0]?.id ?? '', amount: remaining, purpose: '' }) }}>
<Plus className="w-4 h-4" /> Оплатить
</Button>
)}
</div>
</div>
</div>
<Modal open={!!pay} onClose={() => setPay(null)} title="Оплата поставщику"
footer={<><Button variant="secondary" onClick={() => setPay(null)}>Отмена</Button><Button onClick={submit} mutating>Оплатить</Button></>}>
{pay && (
<div className="space-y-3">
{err && <p role="alert" className="text-sm text-red-600">{err}</p>}
<Field label="Откуда платим">
<Select value={pay.accountId} onChange={(e) => setPay({ ...pay, accountId: (e.target as HTMLSelectElement).value })}>
<option value="">— выберите счёт/кассу —</option>
{(accounts.data ?? []).map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
</Select>
</Field>
<Field label="Сумма (можно частично)">
<MoneyInput value={pay.amount} onChange={(v) => setPay({ ...pay, amount: v })} />
</Field>
<p className="text-xs text-slate-500">Остаток к оплате: <span className="font-mono">{fmt(remaining)}</span>. Переплата больше суммы приёмки не допускается.</p>
</div>
)}
</Modal>
</section>
)
}

View file

@ -0,0 +1,187 @@
/**
* Sprint 33 — модуль «Деньги» (простой режим).
*
* Типы-контракты, enum'ы (числовые — бэкенд сериализует enum как число,
* JsonStringEnumConverter в Program.cs не подключён) и справочные хуки
* (счета, статьи расходов, точки продаж) для страниц раздела «Деньги».
*
* Названия/поля — по смыслу МойСклада (см. docs/money-moysklad-reference.md),
* чтобы потом синхронизация легла без хирургии.
*/
import { useQuery } from '@tanstack/react-query'
import { api } from '@/lib/api'
import type { PagedResult, RetailPoint } from '@/lib/types'
// ── enum'ы (значения = числа домена) ─────────────────────────────────────
export const MoneyAccountKind = { Cash: 1, Safe: 2, Bank: 3, Acquiring: 4 } as const
export type MoneyAccountKind = (typeof MoneyAccountKind)[keyof typeof MoneyAccountKind]
export const MoneyTxKind = {
PaymentIn: 1, PaymentOut: 2, CashIn: 3, CashOut: 4, TransferOut: 5, TransferIn: 6,
} as const
export type MoneyTxKind = (typeof MoneyTxKind)[keyof typeof MoneyTxKind]
export const MoneyTxStatus = { Draft: 0, Posted: 1 } as const
export type MoneyTxStatus = (typeof MoneyTxStatus)[keyof typeof MoneyTxStatus]
export const CashShiftStatus = { Open: 1, Closed: 2 } as const
export type CashShiftStatus = (typeof CashShiftStatus)[keyof typeof CashShiftStatus]
export const DrawerDirection = { In: 1, Out: 2 } as const
export type DrawerDirection = (typeof DrawerDirection)[keyof typeof DrawerDirection]
export const AdjustmentDirection = { WeOwe: 1, TheyOwe: 2 } as const
export type AdjustmentDirection = (typeof AdjustmentDirection)[keyof typeof AdjustmentDirection]
// ── человекочитаемые подписи ─────────────────────────────────────────────
export const ACCOUNT_KIND_LABEL: Record<number, string> = {
[MoneyAccountKind.Cash]: 'Касса',
[MoneyAccountKind.Safe]: 'Сейф',
[MoneyAccountKind.Bank]: 'Банковский счёт',
[MoneyAccountKind.Acquiring]: 'Эквайринг',
}
export const TX_KIND_LABEL: Record<number, string> = {
[MoneyTxKind.PaymentIn]: 'Поступление (безнал)',
[MoneyTxKind.PaymentOut]: 'Расход (безнал)',
[MoneyTxKind.CashIn]: 'Приход наличных',
[MoneyTxKind.CashOut]: 'Расход наличных',
[MoneyTxKind.TransferOut]: 'Перевод (списание)',
[MoneyTxKind.TransferIn]: 'Перевод (зачисление)',
}
/** Приходный ли тип (увеличивает баланс счёта) — для знака и цвета суммы. */
export function isIncomeKind(kind: number): boolean {
return kind === MoneyTxKind.PaymentIn || kind === MoneyTxKind.CashIn || kind === MoneyTxKind.TransferIn
}
export const PAYMENT_STATUS_LABEL: Record<string, string> = {
unpaid: 'Не оплачена',
partial: 'Частично',
paid: 'Оплачена',
}
/** Tailwind-классы бейджа статуса оплаты приёмки. */
export const PAYMENT_STATUS_BADGE: Record<string, string> = {
unpaid: 'bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-300',
partial: 'bg-amber-50 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300',
paid: 'bg-green-50 text-green-700 dark:bg-green-950/40 dark:text-green-300',
}
// ── DTO-контракты (повторяют record'ы контроллеров) ──────────────────────
export interface MoneyAccountRow {
id: string; name: string; kind: MoneyAccountKind;
bankName: string | null; accountNumber: string | null;
storeId: string | null; storeName: string | null;
currencyId: string; currencyCode: string;
openingBalance: number; openingDate: string;
currentBalance: number;
isActive: boolean; isDefault: boolean; xmin: number;
}
export interface ExpenseItemRow {
id: string; name: string; code: string | null;
isSystem: boolean; isActive: boolean; notes: string | null; xmin: number;
}
export interface MoneyTxLinkRow {
supplyId: string | null; supplyNumber: string | null;
supplierReturnId: string | null; supplierReturnNumber: string | null;
linkedSum: number;
}
export interface MoneyTxRow {
id: string; number: string; date: string;
kind: MoneyTxKind; status: MoneyTxStatus;
accountId: string; accountName: string;
counterpartyId: string | null; counterpartyName: string | null;
expenseItemId: string | null; expenseItemName: string | null;
currencyId: string; currencyCode: string;
amount: number; purpose: string | null; notes: string | null;
linkedTransactionId: string | null;
postedAt: string | null;
links: MoneyTxLinkRow[];
xmin: number;
}
export interface CashShiftRow {
id: string; number: string; status: CashShiftStatus;
retailPointId: string; retailPointName: string;
cashAccountId: string; cashAccountName: string;
acquiringAccountId: string | null; acquiringAccountName: string | null;
cashierUserId: string; cashierName: string | null;
openedAt: string; closedAt: string | null;
openingFloat: number;
salesCash: number; salesCard: number; returnsCash: number; returnsCard: number;
drawerIn: number; drawerOut: number;
expectedCash: number; actualCash: number | null; cashDifference: number;
notes: string | null; xmin: number;
}
export interface DrawerOpRow {
id: string; direction: DrawerDirection; amount: number; reason: string; at: string; byUserId: string;
}
export interface SupplierDebtRow {
supplierId: string; supplierName: string; bin: string | null; phone: string | null;
suppliesTotal: number; returnsTotal: number; paymentsIn: number; paymentsOut: number;
adjustmentDebt: number; debt: number;
oldestUnpaidDate: string | null; oldestUnpaidNumber: string | null;
}
export interface SupplierDebtsReport { rows: SupplierDebtRow[]; totalDebt: number }
export interface AccountBalanceRow {
accountId: string; name: string; kind: MoneyAccountKind;
currencyCode: string; openingBalance: number; currentBalance: number; isActive: boolean;
}
export interface BalancesReport { accounts: AccountBalanceRow[]; totalByCurrencySum: number }
export interface MoneyPoint { date: string; credit: number; debit: number; balance: number }
export interface MoneyMovementReport {
from: string; to: string; totalCredit: number; totalDebit: number; series: MoneyPoint[]
}
export interface AdjustmentRow {
id: string; number: string; date: string;
counterpartyId: string; counterpartyName: string;
direction: AdjustmentDirection; amount: number; notes: string | null;
status: MoneyTxStatus; postedAt: string | null; xmin: number;
}
// ── справочные хуки ──────────────────────────────────────────────────────
/** Список счетов/касс (до 100). `activeOnly` — отфильтровать архивные для
* выпадающих списков новых документов. */
export function useMoneyAccounts(activeOnly = false) {
return useQuery({
queryKey: ['lookup:money-accounts', activeOnly],
queryFn: async () => {
const params = new URLSearchParams({ pageSize: '100' })
if (activeOnly) params.set('isActive', 'true')
return (await api.get<PagedResult<MoneyAccountRow>>(`/api/money/accounts?${params}`)).data.items
},
staleTime: 0,
refetchOnMount: 'always',
})
}
/** Активные статьи расходов (для расходных документов). */
export function useExpenseItems(activeOnly = true) {
return useQuery({
queryKey: ['lookup:expense-items', activeOnly],
queryFn: async () => {
const params = new URLSearchParams({ pageSize: '100' })
if (activeOnly) params.set('isActive', 'true')
return (await api.get<PagedResult<ExpenseItemRow>>(`/api/money/expense-items?${params}`)).data.items
},
staleTime: 0,
refetchOnMount: 'always',
})
}
/** Точки продаж — для открытия смены. */
export function useRetailPointsLookup() {
return useQuery({
queryKey: ['lookup:retail-points'],
queryFn: async () =>
(await api.get<PagedResult<RetailPoint>>('/api/catalog/retail-points?pageSize=100')).data.items,
staleTime: 0,
refetchOnMount: 'always',
})
}

View file

@ -93,6 +93,9 @@ export interface SupplyListRow {
storeId: string; storeName: string; storeId: string; storeName: string;
currencyId: string; currencyCode: string; currencyId: string; currencyCode: string;
total: number; lineCount: number; postedAt: string | null; total: number; lineCount: number; postedAt: string | null;
/** Sprint 33 (модуль «Деньги»): сколько оплачено и агрегатный статус
* оплаты ("unpaid" | "partial" | "paid"). */
payedSum: number; paymentStatus: string;
} }
export interface SupplyLineDto { export interface SupplyLineDto {
@ -112,6 +115,8 @@ export interface SupplyDto {
notes: string | null; notes: string | null;
total: number; postedAt: string | null; total: number; postedAt: string | null;
lines: SupplyLineDto[]; lines: SupplyLineDto[];
/** Sprint 33: оплаченная сумма + агрегатный статус оплаты приёмки. */
payedSum: number; paymentStatus: string;
} }
export const EnterStatus = { Draft: 0, Posted: 1 } as const export const EnterStatus = { Draft: 0, Posted: 1 } as const

View file

@ -89,7 +89,13 @@
"superAdminConsole": "System console", "superAdminConsole": "System console",
"logout": "Sign out", "logout": "Sign out",
"openMenu": "Open menu", "openMenu": "Open menu",
"closeMenu": "Close menu" "closeMenu": "Close menu",
"section_money": "Money",
"moneyReport": "Money report",
"moneyAccounts": "Accounts & tills",
"moneyPayments": "Payments & orders",
"cashShifts": "Shifts",
"supplierDebts": "Supplier debts"
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",

View file

@ -89,7 +89,13 @@
"superAdminConsole": "Системная консоль", "superAdminConsole": "Системная консоль",
"logout": "Выход", "logout": "Выход",
"openMenu": "Открыть меню", "openMenu": "Открыть меню",
"closeMenu": "Закрыть меню" "closeMenu": "Закрыть меню",
"section_money": "Деньги",
"moneyReport": "Отчёт «Деньги»",
"moneyAccounts": "Счета и кассы",
"moneyPayments": "Платежи и ордера",
"cashShifts": "Смены",
"supplierDebts": "Долги поставщикам"
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",

View file

@ -1,4 +1,4 @@
import { useRef } from 'react' import { useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { PackagePlus, Pencil, Camera, ScanLine } from 'lucide-react' import { PackagePlus, Pencil, Camera, ScanLine } from 'lucide-react'
import { ListPageShell } from '@/components/ListPageShell' import { ListPageShell } from '@/components/ListPageShell'
@ -7,17 +7,20 @@ import { DataTable } from '@/components/DataTable'
import { EmptyState } from '@/components/EmptyState' import { EmptyState } from '@/components/EmptyState'
import { Pagination } from '@/components/Pagination' import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar' import { SearchBar } from '@/components/SearchBar'
import { Select } from '@/components/Field'
import { SplitButtonMenu } from '@/components/SplitButtonMenu' import { SplitButtonMenu } from '@/components/SplitButtonMenu'
import { useCatalogList } from '@/lib/useCatalog' import { useCatalogList } from '@/lib/useCatalog'
import { useShortcuts } from '@/lib/useShortcuts' import { useShortcuts } from '@/lib/useShortcuts'
import { useOrgSettings } from '@/lib/useOrgSettings' import { useOrgSettings } from '@/lib/useOrgSettings'
import { PAYMENT_STATUS_LABEL, PAYMENT_STATUS_BADGE } from '@/lib/money'
import { type SupplyListRow, SupplyStatus } from '@/lib/types' import { type SupplyListRow, SupplyStatus } from '@/lib/types'
const URL = '/api/purchases/supplies' const URL = '/api/purchases/supplies'
export function SuppliesPage() { export function SuppliesPage() {
const navigate = useNavigate() const navigate = useNavigate()
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<SupplyListRow>(URL) const [paymentStatus, setPaymentStatus] = useState('')
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<SupplyListRow>(URL, { paymentStatus: paymentStatus || undefined })
const org = useOrgSettings() const org = useOrgSettings()
const fractional = org.data?.allowFractionalPrices ?? false const fractional = org.data?.allowFractionalPrices ?? false
const moneyFmt = fractional const moneyFmt = fractional
@ -38,6 +41,12 @@ export function SuppliesPage() {
actions={ actions={
<> <>
<SearchBar ref={searchRef} value={search} onChange={setSearch} placeholder="По номеру или поставщику…" /> <SearchBar ref={searchRef} value={search} onChange={setSearch} placeholder="По номеру или поставщику…" />
<Select value={paymentStatus} onChange={(e) => { setPaymentStatus((e.target as HTMLSelectElement).value); setPage(1) }} className="w-40">
<option value="">Оплата: все</option>
<option value="unpaid">Не оплачены</option>
<option value="partial">Частично</option>
<option value="paid">Оплачены</option>
</Select>
<ExportButton url="/api/purchases/supplies/export" defaultName="supplies" /> <ExportButton url="/api/purchases/supplies/export" defaultName="supplies" />
{/* Sprint 31: три входа в создание приёмки — ручной, по фото накладной, {/* Sprint 31: три входа в создание приёмки — ручной, по фото накладной,
сканированием штрихкодов. Ручной — старое поведение; фото и скан — сканированием штрихкодов. Ручной — старое поведение; фото и скан —
@ -88,6 +97,11 @@ export function SuppliesPage() {
{ header: 'Поставщик', sortKey: 'supplier', cell: (r) => r.supplierName }, { header: 'Поставщик', sortKey: 'supplier', cell: (r) => r.supplierName },
{ header: 'Склад', width: '180px', sortKey: 'store', cell: (r) => r.storeName }, { header: 'Склад', width: '180px', sortKey: 'store', cell: (r) => r.storeName },
{ header: 'Позиций', width: '100px', className: 'text-right', cell: (r) => r.lineCount }, { header: 'Позиций', width: '100px', className: 'text-right', cell: (r) => r.lineCount },
{ header: 'Оплата', width: '120px', cell: (r) => (
r.status === SupplyStatus.Posted
? <span className={`text-xs px-2 py-0.5 rounded ${PAYMENT_STATUS_BADGE[r.paymentStatus] ?? ''}`}>{PAYMENT_STATUS_LABEL[r.paymentStatus] ?? r.paymentStatus}</span>
: <span className="text-slate-300">—</span>
)},
{ header: 'Сумма', width: '160px', className: 'text-right font-mono', sortKey: 'total', cell: (r) => `${r.total.toLocaleString('ru', moneyFmt)} ${r.currencyCode}` }, { header: 'Сумма', width: '160px', className: 'text-right font-mono', sortKey: 'total', cell: (r) => `${r.total.toLocaleString('ru', moneyFmt)} ${r.currencyCode}` },
]} ]}
empty="Приёмок пока нет. Создай первую — товар попадёт на склад после проведения." empty="Приёмок пока нет. Создай первую — товар попадёт на склад после проведения."

View file

@ -11,6 +11,7 @@ import { SupplyLineQuickAdd, type AddedProduct } from '@/components/SupplyLineQu
import { ConfirmDialog } from '@/components/ConfirmDialog' import { ConfirmDialog } from '@/components/ConfirmDialog'
import { FormSkeleton } from '@/components/Skeleton' import { FormSkeleton } from '@/components/Skeleton'
import { Breadcrumbs } from '@/components/Breadcrumbs' import { Breadcrumbs } from '@/components/Breadcrumbs'
import { SupplyPaymentSection } from '@/components/money/SupplyPaymentSection'
import { useConfirm } from '@/lib/useConfirm' import { useConfirm } from '@/lib/useConfirm'
import { useShortcuts } from '@/lib/useShortcuts' import { useShortcuts } from '@/lib/useShortcuts'
import { useStores, useCurrencies, usePriceTypes } from '@/lib/useLookups' import { useStores, useCurrencies, usePriceTypes } from '@/lib/useLookups'
@ -521,6 +522,21 @@ export function SupplyEditPage() {
)} )}
</Section> </Section>
{/* Блок оплаты — только у проведённой приёмки. Кладовщик видит
* статус/суммы, оплату проводит тот, у кого есть право MoneyEdit
* (кнопка «Оплатить» для него вернёт 403 — скрывать на фронте не
* обязательно, но спокойнее: показываем всегда, бэкенд решает). */}
{isPosted && existing.data && (
<SupplyPaymentSection
supplyId={existing.data.id}
total={existing.data.total}
payedSum={existing.data.payedSum}
paymentStatus={existing.data.paymentStatus}
currencyId={existing.data.currencyId}
onPaid={() => existing.refetch()}
/>
)}
</div> </div>
</div> </div>

View file

@ -0,0 +1,293 @@
import { useState } from 'react'
import { Plus, DoorOpen, DoorClosed, ArrowDownToLine, ArrowUpFromLine, Printer } from 'lucide-react'
import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal'
import { Field, TextInput, TextArea, Select, MoneyInput } from '@/components/Field'
import { useCatalogList } from '@/lib/useCatalog'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from '@/lib/api'
import { useFormatCurrency } from '@/lib/useFormatCurrency'
import {
CashShiftStatus, DrawerDirection, MoneyAccountKind,
useMoneyAccounts, useRetailPointsLookup, type CashShiftRow, type DrawerOpRow,
} from '@/lib/money'
const URL = '/api/money/shifts'
export function CashShiftsPage() {
const { data, isLoading, page, setPage } = useCatalogList<CashShiftRow>(URL)
const accounts = useMoneyAccounts(true)
const retailPoints = useRetailPointsLookup()
const fmt = useFormatCurrency()
const qc = useQueryClient()
const [openForm, setOpenForm] = useState<{ retailPointId: string; cashAccountId: string; acquiringAccountId: string; openingFloat: number | null } | null>(null)
const [detailId, setDetailId] = useState<string | null>(null)
const [err, setErr] = useState<string | null>(null)
const cashAccounts = (accounts.data ?? []).filter((a) => a.kind === MoneyAccountKind.Cash)
const acquiringAccounts = (accounts.data ?? []).filter((a) => a.kind === MoneyAccountKind.Acquiring)
const invalidate = () => qc.invalidateQueries({ queryKey: [URL] })
const openShift = useMutation({
mutationFn: async (input: unknown) => (await api.post(`${URL}/open`, input)).data,
onSuccess: () => { invalidate() }, meta: { successMessage: 'Смена открыта' },
})
const submitOpen = async () => {
if (!openForm) return
if (!openForm.retailPointId) { setErr('Выберите точку продаж'); return }
if (!openForm.cashAccountId) { setErr('Выберите кассу для наличных'); return }
try {
await openShift.mutateAsync({
retailPointId: openForm.retailPointId,
cashAccountId: openForm.cashAccountId,
acquiringAccountId: openForm.acquiringAccountId || null,
openingFloat: openForm.openingFloat ?? 0,
})
setOpenForm(null); setErr(null)
} catch { /* 409 shift-already-open → тост */ }
}
return (
<>
<ListPageShell
title="Смены кассы"
description="Открытие и закрытие смен, внесения/выплаты, Z-отчёт. Продажа на точке возможна только при открытой смене."
actions={
<Button onClick={() => { setErr(null); setOpenForm({ retailPointId: retailPoints.data?.[0]?.id ?? '', cashAccountId: cashAccounts[0]?.id ?? '', acquiringAccountId: '', openingFloat: 0 }) }}>
<Plus className="w-4 h-4" /> Открыть смену
</Button>
}
footer={data && data.total > 0 && (
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)}
>
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => r.id}
onRowClick={(r) => setDetailId(r.id)}
empty="Смен пока нет. Откройте смену, чтобы начать продажи на точке."
columns={[
{ header: '№', width: '140px', cell: (r) => <span className="font-mono text-slate-600">{r.number}</span> },
{ header: 'Точка', cell: (r) => r.retailPointName },
{ header: 'Открыта', width: '150px', cell: (r) => new Date(r.openedAt).toLocaleString('ru', { dateStyle: 'short', timeStyle: 'short' }) },
{ header: 'Статус', width: '120px', cell: (r) => (
r.status === CashShiftStatus.Open
? <span className="text-xs px-2 py-0.5 rounded bg-green-50 text-green-700 dark:bg-green-950/40 dark:text-green-300 inline-flex items-center gap-1"><DoorOpen className="w-3 h-3" /> Открыта</span>
: <span className="text-xs px-2 py-0.5 rounded bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300 inline-flex items-center gap-1"><DoorClosed className="w-3 h-3" /> Закрыта</span>
)},
{ header: 'Нал (ожид.)', width: '150px', className: 'text-right font-mono', cell: (r) => r.status === CashShiftStatus.Closed ? fmt(r.expectedCash) : '—' },
{ header: 'Разница', width: '140px', className: 'text-right font-mono', cell: (r) => (
r.status === CashShiftStatus.Closed && r.actualCash != null
? <span className={r.cashDifference < 0 ? 'text-red-600' : r.cashDifference > 0 ? 'text-amber-600' : 'text-green-700'}>{fmt(r.cashDifference)}</span>
: '—'
)},
]}
/>
</ListPageShell>
{/* Открыть смену */}
<Modal open={!!openForm} onClose={() => setOpenForm(null)} title="Открыть смену"
footer={<><Button variant="secondary" onClick={() => setOpenForm(null)}>Отмена</Button><Button onClick={submitOpen}>Открыть</Button></>}>
{openForm && (
<div className="space-y-3">
{err && <p role="alert" className="text-sm text-red-600">{err}</p>}
<Field label="Точка продаж">
<Select value={openForm.retailPointId} onChange={(e) => setOpenForm({ ...openForm, retailPointId: (e.target as HTMLSelectElement).value })}>
<option value="">— выберите —</option>
{(retailPoints.data ?? []).map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
</Select>
</Field>
<Field label="Касса для наличных">
<Select value={openForm.cashAccountId} onChange={(e) => setOpenForm({ ...openForm, cashAccountId: (e.target as HTMLSelectElement).value })}>
<option value="">— выберите —</option>
{cashAccounts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
</Select>
{cashAccounts.length === 0 && <span className="text-xs text-amber-600">Нет ни одной кассы типа «Касса» — создайте её в разделе «Счета и кассы».</span>}
</Field>
<Field label="Счёт эквайринга (необязательно)">
<Select value={openForm.acquiringAccountId} onChange={(e) => setOpenForm({ ...openForm, acquiringAccountId: (e.target as HTMLSelectElement).value })}>
<option value="">— без карт —</option>
{acquiringAccounts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
</Select>
</Field>
<Field label="Разменник на начало смены">
<MoneyInput value={openForm.openingFloat} onChange={(v) => setOpenForm({ ...openForm, openingFloat: v })} />
</Field>
</div>
)}
</Modal>
{detailId && <ShiftDetail id={detailId} onClose={() => setDetailId(null)} onChanged={invalidate} />}
</>
)
}
interface ShiftDetailResponse { shift: CashShiftRow; drawerOperations: DrawerOpRow[] }
function ShiftDetail({ id, onClose, onChanged }: { id: string; onClose: () => void; onChanged: () => void }) {
const fmt = useFormatCurrency()
const qc = useQueryClient()
const { data } = useQuery({
queryKey: [`${URL}/${id}`],
queryFn: async () => (await api.get<ShiftDetailResponse>(`${URL}/${id}`)).data,
})
const [drawer, setDrawer] = useState<{ direction: number; amount: number | null; reason: string } | null>(null)
const [closing, setClosing] = useState<{ actualCash: number | null; notes: string } | null>(null)
const [showZ, setShowZ] = useState(false)
const [err, setErr] = useState<string | null>(null)
const refresh = () => { qc.invalidateQueries({ queryKey: [`${URL}/${id}`] }); onChanged() }
const drawerMut = useMutation({
mutationFn: async (input: unknown) => (await api.post(`${URL}/${id}/drawer`, input)).data,
onSuccess: refresh, meta: { successMessage: 'Операция добавлена' },
})
const closeMut = useMutation({
mutationFn: async (input: unknown) => (await api.post(`${URL}/${id}/close`, input)).data,
onSuccess: refresh, meta: { successMessage: 'Смена закрыта' },
})
const s = data?.shift
const isOpen = s?.status === CashShiftStatus.Open
const submitDrawer = async () => {
if (!drawer) return
if (!drawer.amount || drawer.amount <= 0) { setErr('Укажите сумму'); return }
if (!drawer.reason.trim()) { setErr('Укажите причину'); return }
try { await drawerMut.mutateAsync({ direction: drawer.direction, amount: drawer.amount, reason: drawer.reason.trim() }); setDrawer(null); setErr(null) }
catch { /* тост */ }
}
const submitClose = async () => {
if (!closing) return
if (closing.actualCash == null) { setErr('Введите фактическую сумму наличных'); return }
try { await closeMut.mutateAsync({ actualCash: closing.actualCash, notes: closing.notes.trim() || null }); setClosing(null); setErr(null) }
catch { /* тост */ }
}
return (
<>
<Modal open={!showZ && !drawer && !closing} onClose={onClose} title={s ? `Смена ${s.number}` : 'Смена'} width="max-w-xl"
footer={s && (
<>
{isOpen && <>
<Button variant="secondary" size="sm" onClick={() => { setErr(null); setDrawer({ direction: DrawerDirection.In, amount: null, reason: '' }) }}><ArrowDownToLine className="w-4 h-4" /> Внести</Button>
<Button variant="secondary" size="sm" onClick={() => { setErr(null); setDrawer({ direction: DrawerDirection.Out, amount: null, reason: '' }) }}><ArrowUpFromLine className="w-4 h-4" /> Выплатить</Button>
<Button size="sm" onClick={() => { setErr(null); setClosing({ actualCash: null, notes: '' }) }}><DoorClosed className="w-4 h-4" /> Закрыть смену</Button>
</>}
{!isOpen && <Button variant="secondary" size="sm" onClick={() => setShowZ(true)}><Printer className="w-4 h-4" /> Z-отчёт</Button>}
<Button variant="secondary" onClick={onClose}>Закрыть</Button>
</>
)}>
{s ? (
<div className="space-y-3 text-sm">
<dl className="grid grid-cols-2 gap-x-4 gap-y-1.5">
<dt className="text-slate-500">Точка</dt><dd>{s.retailPointName}</dd>
<dt className="text-slate-500">Касса</dt><dd>{s.cashAccountName}</dd>
<dt className="text-slate-500">Открыта</dt><dd>{new Date(s.openedAt).toLocaleString('ru')}</dd>
{s.closedAt && <><dt className="text-slate-500">Закрыта</dt><dd>{new Date(s.closedAt).toLocaleString('ru')}</dd></>}
<dt className="text-slate-500">Разменник</dt><dd className="font-mono">{fmt(s.openingFloat)}</dd>
</dl>
{!isOpen && (
<div className="border-t border-slate-200 dark:border-slate-800 pt-2">
<table className="w-full text-sm">
<tbody className="[&_td]:py-0.5 [&_td:last-child]:text-right [&_td:last-child]:font-mono">
<tr><td>Продажи наличными</td><td>{fmt(s.salesCash)}</td></tr>
<tr><td>Продажи картой</td><td>{fmt(s.salesCard)}</td></tr>
<tr><td>Возвраты наличными</td><td>−{fmt(s.returnsCash)}</td></tr>
<tr><td>Внесения</td><td>{fmt(s.drawerIn)}</td></tr>
<tr><td>Выплаты</td><td>−{fmt(s.drawerOut)}</td></tr>
<tr className="font-medium border-t border-slate-200 dark:border-slate-800"><td>Ожидается наличных</td><td>{fmt(s.expectedCash)}</td></tr>
<tr><td>По факту</td><td>{fmt(s.actualCash)}</td></tr>
<tr className="font-medium"><td>Разница</td><td className={s.cashDifference < 0 ? 'text-red-600' : s.cashDifference > 0 ? 'text-amber-600' : 'text-green-700'}>{fmt(s.cashDifference)}</td></tr>
</tbody>
</table>
</div>
)}
{(data?.drawerOperations.length ?? 0) > 0 && (
<div className="border-t border-slate-200 dark:border-slate-800 pt-2">
<div className="text-xs uppercase text-slate-500 mb-1">Внесения / выплаты</div>
<ul className="space-y-1">
{data!.drawerOperations.map((o) => (
<li key={o.id} className="flex justify-between gap-2">
<span>{o.direction === DrawerDirection.In ? '➕' : '➖'} {o.reason}</span>
<span className="font-mono">{fmt(o.amount)}</span>
</li>
))}
</ul>
</div>
)}
</div>
) : <p className="text-sm text-slate-500">Загрузка…</p>}
</Modal>
{/* Внесение / выплата */}
<Modal open={!!drawer} onClose={() => setDrawer(null)} title={drawer?.direction === DrawerDirection.In ? 'Внести деньги' : 'Выплатить деньги'}
footer={<><Button variant="secondary" onClick={() => setDrawer(null)}>Отмена</Button><Button onClick={submitDrawer}>Сохранить</Button></>}>
{drawer && (
<div className="space-y-3">
{err && <p role="alert" className="text-sm text-red-600">{err}</p>}
<Field label="Сумма"><MoneyInput value={drawer.amount} onChange={(v) => setDrawer({ ...drawer, amount: v })} /></Field>
<Field label="Причина"><TextInput value={drawer.reason} autoFocus placeholder={drawer.direction === DrawerDirection.In ? 'Размен из сейфа…' : 'Инкассация, выдал курьеру…'} onChange={(e) => setDrawer({ ...drawer, reason: e.target.value })} /></Field>
</div>
)}
</Modal>
{/* Закрытие смены */}
<Modal open={!!closing} onClose={() => setClosing(null)} title="Закрыть смену"
footer={<><Button variant="secondary" onClick={() => setClosing(null)}>Отмена</Button><Button onClick={submitClose}>Закрыть смену</Button></>}>
{closing && s && (
<div className="space-y-3">
{err && <p role="alert" className="text-sm text-red-600">{err}</p>}
<p className="text-sm text-slate-500">Пересчитайте наличные в ящике и введите фактическую сумму. Система сравнит с ожидаемой и подсветит разницу.</p>
<Field label="Фактически наличных в ящике"><MoneyInput value={closing.actualCash} onChange={(v) => setClosing({ ...closing, actualCash: v })} /></Field>
<Field label="Комментарий (необязательно)"><TextArea rows={2} value={closing.notes} onChange={(e) => setClosing({ ...closing, notes: e.target.value })} /></Field>
</div>
)}
</Modal>
{showZ && s && <ZReport id={id} onClose={() => setShowZ(false)} />}
</>
)
}
function ZReport({ id, onClose }: { id: string; onClose: () => void }) {
const fmt = useFormatCurrency()
const { data } = useQuery({
queryKey: [`${URL}/${id}/report`],
queryFn: async () => (await api.get<Record<string, unknown>>(`${URL}/${id}/report`)).data,
})
const r = data as {
number?: string; openedAt?: string; closedAt?: string; openingFloat?: number
sales?: { cash: number; card: number }; returns?: { cash: number; card: number }
drawer?: { in: number; out: number }; expectedCash?: number; actualCash?: number; cashDifference?: number
} | undefined
return (
<Modal open onClose={onClose} title={`Z-отчёт ${r?.number ?? ''}`}
footer={<><Button variant="secondary" onClick={onClose}>Закрыть</Button><Button onClick={() => window.print()}><Printer className="w-4 h-4" /> Печать</Button></>}>
{r ? (
<div className="space-y-2 text-sm font-mono">
<div className="text-center font-semibold">Z-ОТЧЁТ · {r.number}</div>
<div className="text-center text-xs text-slate-500">{r.openedAt && new Date(r.openedAt).toLocaleString('ru')} — {r.closedAt && new Date(r.closedAt).toLocaleString('ru')}</div>
<table className="w-full"><tbody className="[&_td:last-child]:text-right">
<tr><td>Разменник</td><td>{fmt(r.openingFloat)}</td></tr>
<tr><td>Продажи нал</td><td>{fmt(r.sales?.cash)}</td></tr>
<tr><td>Продажи карта</td><td>{fmt(r.sales?.card)}</td></tr>
<tr><td>Возвраты нал</td><td>−{fmt(r.returns?.cash)}</td></tr>
<tr><td>Внесения</td><td>{fmt(r.drawer?.in)}</td></tr>
<tr><td>Выплаты</td><td>−{fmt(r.drawer?.out)}</td></tr>
<tr className="border-t border-slate-300"><td>Ожидается нал</td><td>{fmt(r.expectedCash)}</td></tr>
<tr><td>По факту</td><td>{fmt(r.actualCash)}</td></tr>
<tr className="font-semibold"><td>Разница</td><td>{fmt(r.cashDifference)}</td></tr>
</tbody></table>
</div>
) : <p className="text-sm text-slate-500">Загрузка…</p>}
</Modal>
)
}

View file

@ -0,0 +1,104 @@
import { useState } from 'react'
import { Plus, Trash2, Lock } from 'lucide-react'
import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal'
import { Field, TextInput, Checkbox } from '@/components/Field'
import { ConfirmDialog } from '@/components/ConfirmDialog'
import { useConfirm } from '@/lib/useConfirm'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
import { useQueryClient } from '@tanstack/react-query'
import type { ExpenseItemRow } from '@/lib/money'
const URL = '/api/money/expense-items'
interface Form { id?: string; name: string; code: string; isActive: boolean; isSystem: boolean; notes: string; xmin?: number }
const blank: Form = { name: '', code: '', isActive: true, isSystem: false, notes: '' }
export function ExpenseItemsPage() {
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<ExpenseItemRow>(URL)
const { create, update, remove } = useCatalogMutations(URL, URL)
const qc = useQueryClient()
const { confirm, dialogProps } = useConfirm()
const [form, setForm] = useState<Form | null>(null)
const save = async () => {
if (!form || !form.name.trim()) return
const payload = { name: form.name.trim(), code: form.code.trim() || null, notes: form.notes.trim() || null, isActive: form.isActive, xmin: form.xmin }
try {
if (form.id) await update.mutateAsync({ id: form.id, input: payload })
else await create.mutateAsync(payload)
await qc.invalidateQueries({ queryKey: ['lookup:expense-items'] })
setForm(null)
} catch { /* тост */ }
}
return (
<>
<ListPageShell
title="Статьи расходов"
description="На что тратятся деньги: закупка товара, аренда, зарплата, коммунальные и ваши собственные статьи."
actions={
<>
<SearchBar value={search} onChange={setSearch} placeholder="По названию или коду…" />
<Button onClick={() => setForm({ ...blank })}><Plus className="w-4 h-4" /> Добавить</Button>
</>
}
footer={data && data.total > 0 && (
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)}
>
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => r.id}
onRowClick={(r) => setForm({ id: r.id, name: r.name, code: r.code ?? '', isActive: r.isActive, isSystem: r.isSystem, notes: r.notes ?? '', xmin: r.xmin })}
empty="Статей пока нет."
columns={[
{ header: 'Название', cell: (r) => (
<span className="flex items-center gap-1.5">{r.name}{r.isSystem && <Lock className="w-3.5 h-3.5 text-slate-400" />}</span>
)},
{ header: 'Код', width: '140px', cell: (r) => <span className="font-mono text-slate-500">{r.code ?? '—'}</span> },
{ header: 'Активна', width: '100px', cell: (r) => r.isActive ? '✓' : '—' },
]}
/>
</ListPageShell>
<Modal
open={!!form}
onClose={() => setForm(null)}
title={form?.id ? 'Статья расходов' : 'Новая статья'}
footer={
<>
{form?.id && !form.isSystem && (
<Button variant="danger" size="sm" onClick={async () => {
if (await confirm({ title: 'Удалить статью?', description: <>Удалить <strong>«{form.name}»</strong>?</>, confirmLabel: 'Удалить' })) {
try { await remove.mutateAsync(form.id!); await qc.invalidateQueries({ queryKey: ['lookup:expense-items'] }); setForm(null) } catch { /* in-use → тост */ }
}
}}><Trash2 className="w-4 h-4" /> Удалить</Button>
)}
<Button variant="secondary" onClick={() => setForm(null)}>Отмена</Button>
<Button onClick={save} disabled={!form?.name.trim()}>Сохранить</Button>
</>
}
>
{form && (
<div className="space-y-3">
{form.isSystem && (
<p className="text-xs text-slate-500 bg-slate-50 dark:bg-slate-800/40 p-2 rounded border border-slate-200 dark:border-slate-700">
Системная статья — можно переименовать, но нельзя удалить.
</p>
)}
<Field label="Название"><TextInput value={form.name} autoFocus onChange={(e) => setForm({ ...form, name: e.target.value })} /></Field>
<Field label="Код (необязательно, для сверки с 1С)"><TextInput value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} /></Field>
<Checkbox label="Активна" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
</div>
)}
</Modal>
<ConfirmDialog {...dialogProps} />
</>
)
}

View file

@ -0,0 +1,233 @@
import { useState } from 'react'
import { Plus, Trash2, Star, Archive } from 'lucide-react'
import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal'
import { Field, TextInput, Select, MoneyInput, Checkbox } from '@/components/Field'
import { DateField } from '@/components/DateField'
import { ConfirmDialog } from '@/components/ConfirmDialog'
import { useConfirm } from '@/lib/useConfirm'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
import { useQueryClient } from '@tanstack/react-query'
import { useStores, useCurrencies } from '@/lib/useLookups'
import { useFormatCurrency } from '@/lib/useFormatCurrency'
import { MoneyAccountKind, ACCOUNT_KIND_LABEL, type MoneyAccountRow } from '@/lib/money'
const URL = '/api/money/accounts'
interface Form {
id?: string
name: string
kind: number
bankName: string
accountNumber: string
storeId: string
currencyId: string
openingBalance: number | null
openingDate: string
isActive: boolean
isDefault: boolean
xmin?: number
}
function todayIso() { return new Date().toISOString().slice(0, 10) }
export function MoneyAccountsPage() {
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<MoneyAccountRow>(URL)
const { create, update, remove } = useCatalogMutations(URL, URL)
const stores = useStores()
const currencies = useCurrencies()
const fmt = useFormatCurrency()
const qc = useQueryClient()
const { confirm, dialogProps } = useConfirm()
const [form, setForm] = useState<Form | null>(null)
const [err, setErr] = useState<string | null>(null)
const defaultCurrency = currencies.data?.[0]
const openNew = () => {
setErr(null)
setForm({
name: '', kind: MoneyAccountKind.Cash, bankName: '', accountNumber: '',
storeId: '', currencyId: defaultCurrency?.id ?? '',
openingBalance: 0, openingDate: todayIso(), isActive: true, isDefault: false,
})
}
const openEdit = (r: MoneyAccountRow) => {
setErr(null)
setForm({
id: r.id, name: r.name, kind: r.kind,
bankName: r.bankName ?? '', accountNumber: r.accountNumber ?? '',
storeId: r.storeId ?? '', currencyId: r.currencyId,
openingBalance: r.openingBalance, openingDate: r.openingDate.slice(0, 10),
isActive: r.isActive, isDefault: r.isDefault, xmin: r.xmin,
})
}
const isBankLike = (k: number) => k === MoneyAccountKind.Bank || k === MoneyAccountKind.Acquiring
const save = async () => {
if (!form) return
if (!form.name.trim()) { setErr('Укажите название счёта'); return }
if (!form.currencyId) { setErr('Выберите валюту'); return }
if (form.kind === MoneyAccountKind.Cash && !form.storeId) { setErr('Кассе нужна привязка к магазину'); return }
if (form.kind === MoneyAccountKind.Bank && !form.accountNumber.trim()) { setErr('Для банковского счёта укажите номер'); return }
const payload = {
name: form.name.trim(),
kind: form.kind,
bankName: isBankLike(form.kind) ? (form.bankName.trim() || null) : null,
accountNumber: form.accountNumber.trim() || null,
storeId: form.storeId || null,
currencyId: form.currencyId,
openingBalance: form.openingBalance ?? 0,
openingDate: form.openingDate,
isActive: form.isActive,
isDefault: form.isDefault,
xmin: form.xmin,
}
try {
if (form.id) await update.mutateAsync({ id: form.id, input: payload })
else await create.mutateAsync(payload)
await qc.invalidateQueries({ queryKey: ['lookup:money-accounts'] })
setForm(null)
} catch { /* тост показывает axios interceptor */ }
}
return (
<>
<ListPageShell
title="Счета и кассы"
description="Места, где лежат деньги магазина: кассы, сейфы, банковские счета, эквайринг."
actions={
<>
<SearchBar value={search} onChange={setSearch} placeholder="По названию или банку…" />
<Button onClick={openNew}><Plus className="w-4 h-4" /> Добавить</Button>
</>
}
footer={data && data.total > 0 && (
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)}
>
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => r.id}
onRowClick={openEdit}
empty="Счетов пока нет. Добавьте кассу магазина и, если нужно, банковские счета."
columns={[
{ header: 'Название', cell: (r) => (
<span className="flex items-center gap-1.5">
{r.name}
{r.isDefault && <Star className="w-3.5 h-3.5 text-amber-500" aria-label="По умолчанию" />}
{!r.isActive && <Archive className="w-3.5 h-3.5 text-slate-400" aria-label="Архив" />}
</span>
)},
{ header: 'Тип', width: '150px', cell: (r) => ACCOUNT_KIND_LABEL[r.kind] ?? r.kind },
{ header: 'Банк / магазин', cell: (r) => r.bankName ?? r.storeName ?? '—' },
{ header: 'Остаток', width: '180px', className: 'text-right font-mono', cell: (r) => (
<span className={r.currentBalance < 0 ? 'text-red-600' : ''}>{fmt(r.currentBalance)}</span>
)},
]}
/>
</ListPageShell>
<Modal
open={!!form}
onClose={() => setForm(null)}
title={form?.id ? 'Счёт' : 'Новый счёт'}
footer={
<>
{form?.id && (
<Button variant="danger" size="sm" onClick={async () => {
if (await confirm({
title: 'Удалить счёт?',
description: <>Удалить <strong>«{form.name}»</strong>? Если по счёту уже есть движения — удалить нельзя, архивируйте его.</>,
confirmLabel: 'Удалить',
})) {
try {
await remove.mutateAsync(form.id!)
await qc.invalidateQueries({ queryKey: ['lookup:money-accounts'] })
setForm(null)
} catch { /* 409 account-in-use → тост */ }
}
}}>
<Trash2 className="w-4 h-4" /> Удалить
</Button>
)}
<Button variant="secondary" onClick={() => setForm(null)}>Отмена</Button>
<Button onClick={save} disabled={!form?.name}>Сохранить</Button>
</>
}
>
{form && (
<div className="space-y-3">
{err && <p role="alert" className="text-sm text-red-600">{err}</p>}
<Field label="Название">
<TextInput value={form.name} autoFocus
placeholder="Касса магазина, Kaspi основной, Сейф директора…"
onChange={(e) => setForm({ ...form, name: e.target.value })} />
</Field>
<Field label="Тип счёта">
<Select value={String(form.kind)} disabled={!!form.id}
onChange={(e) => setForm({ ...form, kind: Number((e.target as HTMLSelectElement).value) })}>
{Object.entries(ACCOUNT_KIND_LABEL).map(([v, label]) => (
<option key={v} value={v}>{label}</option>
))}
</Select>
</Field>
{isBankLike(form.kind) && (
<Field label="Банк">
<TextInput value={form.bankName} placeholder="Kaspi Bank, Halyk Bank…"
onChange={(e) => setForm({ ...form, bankName: e.target.value })} />
</Field>
)}
{form.kind !== MoneyAccountKind.Safe && (
<Field label={form.kind === MoneyAccountKind.Bank ? 'Номер счёта (IBAN)' : 'Номер счёта (необязательно)'}>
<TextInput value={form.accountNumber}
onChange={(e) => setForm({ ...form, accountNumber: e.target.value })} />
</Field>
)}
<Field label={form.kind === MoneyAccountKind.Cash ? 'Магазин (касса привязана к точке)' : 'Магазин (необязательно)'}>
<Select value={form.storeId}
onChange={(e) => setForm({ ...form, storeId: (e.target as HTMLSelectElement).value })}>
<option value="">— не привязан —</option>
{(stores.data ?? []).map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</Select>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Валюта">
<Select value={form.currencyId}
onChange={(e) => setForm({ ...form, currencyId: (e.target as HTMLSelectElement).value })}>
{(currencies.data ?? []).map((c) => <option key={c.id} value={c.id}>{c.code}</option>)}
</Select>
</Field>
<Field label="Дата начала учёта">
<DateField value={form.openingDate} disabled={!!form.id}
onChange={(iso) => setForm({ ...form, openingDate: iso ?? todayIso() })} />
</Field>
</div>
<Field label="Начальный остаток">
<MoneyInput value={form.openingBalance}
onChange={(v) => setForm({ ...form, openingBalance: v })}
currencyCode={currencies.data?.find((c) => c.id === form.currencyId)?.code} />
</Field>
{form.id && (
<p className="text-xs text-slate-500 bg-slate-50 dark:bg-slate-800/40 p-2 rounded border border-slate-200 dark:border-slate-700">
Тип, дата начала и начальный остаток после создания не меняются — иначе разойдётся история остатков.
</p>
)}
<Checkbox label="По умолчанию для этого типа счёта" checked={form.isDefault}
onChange={(v) => setForm({ ...form, isDefault: v })} />
<Checkbox label="Активен (предлагать в документах)" checked={form.isActive}
onChange={(v) => setForm({ ...form, isActive: v })} />
</div>
)}
</Modal>
<ConfirmDialog {...dialogProps} />
</>
)
}

View file

@ -0,0 +1,98 @@
import { useState } from 'react'
import { Wallet, TrendingUp, TrendingDown } from 'lucide-react'
import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable'
import { Field, Select } from '@/components/Field'
import { DateField } from '@/components/DateField'
import { useQuery } from '@tanstack/react-query'
import { api } from '@/lib/api'
import { useFormatCurrency } from '@/lib/useFormatCurrency'
import { ACCOUNT_KIND_LABEL, type BalancesReport, type MoneyMovementReport } from '@/lib/money'
function isoDaysAgo(days: number) {
const d = new Date(); d.setDate(d.getDate() - days); return d.toISOString().slice(0, 10)
}
function todayIso() { return new Date().toISOString().slice(0, 10) }
export function MoneyReportPage() {
const fmt = useFormatCurrency()
const [from, setFrom] = useState(isoDaysAgo(30))
const [to, setTo] = useState(todayIso())
const [interval, setInterval] = useState('day')
const balances = useQuery({
queryKey: ['money:balances'],
queryFn: async () => (await api.get<BalancesReport>('/api/money/reports/balances')).data,
})
// `to` эксклюзивный на бэке — добавляем день, чтобы включить выбранную дату.
const toExclusive = (() => { const d = new Date(to); d.setDate(d.getDate() + 1); return d.toISOString().slice(0, 10) })()
const movement = useQuery({
queryKey: ['money:movement', from, toExclusive, interval],
queryFn: async () => (await api.get<MoneyMovementReport>(`/api/money/reports/movement?from=${from}&to=${toExclusive}&interval=${interval}`)).data,
enabled: !!from && !!to && to >= from,
})
return (
<ListPageShell title="Отчёт «Деньги»" description="Остатки по счетам на сейчас и движение денег за период.">
{/* Остатки по счетам */}
<section className="mb-6">
<div className="flex items-center gap-2 mb-2 text-sm font-medium text-slate-700 dark:text-slate-200"><Wallet className="w-4 h-4" /> Остатки по счетам</div>
<div className="mb-3 rounded-lg border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-4">
<div className="text-xs text-slate-500">Всего денег</div>
<div className="text-2xl font-semibold font-mono">{fmt(balances.data?.totalByCurrencySum)}</div>
</div>
<DataTable
rows={balances.data?.accounts ?? []}
isLoading={balances.isLoading}
rowKey={(r) => r.accountId}
empty="Счетов пока нет."
columns={[
{ header: 'Счёт', cell: (r) => <span className={r.isActive ? '' : 'text-slate-400'}>{r.name}</span> },
{ header: 'Тип', width: '160px', cell: (r) => ACCOUNT_KIND_LABEL[r.kind] ?? r.kind },
{ header: 'Начальный', width: '160px', className: 'text-right font-mono text-slate-500', cell: (r) => fmt(r.openingBalance) },
{ header: 'Текущий остаток', width: '180px', className: 'text-right font-mono', cell: (r) => (
<span className={r.currentBalance < 0 ? 'text-red-600' : ''}>{fmt(r.currentBalance)}</span>
)},
]}
/>
</section>
{/* Движение за период */}
<section>
<div className="flex items-center gap-2 mb-2 text-sm font-medium text-slate-700 dark:text-slate-200"><TrendingUp className="w-4 h-4" /> Движение за период</div>
<div className="flex flex-wrap gap-3 items-end mb-3">
<Field label="С" className="w-40"><DateField value={from} onChange={(iso) => setFrom(iso ?? from)} /></Field>
<Field label="По" className="w-40"><DateField value={to} onChange={(iso) => setTo(iso ?? to)} /></Field>
<Field label="Интервал" className="w-36">
<Select value={interval} onChange={(e) => setInterval((e.target as HTMLSelectElement).value)}>
<option value="day">По дням</option>
<option value="month">По месяцам</option>
</Select>
</Field>
</div>
<div className="grid grid-cols-2 gap-3 mb-3">
<div className="rounded-lg border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-3">
<div className="text-xs text-slate-500 flex items-center gap-1"><TrendingUp className="w-3.5 h-3.5 text-green-600" /> Приход</div>
<div className="text-lg font-semibold font-mono text-green-700 dark:text-green-400">{fmt(movement.data?.totalCredit)}</div>
</div>
<div className="rounded-lg border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-3">
<div className="text-xs text-slate-500 flex items-center gap-1"><TrendingDown className="w-3.5 h-3.5 text-red-600" /> Расход</div>
<div className="text-lg font-semibold font-mono text-red-700 dark:text-red-400">{fmt(movement.data?.totalDebit)}</div>
</div>
</div>
<DataTable
rows={movement.data?.series ?? []}
isLoading={movement.isLoading}
rowKey={(r) => r.date}
empty="За выбранный период движения денег не было."
columns={[
{ header: 'Дата', cell: (r) => interval === 'month' ? new Date(r.date).toLocaleDateString('ru', { month: 'long', year: 'numeric' }) : new Date(r.date).toLocaleDateString('ru') },
{ header: 'Приход', width: '180px', className: 'text-right font-mono text-green-700 dark:text-green-400', cell: (r) => fmt(r.credit) },
{ header: 'Расход', width: '180px', className: 'text-right font-mono text-red-700 dark:text-red-400', cell: (r) => fmt(r.debit) },
{ header: 'Итого', width: '180px', className: 'text-right font-mono', cell: (r) => <span className={r.balance < 0 ? 'text-red-600' : ''}>{fmt(r.balance)}</span> },
]}
/>
</section>
</ListPageShell>
)
}

View file

@ -0,0 +1,347 @@
import { useState } from 'react'
import { Plus, ArrowRightLeft, Trash2, CheckCircle2, Undo2, Tag } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal'
import { Field, TextInput, TextArea, Select, MoneyInput } from '@/components/Field'
import { DateField } from '@/components/DateField'
import { ConfirmDialog } from '@/components/ConfirmDialog'
import { useConfirm } from '@/lib/useConfirm'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '@/lib/api'
import { useCurrencies } from '@/lib/useLookups'
import { useCounterparties } from '@/lib/useLookups'
import { useFormatCurrency } from '@/lib/useFormatCurrency'
import {
MoneyTxKind, MoneyTxStatus, TX_KIND_LABEL, isIncomeKind,
useMoneyAccounts, useExpenseItems, type MoneyTxRow,
} from '@/lib/money'
const URL = '/api/money/transactions'
// Типы, создаваемые из обычной формы (перевод — отдельная кнопка).
const CREATABLE_KINDS = [MoneyTxKind.CashIn, MoneyTxKind.CashOut, MoneyTxKind.PaymentIn, MoneyTxKind.PaymentOut]
function todayIso() { return new Date().toISOString().slice(0, 10) }
interface TxForm {
id?: string
date: string
kind: number
accountId: string
counterpartyId: string
expenseItemId: string
currencyId: string
amount: number | null
purpose: string
notes: string
xmin?: number
}
export function MoneyTransactionsPage() {
const navigate = useNavigate()
const [kindFilter, setKindFilter] = useState<string>('')
const [statusFilter, setStatusFilter] = useState<string>('')
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<MoneyTxRow>(URL, {
kind: kindFilter || undefined,
status: statusFilter || undefined,
})
const { create, update, remove } = useCatalogMutations(URL, URL)
const accounts = useMoneyAccounts(true)
const expenseItems = useExpenseItems(true)
const counterparties = useCounterparties()
const currencies = useCurrencies()
const fmt = useFormatCurrency()
const qc = useQueryClient()
const { confirm, dialogProps } = useConfirm()
const [form, setForm] = useState<TxForm | null>(null)
const [transfer, setTransfer] = useState<{ date: string; fromAccountId: string; toAccountId: string; amount: number | null; purpose: string } | null>(null)
const [err, setErr] = useState<string | null>(null)
const invalidateAll = () => Promise.all([
qc.invalidateQueries({ queryKey: [URL] }),
qc.invalidateQueries({ queryKey: ['lookup:money-accounts'] }),
])
const post = useMutation({
mutationFn: async (id: string) => (await api.post(`${URL}/${id}/post`)).data,
onSuccess: invalidateAll, meta: { successMessage: 'Документ проведён' },
})
const unpost = useMutation({
mutationFn: async (id: string) => (await api.post(`${URL}/${id}/unpost`)).data,
onSuccess: invalidateAll, meta: { successMessage: 'Проведение отменено' },
})
const createTransfer = useMutation({
mutationFn: async (input: unknown) => (await api.post('/api/money/transfers', input)).data,
onSuccess: invalidateAll, meta: { successMessage: 'Перевод создан (черновик) — проведите его' },
})
const defaultCurrency = currencies.data?.[0]
const openNew = () => {
setErr(null)
setForm({
date: todayIso(), kind: MoneyTxKind.CashOut,
accountId: '', counterpartyId: '', expenseItemId: '',
currencyId: defaultCurrency?.id ?? '', amount: null, purpose: '', notes: '',
})
}
const openEdit = (r: MoneyTxRow) => {
// Проведённые / переводы редактируем не здесь — открываем read-only просмотр.
setErr(null)
setForm({
id: r.id, date: r.date.slice(0, 10), kind: r.kind,
accountId: r.accountId, counterpartyId: r.counterpartyId ?? '',
expenseItemId: r.expenseItemId ?? '', currencyId: r.currencyId,
amount: r.amount, purpose: r.purpose ?? '', notes: r.notes ?? '', xmin: r.xmin,
})
}
const isOut = (k: number) => k === MoneyTxKind.CashOut || k === MoneyTxKind.PaymentOut
const save = async () => {
if (!form) return
if (!form.accountId) { setErr('Выберите счёт'); return }
if (!form.amount || form.amount <= 0) { setErr('Сумма должна быть больше нуля'); return }
if (isOut(form.kind) && !form.expenseItemId) { setErr('У расходного документа укажите статью расхода'); return }
const payload = {
date: form.date, kind: form.kind,
accountId: form.accountId,
counterpartyId: form.counterpartyId || null,
expenseItemId: isOut(form.kind) ? (form.expenseItemId || null) : null,
currencyId: form.currencyId,
amount: form.amount,
purpose: form.purpose.trim() || null,
notes: form.notes.trim() || null,
xmin: form.xmin,
}
try {
if (form.id) await update.mutateAsync({ id: form.id, input: payload })
else await create.mutateAsync(payload)
await invalidateAll()
setForm(null)
} catch { /* тост */ }
}
const saveTransfer = async () => {
if (!transfer) return
if (!transfer.fromAccountId || !transfer.toAccountId) { setErr('Выберите счета'); return }
if (transfer.fromAccountId === transfer.toAccountId) { setErr('Счета должны отличаться'); return }
if (!transfer.amount || transfer.amount <= 0) { setErr('Сумма должна быть больше нуля'); return }
try {
await createTransfer.mutateAsync({
date: transfer.date, fromAccountId: transfer.fromAccountId,
toAccountId: transfer.toAccountId, amount: transfer.amount, purpose: transfer.purpose.trim() || null,
})
setTransfer(null); setErr(null)
} catch { /* тост */ }
}
const activeAccounts = accounts.data ?? []
return (
<>
<ListPageShell
title="Платежи и ордера"
description="Поступления и расходы деньгами — наличные ордера, безналичные платежи, переводы между счетами."
actions={
<>
<SearchBar value={search} onChange={setSearch} placeholder="По номеру или назначению…" />
<Select value={statusFilter} onChange={(e) => { setStatusFilter((e.target as HTMLSelectElement).value); setPage(1) }} className="w-36">
<option value="">Все статусы</option>
<option value={String(MoneyTxStatus.Draft)}>Черновики</option>
<option value={String(MoneyTxStatus.Posted)}>Проведённые</option>
</Select>
<Select value={kindFilter} onChange={(e) => { setKindFilter((e.target as HTMLSelectElement).value); setPage(1) }} className="w-44">
<option value="">Все типы</option>
{CREATABLE_KINDS.map((k) => <option key={k} value={k}>{TX_KIND_LABEL[k]}</option>)}
</Select>
<Button variant="secondary" onClick={() => { setErr(null); setTransfer({ date: todayIso(), fromAccountId: '', toAccountId: '', amount: null, purpose: '' }) }}>
<ArrowRightLeft className="w-4 h-4" /> Перевод
</Button>
<Button variant="secondary" onClick={() => navigate('/money/expense-items')}>
<Tag className="w-4 h-4" /> Статьи
</Button>
<Button onClick={openNew}><Plus className="w-4 h-4" /> Добавить</Button>
</>
}
footer={data && data.total > 0 && (
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)}
>
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => r.id}
onRowClick={openEdit}
empty="Документов пока нет. Добавьте платёж/ордер или сделайте перевод между счетами."
columns={[
{ header: '№', width: '150px', cell: (r) => <span className="font-mono text-slate-600">{r.number}</span> },
{ header: 'Дата', width: '110px', cell: (r) => new Date(r.date).toLocaleDateString('ru') },
{ header: 'Тип', width: '190px', cell: (r) => TX_KIND_LABEL[r.kind] ?? r.kind },
{ header: 'Счёт', cell: (r) => r.accountName },
{ header: 'Контрагент / статья', cell: (r) => r.counterpartyName ?? r.expenseItemName ?? (r.links.length ? r.links.map((l) => l.supplyNumber ?? l.supplierReturnNumber).join(', ') : '—') },
{ header: 'Статус', width: '120px', cell: (r) => (
r.status === MoneyTxStatus.Posted
? <span className="text-xs px-2 py-0.5 rounded bg-green-50 text-green-700 dark:bg-green-950/40 dark:text-green-300">Проведён</span>
: <span className="text-xs px-2 py-0.5 rounded bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300">Черновик</span>
)},
{ header: 'Сумма', width: '160px', className: 'text-right font-mono', cell: (r) => (
<span className={isIncomeKind(r.kind) ? 'text-green-700 dark:text-green-400' : 'text-red-700 dark:text-red-400'}>
{isIncomeKind(r.kind) ? '+' : '−'}{fmt(r.amount)}
</span>
)},
]}
/>
</ListPageShell>
{/* ── Форма платежа/ордера ─────────────────────────────────────── */}
<Modal
open={!!form}
onClose={() => setForm(null)}
title={form?.id ? `Документ` : 'Новый платёж / ордер'}
width="max-w-xl"
footer={form && (
<TxFooter
form={form}
posted={!!form.id && data?.items.find((x) => x.id === form.id)?.status === MoneyTxStatus.Posted}
onDelete={async () => {
if (await confirm({ title: 'Удалить документ?', confirmLabel: 'Удалить' })) {
try { await remove.mutateAsync(form.id!); await invalidateAll(); setForm(null) } catch { /* тост */ }
}
}}
onPost={async () => { try { await post.mutateAsync(form.id!); setForm(null) } catch { /* тост */ } }}
onUnpost={async () => { try { await unpost.mutateAsync(form.id!); setForm(null) } catch { /* тост */ } }}
onCancel={() => setForm(null)}
onSave={save}
/>
)}
>
{form && (() => {
const posted = !!form.id && data?.items.find((x) => x.id === form.id)?.status === MoneyTxStatus.Posted
return (
<div className="space-y-3">
{err && <p role="alert" className="text-sm text-red-600">{err}</p>}
{posted && <p className="text-xs text-slate-500 bg-slate-50 dark:bg-slate-800/40 p-2 rounded border border-slate-200 dark:border-slate-700">Документ проведён — поля заблокированы. Чтобы изменить, отмените проведение.</p>}
<div className="grid grid-cols-2 gap-3">
<Field label="Тип документа">
<Select value={String(form.kind)} disabled={posted || !!form.id}
onChange={(e) => setForm({ ...form, kind: Number((e.target as HTMLSelectElement).value) })}>
{CREATABLE_KINDS.map((k) => <option key={k} value={k}>{TX_KIND_LABEL[k]}</option>)}
</Select>
</Field>
<Field label="Дата">
<DateField value={form.date} disabled={posted}
onChange={(iso) => setForm({ ...form, date: iso ?? todayIso() })} />
</Field>
</div>
<Field label="Счёт / касса">
<Select value={form.accountId} disabled={posted}
onChange={(e) => setForm({ ...form, accountId: (e.target as HTMLSelectElement).value })}>
<option value="">— выберите —</option>
{activeAccounts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
</Select>
</Field>
<Field label="Контрагент (необязательно)">
<Select value={form.counterpartyId} disabled={posted}
onChange={(e) => setForm({ ...form, counterpartyId: (e.target as HTMLSelectElement).value })}>
<option value="">— без контрагента —</option>
{(counterparties.data ?? []).map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</Select>
</Field>
{isOut(form.kind) && (
<Field label="Статья расхода">
<Select value={form.expenseItemId} disabled={posted}
onChange={(e) => setForm({ ...form, expenseItemId: (e.target as HTMLSelectElement).value })}>
<option value="">— выберите —</option>
{(expenseItems.data ?? []).map((i) => <option key={i.id} value={i.id}>{i.name}</option>)}
</Select>
</Field>
)}
<Field label="Сумма">
<MoneyInput value={form.amount} disabled={posted}
onChange={(v) => setForm({ ...form, amount: v })}
currencyCode={currencies.data?.find((c) => c.id === form.currencyId)?.code} />
</Field>
<Field label="Назначение (необязательно)">
<TextInput value={form.purpose} disabled={posted}
onChange={(e) => setForm({ ...form, purpose: e.target.value })} />
</Field>
<Field label="Комментарий">
<TextArea value={form.notes} disabled={posted} rows={2}
onChange={(e) => setForm({ ...form, notes: e.target.value })} />
</Field>
</div>
)
})()}
</Modal>
{/* ── Перевод между счетами ────────────────────────────────────── */}
<Modal
open={!!transfer}
onClose={() => setTransfer(null)}
title="Перевод между счетами"
footer={
<>
<Button variant="secondary" onClick={() => setTransfer(null)}>Отмена</Button>
<Button onClick={saveTransfer}>Создать</Button>
</>
}
>
{transfer && (
<div className="space-y-3">
{err && <p role="alert" className="text-sm text-red-600">{err}</p>}
<p className="text-xs text-slate-500">Инкассация касса→банк, касса→сейф и т.п. Создаётся черновиком — проведите его в списке, чтобы деньги переместились.</p>
<Field label="Со счёта">
<Select value={transfer.fromAccountId} onChange={(e) => setTransfer({ ...transfer, fromAccountId: (e.target as HTMLSelectElement).value })}>
<option value="">— выберите —</option>
{activeAccounts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
</Select>
</Field>
<Field label="На счёт">
<Select value={transfer.toAccountId} onChange={(e) => setTransfer({ ...transfer, toAccountId: (e.target as HTMLSelectElement).value })}>
<option value="">— выберите —</option>
{activeAccounts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
</Select>
</Field>
<Field label="Сумма">
<MoneyInput value={transfer.amount} onChange={(v) => setTransfer({ ...transfer, amount: v })} />
</Field>
<Field label="Назначение (необязательно)">
<TextInput value={transfer.purpose} onChange={(e) => setTransfer({ ...transfer, purpose: e.target.value })} />
</Field>
</div>
)}
</Modal>
<ConfirmDialog {...dialogProps} />
</>
)
}
function TxFooter({ form, posted, onDelete, onPost, onUnpost, onCancel, onSave }: {
form: TxForm; posted: boolean
onDelete: () => void; onPost: () => void; onUnpost: () => void; onCancel: () => void; onSave: () => void
}) {
// Черновик: Удалить + Сохранить + Провести. Проведён: только «Отменить
// проведение» (поля заблокированы, перепровести можно после отмены).
return (
<>
{form.id && !posted && (
<Button variant="danger" size="sm" onClick={onDelete}><Trash2 className="w-4 h-4" /> Удалить</Button>
)}
{form.id && posted && (
<Button variant="secondary" size="sm" onClick={onUnpost}><Undo2 className="w-4 h-4" /> Отменить проведение</Button>
)}
<Button variant="secondary" onClick={onCancel}>Закрыть</Button>
{!posted && <Button onClick={onSave}>Сохранить</Button>}
{form.id && !posted && (
<Button onClick={onPost}><CheckCircle2 className="w-4 h-4" /> Провести</Button>
)}
</>
)
}

View file

@ -0,0 +1,122 @@
import { useState } from 'react'
import { Scale, Plus } from 'lucide-react'
import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable'
import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal'
import { Field, Select, MoneyInput, TextArea } from '@/components/Field'
import { DateField } from '@/components/DateField'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '@/lib/api'
import { useCounterparties } from '@/lib/useLookups'
import { useFormatCurrency } from '@/lib/useFormatCurrency'
import { AdjustmentDirection, type SupplierDebtsReport } from '@/lib/money'
function todayIso() { return new Date().toISOString().slice(0, 10) }
export function SupplierDebtsPage() {
const fmt = useFormatCurrency()
const qc = useQueryClient()
const counterparties = useCounterparties()
const { data, isLoading } = useQuery({
queryKey: ['money:supplier-debts'],
queryFn: async () => (await api.get<SupplierDebtsReport>('/api/money/reports/supplier-debts?withDebtOnly=false')).data,
})
const [adj, setAdj] = useState<{ date: string; counterpartyId: string; direction: number; amount: number | null; notes: string } | null>(null)
const [err, setErr] = useState<string | null>(null)
const createAdj = useMutation({
mutationFn: async (input: unknown) => (await api.post('/api/money/adjustments', input)).data,
meta: { successMessage: 'Корректировка создана (черновик)' },
})
const postAdj = useMutation({
mutationFn: async (id: string) => (await api.post(`/api/money/adjustments/${id}/post`)).data,
})
const submitAdj = async () => {
if (!adj) return
if (!adj.counterpartyId) { setErr('Выберите поставщика'); return }
if (!adj.amount || adj.amount <= 0) { setErr('Укажите сумму'); return }
try {
const created = await createAdj.mutateAsync({
date: adj.date, counterpartyId: adj.counterpartyId,
direction: adj.direction, amount: adj.amount, notes: adj.notes.trim() || null,
})
// Корректировка влияет на отчёт только после проведения.
if (created?.id) await postAdj.mutateAsync(created.id)
await qc.invalidateQueries({ queryKey: ['money:supplier-debts'] })
setAdj(null); setErr(null)
} catch { /* тост */ }
}
const rows = data?.rows ?? []
return (
<>
<ListPageShell
title="Долги поставщикам"
description="Кому и сколько мы должны: приёмки − возвраты − оплаты ± корректировки."
actions={
<Button variant="secondary" onClick={() => { setErr(null); setAdj({ date: todayIso(), counterpartyId: '', direction: AdjustmentDirection.WeOwe, amount: null, notes: '' }) }}>
<Plus className="w-4 h-4" /> Начальный долг / корректировка
</Button>
}
>
{rows.length > 0 && (
<div className="mb-3 rounded-lg border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-4 flex items-center gap-3">
<Scale className="w-5 h-5 text-slate-400" />
<div>
<div className="text-xs text-slate-500">Итого задолженность перед поставщиками</div>
<div className="text-xl font-semibold font-mono">{fmt(data?.totalDebt)}</div>
</div>
</div>
)}
<DataTable
rows={rows}
isLoading={isLoading}
rowKey={(r) => r.supplierId}
empty="Долгов перед поставщиками нет."
columns={[
{ header: 'Поставщик', cell: (r) => (
<span>{r.supplierName}{r.bin && <span className="text-xs text-slate-400 ml-1">БИН {r.bin}</span>}</span>
)},
{ header: 'Приёмки', width: '140px', className: 'text-right font-mono', cell: (r) => fmt(r.suppliesTotal) },
{ header: 'Возвраты', width: '130px', className: 'text-right font-mono', cell: (r) => fmt(r.returnsTotal) },
{ header: 'Оплачено', width: '140px', className: 'text-right font-mono', cell: (r) => fmt(r.paymentsOut) },
{ header: 'Самый старый неоплач.', width: '170px', cell: (r) => (
r.oldestUnpaidNumber ? <span className="text-xs"><span className="font-mono">{r.oldestUnpaidNumber}</span>{r.oldestUnpaidDate && <span className="text-slate-400"> · {new Date(r.oldestUnpaidDate).toLocaleDateString('ru')}</span>}</span> : '—'
)},
{ header: 'Долг', width: '150px', className: 'text-right font-mono', cell: (r) => (
<span className={r.debt > 0.0001 ? 'text-red-700 dark:text-red-400 font-medium' : r.debt < -0.0001 ? 'text-green-700 dark:text-green-400' : ''}>{fmt(r.debt)}</span>
)},
]}
/>
</ListPageShell>
<Modal open={!!adj} onClose={() => setAdj(null)} title="Корректировка взаиморасчётов"
footer={<><Button variant="secondary" onClick={() => setAdj(null)}>Отмена</Button><Button onClick={submitAdj}>Сохранить и провести</Button></>}>
{adj && (
<div className="space-y-3">
{err && <p role="alert" className="text-sm text-red-600">{err}</p>}
<p className="text-xs text-slate-500">Для стартовых долгов при переходе на систему или ручных исправлений. На баланс счетов не влияет — только на этот отчёт.</p>
<Field label="Поставщик">
<Select value={adj.counterpartyId} onChange={(e) => setAdj({ ...adj, counterpartyId: (e.target as HTMLSelectElement).value })}>
<option value="">— выберите —</option>
{(counterparties.data ?? []).map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</Select>
</Field>
<Field label="Направление">
<Select value={String(adj.direction)} onChange={(e) => setAdj({ ...adj, direction: Number((e.target as HTMLSelectElement).value) })}>
<option value={String(AdjustmentDirection.WeOwe)}>Мы должны (увеличить долг)</option>
<option value={String(AdjustmentDirection.TheyOwe)}>Поставщик должен (уменьшить долг)</option>
</Select>
</Field>
<Field label="Дата"><DateField value={adj.date} onChange={(iso) => setAdj({ ...adj, date: iso ?? todayIso() })} /></Field>
<Field label="Сумма"><MoneyInput value={adj.amount} onChange={(v) => setAdj({ ...adj, amount: v })} /></Field>
<Field label="Комментарий"><TextArea rows={2} value={adj.notes} onChange={(e) => setAdj({ ...adj, notes: e.target.value })} /></Field>
</div>
)}
</Modal>
</>
)
}