Unify FunZone voucher sync on the accounting documents page.
Consolidate sync buttons into one flow on ????? ????????, deduplicate treasury vs invoice and customer vouchers, add FunZone inventory lifecycle views, and improve stat card number display. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,50 +1,36 @@
|
|||||||
import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from 'react'
|
import { useCallback, useRef, useState } from 'react'
|
||||||
import { apiGet } from '../../api/client'
|
import { apiGet } from '../api/client'
|
||||||
import { ENDPOINTS } from '../../api/config'
|
import { ENDPOINTS } from '../api/config'
|
||||||
import type { ApiPaymentOrRefund } from '../../api/types'
|
import type { ApiPaymentOrRefund } from '../api/types'
|
||||||
import type { AppData, Invoice } from '../../types'
|
import type { AppData, Invoice } from '../types'
|
||||||
import { useStore } from '../../store/AppStore'
|
import { useStore } from '../store/AppStore'
|
||||||
import { useFunZoneTreasurySync } from '../treasury/useFunZoneTreasurySync'
|
import { useFunZoneTreasurySync } from '../pages/treasury/useFunZoneTreasurySync'
|
||||||
import { invoiceSideEffects } from './invoiceEffects'
|
import { invoiceSideEffects } from '../pages/sales/invoiceEffects'
|
||||||
import { reconcileInvoicePaymentStatus } from './salesUtils'
|
import { reconcileInvoicePaymentStatus } from '../pages/sales/salesUtils'
|
||||||
import { assignInvoiceIds, buildFunZoneSalesSync, invoiceUnchanged } from './funzoneSalesSync'
|
import { assignInvoiceIds, buildFunZoneSalesSync, invoiceUnchanged } from '../pages/sales/funzoneSalesSync'
|
||||||
|
import { supersededVoucherIds } from './voucherDedupe'
|
||||||
|
|
||||||
export interface FunZoneSalesSyncStats {
|
export interface UnifiedFunZoneSyncStats {
|
||||||
treasuryCreated: number
|
treasuryCreated: number
|
||||||
treasuryUpdated: number
|
treasuryUpdated: number
|
||||||
treasurySkipped: number
|
treasurySkipped: number
|
||||||
|
treasuryRemoved: number
|
||||||
invoicesCreated: number
|
invoicesCreated: number
|
||||||
invoicesUpdated: number
|
invoicesUpdated: number
|
||||||
invoicesSkipped: number
|
invoicesSkipped: number
|
||||||
|
duplicatesRemoved: number
|
||||||
unmatchedCustomers: number
|
unmatchedCustomers: number
|
||||||
|
unmatchedOwners: number
|
||||||
funzonePayments: number
|
funzonePayments: number
|
||||||
funzoneCancellations: number
|
funzoneCancellations: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SalesSyncContextValue {
|
export function useUnifiedFunZoneVoucherSync() {
|
||||||
loading: boolean
|
|
||||||
error: string | null
|
|
||||||
stats: FunZoneSalesSyncStats | null
|
|
||||||
lastSyncedAt: string | null
|
|
||||||
sync: () => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
const SalesSyncContext = createContext<SalesSyncContextValue | null>(null)
|
|
||||||
|
|
||||||
export function useSalesFunZoneSync(): SalesSyncContextValue {
|
|
||||||
const ctx = useContext(SalesSyncContext)
|
|
||||||
if (!ctx) {
|
|
||||||
throw new Error('useSalesFunZoneSync must be used within SalesSyncProvider')
|
|
||||||
}
|
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SalesSyncProvider({ children }: { children: ReactNode }) {
|
|
||||||
const { data, upsertInvoice, upsertVoucher, removeVoucher, reload } = useStore()
|
const { data, upsertInvoice, upsertVoucher, removeVoucher, reload } = useStore()
|
||||||
const treasury = useFunZoneTreasurySync()
|
const treasury = useFunZoneTreasurySync()
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [stats, setStats] = useState<FunZoneSalesSyncStats | null>(null)
|
const [stats, setStats] = useState<UnifiedFunZoneSyncStats | null>(null)
|
||||||
const [lastSyncedAt, setLastSyncedAt] = useState<string | null>(null)
|
const [lastSyncedAt, setLastSyncedAt] = useState<string | null>(null)
|
||||||
const syncingRef = useRef(false)
|
const syncingRef = useRef(false)
|
||||||
const dataRef = useRef(data)
|
const dataRef = useRef(data)
|
||||||
@@ -90,25 +76,6 @@ export function SalesSyncProvider({ children }: { children: ReactNode }) {
|
|||||||
[upsertInvoice, upsertVoucher, removeVoucher],
|
[upsertInvoice, upsertVoucher, removeVoucher],
|
||||||
)
|
)
|
||||||
|
|
||||||
const persistSalesBatch = useCallback(
|
|
||||||
(baseData: AppData, items: Invoice[]) => {
|
|
||||||
const saved = assignInvoiceIds(items)
|
|
||||||
let created = 0
|
|
||||||
let updated = 0
|
|
||||||
let working = { ...baseData }
|
|
||||||
|
|
||||||
for (const invoice of saved) {
|
|
||||||
const isNew = !baseData.invoices.some((i) => i.id === invoice.id)
|
|
||||||
if (isNew) created += 1
|
|
||||||
else updated += 1
|
|
||||||
working = applyInvoiceIfChanged(working, invoice)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { created, updated }
|
|
||||||
},
|
|
||||||
[applyInvoiceIfChanged],
|
|
||||||
)
|
|
||||||
|
|
||||||
const syncSalesInvoices = useCallback(async () => {
|
const syncSalesInvoices = useCallback(async () => {
|
||||||
const current = dataRef.current
|
const current = dataRef.current
|
||||||
const customerPayments = await apiGet<{ payments_and_refunds: ApiPaymentOrRefund[] }>(
|
const customerPayments = await apiGet<{ payments_and_refunds: ApiPaymentOrRefund[] }>(
|
||||||
@@ -118,7 +85,17 @@ export function SalesSyncProvider({ children }: { children: ReactNode }) {
|
|||||||
const { toUpsert, skipped, unmatchedCustomers, paymentCount, cancellationCount } =
|
const { toUpsert, skipped, unmatchedCustomers, paymentCount, cancellationCount } =
|
||||||
buildFunZoneSalesSync(current, customerPayments)
|
buildFunZoneSalesSync(current, customerPayments)
|
||||||
|
|
||||||
const { created, updated } = persistSalesBatch(current, toUpsert)
|
const saved = assignInvoiceIds(toUpsert)
|
||||||
|
let created = 0
|
||||||
|
let updated = 0
|
||||||
|
let working = { ...current }
|
||||||
|
|
||||||
|
for (const invoice of saved) {
|
||||||
|
const isNew = !current.invoices.some((i) => i.id === invoice.id)
|
||||||
|
if (isNew) created += 1
|
||||||
|
else updated += 1
|
||||||
|
working = applyInvoiceIfChanged(working, invoice)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
invoicesCreated: created,
|
invoicesCreated: created,
|
||||||
@@ -128,7 +105,7 @@ export function SalesSyncProvider({ children }: { children: ReactNode }) {
|
|||||||
funzonePayments: paymentCount,
|
funzonePayments: paymentCount,
|
||||||
funzoneCancellations: cancellationCount,
|
funzoneCancellations: cancellationCount,
|
||||||
}
|
}
|
||||||
}, [persistSalesBatch])
|
}, [applyInvoiceIfChanged])
|
||||||
|
|
||||||
const sync = useCallback(async () => {
|
const sync = useCallback(async () => {
|
||||||
if (syncingRef.current) return
|
if (syncingRef.current) return
|
||||||
@@ -137,27 +114,36 @@ export function SalesSyncProvider({ children }: { children: ReactNode }) {
|
|||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const treasuryStats = await treasury.sync()
|
const treasuryStats = await treasury.sync()
|
||||||
|
const freshAfterTreasury = await reload()
|
||||||
|
if (freshAfterTreasury) dataRef.current = freshAfterTreasury
|
||||||
|
|
||||||
const salesResult = await syncSalesInvoices()
|
const salesResult = await syncSalesInvoices()
|
||||||
|
|
||||||
|
const freshAfterSales = await reload()
|
||||||
|
const working = freshAfterSales ?? dataRef.current
|
||||||
|
const duplicateIds = supersededVoucherIds(working)
|
||||||
|
for (const voucherId of duplicateIds) {
|
||||||
|
removeVoucher(voucherId)
|
||||||
|
}
|
||||||
|
await reload()
|
||||||
|
|
||||||
setStats({
|
setStats({
|
||||||
treasuryCreated: treasuryStats?.created ?? 0,
|
treasuryCreated: treasuryStats?.created ?? 0,
|
||||||
treasuryUpdated: treasuryStats?.updated ?? 0,
|
treasuryUpdated: treasuryStats?.updated ?? 0,
|
||||||
treasurySkipped: treasuryStats?.skipped ?? 0,
|
treasurySkipped: treasuryStats?.skipped ?? 0,
|
||||||
|
treasuryRemoved: treasuryStats?.removed ?? 0,
|
||||||
...salesResult,
|
...salesResult,
|
||||||
|
duplicatesRemoved: duplicateIds.length,
|
||||||
|
unmatchedOwners: treasuryStats?.unmatchedOwners ?? 0,
|
||||||
})
|
})
|
||||||
setLastSyncedAt(new Date().toISOString())
|
setLastSyncedAt(new Date().toISOString())
|
||||||
reload()
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'خطا در همگامسازی فروش با فانزون')
|
setError(err instanceof Error ? err.message : 'خطا در همگامسازی اسناد از فانزون')
|
||||||
} finally {
|
} finally {
|
||||||
syncingRef.current = false
|
syncingRef.current = false
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [treasury, syncSalesInvoices, reload])
|
}, [treasury, syncSalesInvoices, reload, removeVoucher])
|
||||||
|
|
||||||
return (
|
return { sync, loading, error, stats, lastSyncedAt }
|
||||||
<SalesSyncContext.Provider value={{ loading, error, stats, lastSyncedAt, sync }}>
|
|
||||||
{children}
|
|
||||||
</SalesSyncContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
56
src/accounting/voucherDedupe.ts
Normal file
56
src/accounting/voucherDedupe.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import type { AppData, Voucher } from '../types'
|
||||||
|
import {
|
||||||
|
treasurySourceCustomerPayment,
|
||||||
|
treasurySourceOwnerTxn,
|
||||||
|
treasurySourceWalletPayment,
|
||||||
|
} from '../pages/treasury/funzoneTreasurySync'
|
||||||
|
import { duplicateFunzoneInvoiceVoucherIds } from '../pages/sales/funzoneVoucherLink'
|
||||||
|
import { voucherSourceForTreasury } from '../pages/sales/salesUtils'
|
||||||
|
|
||||||
|
function treasuryVoucherForPaymentSource(
|
||||||
|
data: Pick<AppData, 'treasury' | 'vouchers'>,
|
||||||
|
paymentSource: string,
|
||||||
|
): Voucher | undefined {
|
||||||
|
const txn = data.treasury.find((t) => t.source === paymentSource)
|
||||||
|
if (!txn) return undefined
|
||||||
|
return data.vouchers.find((v) => v.source === voucherSourceForTreasury(txn.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Voucher ids superseded by a more complete treasury or invoice-linked record. */
|
||||||
|
export function supersededVoucherIds(data: AppData): string[] {
|
||||||
|
const remove = new Set<string>(duplicateFunzoneInvoiceVoucherIds(data))
|
||||||
|
|
||||||
|
for (const voucher of data.vouchers) {
|
||||||
|
const custMatch = voucher.source?.match(/^funzone:cust:([^:]+)$/)
|
||||||
|
if (custMatch) {
|
||||||
|
const paymentId = custMatch[1]
|
||||||
|
const treasuryVoucher =
|
||||||
|
treasuryVoucherForPaymentSource(data, treasurySourceCustomerPayment(paymentId)) ??
|
||||||
|
treasuryVoucherForPaymentSource(data, treasurySourceWalletPayment(paymentId))
|
||||||
|
if (treasuryVoucher && treasuryVoucher.id !== voucher.id) {
|
||||||
|
remove.add(voucher.id)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownerMatch = voucher.source?.match(/^funzone:owner-txn:([^:]+)$/)
|
||||||
|
if (ownerMatch) {
|
||||||
|
const txnId = ownerMatch[1]
|
||||||
|
const treasuryVoucher = treasuryVoucherForPaymentSource(data, treasurySourceOwnerTxn(txnId))
|
||||||
|
if (treasuryVoucher && treasuryVoucher.id !== voucher.id) {
|
||||||
|
remove.add(voucher.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...remove]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSupersededVoucher(voucher: Voucher, data: AppData): boolean {
|
||||||
|
return supersededVoucherIds(data).includes(voucher.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function visibleVouchers(vouchers: Voucher[], data: AppData): Voucher[] {
|
||||||
|
const hidden = new Set(supersededVoucherIds(data))
|
||||||
|
return vouchers.filter((v) => !hidden.has(v.id))
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import type { Invoice, Party, TreasuryTxn, Voucher, VoucherAccountMap, VoucherLine } from '../types'
|
import type { Invoice, Party, TreasuryTxn, Voucher, VoucherAccountMap, VoucherLine } from '../types'
|
||||||
import { treasurySourceOwnerTxn } from '../pages/treasury/funzoneTreasurySync'
|
import {
|
||||||
|
treasurySourceCustomerPayment,
|
||||||
|
treasurySourceOwnerTxn,
|
||||||
|
treasurySourceWalletPayment,
|
||||||
|
} from '../pages/treasury/funzoneTreasurySync'
|
||||||
import { voucherSourceForTreasury } from '../pages/sales/salesUtils'
|
import { voucherSourceForTreasury } from '../pages/sales/salesUtils'
|
||||||
import type { ApiOwnerTransaction, ApiPaymentOrRefund } from '../api/types'
|
import type { ApiOwnerTransaction, ApiPaymentOrRefund } from '../api/types'
|
||||||
import { useStore } from '../store/AppStore'
|
import { useStore } from '../store/AppStore'
|
||||||
@@ -66,6 +70,23 @@ function ownerTxnAlreadyBookedViaTreasury(
|
|||||||
return vouchers.some((v) => v.source === voucherSourceForTreasury(treasuryTxn.id))
|
return vouchers.some((v) => v.source === voucherSourceForTreasury(treasuryTxn.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function customerPaymentAlreadyBookedViaTreasury(
|
||||||
|
treasury: TreasuryTxn[],
|
||||||
|
vouchers: Voucher[],
|
||||||
|
paymentId: string,
|
||||||
|
): boolean {
|
||||||
|
for (const source of [
|
||||||
|
treasurySourceCustomerPayment(paymentId),
|
||||||
|
treasurySourceWalletPayment(paymentId),
|
||||||
|
]) {
|
||||||
|
const treasuryTxn = treasury.find((t) => t.source === source)
|
||||||
|
if (treasuryTxn && vouchers.some((v) => v.source === voucherSourceForTreasury(treasuryTxn.id))) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
function buildOwnerVouchers(
|
function buildOwnerVouchers(
|
||||||
records: ApiOwnerTransaction[],
|
records: ApiOwnerTransaction[],
|
||||||
map: VoucherAccountMap,
|
map: VoucherAccountMap,
|
||||||
@@ -141,6 +162,8 @@ function buildCustomerVouchers(
|
|||||||
startNumber: number,
|
startNumber: number,
|
||||||
invoices: Invoice[],
|
invoices: Invoice[],
|
||||||
parties: Party[],
|
parties: Party[],
|
||||||
|
treasury: TreasuryTxn[],
|
||||||
|
existingVouchers: Voucher[],
|
||||||
): { vouchers: Voucher[]; skipped: number } {
|
): { vouchers: Voucher[]; skipped: number } {
|
||||||
const vouchers: Voucher[] = []
|
const vouchers: Voucher[] = []
|
||||||
let skipped = 0
|
let skipped = 0
|
||||||
@@ -151,7 +174,10 @@ function buildCustomerVouchers(
|
|||||||
if (amount <= 0) continue
|
if (amount <= 0) continue
|
||||||
|
|
||||||
const source = `funzone:cust:${item.id}`
|
const source = `funzone:cust:${item.id}`
|
||||||
if (existingSources.has(source)) {
|
if (
|
||||||
|
existingSources.has(source) ||
|
||||||
|
customerPaymentAlreadyBookedViaTreasury(treasury, existingVouchers, item.id)
|
||||||
|
) {
|
||||||
skipped += 1
|
skipped += 1
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -242,9 +268,18 @@ export function useVoucherSync() {
|
|||||||
const syncCustomerActivity = useCallback(
|
const syncCustomerActivity = useCallback(
|
||||||
(records: ApiPaymentOrRefund[]): Promise<SyncResult> =>
|
(records: ApiPaymentOrRefund[]): Promise<SyncResult> =>
|
||||||
run((map, sources, start) =>
|
run((map, sources, start) =>
|
||||||
buildCustomerVouchers(records, map, sources, start, data.invoices, data.parties),
|
buildCustomerVouchers(
|
||||||
|
records,
|
||||||
|
map,
|
||||||
|
sources,
|
||||||
|
start,
|
||||||
|
data.invoices,
|
||||||
|
data.parties,
|
||||||
|
data.treasury,
|
||||||
|
data.vouchers,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
[run, data.invoices, data.parties],
|
[run, data.invoices, data.parties, data.treasury, data.vouchers],
|
||||||
)
|
)
|
||||||
|
|
||||||
return { syncOwnerTransactions, syncCustomerActivity }
|
return { syncOwnerTransactions, syncCustomerActivity }
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ export const ENDPOINTS = {
|
|||||||
ADMIN_LOGIN: '/auth/token/admin/',
|
ADMIN_LOGIN: '/auth/token/admin/',
|
||||||
OWNERS: '/owners/',
|
OWNERS: '/owners/',
|
||||||
CUSTOMERS: '/customers/',
|
CUSTOMERS: '/customers/',
|
||||||
|
EVENTS: '/events/',
|
||||||
|
EVENT_CATEGORIES: '/event-categories/',
|
||||||
|
RESERVATIONS: '/reservations/?status=all',
|
||||||
OWNER_TRANSACTIONS: (ownerId: string) =>
|
OWNER_TRANSACTIONS: (ownerId: string) =>
|
||||||
`/owners/wallet/admin/transactions/?owner_id=${ownerId}`,
|
`/owners/wallet/admin/transactions/?owner_id=${ownerId}`,
|
||||||
ALL_OWNER_TRANSACTIONS: '/owners/wallet/admin/transactions/',
|
ALL_OWNER_TRANSACTIONS: '/owners/wallet/admin/transactions/',
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ export interface ApiPaymentOrRefund {
|
|||||||
ref_num: string
|
ref_num: string
|
||||||
amount: number
|
amount: number
|
||||||
event_name: string
|
event_name: string
|
||||||
|
category_name?: string | null
|
||||||
|
tickets_count?: number | null
|
||||||
|
payment_method?: 'gateway' | 'wallet'
|
||||||
event_date: string | null
|
event_date: string | null
|
||||||
event_time: string | null
|
event_time: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export function StatCard({
|
|||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm text-slate-500">{label}</p>
|
<p className="text-sm text-slate-500">{label}</p>
|
||||||
<p className="mt-2 truncate text-2xl font-bold text-slate-800 tabular-nums">{value}</p>
|
<p className="mt-2 break-words text-base font-bold leading-tight text-slate-800 tabular-nums">{value}</p>
|
||||||
{hint && <p className="mt-1 text-xs text-slate-400">{hint}</p>}
|
{hint && <p className="mt-1 text-xs text-slate-400">{hint}</p>}
|
||||||
</div>
|
</div>
|
||||||
{icon && (
|
{icon && (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
|
import { visibleVouchers } from '../accounting/voucherDedupe'
|
||||||
import { useStore } from '../store/AppStore'
|
import { useStore } from '../store/AppStore'
|
||||||
import { Badge, Card, PageHeader, StatCard } from '../components/ui'
|
import { Badge, Card, PageHeader, StatCard } from '../components/ui'
|
||||||
import { formatMoney, formatMoneyWithUnit, toFa } from '../utils/format'
|
import { formatMoney, formatMoneyWithUnit, toFa } from '../utils/format'
|
||||||
@@ -51,7 +52,8 @@ export function Dashboard() {
|
|||||||
const { data } = useStore()
|
const { data } = useStore()
|
||||||
|
|
||||||
const metrics = useMemo(() => {
|
const metrics = useMemo(() => {
|
||||||
const balances = trialBalance(data.accounts, data.vouchers)
|
const vouchers = visibleVouchers(data.vouchers, data)
|
||||||
|
const balances = trialBalance(data.accounts, vouchers)
|
||||||
const liquidity = balances
|
const liquidity = balances
|
||||||
.filter((b) => ['1001', '1002', '1003'].includes(b.account.code))
|
.filter((b) => ['1001', '1002', '1003'].includes(b.account.code))
|
||||||
.reduce((sum, b) => sum + b.balance, 0)
|
.reduce((sum, b) => sum + b.balance, 0)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useStore } from '../store/AppStore'
|
import { useStore } from '../store/AppStore'
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
@@ -8,14 +8,63 @@ import {
|
|||||||
DataTable,
|
DataTable,
|
||||||
Field,
|
Field,
|
||||||
Input,
|
Input,
|
||||||
|
JalaliDateInput,
|
||||||
Modal,
|
Modal,
|
||||||
PageHeader,
|
PageHeader,
|
||||||
|
Select,
|
||||||
StatCard,
|
StatCard,
|
||||||
type Column,
|
type Column,
|
||||||
} from '../components/ui'
|
} from '../components/ui'
|
||||||
import type { Product } from '../types'
|
import type { Product } from '../types'
|
||||||
import { createId } from '../utils/id'
|
import { createId } from '../utils/id'
|
||||||
import { formatMoney, sumAmounts, toAmount, toFa } from '../utils/format'
|
import { formatDateTime, formatMoney, sumAmounts, toAmount, toFa } from '../utils/format'
|
||||||
|
import { apiGet } from '../api/client'
|
||||||
|
import { ENDPOINTS } from '../api/config'
|
||||||
|
import type { ApiCustomer } from '../api/types'
|
||||||
|
import { customerFullName } from '../api/types'
|
||||||
|
import { partyDisplayName } from '../parties/funzonePartySync'
|
||||||
|
import {
|
||||||
|
buildBuyerRows,
|
||||||
|
buildCategoryInventoryRows,
|
||||||
|
type BuyerRow,
|
||||||
|
type BuyerSort,
|
||||||
|
type CategoryInventoryRow,
|
||||||
|
type EventLifecycle,
|
||||||
|
type InventoryEvent,
|
||||||
|
type InventoryReservation,
|
||||||
|
} from './inventory/funzoneInventory'
|
||||||
|
|
||||||
|
interface ApiCategory {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function toApiPath(nextUrl: string): string {
|
||||||
|
if (!nextUrl.startsWith('http')) return nextUrl
|
||||||
|
const parsed = new URL(nextUrl)
|
||||||
|
const apiIndex = parsed.pathname.indexOf('/api/')
|
||||||
|
if (apiIndex >= 0) {
|
||||||
|
return `${parsed.pathname.slice(apiIndex + 4)}${parsed.search}`
|
||||||
|
}
|
||||||
|
return `${parsed.pathname}${parsed.search}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAllPages<T>(path: string): Promise<T[]> {
|
||||||
|
const items: T[] = []
|
||||||
|
let nextPath: string | null = path
|
||||||
|
while (nextPath) {
|
||||||
|
const response = await apiGet<unknown>(nextPath)
|
||||||
|
if (Array.isArray(response)) {
|
||||||
|
items.push(...(response as T[]))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (!response || typeof response !== 'object') break
|
||||||
|
const record = response as { results?: T[]; next?: string | null }
|
||||||
|
items.push(...(record.results ?? []))
|
||||||
|
nextPath = record.next ? toApiPath(record.next) : null
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
const emptyDraft = (): Product => ({
|
const emptyDraft = (): Product => ({
|
||||||
id: '',
|
id: '',
|
||||||
@@ -34,6 +83,62 @@ export function Inventory() {
|
|||||||
const [draft, setDraft] = useState<Product | null>(null)
|
const [draft, setDraft] = useState<Product | null>(null)
|
||||||
const [adjust, setAdjust] = useState<{ product: Product; delta: number } | null>(null)
|
const [adjust, setAdjust] = useState<{ product: Product; delta: number } | null>(null)
|
||||||
const [toDelete, setToDelete] = useState<Product | null>(null)
|
const [toDelete, setToDelete] = useState<Product | null>(null)
|
||||||
|
const [funZoneCategories, setFunZoneCategories] = useState<ApiCategory[]>([])
|
||||||
|
const [funZoneEvents, setFunZoneEvents] = useState<InventoryEvent[]>([])
|
||||||
|
const [funZoneReservations, setFunZoneReservations] = useState<InventoryReservation[]>([])
|
||||||
|
const [funZoneCustomers, setFunZoneCustomers] = useState<ApiCustomer[]>([])
|
||||||
|
const [funZoneLoading, setFunZoneLoading] = useState(false)
|
||||||
|
const [funZoneError, setFunZoneError] = useState<string | null>(null)
|
||||||
|
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null)
|
||||||
|
const [selectedLifecycle, setSelectedLifecycle] = useState<EventLifecycle | 'all'>('all')
|
||||||
|
const [buyerDateFrom, setBuyerDateFrom] = useState('')
|
||||||
|
const [buyerDateTo, setBuyerDateTo] = useState('')
|
||||||
|
const [buyerSearch, setBuyerSearch] = useState('')
|
||||||
|
const [buyerSort, setBuyerSort] = useState<BuyerSort>('mostTickets')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
const loadFunZoneInventory = async () => {
|
||||||
|
setFunZoneLoading(true)
|
||||||
|
setFunZoneError(null)
|
||||||
|
try {
|
||||||
|
const [categories, events, reservations, customers] = await Promise.all([
|
||||||
|
getAllPages<ApiCategory>(ENDPOINTS.EVENT_CATEGORIES),
|
||||||
|
getAllPages<InventoryEvent>(ENDPOINTS.EVENTS),
|
||||||
|
getAllPages<InventoryReservation>(ENDPOINTS.RESERVATIONS),
|
||||||
|
getAllPages<ApiCustomer>(ENDPOINTS.CUSTOMERS),
|
||||||
|
])
|
||||||
|
if (cancelled) return
|
||||||
|
setFunZoneCategories(categories)
|
||||||
|
setFunZoneEvents(events)
|
||||||
|
setFunZoneReservations(reservations)
|
||||||
|
setFunZoneCustomers(customers)
|
||||||
|
} catch (err) {
|
||||||
|
if (cancelled) return
|
||||||
|
const message = err instanceof Error ? err.message : 'خطا در دریافت دادههای دستهبندی فانزون'
|
||||||
|
setFunZoneError(message)
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setFunZoneLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void loadFunZoneInventory()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const customerNameById = useMemo(() => {
|
||||||
|
const map = new Map<string, string>()
|
||||||
|
for (const customer of funZoneCustomers) {
|
||||||
|
map.set(customer.id, customerFullName(customer))
|
||||||
|
}
|
||||||
|
for (const party of data.parties) {
|
||||||
|
if (party.externalSource === 'funzone_customer' && party.externalId) {
|
||||||
|
map.set(party.externalId, partyDisplayName(party))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}, [funZoneCustomers, data.parties])
|
||||||
|
|
||||||
const summary = useMemo(() => {
|
const summary = useMemo(() => {
|
||||||
const value = sumAmounts(data.products.map((p) => toAmount(p.stock) * toAmount(p.purchasePrice)))
|
const value = sumAmounts(data.products.map((p) => toAmount(p.stock) * toAmount(p.purchasePrice)))
|
||||||
@@ -49,6 +154,128 @@ export function Inventory() {
|
|||||||
)
|
)
|
||||||
}, [data.products, search])
|
}, [data.products, search])
|
||||||
|
|
||||||
|
const categoryInventoryRows = useMemo(
|
||||||
|
() => buildCategoryInventoryRows(funZoneCategories, funZoneEvents, funZoneReservations),
|
||||||
|
[funZoneCategories, funZoneEvents, funZoneReservations],
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectedCategory = useMemo(
|
||||||
|
() => categoryInventoryRows.find((row) => row.id === selectedCategoryId) ?? null,
|
||||||
|
[categoryInventoryRows, selectedCategoryId],
|
||||||
|
)
|
||||||
|
|
||||||
|
const buyerRows = useMemo(
|
||||||
|
() =>
|
||||||
|
selectedCategory
|
||||||
|
? buildBuyerRows(
|
||||||
|
selectedCategory.id,
|
||||||
|
funZoneEvents,
|
||||||
|
funZoneReservations,
|
||||||
|
customerNameById,
|
||||||
|
{
|
||||||
|
lifecycle: selectedLifecycle,
|
||||||
|
dateFrom: buyerDateFrom,
|
||||||
|
dateTo: buyerDateTo,
|
||||||
|
search: buyerSearch,
|
||||||
|
sort: buyerSort,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: [],
|
||||||
|
[
|
||||||
|
selectedCategory,
|
||||||
|
funZoneEvents,
|
||||||
|
funZoneReservations,
|
||||||
|
customerNameById,
|
||||||
|
selectedLifecycle,
|
||||||
|
buyerDateFrom,
|
||||||
|
buyerDateTo,
|
||||||
|
buyerSearch,
|
||||||
|
buyerSort,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
const buyerColumns: Array<Column<BuyerRow>> = [
|
||||||
|
{ key: 'name', header: 'خریدار', render: (row) => <span className="font-medium text-slate-800">{row.customerName}</span> },
|
||||||
|
{ key: 'tickets', header: 'تعداد بلیت', align: 'center', render: (row) => <span className="tabular-nums font-semibold text-brand-700">{toFa(row.tickets)}</span> },
|
||||||
|
{ key: 'purchases', header: 'دفعات خرید', align: 'center', render: (row) => <span className="tabular-nums">{toFa(row.purchases)}</span> },
|
||||||
|
{ key: 'amount', header: 'مبلغ کل', align: 'end', render: (row) => <span className="tabular-nums">{formatMoney(row.totalAmount)}</span> },
|
||||||
|
{ key: 'last', header: 'آخرین خرید', align: 'center', render: (row) => <span className="text-slate-600">{formatDateTime(row.lastBuyAt)}</span> },
|
||||||
|
]
|
||||||
|
|
||||||
|
const openBuyers = (categoryId: string, lifecycle: EventLifecycle | 'all') => {
|
||||||
|
setSelectedCategoryId(categoryId)
|
||||||
|
setSelectedLifecycle(lifecycle)
|
||||||
|
setBuyerSearch('')
|
||||||
|
setBuyerDateFrom('')
|
||||||
|
setBuyerDateTo('')
|
||||||
|
setBuyerSort('mostTickets')
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryColumns: Array<Column<CategoryInventoryRow>> = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
header: 'کالا',
|
||||||
|
render: (row) => <span className="font-medium text-slate-800">{row.name}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'inProcessCount',
|
||||||
|
header: 'در حال فرآیند',
|
||||||
|
align: 'center',
|
||||||
|
render: (row) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openBuyers(row.id, 'inProcess')}
|
||||||
|
className="tabular-nums font-semibold text-brand-700 underline decoration-dotted underline-offset-4 hover:text-brand-800"
|
||||||
|
>
|
||||||
|
{toFa(row.inProcessCount)}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'completedCount',
|
||||||
|
header: 'تکمیلشده',
|
||||||
|
align: 'center',
|
||||||
|
render: (row) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openBuyers(row.id, 'completed')}
|
||||||
|
className="tabular-nums font-semibold text-emerald-700 underline decoration-dotted underline-offset-4 hover:text-emerald-800"
|
||||||
|
>
|
||||||
|
{toFa(row.completedCount)}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'canceledCount',
|
||||||
|
header: 'لغوشده',
|
||||||
|
align: 'center',
|
||||||
|
render: (row) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openBuyers(row.id, 'canceled')}
|
||||||
|
className="tabular-nums font-semibold text-rose-700 underline decoration-dotted underline-offset-4 hover:text-rose-800"
|
||||||
|
title={row.canceledTickets > 0 ? `${toFa(row.canceledTickets)} بلیت لغوشده` : undefined}
|
||||||
|
>
|
||||||
|
{toFa(row.canceledCount)}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'boughtTickets',
|
||||||
|
header: 'تعداد بلیت خریداریشده',
|
||||||
|
align: 'center',
|
||||||
|
render: (row) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openBuyers(row.id, 'all')}
|
||||||
|
className="tabular-nums text-brand-700 underline decoration-dotted underline-offset-4 hover:text-brand-800"
|
||||||
|
>
|
||||||
|
{toFa(row.boughtTickets)}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
if (!draft || !draft.name.trim() || !draft.code.trim()) return
|
if (!draft || !draft.name.trim() || !draft.code.trim()) return
|
||||||
upsertProduct({ ...draft, id: draft.id || createId('prod-') })
|
upsertProduct({ ...draft, id: draft.id || createId('prod-') })
|
||||||
@@ -117,6 +344,24 @@ export function Inventory() {
|
|||||||
<StatCard label="کالاهای کمتر از حد سفارش" value={toFa(summary.lowStock)} icon="⚠️" tone={summary.lowStock > 0 ? 'rose' : 'green'} />
|
<StatCard label="کالاهای کمتر از حد سفارش" value={toFa(summary.lowStock)} icon="⚠️" tone={summary.lowStock > 0 ? 'rose' : 'green'} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-bold text-slate-800">موجودی بر اساس دستهبندی فانزون</h2>
|
||||||
|
{funZoneLoading ? (
|
||||||
|
<Badge tone="slate">در حال بروزرسانی…</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge tone="blue">{toFa(categoryInventoryRows.length)} دستهبندی</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{funZoneError ? <p className="mb-3 text-sm text-rose-600">{funZoneError}</p> : null}
|
||||||
|
<DataTable
|
||||||
|
columns={categoryColumns}
|
||||||
|
rows={categoryInventoryRows}
|
||||||
|
rowKey={(row) => row.id}
|
||||||
|
emptyTitle="دستهبندیای برای نمایش یافت نشد"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<Input
|
<Input
|
||||||
placeholder="جستجوی کالا…"
|
placeholder="جستجوی کالا…"
|
||||||
@@ -127,6 +372,50 @@ export function Inventory() {
|
|||||||
<DataTable columns={columns} rows={filtered} rowKey={(p) => p.id} emptyTitle="کالایی یافت نشد" />
|
<DataTable columns={columns} rows={filtered} rowKey={(p) => p.id} emptyTitle="کالایی یافت نشد" />
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={selectedCategory !== null}
|
||||||
|
title={selectedCategory ? `خریداران بلیت - ${selectedCategory.name}` : 'خریداران بلیت'}
|
||||||
|
onClose={() => setSelectedCategoryId(null)}
|
||||||
|
size="xl"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Field label="از تاریخ">
|
||||||
|
<JalaliDateInput value={buyerDateFrom} onChange={setBuyerDateFrom} placeholder="انتخاب تاریخ" />
|
||||||
|
</Field>
|
||||||
|
<Field label="تا تاریخ">
|
||||||
|
<JalaliDateInput value={buyerDateTo} onChange={setBuyerDateTo} placeholder="انتخاب تاریخ" />
|
||||||
|
</Field>
|
||||||
|
<Field label="مرتبسازی">
|
||||||
|
<Select value={buyerSort} onChange={(e) => setBuyerSort(e.target.value as BuyerSort)}>
|
||||||
|
<option value="mostTickets">بیشترین بلیت</option>
|
||||||
|
<option value="mostPurchases">بیشترین دفعات خرید</option>
|
||||||
|
<option value="mostAmount">بیشترین مبلغ خرید</option>
|
||||||
|
<option value="newest">جدیدترین خرید</option>
|
||||||
|
<option value="oldest">قدیمیترین خرید</option>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field label="وضعیت رویداد">
|
||||||
|
<Select value={selectedLifecycle} onChange={(e) => setSelectedLifecycle(e.target.value as EventLifecycle | 'all')}>
|
||||||
|
<option value="all">همه (فعال + تکمیلشده)</option>
|
||||||
|
<option value="inProcess">در حال فرآیند</option>
|
||||||
|
<option value="completed">تکمیلشده</option>
|
||||||
|
<option value="canceled">لغوشده</option>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field label="جستجوی خریدار">
|
||||||
|
<Input value={buyerSearch} onChange={(e) => setBuyerSearch(e.target.value)} placeholder="نام مشتری…" />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<DataTable
|
||||||
|
columns={buyerColumns}
|
||||||
|
rows={buyerRows}
|
||||||
|
rowKey={(row) => row.customerId}
|
||||||
|
emptyTitle="خریداری برای این دستهبندی یافت نشد"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
open={draft !== null}
|
open={draft !== null}
|
||||||
title={draft?.id ? 'ویرایش کالا' : 'تعریف کالای جدید'}
|
title={draft?.id ? 'ویرایش کالا' : 'تعریف کالای جدید'}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
|
import { visibleVouchers } from '../accounting/voucherDedupe'
|
||||||
import { useStore } from '../store/AppStore'
|
import { useStore } from '../store/AppStore'
|
||||||
import { Card, DataTable, PageHeader, Select, StatCard, type Column } from '../components/ui'
|
import { Card, DataTable, PageHeader, Select, StatCard, type Column } from '../components/ui'
|
||||||
import { formatDate, formatMoney } from '../utils/format'
|
import { formatDate, formatMoney } from '../utils/format'
|
||||||
@@ -24,10 +25,10 @@ export function Reports() {
|
|||||||
const { data } = useStore()
|
const { data } = useStore()
|
||||||
const [tab, setTab] = useState<ReportTab>('trial')
|
const [tab, setTab] = useState<ReportTab>('trial')
|
||||||
|
|
||||||
const balances = useMemo(
|
const balances = useMemo(() => {
|
||||||
() => trialBalance(data.accounts, data.vouchers),
|
const vouchers = visibleVouchers(data.vouchers, data)
|
||||||
[data.accounts, data.vouchers],
|
return trialBalance(data.accounts, vouchers)
|
||||||
)
|
}, [data])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -103,10 +104,10 @@ function LedgerReport() {
|
|||||||
const [accountId, setAccountId] = useState(leafAccounts[0]?.id ?? '')
|
const [accountId, setAccountId] = useState(leafAccounts[0]?.id ?? '')
|
||||||
|
|
||||||
const account = leafAccounts.find((a) => a.id === accountId)
|
const account = leafAccounts.find((a) => a.id === accountId)
|
||||||
const rows = useMemo(
|
const rows = useMemo(() => {
|
||||||
() => accountLedger(accountId, account, data.vouchers),
|
const vouchers = visibleVouchers(data.vouchers, data)
|
||||||
[accountId, account, data.vouchers],
|
return accountLedger(accountId, account, vouchers)
|
||||||
)
|
}, [accountId, account, data])
|
||||||
|
|
||||||
const columns: Array<Column<LedgerRow>> = [
|
const columns: Array<Column<LedgerRow>> = [
|
||||||
{ key: 'number', header: 'سند', render: (r) => <span className="font-mono">{r.voucherNumber}</span> },
|
{ key: 'number', header: 'سند', render: (r) => <span className="font-mono">{r.voucherNumber}</span> },
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
import { useStore } from '../store/AppStore'
|
import { useStore } from '../store/AppStore'
|
||||||
|
|
||||||
@@ -44,8 +45,6 @@ import { buildTreasuryVoucher, customers, reconcileInvoicePaymentStatus, supplie
|
|||||||
|
|
||||||
import { isFunZoneTreasuryTxn } from './treasury/funzoneTreasurySync'
|
import { isFunZoneTreasuryTxn } from './treasury/funzoneTreasurySync'
|
||||||
|
|
||||||
import { useFunZoneTreasurySync } from './treasury/useFunZoneTreasurySync'
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const kindLabels: Record<TreasuryKind, string> = { receipt: 'دریافت', payment: 'پرداخت' }
|
const kindLabels: Record<TreasuryKind, string> = { receipt: 'دریافت', payment: 'پرداخت' }
|
||||||
@@ -58,8 +57,6 @@ export function Treasury() {
|
|||||||
|
|
||||||
const { data, upsertTreasury, removeTreasury, upsertVoucher, removeVoucher, upsertInvoice } = useStore()
|
const { data, upsertTreasury, removeTreasury, upsertVoucher, removeVoucher, upsertInvoice } = useStore()
|
||||||
|
|
||||||
const { sync, loading: syncLoading, error: syncError, stats: syncStats } = useFunZoneTreasurySync()
|
|
||||||
|
|
||||||
const [draft, setDraft] = useState<TreasuryTxn | null>(null)
|
const [draft, setDraft] = useState<TreasuryTxn | null>(null)
|
||||||
|
|
||||||
const [toDelete, setToDelete] = useState<TreasuryTxn | null>(null)
|
const [toDelete, setToDelete] = useState<TreasuryTxn | null>(null)
|
||||||
@@ -344,12 +341,6 @@ export function Treasury() {
|
|||||||
|
|
||||||
<>
|
<>
|
||||||
|
|
||||||
<Button variant="secondary" icon="↻" onClick={() => void sync()} disabled={syncLoading}>
|
|
||||||
|
|
||||||
{syncLoading ? 'همگامسازی…' : 'همگامسازی فانزون'}
|
|
||||||
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button variant="success" icon="↓" onClick={() => setDraft(createDraft('receipt'))}>
|
<Button variant="success" icon="↓" onClick={() => setDraft(createDraft('receipt'))}>
|
||||||
|
|
||||||
دریافت دستی
|
دریافت دستی
|
||||||
@@ -383,47 +374,19 @@ export function Treasury() {
|
|||||||
<li>↳ تفکیک رویداد: مالیات ۱۰٪ + سود پلتفرم ۱۴٪ + سهم مالک ۷۶٪ — سود در حساب درآمد (۴۰۰۱)</li>
|
<li>↳ تفکیک رویداد: مالیات ۱۰٪ + سود پلتفرم ۱۴٪ + سهم مالک ۷۶٪ — سود در حساب درآمد (۴۰۰۱)</li>
|
||||||
<li>↑ <strong>پرداخت:</strong> فقط برداشت بانکی و بازپرداخت مالک (بدون واریز کیف پول و سهم رزرو)</li>
|
<li>↑ <strong>پرداخت:</strong> فقط برداشت بانکی و بازپرداخت مالک (بدون واریز کیف پول و سهم رزرو)</li>
|
||||||
<li>↳ واریز کیف پول مالک و سهم رزرو در خزانه نیست — یکبار هنگام خرید بلیت در «دریافت» لحاظ شده</li>
|
<li>↳ واریز کیف پول مالک و سهم رزرو در خزانه نیست — یکبار هنگام خرید بلیت در «دریافت» لحاظ شده</li>
|
||||||
|
<li>
|
||||||
|
برای همگامسازی خودکار از فانزون به{' '}
|
||||||
|
<Link to="/vouchers" className="font-medium text-brand-700 underline decoration-dotted underline-offset-4">
|
||||||
|
اسناد حسابداری
|
||||||
|
</Link>{' '}
|
||||||
|
بروید.
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{syncStats && (
|
|
||||||
|
|
||||||
<div className="text-sm text-slate-600">
|
|
||||||
|
|
||||||
<p>
|
|
||||||
|
|
||||||
آخرین همگامسازی: {toFa(syncStats.created)} جدید، {toFa(syncStats.updated)} بهروز،{' '}
|
|
||||||
{toFa(syncStats.removed)} حذف تکراری، {toFa(syncStats.skipped)} بدون تغییر
|
|
||||||
{syncStats.excludedOwnerTxns > 0 &&
|
|
||||||
` — ${toFa(syncStats.excludedOwnerTxns)} تراکنش داخلی مالک نادیده گرفته شد`}
|
|
||||||
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{(syncStats.unmatchedCustomers > 0 || syncStats.unmatchedOwners > 0) && (
|
|
||||||
|
|
||||||
<p className="mt-1 text-amber-700">
|
|
||||||
|
|
||||||
{syncStats.unmatchedCustomers > 0 &&
|
|
||||||
|
|
||||||
`${toFa(syncStats.unmatchedCustomers)} پرداخت مشتری بدون طرف حساب — ابتدا مشتریان را همگام کنید. `}
|
|
||||||
|
|
||||||
{syncStats.unmatchedOwners > 0 &&
|
|
||||||
|
|
||||||
`${toFa(syncStats.unmatchedOwners)} تراکنش مالک بدون طرف حساب — ابتدا تأمینکنندگان را همگام کنید.`}
|
|
||||||
|
|
||||||
</p>
|
|
||||||
|
|
||||||
)}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
)}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{syncError && <p className="mt-3 text-sm text-rose-600">{syncError}</p>}
|
|
||||||
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
|
import { useUnifiedFunZoneVoucherSync } from '../accounting/unifiedVoucherSync'
|
||||||
|
import { visibleVouchers } from '../accounting/voucherDedupe'
|
||||||
import { useStore } from '../store/AppStore'
|
import { useStore } from '../store/AppStore'
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
@@ -17,7 +19,7 @@ import {
|
|||||||
} from '../components/ui'
|
} from '../components/ui'
|
||||||
import type { Voucher, VoucherLine } from '../types'
|
import type { Voucher, VoucherLine } from '../types'
|
||||||
import { createId, nextNumber } from '../utils/id'
|
import { createId, nextNumber } from '../utils/id'
|
||||||
import { formatDate, formatMoney, todayISO, toFa } from '../utils/format'
|
import { formatDate, formatDateTime, formatMoney, todayISO, toFa } from '../utils/format'
|
||||||
import { voucherBalance } from '../utils/accounting'
|
import { voucherBalance } from '../utils/accounting'
|
||||||
import { resolveVoucherRegarding } from '../utils/voucherRegarding'
|
import { resolveVoucherRegarding } from '../utils/voucherRegarding'
|
||||||
import { normalizeEventName } from '../utils/eventName'
|
import { normalizeEventName } from '../utils/eventName'
|
||||||
@@ -55,6 +57,8 @@ function createDraft(items: Voucher[]): Voucher {
|
|||||||
|
|
||||||
export function Vouchers() {
|
export function Vouchers() {
|
||||||
const { data, upsertVoucher, removeVoucher } = useStore()
|
const { data, upsertVoucher, removeVoucher } = useStore()
|
||||||
|
const { sync, loading: syncLoading, error: syncError, stats: syncStats, lastSyncedAt } =
|
||||||
|
useUnifiedFunZoneVoucherSync()
|
||||||
const [draft, setDraft] = useState<Voucher | null>(null)
|
const [draft, setDraft] = useState<Voucher | null>(null)
|
||||||
const [toDelete, setToDelete] = useState<Voucher | null>(null)
|
const [toDelete, setToDelete] = useState<Voucher | null>(null)
|
||||||
const [category, setCategory] = useState<VoucherCategoryFilter>('all')
|
const [category, setCategory] = useState<VoucherCategoryFilter>('all')
|
||||||
@@ -70,10 +74,15 @@ export function Vouchers() {
|
|||||||
)
|
)
|
||||||
const accountName = (id: string) => leafAccounts.find((a) => a.id === id)?.name ?? '—'
|
const accountName = (id: string) => leafAccounts.find((a) => a.id === id)?.name ?? '—'
|
||||||
|
|
||||||
const summary = useMemo(() => voucherFilterSummary(data.vouchers, data), [data])
|
const displayedVouchers = useMemo(
|
||||||
|
() => visibleVouchers(data.vouchers, data),
|
||||||
|
[data.vouchers, data],
|
||||||
|
)
|
||||||
|
|
||||||
|
const summary = useMemo(() => voucherFilterSummary(displayedVouchers, data), [displayedVouchers, data])
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const rows = filterVouchers(data.vouchers, data, {
|
const rows = filterVouchers(displayedVouchers, data, {
|
||||||
...defaultVoucherFilters,
|
...defaultVoucherFilters,
|
||||||
category,
|
category,
|
||||||
status: statusFilter,
|
status: statusFilter,
|
||||||
@@ -83,7 +92,7 @@ export function Vouchers() {
|
|||||||
search,
|
search,
|
||||||
})
|
})
|
||||||
return rows.sort((a, b) => b.date.localeCompare(a.date) || b.number - a.number)
|
return rows.sort((a, b) => b.date.localeCompare(a.date) || b.number - a.number)
|
||||||
}, [data, category, statusFilter, origin, dateFrom, dateTo, search])
|
}, [data, displayedVouchers, category, statusFilter, origin, dateFrom, dateTo, search])
|
||||||
|
|
||||||
const hasActiveFilters =
|
const hasActiveFilters =
|
||||||
category !== 'all' ||
|
category !== 'all' ||
|
||||||
@@ -220,14 +229,49 @@ export function Vouchers() {
|
|||||||
title="اسناد حسابداری"
|
title="اسناد حسابداری"
|
||||||
subtitle="ثبت اسناد دوطرفه با کنترل تراز بدهکار و بستانکار"
|
subtitle="ثبت اسناد دوطرفه با کنترل تراز بدهکار و بستانکار"
|
||||||
actions={
|
actions={
|
||||||
<Button icon="+" onClick={() => setDraft(createDraft(data.vouchers))}>
|
<>
|
||||||
سند جدید
|
<Button
|
||||||
</Button>
|
variant="secondary"
|
||||||
|
icon="↻"
|
||||||
|
disabled={syncLoading}
|
||||||
|
onClick={() => void sync()}
|
||||||
|
>
|
||||||
|
{syncLoading ? 'در حال همگامسازی…' : 'همگامسازی اسناد از فانزون'}
|
||||||
|
</Button>
|
||||||
|
<Button icon="+" onClick={() => setDraft(createDraft(data.vouchers))}>
|
||||||
|
سند جدید
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Card className="border-brand-200 bg-gradient-to-l from-brand-50/80 to-white p-4">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="font-bold text-slate-800">صدور خودکار از فانزون</h3>
|
||||||
|
<p className="mt-1 text-sm leading-relaxed text-slate-600">
|
||||||
|
یکبار همگامسازی: دریافت/پرداخت خزانه، فاکتور فروش، و سند حسابداری — بدون تکرار.
|
||||||
|
برای هر تراکنش فقط سند کامل (با تفکیک مالیات و سود) نگه داشته میشود.
|
||||||
|
</p>
|
||||||
|
{lastSyncedAt ? (
|
||||||
|
<p className="mt-2 text-xs text-slate-500">
|
||||||
|
آخرین همگامسازی: {formatDateTime(lastSyncedAt)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{syncStats ? (
|
||||||
|
<p className="mt-2 text-xs text-emerald-700">
|
||||||
|
دریافت/پرداخت جدید: {toFa(syncStats.treasuryCreated)} · فاکتور جدید:{' '}
|
||||||
|
{toFa(syncStats.invoicesCreated)} · اسناد تکراری حذفشده:{' '}
|
||||||
|
{toFa(syncStats.duplicatesRemoved)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{syncError ? <p className="mt-2 text-sm text-rose-600">{syncError}</p> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||||
<StatCard label="همه اسناد" value={toFa(data.vouchers.length)} />
|
<StatCard label="همه اسناد" value={toFa(displayedVouchers.length)} />
|
||||||
<StatCard label="مشتریان" value={toFa(summary.customer)} tone="brand" />
|
<StatCard label="مشتریان" value={toFa(summary.customer)} tone="brand" />
|
||||||
<StatCard label="تأمینکنندگان" value={toFa(summary.supplier)} tone="amber" />
|
<StatCard label="تأمینکنندگان" value={toFa(summary.supplier)} tone="amber" />
|
||||||
<StatCard label="دستی" value={toFa(summary.manual)} />
|
<StatCard label="دستی" value={toFa(summary.manual)} />
|
||||||
@@ -279,7 +323,7 @@ export function Vouchers() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mb-3 text-sm text-slate-500">
|
<p className="mb-3 text-sm text-slate-500">
|
||||||
نمایش {toFa(filtered.length)} از {toFa(data.vouchers.length)} سند
|
نمایش {toFa(filtered.length)} از {toFa(displayedVouchers.length)} سند
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
|
|||||||
105
src/pages/inventory/funzoneInventory.test.ts
Normal file
105
src/pages/inventory/funzoneInventory.test.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
buildBuyerRows,
|
||||||
|
buildCategoryInventoryRows,
|
||||||
|
eventLifecycle,
|
||||||
|
type InventoryEvent,
|
||||||
|
type InventoryReservation,
|
||||||
|
} from './funzoneInventory'
|
||||||
|
|
||||||
|
const categories = [{ id: 'cat-1', name: 'بازی' }]
|
||||||
|
|
||||||
|
const events: InventoryEvent[] = [
|
||||||
|
{
|
||||||
|
id: 'evt-active',
|
||||||
|
name: 'Active Event',
|
||||||
|
minimum: 5,
|
||||||
|
price: 100,
|
||||||
|
category: { id: 'cat-1', name: 'بازی' },
|
||||||
|
start_time: '2099-01-01T10:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'evt-done',
|
||||||
|
name: 'Done Event',
|
||||||
|
minimum: 2,
|
||||||
|
price: 100,
|
||||||
|
category: { id: 'cat-1', name: 'بازی' },
|
||||||
|
start_time: '2020-01-01T10:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'evt-failed',
|
||||||
|
name: 'Failed Event',
|
||||||
|
minimum: 10,
|
||||||
|
price: 100,
|
||||||
|
category: { id: 'cat-1', name: 'بازی' },
|
||||||
|
start_time: '2020-01-01T10:00:00Z',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const reservations: InventoryReservation[] = [
|
||||||
|
{
|
||||||
|
id: 'r1',
|
||||||
|
customer: 'c1',
|
||||||
|
customer_name: 'علی رضایی',
|
||||||
|
number_of_people: 3,
|
||||||
|
total_amount: 300,
|
||||||
|
status: 'confirmed',
|
||||||
|
reservation_date: '2024-01-01T10:00:00Z',
|
||||||
|
event: 'evt-active',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'r2',
|
||||||
|
customer: 'c2',
|
||||||
|
customer_name: 'سارا محمدی',
|
||||||
|
number_of_people: 2,
|
||||||
|
total_amount: 200,
|
||||||
|
status: 'completed',
|
||||||
|
reservation_date: '2024-02-01T10:00:00Z',
|
||||||
|
event: 'evt-done',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'r3',
|
||||||
|
customer: 'c3',
|
||||||
|
customer_name: 'رضا کریمی',
|
||||||
|
number_of_people: 4,
|
||||||
|
total_amount: 400,
|
||||||
|
status: 'confirmed',
|
||||||
|
reservation_date: '2024-03-01T10:00:00Z',
|
||||||
|
event: 'evt-failed',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('funzoneInventory', () => {
|
||||||
|
it('classifies lifecycle by start time and minimum seats', () => {
|
||||||
|
const now = Date.parse('2025-01-01T00:00:00Z')
|
||||||
|
expect(eventLifecycle(events[0], 3, now)).toBe('inProcess')
|
||||||
|
expect(eventLifecycle(events[1], 2, now)).toBe('completed')
|
||||||
|
expect(eventLifecycle(events[2], 4, now)).toBe('canceled')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('aggregates category counts and bought/canceled tickets', () => {
|
||||||
|
const now = Date.parse('2025-01-01T00:00:00Z')
|
||||||
|
const rows = buildCategoryInventoryRows(categories, events, reservations, now)
|
||||||
|
expect(rows).toHaveLength(1)
|
||||||
|
expect(rows[0].inProcessCount).toBe(1)
|
||||||
|
expect(rows[0].completedCount).toBe(1)
|
||||||
|
expect(rows[0].canceledCount).toBe(1)
|
||||||
|
expect(rows[0].boughtTickets).toBe(5)
|
||||||
|
expect(rows[0].canceledTickets).toBe(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows confirmed buyers for failed events in canceled lifecycle', () => {
|
||||||
|
const now = Date.parse('2025-01-01T00:00:00Z')
|
||||||
|
const buyers = buildBuyerRows('cat-1', events, reservations, new Map(), {
|
||||||
|
lifecycle: 'canceled',
|
||||||
|
dateFrom: '',
|
||||||
|
dateTo: '',
|
||||||
|
search: '',
|
||||||
|
sort: 'mostTickets',
|
||||||
|
nowMs: now,
|
||||||
|
})
|
||||||
|
expect(buyers).toHaveLength(1)
|
||||||
|
expect(buyers[0].customerName).toBe('رضا کریمی')
|
||||||
|
expect(buyers[0].tickets).toBe(4)
|
||||||
|
})
|
||||||
|
})
|
||||||
253
src/pages/inventory/funzoneInventory.ts
Normal file
253
src/pages/inventory/funzoneInventory.ts
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
import { toAmount } from '../../utils/format'
|
||||||
|
|
||||||
|
export interface InventoryCategory {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryEvent {
|
||||||
|
id: string
|
||||||
|
name?: string
|
||||||
|
minimum?: number
|
||||||
|
price?: number
|
||||||
|
category?: { id?: string; name?: string } | null
|
||||||
|
start_time?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryReservation {
|
||||||
|
id: string
|
||||||
|
customer: string
|
||||||
|
customer_name?: string
|
||||||
|
number_of_people: number
|
||||||
|
total_amount?: number | null
|
||||||
|
status: string
|
||||||
|
reservation_date: string
|
||||||
|
event?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventLifecycle = 'inProcess' | 'completed' | 'canceled'
|
||||||
|
|
||||||
|
export interface CategoryInventoryRow {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
inProcessCount: number
|
||||||
|
completedCount: number
|
||||||
|
canceledCount: number
|
||||||
|
boughtTickets: number
|
||||||
|
canceledTickets: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuyerRow {
|
||||||
|
customerId: string
|
||||||
|
customerName: string
|
||||||
|
tickets: number
|
||||||
|
purchases: number
|
||||||
|
totalAmount: number
|
||||||
|
firstBuyAt: string
|
||||||
|
lastBuyAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BuyerSort = 'mostTickets' | 'mostPurchases' | 'mostAmount' | 'newest' | 'oldest'
|
||||||
|
|
||||||
|
const PURCHASE_STATUSES = new Set(['confirmed', 'completed'])
|
||||||
|
const CANCELED_PURCHASE_STATUSES = new Set(['confirmed', 'completed', 'cancelled'])
|
||||||
|
const MINIMUM_STATUSES = new Set(['pending', 'confirmed', 'completed'])
|
||||||
|
|
||||||
|
export function eventLifecycle(
|
||||||
|
event: InventoryEvent,
|
||||||
|
seatsForMinimum: number,
|
||||||
|
nowMs: number = Date.now(),
|
||||||
|
): EventLifecycle {
|
||||||
|
const startMs = event.start_time ? Date.parse(event.start_time) : NaN
|
||||||
|
const started = Number.isFinite(startMs) && startMs <= nowMs
|
||||||
|
if (!started) return 'inProcess'
|
||||||
|
|
||||||
|
const minimum = toAmount(event.minimum ?? 0)
|
||||||
|
return seatsForMinimum >= minimum ? 'completed' : 'canceled'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTicketMaps(reservations: InventoryReservation[]) {
|
||||||
|
const seatsForMinimum = new Map<string, number>()
|
||||||
|
const purchased = new Map<string, number>()
|
||||||
|
const canceledPurchases = new Map<string, number>()
|
||||||
|
|
||||||
|
for (const reservation of reservations) {
|
||||||
|
const eventId = reservation.event
|
||||||
|
if (!eventId) continue
|
||||||
|
const count = Math.max(0, toAmount(reservation.number_of_people))
|
||||||
|
|
||||||
|
if (MINIMUM_STATUSES.has(reservation.status)) {
|
||||||
|
seatsForMinimum.set(eventId, (seatsForMinimum.get(eventId) ?? 0) + count)
|
||||||
|
}
|
||||||
|
if (PURCHASE_STATUSES.has(reservation.status)) {
|
||||||
|
purchased.set(eventId, (purchased.get(eventId) ?? 0) + count)
|
||||||
|
}
|
||||||
|
if (reservation.status === 'cancelled') {
|
||||||
|
canceledPurchases.set(eventId, (canceledPurchases.get(eventId) ?? 0) + count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { seatsForMinimum, purchased, canceledPurchases }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCategoryInventoryRows(
|
||||||
|
categories: InventoryCategory[],
|
||||||
|
events: InventoryEvent[],
|
||||||
|
reservations: InventoryReservation[],
|
||||||
|
nowMs: number = Date.now(),
|
||||||
|
): CategoryInventoryRow[] {
|
||||||
|
const { seatsForMinimum, purchased, canceledPurchases } = buildTicketMaps(reservations)
|
||||||
|
const lifecycleByEvent = new Map<string, EventLifecycle>()
|
||||||
|
for (const event of events) {
|
||||||
|
lifecycleByEvent.set(
|
||||||
|
event.id,
|
||||||
|
eventLifecycle(event, seatsForMinimum.get(event.id) ?? 0, nowMs),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const byCategory = new Map<string, CategoryInventoryRow>()
|
||||||
|
for (const category of categories) {
|
||||||
|
byCategory.set(category.id, {
|
||||||
|
id: category.id,
|
||||||
|
name: category.name,
|
||||||
|
inProcessCount: 0,
|
||||||
|
completedCount: 0,
|
||||||
|
canceledCount: 0,
|
||||||
|
boughtTickets: 0,
|
||||||
|
canceledTickets: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
const categoryId = event.category?.id
|
||||||
|
if (!categoryId) continue
|
||||||
|
|
||||||
|
const row = byCategory.get(categoryId) ?? {
|
||||||
|
id: categoryId,
|
||||||
|
name: event.category?.name ?? 'بدون دستهبندی',
|
||||||
|
inProcessCount: 0,
|
||||||
|
completedCount: 0,
|
||||||
|
canceledCount: 0,
|
||||||
|
boughtTickets: 0,
|
||||||
|
canceledTickets: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
const lifecycle = lifecycleByEvent.get(event.id) ?? 'inProcess'
|
||||||
|
if (lifecycle === 'inProcess') row.inProcessCount += 1
|
||||||
|
else if (lifecycle === 'completed') row.completedCount += 1
|
||||||
|
else row.canceledCount += 1
|
||||||
|
|
||||||
|
const sold = purchased.get(event.id) ?? 0
|
||||||
|
if (lifecycle === 'completed' || lifecycle === 'inProcess') {
|
||||||
|
row.boughtTickets += sold
|
||||||
|
} else {
|
||||||
|
// Failed events: count purchased tickets as canceled inventory.
|
||||||
|
row.canceledTickets += sold + (canceledPurchases.get(event.id) ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
byCategory.set(categoryId, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(byCategory.values()).sort(
|
||||||
|
(a, b) =>
|
||||||
|
b.inProcessCount +
|
||||||
|
b.completedCount +
|
||||||
|
b.canceledCount -
|
||||||
|
(a.inProcessCount + a.completedCount + a.canceledCount),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function reservationMatchesLifecycle(
|
||||||
|
reservation: InventoryReservation,
|
||||||
|
lifecycle: EventLifecycle,
|
||||||
|
): boolean {
|
||||||
|
if (lifecycle === 'canceled') {
|
||||||
|
return CANCELED_PURCHASE_STATUSES.has(reservation.status)
|
||||||
|
}
|
||||||
|
return PURCHASE_STATUSES.has(reservation.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBuyerRows(
|
||||||
|
categoryId: string,
|
||||||
|
events: InventoryEvent[],
|
||||||
|
reservations: InventoryReservation[],
|
||||||
|
customerNameById: Map<string, string>,
|
||||||
|
options: {
|
||||||
|
lifecycle: EventLifecycle | 'all'
|
||||||
|
dateFrom: string
|
||||||
|
dateTo: string
|
||||||
|
search: string
|
||||||
|
sort: BuyerSort
|
||||||
|
nowMs?: number
|
||||||
|
},
|
||||||
|
): BuyerRow[] {
|
||||||
|
const { seatsForMinimum } = buildTicketMaps(reservations)
|
||||||
|
const eventById = new Map(events.map((event) => [event.id, event]))
|
||||||
|
const lifecycleByEvent = new Map<string, EventLifecycle>()
|
||||||
|
for (const event of events) {
|
||||||
|
lifecycleByEvent.set(
|
||||||
|
event.id,
|
||||||
|
eventLifecycle(event, seatsForMinimum.get(event.id) ?? 0, options.nowMs),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = options.search.trim().toLowerCase()
|
||||||
|
const byCustomer = new Map<string, BuyerRow>()
|
||||||
|
|
||||||
|
for (const reservation of reservations) {
|
||||||
|
const eventId = reservation.event
|
||||||
|
if (!eventId) continue
|
||||||
|
|
||||||
|
const event = eventById.get(eventId)
|
||||||
|
if (!event || event.category?.id !== categoryId) continue
|
||||||
|
|
||||||
|
const lifecycle = lifecycleByEvent.get(eventId) ?? 'inProcess'
|
||||||
|
if (options.lifecycle !== 'all' && lifecycle !== options.lifecycle) continue
|
||||||
|
if (options.lifecycle === 'all' && lifecycle === 'canceled') continue
|
||||||
|
if (!reservationMatchesLifecycle(reservation, lifecycle)) continue
|
||||||
|
|
||||||
|
const day = reservation.reservation_date.slice(0, 10)
|
||||||
|
if (options.dateFrom && day < options.dateFrom) continue
|
||||||
|
if (options.dateTo && day > options.dateTo) continue
|
||||||
|
|
||||||
|
const customerName =
|
||||||
|
reservation.customer_name?.trim() ||
|
||||||
|
customerNameById.get(reservation.customer) ||
|
||||||
|
'بدون نام'
|
||||||
|
if (query && !customerName.toLowerCase().includes(query)) continue
|
||||||
|
|
||||||
|
const tickets = Math.max(0, toAmount(reservation.number_of_people))
|
||||||
|
const amount = toAmount(reservation.total_amount ?? tickets * toAmount(event.price ?? 0))
|
||||||
|
const existing = byCustomer.get(reservation.customer) ?? {
|
||||||
|
customerId: reservation.customer,
|
||||||
|
customerName,
|
||||||
|
tickets: 0,
|
||||||
|
purchases: 0,
|
||||||
|
totalAmount: 0,
|
||||||
|
firstBuyAt: reservation.reservation_date,
|
||||||
|
lastBuyAt: reservation.reservation_date,
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.customerName = customerName
|
||||||
|
existing.tickets += tickets
|
||||||
|
existing.purchases += 1
|
||||||
|
existing.totalAmount += amount
|
||||||
|
if (reservation.reservation_date < existing.firstBuyAt) {
|
||||||
|
existing.firstBuyAt = reservation.reservation_date
|
||||||
|
}
|
||||||
|
if (reservation.reservation_date > existing.lastBuyAt) {
|
||||||
|
existing.lastBuyAt = reservation.reservation_date
|
||||||
|
}
|
||||||
|
byCustomer.set(reservation.customer, existing)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = Array.from(byCustomer.values())
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
if (options.sort === 'mostTickets') return b.tickets - a.tickets
|
||||||
|
if (options.sort === 'mostPurchases') return b.purchases - a.purchases
|
||||||
|
if (options.sort === 'mostAmount') return b.totalAmount - a.totalAmount
|
||||||
|
if (options.sort === 'oldest') return a.firstBuyAt.localeCompare(b.firstBuyAt)
|
||||||
|
return b.lastBuyAt.localeCompare(a.lastBuyAt)
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
}
|
||||||
@@ -1,46 +1,17 @@
|
|||||||
import { useMemo } from 'react'
|
import { Link } from 'react-router-dom'
|
||||||
import { Button, Card } from '../../components/ui'
|
import { Card } from '../../components/ui'
|
||||||
import { useStore } from '../../store/AppStore'
|
|
||||||
import { countFunZoneSupplierDocuments } from '../../utils/voucherFilters'
|
|
||||||
import { formatDateTime, toFa } from '../../utils/format'
|
|
||||||
import { usePurchasesFunZoneSync } from './PurchasesSyncContext'
|
|
||||||
|
|
||||||
export function FunZonePurchasesSyncBanner() {
|
export function FunZonePurchasesSyncBanner() {
|
||||||
const { loading, error, stats, lastSyncedAt, sync } = usePurchasesFunZoneSync()
|
|
||||||
const { data } = useStore()
|
|
||||||
|
|
||||||
const syncedCount = useMemo(() => countFunZoneSupplierDocuments(data), [data])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border-brand-200 bg-gradient-to-l from-brand-50/80 to-white p-4">
|
<Card className="border-slate-200 bg-slate-50/80 p-4">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<h3 className="font-bold text-slate-800">اسناد حسابداری فانزون</h3>
|
||||||
<div className="min-w-0 flex-1">
|
<p className="mt-1 text-sm leading-relaxed text-slate-600">
|
||||||
<h3 className="font-bold text-slate-800">همگامسازی با فانزون</h3>
|
برای دریافت برداشتها و بازپرداختهای مالکان و صدور سند حسابداری، از صفحه{' '}
|
||||||
<p className="mt-1 text-sm leading-relaxed text-slate-600">
|
<Link to="/vouchers" className="font-medium text-brand-700 underline decoration-dotted underline-offset-4">
|
||||||
برای دریافت برداشتها و بازپرداختهای مالکان از فانزون و تبدیل آنها به پرداخت و سند حسابداری،
|
اسناد حسابداری
|
||||||
دکمه همگامسازی را بزنید.
|
</Link>{' '}
|
||||||
</p>
|
دکمه «همگامسازی اسناد از فانزون» را بزنید.
|
||||||
<div className="mt-3 flex flex-wrap gap-2 text-xs">
|
</p>
|
||||||
<span className="rounded-full bg-white px-2.5 py-1 text-slate-600 ring-1 ring-slate-200">
|
|
||||||
{toFa(syncedCount)} تراکنش همگامشده
|
|
||||||
</span>
|
|
||||||
{lastSyncedAt ? (
|
|
||||||
<span className="rounded-full bg-white px-2.5 py-1 text-slate-500 ring-1 ring-slate-200">
|
|
||||||
آخرین بروزرسانی: {formatDateTime(lastSyncedAt)}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{stats ? (
|
|
||||||
<span className="rounded-full bg-emerald-50 px-2.5 py-1 text-emerald-700 ring-1 ring-emerald-200">
|
|
||||||
پرداخت جدید: {toFa(stats.treasuryCreated)} · سند جدید: {toFa(stats.ownerVouchersCreated)}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{error ? <p className="mt-2 text-sm text-rose-600">{error}</p> : null}
|
|
||||||
</div>
|
|
||||||
<Button variant="primary" icon="↻" size="sm" onClick={() => void sync()} disabled={loading}>
|
|
||||||
{loading ? 'در حال همگامسازی…' : 'همگامسازی با فانزون'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { NavLink, Outlet } from 'react-router-dom'
|
import { NavLink, Outlet } from 'react-router-dom'
|
||||||
import { PageHeader } from '../../components/ui'
|
import { PageHeader } from '../../components/ui'
|
||||||
import { PurchasesSyncProvider } from './PurchasesSyncContext'
|
|
||||||
|
|
||||||
const purchaseNavItems = [
|
const purchaseNavItems = [
|
||||||
{ to: '/purchases/suppliers', label: 'تأمینکنندگان', icon: '🏢', end: false },
|
{ to: '/purchases/suppliers', label: 'تأمینکنندگان', icon: '🏢', end: false },
|
||||||
@@ -10,7 +9,6 @@ const purchaseNavItems = [
|
|||||||
|
|
||||||
export function PurchasesLayout() {
|
export function PurchasesLayout() {
|
||||||
return (
|
return (
|
||||||
<PurchasesSyncProvider>
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="تأمینکنندگان و خرید"
|
title="تأمینکنندگان و خرید"
|
||||||
@@ -48,6 +46,5 @@ export function PurchasesLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PurchasesSyncProvider>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from 'react'
|
|
||||||
import { apiGet, asList } from '../../api/client'
|
|
||||||
import { ENDPOINTS } from '../../api/config'
|
|
||||||
import type { ApiOwnerTransaction } from '../../api/types'
|
|
||||||
import { useVoucherSync } from '../../accounting/voucherSync'
|
|
||||||
import { useStore } from '../../store/AppStore'
|
|
||||||
import { useFunZoneTreasurySync } from '../treasury/useFunZoneTreasurySync'
|
|
||||||
|
|
||||||
export interface FunZonePurchasesSyncStats {
|
|
||||||
treasuryCreated: number
|
|
||||||
treasuryUpdated: number
|
|
||||||
treasurySkipped: number
|
|
||||||
treasuryRemoved: number
|
|
||||||
ownerVouchersCreated: number
|
|
||||||
ownerVouchersSkipped: number
|
|
||||||
unmatchedOwners: number
|
|
||||||
ownerWithdrawCount: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PurchasesSyncContextValue {
|
|
||||||
loading: boolean
|
|
||||||
error: string | null
|
|
||||||
stats: FunZonePurchasesSyncStats | null
|
|
||||||
lastSyncedAt: string | null
|
|
||||||
sync: () => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
const PurchasesSyncContext = createContext<PurchasesSyncContextValue | null>(null)
|
|
||||||
|
|
||||||
export function usePurchasesFunZoneSync(): PurchasesSyncContextValue {
|
|
||||||
const ctx = useContext(PurchasesSyncContext)
|
|
||||||
if (!ctx) {
|
|
||||||
throw new Error('usePurchasesFunZoneSync must be used within PurchasesSyncProvider')
|
|
||||||
}
|
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PurchasesSyncProvider({ children }: { children: ReactNode }) {
|
|
||||||
const { reload } = useStore()
|
|
||||||
const treasury = useFunZoneTreasurySync()
|
|
||||||
const { syncOwnerTransactions } = useVoucherSync()
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [stats, setStats] = useState<FunZonePurchasesSyncStats | null>(null)
|
|
||||||
const [lastSyncedAt, setLastSyncedAt] = useState<string | null>(null)
|
|
||||||
const syncingRef = useRef(false)
|
|
||||||
|
|
||||||
const sync = useCallback(async () => {
|
|
||||||
if (syncingRef.current) return
|
|
||||||
syncingRef.current = true
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const treasuryStats = await treasury.sync()
|
|
||||||
|
|
||||||
const ownerTransactions = await apiGet<unknown>(ENDPOINTS.ALL_OWNER_TRANSACTIONS).then(
|
|
||||||
asList<ApiOwnerTransaction>,
|
|
||||||
)
|
|
||||||
const ownerVoucherResult = await syncOwnerTransactions(ownerTransactions)
|
|
||||||
|
|
||||||
setStats({
|
|
||||||
treasuryCreated: treasuryStats?.created ?? 0,
|
|
||||||
treasuryUpdated: treasuryStats?.updated ?? 0,
|
|
||||||
treasurySkipped: treasuryStats?.skipped ?? 0,
|
|
||||||
treasuryRemoved: treasuryStats?.removed ?? 0,
|
|
||||||
ownerVouchersCreated: ownerVoucherResult.created,
|
|
||||||
ownerVouchersSkipped: ownerVoucherResult.skipped,
|
|
||||||
unmatchedOwners: treasuryStats?.unmatchedOwners ?? 0,
|
|
||||||
ownerWithdrawCount: treasuryStats?.ownerTxnCount ?? 0,
|
|
||||||
})
|
|
||||||
setLastSyncedAt(new Date().toISOString())
|
|
||||||
reload()
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'خطا در همگامسازی تأمینکنندگان با فانزون')
|
|
||||||
} finally {
|
|
||||||
syncingRef.current = false
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [treasury, syncOwnerTransactions, reload])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PurchasesSyncContext.Provider value={{ loading, error, stats, lastSyncedAt, sync }}>
|
|
||||||
{children}
|
|
||||||
</PurchasesSyncContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,50 +1,17 @@
|
|||||||
import { Button, Card } from '../../components/ui'
|
import { Link } from 'react-router-dom'
|
||||||
import { formatDateTime, toFa } from '../../utils/format'
|
import { Card } from '../../components/ui'
|
||||||
import { useSalesFunZoneSync } from './SalesSyncContext'
|
|
||||||
import { useStore } from '../../store/AppStore'
|
|
||||||
import { useMemo } from 'react'
|
|
||||||
import { isFunZoneSalesInvoice } from './funzoneSalesSync'
|
|
||||||
import { salesDocuments } from './salesUtils'
|
|
||||||
|
|
||||||
export function FunZoneSalesSyncBanner() {
|
export function FunZoneSalesSyncBanner() {
|
||||||
const { loading, error, stats, lastSyncedAt, sync } = useSalesFunZoneSync()
|
|
||||||
const { data } = useStore()
|
|
||||||
|
|
||||||
const funzoneDocCount = useMemo(
|
|
||||||
() => salesDocuments(data.invoices).filter(isFunZoneSalesInvoice).length,
|
|
||||||
[data.invoices],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border-brand-200 bg-gradient-to-l from-brand-50/80 to-white p-4">
|
<Card className="border-slate-200 bg-slate-50/80 p-4">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<h3 className="font-bold text-slate-800">اسناد حسابداری فانزون</h3>
|
||||||
<div className="min-w-0 flex-1">
|
<p className="mt-1 text-sm leading-relaxed text-slate-600">
|
||||||
<h3 className="font-bold text-slate-800">همگامسازی با فانزون</h3>
|
برای دریافت پرداختهای رویداد، فاکتور فروش و سند حسابداری از فانزون، از صفحه{' '}
|
||||||
<p className="mt-1 text-sm leading-relaxed text-slate-600">
|
<Link to="/vouchers" className="font-medium text-brand-700 underline decoration-dotted underline-offset-4">
|
||||||
برای دریافت پرداختهای رویداد و لغو رزرو از فانزون و تبدیل آنها به فاکتور فروش/برگشتی و
|
اسناد حسابداری
|
||||||
دریافت و پرداخت، دکمه همگامسازی را بزنید.
|
</Link>{' '}
|
||||||
</p>
|
دکمه «همگامسازی اسناد از فانزون» را بزنید.
|
||||||
<div className="mt-3 flex flex-wrap gap-2 text-xs">
|
</p>
|
||||||
<span className="rounded-full bg-white px-2.5 py-1 text-slate-600 ring-1 ring-slate-200">
|
|
||||||
{toFa(funzoneDocCount)} سند همگامشده در فروش
|
|
||||||
</span>
|
|
||||||
{lastSyncedAt ? (
|
|
||||||
<span className="rounded-full bg-white px-2.5 py-1 text-slate-500 ring-1 ring-slate-200">
|
|
||||||
آخرین بروزرسانی: {formatDateTime(lastSyncedAt)}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{stats ? (
|
|
||||||
<span className="rounded-full bg-emerald-50 px-2.5 py-1 text-emerald-700 ring-1 ring-emerald-200">
|
|
||||||
فاکتور جدید: {toFa(stats.invoicesCreated)} · دریافت/پرداخت: {toFa(stats.treasuryCreated)}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{error ? <p className="mt-2 text-sm text-rose-600">{error}</p> : null}
|
|
||||||
</div>
|
|
||||||
<Button variant="primary" icon="↻" size="sm" onClick={() => void sync()} disabled={loading}>
|
|
||||||
{loading ? 'در حال همگامسازی…' : 'همگامسازی با فانزون'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { NavLink, Outlet } from 'react-router-dom'
|
import { NavLink, Outlet } from 'react-router-dom'
|
||||||
import { PageHeader } from '../../components/ui'
|
import { PageHeader } from '../../components/ui'
|
||||||
import { salesNavItems } from './salesNavigation'
|
import { salesNavItems } from './salesNavigation'
|
||||||
import { SalesSyncProvider } from './SalesSyncContext'
|
|
||||||
|
|
||||||
export function SalesLayout() {
|
export function SalesLayout() {
|
||||||
return (
|
return (
|
||||||
<SalesSyncProvider>
|
<div className="space-y-6">
|
||||||
<div className="space-y-6">
|
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="مشتریان و فروش"
|
title="مشتریان و فروش"
|
||||||
subtitle="مدیریت فرایند فروش، فاکتورها، اعلامیهها و مرور فروش"
|
subtitle="مدیریت فرایند فروش، فاکتورها، اعلامیهها و مرور فروش"
|
||||||
@@ -43,6 +41,5 @@ export function SalesLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SalesSyncProvider>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,16 @@ import { findExistingParty } from '../../parties/funzonePartySync'
|
|||||||
import { createId, nextNumber } from '../../utils/id'
|
import { createId, nextNumber } from '../../utils/id'
|
||||||
import { isEventCustomerPayment } from '../../utils/funzoneFees'
|
import { isEventCustomerPayment } from '../../utils/funzoneFees'
|
||||||
import { invoiceTotals } from '../../utils/accounting'
|
import { invoiceTotals } from '../../utils/accounting'
|
||||||
|
import { eventNameWithCategory } from '../../utils/eventName'
|
||||||
import { byDocumentType, isWithdrawalReturn } from './salesUtils'
|
import { byDocumentType, isWithdrawalReturn } from './salesUtils'
|
||||||
|
|
||||||
export const FUNZONE_PAYMENT_NOTE_PREFIX = 'funzone:pay:'
|
export const FUNZONE_PAYMENT_NOTE_PREFIX = 'funzone:pay:'
|
||||||
export const FUNZONE_CANCEL_NOTE_PREFIX = 'funzone:cancel:'
|
export const FUNZONE_CANCEL_NOTE_PREFIX = 'funzone:cancel:'
|
||||||
|
|
||||||
export function funzonePaymentNoteTag(paymentId: string): string {
|
export function funzonePaymentNoteTag(paymentId: string, wallet = false): string {
|
||||||
return `${FUNZONE_PAYMENT_NOTE_PREFIX}${paymentId}`
|
return wallet
|
||||||
|
? `${FUNZONE_PAYMENT_NOTE_PREFIX}wallet:${paymentId}`
|
||||||
|
: `${FUNZONE_PAYMENT_NOTE_PREFIX}${paymentId}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function funzoneCancellationNoteTag(paymentId: string): string {
|
export function funzoneCancellationNoteTag(paymentId: string): string {
|
||||||
@@ -75,7 +78,7 @@ export function buildSalesInvoiceFromCustomerPayment(
|
|||||||
if (item.type === 'payment') {
|
if (item.type === 'payment') {
|
||||||
if (!isEventCustomerPayment(item.event_name)) return null
|
if (!isEventCustomerPayment(item.event_name)) return null
|
||||||
|
|
||||||
const tag = funzonePaymentNoteTag(item.id)
|
const tag = funzonePaymentNoteTag(item.id, item.payment_method === 'wallet')
|
||||||
const prior = existing ?? findInvoiceByNoteTag(existingInvoices, tag)
|
const prior = existing ?? findInvoiceByNoteTag(existingInvoices, tag)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -95,7 +98,7 @@ export function buildSalesInvoiceFromCustomerPayment(
|
|||||||
unitPrice: amount,
|
unitPrice: amount,
|
||||||
discount: 0,
|
discount: 0,
|
||||||
taxRate: 0,
|
taxRate: 0,
|
||||||
eventName: item.event_name,
|
eventName: eventNameWithCategory(item.event_name, item.category_name),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -127,7 +130,7 @@ export function buildSalesInvoiceFromCustomerPayment(
|
|||||||
unitPrice: amount,
|
unitPrice: amount,
|
||||||
discount: 0,
|
discount: 0,
|
||||||
taxRate: 0,
|
taxRate: 0,
|
||||||
eventName: item.event_name,
|
eventName: eventNameWithCategory(item.event_name, item.category_name),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
relatedInvoiceId: prior?.relatedInvoiceId,
|
relatedInvoiceId: prior?.relatedInvoiceId,
|
||||||
@@ -167,7 +170,7 @@ export function buildFunZoneSalesSync(
|
|||||||
|
|
||||||
const tag =
|
const tag =
|
||||||
item.type === 'payment'
|
item.type === 'payment'
|
||||||
? funzonePaymentNoteTag(item.id)
|
? funzonePaymentNoteTag(item.id, item.payment_method === 'wallet')
|
||||||
: funzoneCancellationNoteTag(item.id)
|
: funzoneCancellationNoteTag(item.id)
|
||||||
const existing = findInvoiceByNoteTag(data.invoices, tag)
|
const existing = findInvoiceByNoteTag(data.invoices, tag)
|
||||||
const built = buildSalesInvoiceFromCustomerPayment(
|
const built = buildSalesInvoiceFromCustomerPayment(
|
||||||
|
|||||||
59
src/pages/sales/funzoneVoucherLink.ts
Normal file
59
src/pages/sales/funzoneVoucherLink.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import type { AppData, Invoice } from '../../types'
|
||||||
|
import {
|
||||||
|
treasurySourceCustomerPayment,
|
||||||
|
treasurySourceWalletPayment,
|
||||||
|
} from '../treasury/funzoneTreasurySync'
|
||||||
|
import { findVoucherForInvoice, voucherSourceForTreasury } from './salesUtils'
|
||||||
|
import { isFunZoneTicketSalesInvoice } from './funzoneSalesSync'
|
||||||
|
|
||||||
|
/** Parses FunZone payment id embedded in a synced sales invoice note. */
|
||||||
|
export function funzonePaymentRefFromInvoice(
|
||||||
|
invoice: Invoice,
|
||||||
|
): { method: 'gateway' | 'wallet'; id: string } | null {
|
||||||
|
if (!isFunZoneTicketSalesInvoice(invoice)) return null
|
||||||
|
const walletMatch = invoice.note.match(/funzone:pay:wallet:([^\s\n]+)/)
|
||||||
|
if (walletMatch?.[1]) return { method: 'wallet', id: walletMatch[1] }
|
||||||
|
const gatewayMatch = invoice.note.match(/funzone:pay:([^\s\n]+)/)
|
||||||
|
if (gatewayMatch?.[1] && !gatewayMatch[1].startsWith('wallet:')) {
|
||||||
|
return { method: 'gateway', id: gatewayMatch[1] }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function treasurySourceForFunzoneInvoice(invoice: Invoice): string | null {
|
||||||
|
const ref = funzonePaymentRefFromInvoice(invoice)
|
||||||
|
if (!ref) return null
|
||||||
|
return ref.method === 'wallet'
|
||||||
|
? treasurySourceWalletPayment(ref.id)
|
||||||
|
: treasurySourceCustomerPayment(ref.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Event ticket sales are booked once via treasury (fee split); skip duplicate invoice vouchers. */
|
||||||
|
export function shouldSkipInvoiceVoucherForFunzoneTicket(
|
||||||
|
invoice: Invoice,
|
||||||
|
data: Pick<AppData, 'treasury'>,
|
||||||
|
): boolean {
|
||||||
|
const source = treasurySourceForFunzoneInvoice(invoice)
|
||||||
|
if (!source) return false
|
||||||
|
return data.treasury.some((txn) => txn.source === source)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes invoice vouchers when a treasury voucher already exists for the same FunZone payment. */
|
||||||
|
export function duplicateFunzoneInvoiceVoucherIds(data: AppData): string[] {
|
||||||
|
const toRemove: string[] = []
|
||||||
|
for (const invoice of data.invoices) {
|
||||||
|
if (!isFunZoneTicketSalesInvoice(invoice)) continue
|
||||||
|
const source = treasurySourceForFunzoneInvoice(invoice)
|
||||||
|
if (!source) continue
|
||||||
|
const treasuryTxn = data.treasury.find((txn) => txn.source === source)
|
||||||
|
if (!treasuryTxn) continue
|
||||||
|
const treasuryVoucher = data.vouchers.find(
|
||||||
|
(v) => v.source === voucherSourceForTreasury(treasuryTxn.id),
|
||||||
|
)
|
||||||
|
const invoiceVoucher = findVoucherForInvoice(data.vouchers, invoice)
|
||||||
|
if (treasuryVoucher && invoiceVoucher && treasuryVoucher.id !== invoiceVoucher.id) {
|
||||||
|
toRemove.push(invoiceVoucher.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return toRemove
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
stockDeltas,
|
stockDeltas,
|
||||||
voucherSourceForInvoice,
|
voucherSourceForInvoice,
|
||||||
} from './salesUtils'
|
} from './salesUtils'
|
||||||
|
import { shouldSkipInvoiceVoucherForFunzoneTicket } from './funzoneVoucherLink'
|
||||||
|
|
||||||
/** Applies stock + voucher side-effects when an invoice is saved (Sepidar-style). */
|
/** Applies stock + voucher side-effects when an invoice is saved (Sepidar-style). */
|
||||||
export function invoiceSideEffects(
|
export function invoiceSideEffects(
|
||||||
@@ -23,7 +24,10 @@ export function invoiceSideEffects(
|
|||||||
} {
|
} {
|
||||||
const products = applyStockChanges(data.products, saved, previous ?? null)
|
const products = applyStockChanges(data.products, saved, previous ?? null)
|
||||||
const existingId = findVoucherForInvoice(data.vouchers, saved)?.id
|
const existingId = findVoucherForInvoice(data.vouchers, saved)?.id
|
||||||
const voucher = buildInvoiceVoucher(saved, data)
|
let voucher = buildInvoiceVoucher(saved, data)
|
||||||
|
if (voucher && shouldSkipInvoiceVoucherForFunzoneTicket(saved, data)) {
|
||||||
|
voucher = null
|
||||||
|
}
|
||||||
return { products, voucher, removeVoucherId: existingId }
|
return { products, voucher, removeVoucherId: existingId }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -456,10 +456,13 @@ export function buildTreasuryVoucher(
|
|||||||
): Voucher | null {
|
): Voucher | null {
|
||||||
if (txn.amount <= 0) return null
|
if (txn.amount <= 0) return null
|
||||||
|
|
||||||
const { bankAccountId, salesIncomeAccountId, ownerPayableAccountId } = data.settings.voucherAccounts
|
const { bankAccountId, salesIncomeAccountId, ownerPayableAccountId, customerPayableAccountId } =
|
||||||
|
data.settings.voucherAccounts
|
||||||
const bankId = accountByCode(data.accounts, '1002', bankAccountId)
|
const bankId = accountByCode(data.accounts, '1002', bankAccountId)
|
||||||
if (!bankId) return null
|
if (!bankId) return null
|
||||||
|
|
||||||
|
const customerPayableId = accountByCode(data.accounts, '2006', customerPayableAccountId)
|
||||||
|
|
||||||
const party = txn.partyId ? data.parties.find((p) => p.id === txn.partyId) : undefined
|
const party = txn.partyId ? data.parties.find((p) => p.id === txn.partyId) : undefined
|
||||||
const counterAccountId =
|
const counterAccountId =
|
||||||
party?.kind === 'supplier'
|
party?.kind === 'supplier'
|
||||||
@@ -491,6 +494,10 @@ export function buildTreasuryVoucher(
|
|||||||
|
|
||||||
if (hasEventSplit) {
|
if (hasEventSplit) {
|
||||||
const regarding = txn.eventName || eventNameFromTreasuryDescription(txn.description)
|
const regarding = txn.eventName || eventNameFromTreasuryDescription(txn.description)
|
||||||
|
const debitAccountId =
|
||||||
|
txn.method === 'cash' && customerPayableId ? customerPayableId : bankId
|
||||||
|
const debitLabel =
|
||||||
|
txn.method === 'cash' ? 'پرداخت از کیف پول مشتری' : 'واریز بابت رویداد'
|
||||||
return {
|
return {
|
||||||
id: createId('v-'),
|
id: createId('v-'),
|
||||||
number: nextNumber(data.vouchers),
|
number: nextNumber(data.vouchers),
|
||||||
@@ -500,7 +507,7 @@ export function buildTreasuryVoucher(
|
|||||||
status: 'posted',
|
status: 'posted',
|
||||||
source: voucherSourceForTreasury(txn.id),
|
source: voucherSourceForTreasury(txn.id),
|
||||||
lines: [
|
lines: [
|
||||||
makeLine(bankId, amount, 0, `واریز بابت رویداد - ${label}`),
|
makeLine(debitAccountId, amount, 0, `${debitLabel} - ${label}`),
|
||||||
makeLine(ownerPayableId!, 0, ownerNet, `سهم مالک - ${label}`),
|
makeLine(ownerPayableId!, 0, ownerNet, `سهم مالک - ${label}`),
|
||||||
makeLine(platformIncomeId!, 0, profit, `سود پلتفرم (۱۴٪) - ${label}`),
|
makeLine(platformIncomeId!, 0, profit, `سود پلتفرم (۱۴٪) - ${label}`),
|
||||||
makeLine(taxAccountId!, 0, tax, `مالیات (۱۰٪) - ${label}`),
|
makeLine(taxAccountId!, 0, tax, `مالیات (۱۰٪) - ${label}`),
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ export function treasurySourceCustomerPayment(id: string): string {
|
|||||||
return `funzone:cust-pay:${id}`
|
return `funzone:cust-pay:${id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function treasurySourceWalletPayment(id: string): string {
|
||||||
|
return `funzone:wallet-pay:${id}`
|
||||||
|
}
|
||||||
|
|
||||||
export function treasurySourceOwnerTxn(id: string): string {
|
export function treasurySourceOwnerTxn(id: string): string {
|
||||||
return `funzone:owner-txn:${id}`
|
return `funzone:owner-txn:${id}`
|
||||||
}
|
}
|
||||||
@@ -57,15 +61,18 @@ export function buildTreasuryFromCustomerPayment(
|
|||||||
if (amount <= 0) return null
|
if (amount <= 0) return null
|
||||||
|
|
||||||
const isPayment = item.type === 'payment'
|
const isPayment = item.type === 'payment'
|
||||||
|
const fromWallet = item.payment_method === 'wallet'
|
||||||
const kind = isPayment ? 'receipt' : 'payment'
|
const kind = isPayment ? 'receipt' : 'payment'
|
||||||
const isEvent = isPayment && isEventCustomerPayment(item.event_name)
|
const isEvent = isPayment && isEventCustomerPayment(item.event_name)
|
||||||
const split = isEvent ? splitEventPayment(amount) : null
|
const split = isEvent ? splitEventPayment(amount) : null
|
||||||
|
|
||||||
const lines = [
|
const lines = [
|
||||||
isPayment
|
isPayment
|
||||||
? `دریافت از مشتری — ${item.customer_name}`
|
? fromWallet
|
||||||
|
? `پرداخت از کیف پول — ${item.customer_name}`
|
||||||
|
: `دریافت از مشتری — ${item.customer_name}`
|
||||||
: `بازپرداخت به مشتری — ${item.customer_name}`,
|
: `بازپرداخت به مشتری — ${item.customer_name}`,
|
||||||
`نوع: ${isPayment ? 'پرداخت مشتری' : 'لغو/بازپرداخت'}`,
|
`نوع: ${isPayment ? (fromWallet ? 'پرداخت کیف پول' : 'پرداخت مشتری') : 'لغو/بازپرداخت'}`,
|
||||||
`رویداد: ${eventLabel(item.event_name)}`,
|
`رویداد: ${eventLabel(item.event_name)}`,
|
||||||
split
|
split
|
||||||
? [
|
? [
|
||||||
@@ -87,7 +94,7 @@ export function buildTreasuryFromCustomerPayment(
|
|||||||
id: existing?.id ?? '',
|
id: existing?.id ?? '',
|
||||||
number: existing?.number ?? number,
|
number: existing?.number ?? number,
|
||||||
kind,
|
kind,
|
||||||
method: 'bank',
|
method: fromWallet ? 'cash' : 'bank',
|
||||||
date: isoDateFromApi(item.created_at),
|
date: isoDateFromApi(item.created_at),
|
||||||
partyId,
|
partyId,
|
||||||
amount,
|
amount,
|
||||||
@@ -97,7 +104,10 @@ export function buildTreasuryFromCustomerPayment(
|
|||||||
reference: item.trace_no || item.ref_num || item.id.slice(0, 8),
|
reference: item.trace_no || item.ref_num || item.id.slice(0, 8),
|
||||||
description: lines.join('\n'),
|
description: lines.join('\n'),
|
||||||
eventName: item.event_name || undefined,
|
eventName: item.event_name || undefined,
|
||||||
source: treasurySourceCustomerPayment(item.id),
|
source:
|
||||||
|
isPayment && fromWallet
|
||||||
|
? treasurySourceWalletPayment(item.id)
|
||||||
|
: treasurySourceCustomerPayment(item.id),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +186,10 @@ export function buildFunZoneTreasurySync(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const source = treasurySourceCustomerPayment(item.id)
|
const source =
|
||||||
|
item.type === 'payment' && item.payment_method === 'wallet'
|
||||||
|
? treasurySourceWalletPayment(item.id)
|
||||||
|
: treasurySourceCustomerPayment(item.id)
|
||||||
const existing = findTreasuryBySource(data.treasury, source)
|
const existing = findTreasuryBySource(data.treasury, source)
|
||||||
const built = buildTreasuryFromCustomerPayment(
|
const built = buildTreasuryFromCustomerPayment(
|
||||||
item,
|
item,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useState } from 'react'
|
|||||||
import { apiGet, asList } from '../api/client'
|
import { apiGet, asList } from '../api/client'
|
||||||
import { ENDPOINTS } from '../api/config'
|
import { ENDPOINTS } from '../api/config'
|
||||||
import type { ApiOwnerTransaction, ApiPaymentOrRefund } from '../api/types'
|
import type { ApiOwnerTransaction, ApiPaymentOrRefund } from '../api/types'
|
||||||
import { useVoucherSync } from '../accounting/voucherSync'
|
|
||||||
import { useStore } from '../store/AppStore'
|
import { useStore } from '../store/AppStore'
|
||||||
import type { Party } from '../types'
|
import type { Party } from '../types'
|
||||||
import {
|
import {
|
||||||
@@ -40,13 +39,10 @@ export function FunZonePartySection({ config }: { config: FunZonePartyConfig })
|
|||||||
const { data, upsertPartyAsync } = useStore()
|
const { data, upsertPartyAsync } = useStore()
|
||||||
const { parties, loading, syncing, error, reload, syncToAccounting } = useFunZoneParties(config)
|
const { parties, loading, syncing, error, reload, syncToAccounting } = useFunZoneParties(config)
|
||||||
const withdrawals = usePartyWithdrawals(config.kind)
|
const withdrawals = usePartyWithdrawals(config.kind)
|
||||||
const { syncOwnerTransactions, syncCustomerActivity } = useVoucherSync()
|
|
||||||
|
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [selected, setSelected] = useState<Party | null>(null)
|
const [selected, setSelected] = useState<Party | null>(null)
|
||||||
const [editAccounting, setEditAccounting] = useState<Party | null>(null)
|
const [editAccounting, setEditAccounting] = useState<Party | null>(null)
|
||||||
const [syncBusy, setSyncBusy] = useState(false)
|
|
||||||
const [syncMsg, setSyncMsg] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const query = search.trim().toLowerCase()
|
const query = search.trim().toLowerCase()
|
||||||
@@ -67,28 +63,6 @@ export function FunZonePartySection({ config }: { config: FunZonePartyConfig })
|
|||||||
return { count: parties.length, totalWallet }
|
return { count: parties.length, totalWallet }
|
||||||
}, [parties])
|
}, [parties])
|
||||||
|
|
||||||
const handleBulkSync = async () => {
|
|
||||||
setSyncBusy(true)
|
|
||||||
setSyncMsg(null)
|
|
||||||
try {
|
|
||||||
if (config.kind === 'supplier') {
|
|
||||||
const all = await apiGet<unknown>(ENDPOINTS.ALL_OWNER_TRANSACTIONS).then(asList<ApiOwnerTransaction>)
|
|
||||||
const res = await syncOwnerTransactions(all)
|
|
||||||
setSyncMsg(`صدور سند: ${toFa(res.created)} جدید، ${toFa(res.skipped)} قبلی`)
|
|
||||||
} else {
|
|
||||||
const all = await apiGet<{ payments_and_refunds: ApiPaymentOrRefund[] }>(
|
|
||||||
ENDPOINTS.CUSTOMER_PAYMENTS_REFUNDS(),
|
|
||||||
).then((res) => res.payments_and_refunds ?? [])
|
|
||||||
const res = await syncCustomerActivity(all)
|
|
||||||
setSyncMsg(`صدور سند: ${toFa(res.created)} جدید، ${toFa(res.skipped)} قبلی`)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setSyncMsg(err instanceof Error ? err.message : 'خطا در صدور سند')
|
|
||||||
} finally {
|
|
||||||
setSyncBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSaveAccounting = async () => {
|
const handleSaveAccounting = async () => {
|
||||||
if (!editAccounting?.id) return
|
if (!editAccounting?.id) return
|
||||||
await upsertPartyAsync(editAccounting)
|
await upsertPartyAsync(editAccounting)
|
||||||
@@ -259,16 +233,12 @@ export function FunZonePartySection({ config }: { config: FunZonePartyConfig })
|
|||||||
<p className="text-sm text-slate-500">{config.subtitle}</p>
|
<p className="text-sm text-slate-500">{config.subtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button variant="primary" icon="📑" disabled={syncBusy} onClick={handleBulkSync}>
|
|
||||||
{syncBusy ? 'در حال صدور…' : 'صدور سند همه تراکنشها'}
|
|
||||||
</Button>
|
|
||||||
<Button variant="secondary" icon="↻" disabled={loading || syncing} onClick={() => { void reload(); void syncToAccounting() }}>
|
<Button variant="secondary" icon="↻" disabled={loading || syncing} onClick={() => { void reload(); void syncToAccounting() }}>
|
||||||
{syncing ? 'همگامسازی…' : 'بروزرسانی'}
|
{syncing ? 'همگامسازی…' : 'بروزرسانی'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{syncMsg && <p className="mb-4 rounded-lg bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{syncMsg}</p>}
|
|
||||||
{error && <p className="mb-4 rounded-lg bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</p>}
|
{error && <p className="mb-4 rounded-lg bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</p>}
|
||||||
|
|
||||||
<div className="mb-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="mb-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
@@ -352,9 +322,6 @@ function PartyDetail({
|
|||||||
onBack: () => void
|
onBack: () => void
|
||||||
}) {
|
}) {
|
||||||
const { data } = useStore()
|
const { data } = useStore()
|
||||||
const { syncOwnerTransactions, syncCustomerActivity } = useVoucherSync()
|
|
||||||
const [syncMsg, setSyncMsg] = useState<string | null>(null)
|
|
||||||
const [syncBusy, setSyncBusy] = useState(false)
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [ownerTxns, setOwnerTxns] = useState<ApiOwnerTransaction[]>([])
|
const [ownerTxns, setOwnerTxns] = useState<ApiOwnerTransaction[]>([])
|
||||||
const [customerActivity, setCustomerActivity] = useState<ApiPaymentOrRefund[]>([])
|
const [customerActivity, setCustomerActivity] = useState<ApiPaymentOrRefund[]>([])
|
||||||
@@ -390,23 +357,6 @@ function PartyDetail({
|
|||||||
|
|
||||||
const partyWithdrawals = withdrawals.filter((w) => w.user_id === externalId)
|
const partyWithdrawals = withdrawals.filter((w) => w.user_id === externalId)
|
||||||
|
|
||||||
const handleSync = async () => {
|
|
||||||
setSyncBusy(true)
|
|
||||||
try {
|
|
||||||
if (config.kind === 'supplier') {
|
|
||||||
const res = await syncOwnerTransactions(ownerTxns)
|
|
||||||
setSyncMsg(`صدور سند: ${toFa(res.created)} جدید، ${toFa(res.skipped)} قبلی`)
|
|
||||||
} else {
|
|
||||||
const res = await syncCustomerActivity(customerActivity)
|
|
||||||
setSyncMsg(`صدور سند: ${toFa(res.created)} جدید، ${toFa(res.skipped)} قبلی`)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setSyncMsg(err instanceof Error ? err.message : 'خطا')
|
|
||||||
} finally {
|
|
||||||
setSyncBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const invoiceCount =
|
const invoiceCount =
|
||||||
party.id ? salesDocuments(data.invoices).filter((i) => i.partyId === party.id).length : 0
|
party.id ? salesDocuments(data.invoices).filter((i) => i.partyId === party.id).length : 0
|
||||||
|
|
||||||
@@ -422,13 +372,8 @@ function PartyDetail({
|
|||||||
<p className="text-sm text-slate-500">{toFa(party.phone)}</p>
|
<p className="text-sm text-slate-500">{toFa(party.phone)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="primary" icon="📑" disabled={syncBusy || loading} onClick={() => void handleSync()}>
|
|
||||||
{syncBusy ? 'در حال صدور…' : 'صدور سند'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{syncMsg && <Card className="border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">{syncMsg}</Card>}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||||
<StatCard label="موجودی کیف پول" value={formatMoney(party.walletBalance ?? 0)} icon="💼" tone="brand" />
|
<StatCard label="موجودی کیف پول" value={formatMoney(party.walletBalance ?? 0)} icon="💼" tone="brand" />
|
||||||
<StatCard
|
<StatCard
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ interface AppStoreValue {
|
|||||||
loading: boolean
|
loading: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
ready: boolean
|
ready: boolean
|
||||||
reload: () => void
|
reload: () => Promise<AppData | null>
|
||||||
upsertAccount: (item: Account) => void
|
upsertAccount: (item: Account) => void
|
||||||
removeAccount: (id: string) => void
|
removeAccount: (id: string) => void
|
||||||
upsertVoucher: (item: Voucher) => void
|
upsertVoucher: (item: Voucher) => void
|
||||||
@@ -163,7 +163,7 @@ export function AppStoreProvider({ children }: { children: ReactNode }) {
|
|||||||
[disconnect],
|
[disconnect],
|
||||||
)
|
)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async (): Promise<AppData | null> => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
@@ -181,11 +181,14 @@ export function AppStoreProvider({ children }: { children: ReactNode }) {
|
|||||||
acct.get(ENDPOINT.payslips).then(asList<Payslip>),
|
acct.get(ENDPOINT.payslips).then(asList<Payslip>),
|
||||||
acct.get<CompanySettings>(ACCOUNTING_ENDPOINTS.SETTINGS),
|
acct.get<CompanySettings>(ACCOUNTING_ENDPOINTS.SETTINGS),
|
||||||
])
|
])
|
||||||
setData({ accounts, vouchers, parties, products, invoices, salesTypes, partyAdjustments, treasury, employees, payslips, settings })
|
const loaded = { accounts, vouchers, parties, products, invoices, salesTypes, partyAdjustments, treasury, employees, payslips, settings }
|
||||||
|
setData(loaded)
|
||||||
setReady(true)
|
setReady(true)
|
||||||
|
return loaded
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setReady(false)
|
setReady(false)
|
||||||
handleApiError(err)
|
handleApiError(err)
|
||||||
|
return null
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -279,9 +282,7 @@ export function AppStoreProvider({ children }: { children: ReactNode }) {
|
|||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
ready,
|
ready,
|
||||||
reload: () => {
|
reload: load,
|
||||||
void load()
|
|
||||||
},
|
|
||||||
upsertAccount: (item) => void upsert('accounts', item),
|
upsertAccount: (item) => void upsert('accounts', item),
|
||||||
removeAccount: (id) => void remove('accounts', id),
|
removeAccount: (id) => void remove('accounts', id),
|
||||||
upsertVoucher: (item) => void upsert('vouchers', item),
|
upsertVoucher: (item) => void upsert('vouchers', item),
|
||||||
|
|||||||
@@ -20,3 +20,16 @@ export function eventNameFromTreasuryDescription(description: string): string |
|
|||||||
if (!raw || raw === '—') return undefined
|
if (!raw || raw === '—') return undefined
|
||||||
return raw === 'کیف پول' ? 'wallet' : raw
|
return raw === 'کیف پول' ? 'wallet' : raw
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Event label with category context for accounting "regarding" fields. */
|
||||||
|
export function eventNameWithCategory(
|
||||||
|
eventName: string | null | undefined,
|
||||||
|
categoryName: string | null | undefined,
|
||||||
|
): string {
|
||||||
|
const rawEvent = eventName?.trim() ?? ''
|
||||||
|
const rawCategory = categoryName?.trim() ?? ''
|
||||||
|
if (!rawEvent) return ''
|
||||||
|
if (rawEvent.toLowerCase() === 'wallet') return 'wallet'
|
||||||
|
if (!rawCategory) return rawEvent
|
||||||
|
return `${rawEvent} (${rawCategory})`
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { AppData, Voucher } from '../types'
|
import type { AppData, Voucher } from '../types'
|
||||||
import { resolveVoucherRegarding } from './voucherRegarding'
|
import { resolveVoucherRegarding } from './voucherRegarding'
|
||||||
|
import { visibleVouchers } from '../accounting/voucherDedupe'
|
||||||
|
|
||||||
export type VoucherCategoryFilter = 'all' | 'customer' | 'supplier' | 'manual'
|
export type VoucherCategoryFilter = 'all' | 'customer' | 'supplier' | 'manual'
|
||||||
export type VoucherStatusFilter = 'all' | 'draft' | 'posted'
|
export type VoucherStatusFilter = 'all' | 'draft' | 'posted'
|
||||||
@@ -96,8 +97,9 @@ export function filterVouchers(
|
|||||||
filters: VoucherFilters,
|
filters: VoucherFilters,
|
||||||
): Voucher[] {
|
): Voucher[] {
|
||||||
const query = filters.search.trim().toLowerCase()
|
const query = filters.search.trim().toLowerCase()
|
||||||
|
const rows = visibleVouchers(vouchers, data)
|
||||||
|
|
||||||
return vouchers.filter((voucher) => {
|
return rows.filter((voucher) => {
|
||||||
if (filters.category !== 'all' && classifyVoucher(voucher, data) !== filters.category) return false
|
if (filters.category !== 'all' && classifyVoucher(voucher, data) !== filters.category) return false
|
||||||
|
|
||||||
if (filters.status !== 'all' && voucher.status !== filters.status) return false
|
if (filters.status !== 'all' && voucher.status !== filters.status) return false
|
||||||
@@ -125,7 +127,7 @@ export function voucherFilterSummary(
|
|||||||
data: AppData,
|
data: AppData,
|
||||||
): Record<Exclude<VoucherCategoryFilter, 'all'>, number> {
|
): Record<Exclude<VoucherCategoryFilter, 'all'>, number> {
|
||||||
const summary = { customer: 0, supplier: 0, manual: 0 }
|
const summary = { customer: 0, supplier: 0, manual: 0 }
|
||||||
for (const voucher of vouchers) {
|
for (const voucher of visibleVouchers(vouchers, data)) {
|
||||||
summary[classifyVoucher(voucher, data)] += 1
|
summary[classifyVoucher(voucher, data)] += 1
|
||||||
}
|
}
|
||||||
return summary
|
return summary
|
||||||
|
|||||||
Reference in New Issue
Block a user