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
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:
parent
a25b5b3277
commit
830cd73ee0
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<Route path="/reports/stock" element={lz(StockReportPage)} />
|
||||
<Route path="/reports/profit" element={lz(ProfitReportPage)} />
|
||||
<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/new" element={<RetailSaleEditPage />} />
|
||||
<Route path="/sales/retail/:id" element={<RetailSaleEditPage />} />
|
||||
|
|
|
|||
|
|
@ -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[] = [
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
187
src/food-market.web/src/lib/money.ts
Normal file
187
src/food-market.web/src/lib/money.ts
Normal 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',
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -89,7 +89,13 @@
|
|||
"superAdminConsole": "Системная консоль",
|
||||
"logout": "Выход",
|
||||
"openMenu": "Открыть меню",
|
||||
"closeMenu": "Закрыть меню"
|
||||
"closeMenu": "Закрыть меню",
|
||||
"section_money": "Деньги",
|
||||
"moneyReport": "Отчёт «Деньги»",
|
||||
"moneyAccounts": "Счета и кассы",
|
||||
"moneyPayments": "Платежи и ордера",
|
||||
"cashShifts": "Смены",
|
||||
"supplierDebts": "Долги поставщикам"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
|
|
|
|||
|
|
@ -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<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 fractional = org.data?.allowFractionalPrices ?? false
|
||||
const moneyFmt = fractional
|
||||
|
|
@ -38,6 +41,12 @@ export function SuppliesPage() {
|
|||
actions={
|
||||
<>
|
||||
<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" />
|
||||
{/* 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
|
||||
? <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}` },
|
||||
]}
|
||||
empty="Приёмок пока нет. Создай первую — товар попадёт на склад после проведения."
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
)}
|
||||
|
||||
</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>
|
||||
|
||||
|
|
|
|||
293
src/food-market.web/src/pages/money/CashShiftsPage.tsx
Normal file
293
src/food-market.web/src/pages/money/CashShiftsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
104
src/food-market.web/src/pages/money/ExpenseItemsPage.tsx
Normal file
104
src/food-market.web/src/pages/money/ExpenseItemsPage.tsx
Normal 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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
233
src/food-market.web/src/pages/money/MoneyAccountsPage.tsx
Normal file
233
src/food-market.web/src/pages/money/MoneyAccountsPage.tsx
Normal 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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
98
src/food-market.web/src/pages/money/MoneyReportPage.tsx
Normal file
98
src/food-market.web/src/pages/money/MoneyReportPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
347
src/food-market.web/src/pages/money/MoneyTransactionsPage.tsx
Normal file
347
src/food-market.web/src/pages/money/MoneyTransactionsPage.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
122
src/food-market.web/src/pages/money/SupplierDebtsPage.tsx
Normal file
122
src/food-market.web/src/pages/money/SupplierDebtsPage.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue