From 4508be582a99ea18a9cb25206334aeb8715c2b05 Mon Sep 17 00:00:00 2001 From: Shayan Azadi Date: Fri, 3 Jul 2026 03:15:52 +0330 Subject: [PATCH] Expand sales, treasury, and parties modules with Jalali calendar and FunZone integrations Co-authored-by: Cursor --- package-lock.json | 49 ++ package.json | 6 +- src/App.tsx | 8 +- src/accounting/voucherSync.ts | 47 +- src/api/config.ts | 2 + src/api/types.ts | 17 + src/components/business/TradeModule.tsx | 109 ++- src/components/layout/Layout.tsx | 16 +- src/components/layout/Sidebar.tsx | 81 ++- src/components/layout/navigation.ts | 3 - src/components/ui/DataTable.tsx | 70 +- src/components/ui/JalaliDateInput.tsx | 61 ++ src/components/ui/index.ts | 1 + src/index.css | 15 + src/main.tsx | 2 + src/pages/Customers.tsx | 296 -------- src/pages/Dashboard.tsx | 22 +- src/pages/Inventory.tsx | 4 +- src/pages/Owners.tsx | 291 -------- src/pages/Payroll.tsx | 7 +- src/pages/Purchases.tsx | 34 +- src/pages/Sales.tsx | 31 +- src/pages/Treasury.tsx | 675 +++++++++++++++--- src/pages/Vouchers.tsx | 45 +- src/pages/purchases/PurchaseSuppliers.tsx | 14 + src/pages/purchases/PurchasesLayout.tsx | 50 ++ src/pages/sales/PartyAdjustmentsPage.tsx | 238 ++++++ src/pages/sales/PendingWithdrawalsPanel.tsx | 116 +++ src/pages/sales/SalesCustomers.tsx | 14 + src/pages/sales/SalesDocumentSection.tsx | 595 +++++++++++++++ src/pages/sales/SalesDocuments.tsx | 33 + src/pages/sales/SalesLayout.tsx | 45 ++ src/pages/sales/SalesProcess.tsx | 110 +++ src/pages/sales/SalesReview.tsx | 136 ++++ src/pages/sales/SalesTypesPage.tsx | 153 ++++ src/pages/sales/invoiceEffects.ts | 49 ++ src/pages/sales/salesNavigation.ts | 18 + src/pages/sales/salesUtils.test.ts | 284 ++++++++ src/pages/sales/salesUtils.ts | 559 +++++++++++++++ .../sales/usePendingCustomerWithdrawals.ts | 44 ++ .../treasury/funzoneTreasurySync.test.ts | 151 ++++ src/pages/treasury/funzoneTreasurySync.ts | 257 +++++++ src/pages/treasury/useFunZoneTreasurySync.ts | 140 ++++ src/parties/FunZonePartySection.tsx | 538 ++++++++++++++ src/parties/GlobalPartySync.tsx | 51 ++ src/parties/funzonePartySync.ts | 106 +++ src/parties/useFunZoneParties.ts | 107 +++ src/store/AppStore.tsx | 24 +- src/types/index.ts | 51 ++ src/utils/format.ts | 22 +- src/utils/funzoneFees.ts | 36 + src/utils/solarHijriCalendar.ts | 44 ++ tsconfig.json | 1 + tsconfig.node.tsbuildinfo | 2 +- tsconfig.tsbuildinfo | 2 +- 55 files changed, 5083 insertions(+), 799 deletions(-) create mode 100644 src/components/ui/JalaliDateInput.tsx delete mode 100644 src/pages/Customers.tsx delete mode 100644 src/pages/Owners.tsx create mode 100644 src/pages/purchases/PurchaseSuppliers.tsx create mode 100644 src/pages/purchases/PurchasesLayout.tsx create mode 100644 src/pages/sales/PartyAdjustmentsPage.tsx create mode 100644 src/pages/sales/PendingWithdrawalsPanel.tsx create mode 100644 src/pages/sales/SalesCustomers.tsx create mode 100644 src/pages/sales/SalesDocumentSection.tsx create mode 100644 src/pages/sales/SalesDocuments.tsx create mode 100644 src/pages/sales/SalesLayout.tsx create mode 100644 src/pages/sales/SalesProcess.tsx create mode 100644 src/pages/sales/SalesReview.tsx create mode 100644 src/pages/sales/SalesTypesPage.tsx create mode 100644 src/pages/sales/invoiceEffects.ts create mode 100644 src/pages/sales/salesNavigation.ts create mode 100644 src/pages/sales/salesUtils.test.ts create mode 100644 src/pages/sales/salesUtils.ts create mode 100644 src/pages/sales/usePendingCustomerWithdrawals.ts create mode 100644 src/pages/treasury/funzoneTreasurySync.test.ts create mode 100644 src/pages/treasury/funzoneTreasurySync.ts create mode 100644 src/pages/treasury/useFunZoneTreasurySync.ts create mode 100644 src/parties/FunZonePartySection.tsx create mode 100644 src/parties/GlobalPartySync.tsx create mode 100644 src/parties/funzonePartySync.ts create mode 100644 src/parties/useFunZoneParties.ts create mode 100644 src/utils/funzoneFees.ts create mode 100644 src/utils/solarHijriCalendar.ts diff --git a/package-lock.json b/package-lock.json index f764165..ea5cff6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +9,14 @@ "version": "0.1.0", "dependencies": { "caniuse-lite": "^1.0.30001799", + "jalaali-js": "^2.0.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-multi-date-picker": "^4.5.2", "react-router-dom": "^6.26.2" }, "devDependencies": { + "@types/jalaali-js": "^1.2.0", "@types/node": "^22.7.4", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", @@ -1217,6 +1220,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jalaali-js": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@types/jalaali-js/-/jalaali-js-1.2.0.tgz", + "integrity": "sha512-DRQKjbfFe0/v3siCou9eFzYjDxJItkJpxbU+/CSptvoRqq2/VmTA6y8QvqK/VELKW6xX3qb174nLZlrFV9OT3Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.20.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", @@ -1807,6 +1817,15 @@ "node": ">=0.12.0" } }, + "node_modules/jalaali-js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jalaali-js/-/jalaali-js-2.0.0.tgz", + "integrity": "sha512-HkWlwO3KxuYwERP1jsn+5M+QA+EKpIJ+zGesLee5VJNn2d2Melue0uQIvd9C/0QYFR7XVWvfF/uiNEz9Jbr9Hw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -2236,6 +2255,12 @@ "node": ">=0.10.0" } }, + "node_modules/react-date-object": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/react-date-object/-/react-date-object-2.1.9.tgz", + "integrity": "sha512-BHxD/quWOTo9fLKV/cfL/M31ePoj4a1JaJ/CnOf8Ndg3mrkh4x9wEMMkCfTrzduxDOgU8ZgR8uarhqI5G71sTg==", + "license": "MIT" + }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", @@ -2249,6 +2274,30 @@ "react": "^18.3.1" } }, + "node_modules/react-element-popper": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-element-popper/-/react-element-popper-2.1.7.tgz", + "integrity": "sha512-tuM2OxKlW32h+6uFSK6EENHPeZ2OGgOipHfOAl+VLWEv9/j3QkSGbD+ADX3A9uJlmq24i37n28RjJmAbGTfpEg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/react-multi-date-picker": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/react-multi-date-picker/-/react-multi-date-picker-4.5.2.tgz", + "integrity": "sha512-FgWjZB3Z6IA6XpcWiLPk85PwcRUhOiYhKK42o5k672gD/n2I6rzPfQ8bUrldOIiF/Z7FfOCdH7a6FeubzqteLg==", + "license": "MIT", + "dependencies": { + "react-date-object": "^2.1.8", + "react-element-popper": "^2.1.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", diff --git a/package.json b/package.json index 3fa7d4e..746e55c 100644 --- a/package.json +++ b/package.json @@ -8,15 +8,19 @@ "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", - "lint": "tsc --noEmit" + "lint": "tsc --noEmit", + "test:integration": "npx tsx src/pages/sales/salesUtils.test.ts" }, "dependencies": { "caniuse-lite": "^1.0.30001799", + "jalaali-js": "^2.0.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-multi-date-picker": "^4.5.2", "react-router-dom": "^6.26.2" }, "devDependencies": { + "@types/jalaali-js": "^1.2.0", "@types/node": "^22.7.4", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", diff --git a/src/App.tsx b/src/App.tsx index 9da3605..8e9229c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,8 +11,6 @@ import { Purchases } from './pages/Purchases' import { Inventory } from './pages/Inventory' import { Treasury } from './pages/Treasury' import { Payroll } from './pages/Payroll' -import { Owners } from './pages/Owners' -import { Customers } from './pages/Customers' import { Reports } from './pages/Reports' import { Settings } from './pages/Settings' @@ -30,13 +28,11 @@ function AppRoutes() { } /> } /> } /> - } /> - } /> + } /> + } /> } /> } /> } /> - } /> - } /> } /> } /> } /> diff --git a/src/accounting/voucherSync.ts b/src/accounting/voucherSync.ts index 2ec4468..80caafc 100644 --- a/src/accounting/voucherSync.ts +++ b/src/accounting/voucherSync.ts @@ -1,8 +1,10 @@ import { useCallback } from 'react' -import type { Voucher, VoucherAccountMap, VoucherLine } from '../types' +import type { Invoice, Party, Voucher, VoucherAccountMap, VoucherLine } from '../types' import type { ApiOwnerTransaction, ApiPaymentOrRefund } from '../api/types' import { useStore } from '../store/AppStore' import { createId, nextNumber } from '../utils/id' +import { invoiceTotals } from '../utils/accounting' +import { isWithdrawalReturn } from '../pages/sales/salesUtils' export interface SyncResult { created: number @@ -61,6 +63,7 @@ function buildOwnerVouchers( for (const txn of records) { if (txn.status !== 'completed') continue + if (txn.type === 'deposit' || txn.type === 'booking_payment') continue const amount = Math.abs(txn.amount) if (amount <= 0) continue @@ -73,13 +76,6 @@ function buildOwnerVouchers( let lines: VoucherLine[] let description: string switch (txn.type) { - case 'deposit': - lines = [ - makeLine(map.bankAccountId, amount, 0, 'واریز کیف پول مالک'), - makeLine(map.ownerPayableAccountId, 0, amount, 'بدهی به مالک'), - ] - description = 'واریز به کیف پول مالک' - break case 'withdraw': lines = [ makeLine(map.ownerPayableAccountId, amount, 0, 'تسویه با مالک'), @@ -87,13 +83,6 @@ function buildOwnerVouchers( ] description = 'برداشت مالک از کیف پول' break - case 'booking_payment': - lines = [ - makeLine(map.ownerShareAccountId, amount, 0, 'سهم مالک از رزرو'), - makeLine(map.ownerPayableAccountId, 0, amount, 'بدهی به مالک'), - ] - description = 'سهم مالک از فروش رزرو' - break case 'refund': lines = [ makeLine(map.ownerPayableAccountId, amount, 0, 'بازپرداخت به مالک'), @@ -112,11 +101,29 @@ function buildOwnerVouchers( return { vouchers, skipped } } +function walletWithdrawAlreadyBooked( + invoices: Invoice[], + parties: Party[], + customerName: string, + amount: number, +): boolean { + for (const inv of invoices) { + if (!isWithdrawalReturn(inv) || inv.status === 'draft') continue + const party = parties.find((p) => p.id === inv.partyId) + const net = invoiceTotals(inv).net + if (Math.abs(net - amount) > 0.01) continue + if (inv.note.includes(customerName) || party?.name === customerName) return true + } + return false +} + function buildCustomerVouchers( records: ApiPaymentOrRefund[], map: VoucherAccountMap, existingSources: Set, startNumber: number, + invoices: Invoice[], + parties: Party[], ): { vouchers: Voucher[]; skipped: number } { const vouchers: Voucher[] = [] let skipped = 0 @@ -152,6 +159,10 @@ function buildCustomerVouchers( description = `فروش/رزرو - ${label}` } } else if (isWallet) { + if (walletWithdrawAlreadyBooked(invoices, parties, label, amount)) { + skipped += 1 + continue + } lines = [ makeLine(map.customerPayableAccountId, amount, 0, 'برداشت کیف پول'), makeLine(map.bankAccountId, 0, amount, 'پرداخت از بانک'), @@ -210,8 +221,10 @@ export function useVoucherSync() { const syncCustomerActivity = useCallback( (records: ApiPaymentOrRefund[]): Promise => - run((map, sources, start) => buildCustomerVouchers(records, map, sources, start)), - [run], + run((map, sources, start) => + buildCustomerVouchers(records, map, sources, start, data.invoices, data.parties), + ), + [run, data.invoices, data.parties], ) return { syncOwnerTransactions, syncCustomerActivity } diff --git a/src/api/config.ts b/src/api/config.ts index 9ee1bea..848e47d 100644 --- a/src/api/config.ts +++ b/src/api/config.ts @@ -50,6 +50,8 @@ export const ACCOUNTING_ENDPOINTS = { PRODUCTS: '/accounting/products/', VOUCHERS: '/accounting/vouchers/', INVOICES: '/accounting/invoices/', + SALES_TYPES: '/accounting/sales-types/', + PARTY_ADJUSTMENTS: '/accounting/party-adjustments/', TREASURY: '/accounting/treasury/', EMPLOYEES: '/accounting/employees/', PAYSLIPS: '/accounting/payslips/', diff --git a/src/api/types.ts b/src/api/types.ts index 6480a43..60941c2 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -6,10 +6,14 @@ export interface ApiOwner { mobile_number: string | null email: string | null address: string | null + national_code: string | null balance: number credit_number: string | null is_active: boolean + is_verified?: boolean created_at: string + birthday?: string | null + avatar?: string | null social_hubs?: string[] } @@ -21,10 +25,17 @@ export interface ApiCustomer { mobile_number: string | null email: string | null address: string | null + national_code: number | string | null balance: number iban: string | null is_active: boolean + is_verified?: boolean created_at: string + birthday?: string | null + avatar?: string | null + latitude?: number | null + longitude?: number | null + favorites?: string[] } export type WalletTxnType = 'deposit' | 'withdraw' | 'booking_payment' | 'refund' @@ -69,3 +80,9 @@ export interface ApiWithdrawal { iban: string created_at: string } + +export const ownerFullName = (owner: ApiOwner): string => + `${owner.f_name ?? ''} ${owner.l_name ?? ''}`.trim() || owner.username || 'بدون نام' + +export const customerFullName = (customer: ApiCustomer): string => + `${customer.f_name ?? ''} ${customer.l_name ?? ''}`.trim() || customer.username || 'بدون نام' diff --git a/src/components/business/TradeModule.tsx b/src/components/business/TradeModule.tsx index 4d19a55..346c92f 100644 --- a/src/components/business/TradeModule.tsx +++ b/src/components/business/TradeModule.tsx @@ -8,6 +8,7 @@ import { DataTable, Field, Input, + JalaliDateInput, Modal, PageHeader, Select, @@ -16,8 +17,10 @@ import { } from '../ui' import type { Invoice, InvoiceKind, InvoiceLine, InvoiceStatus, Party } from '../../types' import { createId, nextNumber } from '../../utils/id' -import { formatDate, formatMoney, toFa } from '../../utils/format' +import { formatDate, formatMoney, todayISO, toFa } from '../../utils/format' import { invoiceTotals, lineTotals } from '../../utils/accounting' +import { customers, reconcileInvoicePaymentStatus, suppliers } from '../../pages/sales/salesUtils' +import { invoiceSideEffects, invoiceDeleteEffects } from '../../pages/sales/invoiceEffects' interface TradeConfig { kind: InvoiceKind @@ -25,6 +28,7 @@ interface TradeConfig { subtitle: string partyLabel: string partyTabLabel: string + hidePartyTab?: boolean } const statusLabels: Record = { @@ -40,22 +44,28 @@ const statusTones: Record = { } export function TradeModule({ config }: { config: TradeConfig }) { - const [tab, setTab] = useState<'invoices' | 'parties'>('invoices') + const [tab, setTab] = useState<'invoices' | 'parties'>(config.hidePartyTab ? 'invoices' : 'invoices') return (
-
- setTab('invoices')}> - فاکتورها - - setTab('parties')}> - {config.partyTabLabel} - -
+ {!config.hidePartyTab && ( +
+ setTab('invoices')}> + فاکتورها + + setTab('parties')}> + {config.partyTabLabel} + +
+ )} - {tab === 'invoices' ? : } + {tab === 'invoices' || config.hidePartyTab ? ( + + ) : ( + + )}
) } @@ -93,12 +103,14 @@ const newInvoiceLine = (): InvoiceLine => ({ }) function InvoiceSection({ config }: { config: TradeConfig }) { - const { data, upsertInvoice, removeInvoice } = useStore() + const { data, upsertInvoice, removeInvoice, upsertProduct, upsertVoucher, removeVoucher } = useStore() const [draft, setDraft] = useState(null) const [toDelete, setToDelete] = useState(null) + const [previousDraft, setPreviousDraft] = useState(null) const parties = useMemo( - () => data.parties.filter((party) => party.kind === (config.kind === 'sale' ? 'customer' : 'supplier')), + () => + (config.kind === 'sale' ? customers(data.parties) : suppliers(data.parties)).filter((p) => p.id), [data.parties, config.kind], ) const invoices = useMemo( @@ -114,9 +126,10 @@ function InvoiceSection({ config }: { config: TradeConfig }) { const createDraft = (): Invoice => ({ id: '', kind: config.kind, + documentType: 'invoice', number: nextNumber(data.invoices.filter((i) => i.kind === config.kind)), partyId: parties[0]?.id ?? '', - date: new Date().toISOString().slice(0, 10), + date: todayISO(), status: 'draft', note: '', lines: [newInvoiceLine()], @@ -143,8 +156,37 @@ function InvoiceSection({ config }: { config: TradeConfig }) { if (!draft || !draft.partyId) return const cleanLines = draft.lines.filter((line) => line.productId && line.quantity > 0) if (cleanLines.length === 0) return - upsertInvoice({ ...draft, lines: cleanLines, id: draft.id || createId('inv-') }) + + const saved: Invoice = { + ...draft, + kind: config.kind, + documentType: draft.documentType ?? 'invoice', + lines: cleanLines, + id: draft.id || createId('inv-'), + } + + const effects = invoiceSideEffects(saved, previousDraft, data) + for (const product of effects.products) { + const original = data.products.find((p) => p.id === product.id) + if (original && original.stock !== product.stock) upsertProduct(product) + } + + upsertInvoice(saved) + if (effects.removeVoucherId) removeVoucher(effects.removeVoucherId) + if (effects.voucher) upsertVoucher(effects.voucher) + + if (saved.partyId && saved.status !== 'draft') { + const dataAfterSave = { + ...data, + invoices: [...data.invoices.filter((i) => i.id !== saved.id), saved], + } + for (const inv of reconcileInvoicePaymentStatus(saved.partyId, dataAfterSave)) { + upsertInvoice(inv) + } + } + setDraft(null) + setPreviousDraft(null) } const totals = draft ? invoiceTotals(draft) : null @@ -172,7 +214,10 @@ function InvoiceSection({ config }: { config: TradeConfig }) { align: 'end', render: (i) => (
-
{parties.length === 0 ? (

- ابتدا حداقل یک {config.partyLabel} تعریف کنید. + {config.kind === 'sale' + ? 'مشتری‌ای از فان‌زون همگام نشده است. به بخش مشتریان بروید و «بروزرسانی» را بزنید.' + : 'تأمین‌کننده‌ای از فان‌زون همگام نشده است. به بخش تأمین‌کنندگان بروید و «بروزرسانی» را بزنید.'}

) : ( setDraft(null)} + onClose={() => { setDraft(null); setPreviousDraft(null) }} size="xl" footer={ <> @@ -221,7 +268,7 @@ function InvoiceSection({ config }: { config: TradeConfig }) { )} - @@ -239,7 +286,7 @@ function InvoiceSection({ config }: { config: TradeConfig }) { /> - setDraft({ ...draft, date: e.target.value })} /> + setDraft({ ...draft, date })} /> setSearch(e.target.value)} - className="mb-4 max-w-sm" - /> - {customers.loading ? ( -

در حال بارگذاری…

- ) : ( - c.id} onRowClick={(c) => setSelected(c)} emptyTitle="کاربری یافت نشد" /> - )} - - - ) -} - -function CustomerDetail({ - customer, - withdrawals, - onBack, -}: { - customer: ApiCustomer - withdrawals: ApiWithdrawal[] - onBack: () => void -}) { - const { syncCustomerActivity } = useVoucherSync() - const [syncMsg, setSyncMsg] = useState(null) - const [syncBusy, setSyncBusy] = useState(false) - - const activity = useApiResource( - () => - apiGet<{ payments_and_refunds: ApiPaymentOrRefund[] }>( - ENDPOINTS.CUSTOMER_PAYMENTS_REFUNDS(customer.id), - ).then((res) => res.payments_and_refunds ?? []), - [customer.id], - ) - - const rows = activity.data ?? [] - const customerWithdrawals = withdrawals.filter((w) => w.user_id === customer.id) - - const handleSync = async () => { - setSyncBusy(true) - try { - const res = await syncCustomerActivity(rows) - setSyncMsg(`صدور سند انجام شد: ${toFa(res.created)} سند جدید ثبت شد، ${toFa(res.skipped)} مورد قبلاً ثبت شده بود.`) - } catch (err) { - setSyncMsg(err instanceof Error ? err.message : 'خطا در صدور سند') - } finally { - setSyncBusy(false) - } - } - - const totals = useMemo(() => { - const payments = rows.filter((r) => r.type === 'payment').reduce((s, r) => s + r.amount, 0) - const refunds = rows.filter((r) => r.type === 'cancellation').reduce((s, r) => s + r.amount, 0) - return { payments, refunds } - }, [rows]) - - const columns: Array> = [ - { key: 'date', header: 'تاریخ و زمان', render: (r) => formatDateTime(r.created_at) }, - { - key: 'type', - header: 'نوع', - render: (r) => ( - - {r.type === 'payment' ? 'پرداخت' : 'لغو/بازپرداخت'} - - ), - }, - { key: 'event', header: 'رویداد/کیف پول', render: (r) => (r.event_name === 'wallet' ? 'کیف پول' : r.event_name) }, - { key: 'trace', header: 'شماره پیگیری', render: (r) => {toFa(r.trace_no) || '—'} }, - { key: 'ref', header: 'شماره مرجع', render: (r) => {toFa(r.ref_num) || '—'} }, - { - key: 'amount', - header: 'مبلغ', - align: 'end', - render: (r) => ( - - {formatMoney(r.amount)} - - ), - }, - ] - - return ( -
-
-
- -
-

{customerFullName(customer)}

-

{toFa(customer.mobile_number ?? '')}

-
-
-
- - -
-
- - {syncMsg && ( - {syncMsg} - )} - -
- - - - -
- - {activity.error && ( - {activity.error} - )} - - -

پرداخت‌ها و بازپرداخت‌ها

- {activity.loading ? ( -

در حال بارگذاری…

- ) : ( - r.id} emptyTitle="پرداختی ثبت نشده است" /> - )} -
-
- ) -} diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index af717ad..dea3806 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -16,6 +16,10 @@ interface MonthBucket { purchases: number } +function isSaleInvoice(invoice: ReturnType['data']['invoices'][number]): boolean { + return invoice.kind === 'sale' && (invoice.documentType ?? 'invoice') === 'invoice' && invoice.status !== 'draft' +} + function monthlyBuckets( invoices: ReturnType['data']['invoices'], ): MonthBucket[] { @@ -33,10 +37,10 @@ function monthlyBuckets( buckets.push({ label: formatter.format(date), sales: inMonth - .filter((i) => i.kind === 'sale') + .filter(isSaleInvoice) .reduce((sum, i) => sum + invoiceTotals(i).net, 0), purchases: inMonth - .filter((i) => i.kind === 'purchase') + .filter((i) => i.kind === 'purchase' && i.status !== 'draft') .reduce((sum, i) => sum + invoiceTotals(i).net, 0), }) } @@ -53,16 +57,18 @@ export function Dashboard() { .reduce((sum, b) => sum + b.balance, 0) const sales = data.invoices - .filter((i) => i.kind === 'sale') + .filter(isSaleInvoice) .reduce((sum, i) => sum + invoiceTotals(i).net, 0) const purchases = data.invoices - .filter((i) => i.kind === 'purchase') + .filter((i) => i.kind === 'purchase' && i.status !== 'draft') .reduce((sum, i) => sum + invoiceTotals(i).net, 0) const income = incomeStatement(balances) const sheet = balanceSheet(balances) const lowStock = data.products.filter((p) => p.stock <= p.reorderLevel) - const unpaidSales = data.invoices.filter((i) => i.kind === 'sale' && i.status !== 'paid') + const unpaidSales = data.invoices.filter( + (i) => i.kind === 'sale' && (i.documentType ?? 'invoice') === 'invoice' && i.status === 'confirmed', + ) return { liquidity, sales, purchases, income, sheet, lowStock, unpaidSales } }, [data]) @@ -88,7 +94,7 @@ export function Dashboard() { i.kind === 'sale').length)} فاکتور فروش`} + hint={`${toFa(data.invoices.filter(isSaleInvoice).length)} فاکتور فروش`} icon="🧾" tone="green" /> @@ -190,8 +196,8 @@ export function Dashboard() {

فاکتورهای فروش تسویه‌نشده

- - مشاهده فروش + + مرور فروش
{metrics.unpaidSales.length === 0 ? ( diff --git a/src/pages/Inventory.tsx b/src/pages/Inventory.tsx index 30ab1c7..49c3bcb 100644 --- a/src/pages/Inventory.tsx +++ b/src/pages/Inventory.tsx @@ -15,7 +15,7 @@ import { } from '../components/ui' import type { Product } from '../types' import { createId } from '../utils/id' -import { formatMoney, toFa } from '../utils/format' +import { formatMoney, sumAmounts, toAmount, toFa } from '../utils/format' const emptyDraft = (): Product => ({ id: '', @@ -36,7 +36,7 @@ export function Inventory() { const [toDelete, setToDelete] = useState(null) const summary = useMemo(() => { - const value = data.products.reduce((sum, p) => sum + p.stock * p.purchasePrice, 0) + const value = sumAmounts(data.products.map((p) => toAmount(p.stock) * toAmount(p.purchasePrice))) const lowStock = data.products.filter((p) => p.stock <= p.reorderLevel).length return { value, lowStock, count: data.products.length } }, [data.products]) diff --git a/src/pages/Owners.tsx b/src/pages/Owners.tsx deleted file mode 100644 index 4246a7c..0000000 --- a/src/pages/Owners.tsx +++ /dev/null @@ -1,291 +0,0 @@ -import { useMemo, useState } from 'react' -import { useConnection } from '../connection/ConnectionContext' -import { useApiResource } from '../hooks/useApiResource' -import { apiGet, asList } from '../api/client' -import { ENDPOINTS } from '../api/config' -import type { ApiOwner, ApiOwnerTransaction, ApiWithdrawal, WalletTxnType } from '../api/types' -import { - Badge, - Button, - Card, - DataTable, - Input, - PageHeader, - StatCard, - type Column, -} from '../components/ui' -import { ConnectionRequired } from '../components/business/ConnectionRequired' -import { useVoucherSync } from '../accounting/voucherSync' -import { formatDateTime, formatMoney, toFa } from '../utils/format' - -const txnTypeLabels: Record = { - deposit: 'واریز', - withdraw: 'برداشت', - booking_payment: 'پرداخت رزرو', - refund: 'بازپرداخت', -} - -const txnTypeTones: Record = { - deposit: 'green', - withdraw: 'rose', - booking_payment: 'blue', - refund: 'amber', -} - -const ownerFullName = (owner: ApiOwner): string => - `${owner.f_name ?? ''} ${owner.l_name ?? ''}`.trim() || owner.username || 'بدون نام' - -export function Owners() { - const { connected } = useConnection() - const { syncOwnerTransactions } = useVoucherSync() - const [search, setSearch] = useState('') - const [selected, setSelected] = useState(null) - const [syncBusy, setSyncBusy] = useState(false) - const [syncMsg, setSyncMsg] = useState(null) - - const owners = useApiResource( - () => apiGet(ENDPOINTS.OWNERS).then(asList), - [connected], - connected, - ) - const withdrawals = useApiResource( - () => - apiGet<{ withdrawals: ApiWithdrawal[] }>(ENDPOINTS.WITHDRAWALS).then( - (res) => res.withdrawals ?? [], - ), - [connected], - connected, - ) - - const list = owners.data ?? [] - const ownerWithdrawals = useMemo( - () => (withdrawals.data ?? []).filter((w) => w.user_type === 'owner'), - [withdrawals.data], - ) - - const summary = useMemo(() => { - const totalBalance = list.reduce((sum, o) => sum + (o.balance || 0), 0) - const pending = ownerWithdrawals.filter((w) => w.status === 'pending').length - return { count: list.length, totalBalance, pending } - }, [list, ownerWithdrawals]) - - const filtered = useMemo(() => { - const query = search.trim().toLowerCase() - if (!query) return list - return list.filter((o) => { - const name = ownerFullName(o).toLowerCase() - return ( - name.includes(query) || - (o.username ?? '').toLowerCase().includes(query) || - (o.mobile_number ?? '').includes(query) - ) - }) - }, [list, search]) - - const handleBulkSync = async () => { - setSyncBusy(true) - setSyncMsg(null) - try { - const all = await apiGet(ENDPOINTS.ALL_OWNER_TRANSACTIONS).then(asList) - const res = await syncOwnerTransactions(all) - setSyncMsg(`صدور سند انجام شد: ${toFa(res.created)} سند جدید ثبت شد، ${toFa(res.skipped)} مورد قبلاً ثبت شده بود.`) - } catch (err) { - setSyncMsg(err instanceof Error ? err.message : 'خطا در صدور سند') - } finally { - setSyncBusy(false) - } - } - - if (!connected) { - return ( -
- - -
- ) - } - - if (selected) { - return setSelected(null)} /> - } - - const columns: Array> = [ - { key: 'name', header: 'نام مالک', render: (o) => {ownerFullName(o)} }, - { key: 'username', header: 'نام کاربری', render: (o) => o.username || '—' }, - { key: 'mobile', header: 'موبایل', render: (o) => {toFa(o.mobile_number ?? '') || '—'} }, - { key: 'venues', header: 'مجموعه‌ها', render: (o) => toFa(o.social_hubs?.length ?? 0) }, - { key: 'iban', header: 'شبا', render: (o) => {o.credit_number || '—'} }, - { - key: 'balance', - header: 'موجودی کیف پول', - align: 'end', - render: (o) => {formatMoney(o.balance || 0)}, - }, - { - key: 'actions', - header: '', - align: 'end', - render: (o) => ( - - ), - }, - ] - - return ( -
- - - -
- } - /> - - {syncMsg && ( - {syncMsg} - )} - -
- - - 0 ? 'amber' : 'green'} /> -
- - {owners.error && ( - {owners.error} - )} - - - setSearch(e.target.value)} - className="mb-4 max-w-sm" - /> - {owners.loading ? ( -

در حال بارگذاری…

- ) : ( - o.id} onRowClick={(o) => setSelected(o)} emptyTitle="مالکی یافت نشد" /> - )} -
- - ) -} - -function OwnerDetail({ - owner, - withdrawals, - onBack, -}: { - owner: ApiOwner - withdrawals: ApiWithdrawal[] - onBack: () => void -}) { - const { syncOwnerTransactions } = useVoucherSync() - const [syncMsg, setSyncMsg] = useState(null) - const [syncBusy, setSyncBusy] = useState(false) - - const transactions = useApiResource( - () => apiGet(ENDPOINTS.OWNER_TRANSACTIONS(owner.id)).then(asList), - [owner.id], - ) - - const rows = transactions.data ?? [] - const ownerWithdrawals = withdrawals.filter((w) => w.user_id === owner.id) - - const handleSync = async () => { - setSyncBusy(true) - try { - const res = await syncOwnerTransactions(rows) - setSyncMsg(`صدور سند انجام شد: ${toFa(res.created)} سند جدید ثبت شد، ${toFa(res.skipped)} مورد قبلاً ثبت شده بود.`) - } catch (err) { - setSyncMsg(err instanceof Error ? err.message : 'خطا در صدور سند') - } finally { - setSyncBusy(false) - } - } - - const columns: Array> = [ - { key: 'date', header: 'تاریخ و زمان', render: (t) => formatDateTime(t.created_at) }, - { key: 'type', header: 'نوع', render: (t) => {txnTypeLabels[t.type]} }, - { - key: 'amount', - header: 'مبلغ', - align: 'end', - render: (t) => {formatMoney(t.amount)}, - }, - { - key: 'status', - header: 'وضعیت', - render: (t) => ( - - {t.status === 'completed' ? 'انجام‌شده' : t.status === 'pending' ? 'در انتظار' : 'ناموفق'} - - ), - }, - { key: 'desc', header: 'توضیحات', render: (t) => {t.description || '—'} }, - ] - - return ( -
-
-
- -
-

{ownerFullName(owner)}

-

{toFa(owner.mobile_number ?? '')}

-
-
-
- - -
-
- - {syncMsg && ( - {syncMsg} - )} - -
- - - -
- - {transactions.error && ( - {transactions.error} - )} - - -

گردش حساب کیف پول

- {transactions.loading ? ( -

در حال بارگذاری…

- ) : ( - t.id} emptyTitle="تراکنشی ثبت نشده است" /> - )} -
-
- ) -} diff --git a/src/pages/Payroll.tsx b/src/pages/Payroll.tsx index d873cb9..0703bdd 100644 --- a/src/pages/Payroll.tsx +++ b/src/pages/Payroll.tsx @@ -8,6 +8,7 @@ import { DataTable, Field, Input, + JalaliDateInput, Modal, PageHeader, Select, @@ -16,7 +17,7 @@ import { } from '../components/ui' import type { Employee, Payslip } from '../types' import { createId } from '../utils/id' -import { formatMoney, toFa } from '../utils/format' +import { formatMoney, todayISO, toFa } from '../utils/format' import { payslipNet } from '../utils/accounting' export function Payroll() { @@ -57,7 +58,7 @@ const emptyEmployee = (): Employee => ({ position: '', baseSalary: 0, insuranceNo: '', - hireDate: new Date().toISOString().slice(0, 10), + hireDate: todayISO(), active: true, }) @@ -152,7 +153,7 @@ function EmployeeSection() { setDraft({ ...draft, insuranceNo: e.target.value })} />
- setDraft({ ...draft, hireDate: e.target.value })} /> + setDraft({ ...draft, hireDate })} /> setDraft({ ...draft, date: e.target.value })} /> - - - - - - - - - setDraft({ ...draft, amount: Number(e.target.value) || 0 })} /> - - - setDraft({ ...draft, reference: e.target.value })} /> - -
- -