From 830cd73ee00e565fc44bfff342d23754da763226 Mon Sep 17 00:00:00 2001 From: nns Date: Wed, 7 Oct 2026 21:59:32 +0500 Subject: [PATCH] =?UTF-8?q?feat(money/web):=20UI=20=D1=80=D0=B0=D0=B7?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D0=B0=20=C2=AB=D0=94=D0=B5=D0=BD=D1=8C=D0=B3?= =?UTF-8?q?=D0=B8=C2=BB=20=E2=80=94=20=D1=81=D1=87=D0=B5=D1=82=D0=B0,=20?= =?UTF-8?q?=D0=BF=D0=BB=D0=B0=D1=82=D0=B5=D0=B6=D0=B8,=20=D1=81=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B,=20=D0=B4=D0=BE=D0=BB=D0=B3=D0=B8,=20=D0=BE?= =?UTF-8?q?=D1=82=D1=87=D1=91=D1=82=20(=D0=BF.7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Раздел «Деньги» в 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 --- docs/money-module-progress.md | 7 +- src/food-market.web/src/App.tsx | 15 + .../src/components/AppLayout.tsx | 18 + .../components/money/SupplyPaymentSection.tsx | 106 ++++++ src/food-market.web/src/lib/money.ts | 187 ++++++++++ src/food-market.web/src/lib/types.ts | 5 + src/food-market.web/src/locales/en.json | 8 +- src/food-market.web/src/locales/ru.json | 8 +- .../src/pages/SuppliesPage.tsx | 18 +- .../src/pages/SupplyEditPage.tsx | 16 + .../src/pages/money/CashShiftsPage.tsx | 293 +++++++++++++++ .../src/pages/money/ExpenseItemsPage.tsx | 104 ++++++ .../src/pages/money/MoneyAccountsPage.tsx | 233 ++++++++++++ .../src/pages/money/MoneyReportPage.tsx | 98 +++++ .../src/pages/money/MoneyTransactionsPage.tsx | 347 ++++++++++++++++++ .../src/pages/money/SupplierDebtsPage.tsx | 122 ++++++ 16 files changed, 1579 insertions(+), 6 deletions(-) create mode 100644 src/food-market.web/src/components/money/SupplyPaymentSection.tsx create mode 100644 src/food-market.web/src/lib/money.ts create mode 100644 src/food-market.web/src/pages/money/CashShiftsPage.tsx create mode 100644 src/food-market.web/src/pages/money/ExpenseItemsPage.tsx create mode 100644 src/food-market.web/src/pages/money/MoneyAccountsPage.tsx create mode 100644 src/food-market.web/src/pages/money/MoneyReportPage.tsx create mode 100644 src/food-market.web/src/pages/money/MoneyTransactionsPage.tsx create mode 100644 src/food-market.web/src/pages/money/SupplierDebtsPage.tsx diff --git a/docs/money-module-progress.md b/docs/money-module-progress.md index 62ac3d2..de80ab5 100644 --- a/docs/money-module-progress.md +++ b/docs/money-module-progress.md @@ -27,8 +27,11 @@ в `/shifts/{id}/report`, проверка в `RetailSalesController.PostCoreAsync` - [x] **6. Отчёт «Деньги»** (остатки по счетам, движение за период, инвариант остатков) — `MoneyReportsController.Balances`/`Movement` -- [ ] **7. UI и меню** (раздел «Деньги», блок оплаты в приёмке, - mobile-first, права кассир/кладовщик/админ) +- [x] **7. UI и меню** (раздел «Деньги»: Отчёт/Счета/Платежи/Смены/Долги; + блок оплаты в приёмке `SupplyPaymentSection`; колонка+фильтр оплаты в списке + приёмок; mobile-first через ListPageShell/Modal; меню: админ — всё, кассир — + только смены, кладовщик — ничего (оплаченность видит в приёмке). + `src/pages/money/*`, `src/lib/money.ts`, роуты в App.tsx, i18n ru/en) - [ ] **8. Тесты** (unit + integration на реальном Postgres + e2e на стенде изнутри сети + меню-прогон watchdog step 9 зелёный) - [ ] **9. Деплой + документация** (`~/deploy-stage.sh`, `docs/money.md`) diff --git a/src/food-market.web/src/App.tsx b/src/food-market.web/src/App.tsx index f749577..1084c27 100644 --- a/src/food-market.web/src/App.tsx +++ b/src/food-market.web/src/App.tsx @@ -78,6 +78,13 @@ const PriceTypesPage = lazy(() => import('@/pages/PriceTypesPage').then(m => ({ const StoresPage = lazy(() => import('@/pages/StoresPage').then(m => ({ default: m.StoresPage }))) const RetailPointsPage = lazy(() => import('@/pages/RetailPointsPage').then(m => ({ default: m.RetailPointsPage }))) 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 консоль — почти всегда редко открываемая (доступна только // супер-админу платформы, обычные владельцы её никогда не видят). @@ -196,6 +203,14 @@ export default function App() { {lz(AbcReportPage)}} /> + {/* Sprint 33: раздел «Деньги» (простой режим, без AdvancedModeGuard). + * Смены — админ + кассир; остальное — админ (MoneyEdit). */} + {lz(MoneyAccountsPage)}} /> + {lz(ExpenseItemsPage)}} /> + {lz(MoneyTransactionsPage)}} /> + {lz(CashShiftsPage)}} /> + {lz(SupplierDebtsPage)}} /> + {lz(MoneyReportPage)}} /> } /> } /> } /> diff --git a/src/food-market.web/src/components/AppLayout.tsx b/src/food-market.web/src/components/AppLayout.tsx index 41e3700..ead9667 100644 --- a/src/food-market.web/src/components/AppLayout.tsx +++ b/src/food-market.web/src/components/AppLayout.tsx @@ -9,6 +9,7 @@ import { 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, Camera, Smartphone, Stethoscope, + Wallet, Landmark, Receipt, Scale, DoorOpen, } from 'lucide-react' import { useTranslation } from 'react-i18next' 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 — только расширенный режим. if (isAdmin || isStorekeeper) { const reports: NavItem[] = [ diff --git a/src/food-market.web/src/components/money/SupplyPaymentSection.tsx b/src/food-market.web/src/components/money/SupplyPaymentSection.tsx new file mode 100644 index 0000000..284e8e3 --- /dev/null +++ b/src/food-market.web/src/components/money/SupplyPaymentSection.tsx @@ -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(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 ( +
+
+

Оплата

+ {PAYMENT_STATUS_LABEL[paymentStatus] ?? paymentStatus} +
+
+
+
Сумма приёмки
{fmt(total)}
+
Оплачено
{fmt(payedSum)}
+
Остаток
{fmt(remaining)}
+
+ {remaining > 0.0001 && ( + + )} +
+
+
+ + setPay(null)} title="Оплата поставщику" + footer={<>}> + {pay && ( +
+ {err &&

{err}

} + + + + + setPay({ ...pay, amount: v })} /> + +

Остаток к оплате: {fmt(remaining)}. Переплата больше суммы приёмки не допускается.

+
+ )} +
+
+ ) +} diff --git a/src/food-market.web/src/lib/money.ts b/src/food-market.web/src/lib/money.ts new file mode 100644 index 0000000..043b884 --- /dev/null +++ b/src/food-market.web/src/lib/money.ts @@ -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 = { + [MoneyAccountKind.Cash]: 'Касса', + [MoneyAccountKind.Safe]: 'Сейф', + [MoneyAccountKind.Bank]: 'Банковский счёт', + [MoneyAccountKind.Acquiring]: 'Эквайринг', +} + +export const TX_KIND_LABEL: Record = { + [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 = { + unpaid: 'Не оплачена', + partial: 'Частично', + paid: 'Оплачена', +} +/** Tailwind-классы бейджа статуса оплаты приёмки. */ +export const PAYMENT_STATUS_BADGE: Record = { + 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>(`/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>(`/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>('/api/catalog/retail-points?pageSize=100')).data.items, + staleTime: 0, + refetchOnMount: 'always', + }) +} diff --git a/src/food-market.web/src/lib/types.ts b/src/food-market.web/src/lib/types.ts index 6c5cfa8..122ae70 100644 --- a/src/food-market.web/src/lib/types.ts +++ b/src/food-market.web/src/lib/types.ts @@ -93,6 +93,9 @@ export interface SupplyListRow { storeId: string; storeName: string; currencyId: string; currencyCode: string; total: number; lineCount: number; postedAt: string | null; + /** Sprint 33 (модуль «Деньги»): сколько оплачено и агрегатный статус + * оплаты ("unpaid" | "partial" | "paid"). */ + payedSum: number; paymentStatus: string; } export interface SupplyLineDto { @@ -112,6 +115,8 @@ export interface SupplyDto { notes: string | null; total: number; postedAt: string | null; lines: SupplyLineDto[]; + /** Sprint 33: оплаченная сумма + агрегатный статус оплаты приёмки. */ + payedSum: number; paymentStatus: string; } export const EnterStatus = { Draft: 0, Posted: 1 } as const diff --git a/src/food-market.web/src/locales/en.json b/src/food-market.web/src/locales/en.json index bae5942..c37c07b 100644 --- a/src/food-market.web/src/locales/en.json +++ b/src/food-market.web/src/locales/en.json @@ -89,7 +89,13 @@ "superAdminConsole": "System console", "logout": "Sign out", "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": { "title": "Dashboard", diff --git a/src/food-market.web/src/locales/ru.json b/src/food-market.web/src/locales/ru.json index fb77c59..8412964 100644 --- a/src/food-market.web/src/locales/ru.json +++ b/src/food-market.web/src/locales/ru.json @@ -89,7 +89,13 @@ "superAdminConsole": "Системная консоль", "logout": "Выход", "openMenu": "Открыть меню", - "closeMenu": "Закрыть меню" + "closeMenu": "Закрыть меню", + "section_money": "Деньги", + "moneyReport": "Отчёт «Деньги»", + "moneyAccounts": "Счета и кассы", + "moneyPayments": "Платежи и ордера", + "cashShifts": "Смены", + "supplierDebts": "Долги поставщикам" }, "dashboard": { "title": "Dashboard", diff --git a/src/food-market.web/src/pages/SuppliesPage.tsx b/src/food-market.web/src/pages/SuppliesPage.tsx index 6bca660..b42f525 100644 --- a/src/food-market.web/src/pages/SuppliesPage.tsx +++ b/src/food-market.web/src/pages/SuppliesPage.tsx @@ -1,4 +1,4 @@ -import { useRef } from 'react' +import { useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' import { PackagePlus, Pencil, Camera, ScanLine } from 'lucide-react' import { ListPageShell } from '@/components/ListPageShell' @@ -7,17 +7,20 @@ import { DataTable } from '@/components/DataTable' import { EmptyState } from '@/components/EmptyState' import { Pagination } from '@/components/Pagination' import { SearchBar } from '@/components/SearchBar' +import { Select } from '@/components/Field' import { SplitButtonMenu } from '@/components/SplitButtonMenu' import { useCatalogList } from '@/lib/useCatalog' import { useShortcuts } from '@/lib/useShortcuts' import { useOrgSettings } from '@/lib/useOrgSettings' +import { PAYMENT_STATUS_LABEL, PAYMENT_STATUS_BADGE } from '@/lib/money' import { type SupplyListRow, SupplyStatus } from '@/lib/types' const URL = '/api/purchases/supplies' export function SuppliesPage() { const navigate = useNavigate() - const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList(URL) + const [paymentStatus, setPaymentStatus] = useState('') + const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList(URL, { paymentStatus: paymentStatus || undefined }) const org = useOrgSettings() const fractional = org.data?.allowFractionalPrices ?? false const moneyFmt = fractional @@ -38,6 +41,12 @@ export function SuppliesPage() { actions={ <> + {/* Sprint 31: три входа в создание приёмки — ручной, по фото накладной, сканированием штрихкодов. Ручной — старое поведение; фото и скан — @@ -88,6 +97,11 @@ export function SuppliesPage() { { header: 'Поставщик', sortKey: 'supplier', cell: (r) => r.supplierName }, { header: 'Склад', width: '180px', sortKey: 'store', cell: (r) => r.storeName }, { header: 'Позиций', width: '100px', className: 'text-right', cell: (r) => r.lineCount }, + { header: 'Оплата', width: '120px', cell: (r) => ( + r.status === SupplyStatus.Posted + ? {PAYMENT_STATUS_LABEL[r.paymentStatus] ?? r.paymentStatus} + : — + )}, { header: 'Сумма', width: '160px', className: 'text-right font-mono', sortKey: 'total', cell: (r) => `${r.total.toLocaleString('ru', moneyFmt)} ${r.currencyCode}` }, ]} empty="Приёмок пока нет. Создай первую — товар попадёт на склад после проведения." diff --git a/src/food-market.web/src/pages/SupplyEditPage.tsx b/src/food-market.web/src/pages/SupplyEditPage.tsx index f495dba..c2528bb 100644 --- a/src/food-market.web/src/pages/SupplyEditPage.tsx +++ b/src/food-market.web/src/pages/SupplyEditPage.tsx @@ -11,6 +11,7 @@ import { SupplyLineQuickAdd, type AddedProduct } from '@/components/SupplyLineQu import { ConfirmDialog } from '@/components/ConfirmDialog' import { FormSkeleton } from '@/components/Skeleton' import { Breadcrumbs } from '@/components/Breadcrumbs' +import { SupplyPaymentSection } from '@/components/money/SupplyPaymentSection' import { useConfirm } from '@/lib/useConfirm' import { useShortcuts } from '@/lib/useShortcuts' import { useStores, useCurrencies, usePriceTypes } from '@/lib/useLookups' @@ -521,6 +522,21 @@ export function SupplyEditPage() { )} + + {/* Блок оплаты — только у проведённой приёмки. Кладовщик видит + * статус/суммы, оплату проводит тот, у кого есть право MoneyEdit + * (кнопка «Оплатить» для него вернёт 403 — скрывать на фронте не + * обязательно, но спокойнее: показываем всегда, бэкенд решает). */} + {isPosted && existing.data && ( + existing.refetch()} + /> + )} diff --git a/src/food-market.web/src/pages/money/CashShiftsPage.tsx b/src/food-market.web/src/pages/money/CashShiftsPage.tsx new file mode 100644 index 0000000..a8ee3a6 --- /dev/null +++ b/src/food-market.web/src/pages/money/CashShiftsPage.tsx @@ -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(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(null) + const [err, setErr] = useState(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 ( + <> + { setErr(null); setOpenForm({ retailPointId: retailPoints.data?.[0]?.id ?? '', cashAccountId: cashAccounts[0]?.id ?? '', acquiringAccountId: '', openingFloat: 0 }) }}> + Открыть смену + + } + footer={data && data.total > 0 && ( + + )} + > + r.id} + onRowClick={(r) => setDetailId(r.id)} + empty="Смен пока нет. Откройте смену, чтобы начать продажи на точке." + columns={[ + { header: '№', width: '140px', cell: (r) => {r.number} }, + { 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 + ? Открыта + : Закрыта + )}, + { 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 + ? 0 ? 'text-amber-600' : 'text-green-700'}>{fmt(r.cashDifference)} + : '—' + )}, + ]} + /> + + + {/* Открыть смену */} + setOpenForm(null)} title="Открыть смену" + footer={<>}> + {openForm && ( +
+ {err &&

{err}

} + + + + + + {cashAccounts.length === 0 && Нет ни одной кассы типа «Касса» — создайте её в разделе «Счета и кассы».} + + + + + + setOpenForm({ ...openForm, openingFloat: v })} /> + +
+ )} +
+ + {detailId && 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(`${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(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 ( + <> + + {isOpen && <> + + + + } + {!isOpen && } + + + )}> + {s ? ( +
+
+
Точка
{s.retailPointName}
+
Касса
{s.cashAccountName}
+
Открыта
{new Date(s.openedAt).toLocaleString('ru')}
+ {s.closedAt && <>
Закрыта
{new Date(s.closedAt).toLocaleString('ru')}
} +
Разменник
{fmt(s.openingFloat)}
+
+ {!isOpen && ( +
+ + + + + + + + + + + +
Продажи наличными{fmt(s.salesCash)}
Продажи картой{fmt(s.salesCard)}
Возвраты наличными−{fmt(s.returnsCash)}
Внесения{fmt(s.drawerIn)}
Выплаты−{fmt(s.drawerOut)}
Ожидается наличных{fmt(s.expectedCash)}
По факту{fmt(s.actualCash)}
Разница 0 ? 'text-amber-600' : 'text-green-700'}>{fmt(s.cashDifference)}
+
+ )} + {(data?.drawerOperations.length ?? 0) > 0 && ( +
+
Внесения / выплаты
+
    + {data!.drawerOperations.map((o) => ( +
  • + {o.direction === DrawerDirection.In ? '➕' : '➖'} {o.reason} + {fmt(o.amount)} +
  • + ))} +
+
+ )} +
+ ) :

Загрузка…

} +
+ + {/* Внесение / выплата */} + setDrawer(null)} title={drawer?.direction === DrawerDirection.In ? 'Внести деньги' : 'Выплатить деньги'} + footer={<>}> + {drawer && ( +
+ {err &&

{err}

} + setDrawer({ ...drawer, amount: v })} /> + setDrawer({ ...drawer, reason: e.target.value })} /> +
+ )} +
+ + {/* Закрытие смены */} + setClosing(null)} title="Закрыть смену" + footer={<>}> + {closing && s && ( +
+ {err &&

{err}

} +

Пересчитайте наличные в ящике и введите фактическую сумму. Система сравнит с ожидаемой и подсветит разницу.

+ setClosing({ ...closing, actualCash: v })} /> +