Initial commit: FunZone accounting frontend with Sepidar-inspired RTL modules

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Shayan Azadi
2026-07-01 00:29:25 +03:30
commit ce6c110e06
52 changed files with 8257 additions and 0 deletions

302
src/store/AppStore.tsx Normal file
View File

@@ -0,0 +1,302 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react'
import type {
Account,
AppData,
CompanySettings,
Employee,
Invoice,
Party,
Payslip,
Product,
TreasuryTxn,
Voucher,
} from '../types'
import { acct, ApiError, asList } from '../api/client'
import { ACCOUNTING_ENDPOINTS } from '../api/config'
import { useConnection } from '../connection/ConnectionContext'
type Identifiable = { id: string }
/** Collections of AppData that follow the standard id-based CRUD pattern. */
type CollectionKey =
| 'accounts'
| 'vouchers'
| 'parties'
| 'products'
| 'invoices'
| 'treasury'
| 'employees'
| 'payslips'
const ENDPOINT: Record<CollectionKey, string> = {
accounts: ACCOUNTING_ENDPOINTS.ACCOUNTS,
vouchers: ACCOUNTING_ENDPOINTS.VOUCHERS,
parties: ACCOUNTING_ENDPOINTS.PARTIES,
products: ACCOUNTING_ENDPOINTS.PRODUCTS,
invoices: ACCOUNTING_ENDPOINTS.INVOICES,
treasury: ACCOUNTING_ENDPOINTS.TREASURY,
employees: ACCOUNTING_ENDPOINTS.EMPLOYEES,
payslips: ACCOUNTING_ENDPOINTS.PAYSLIPS,
}
const EMPTY_SETTINGS: CompanySettings = {
name: '',
economicCode: '',
fiscalYear: '',
baseCurrency: 'تومان',
taxRate: 0,
address: '',
phone: '',
voucherAccounts: {
bankAccountId: '',
ownerPayableAccountId: '',
customerPayableAccountId: '',
ownerShareAccountId: '',
salesIncomeAccountId: '',
},
}
const EMPTY_DATA: AppData = {
accounts: [],
vouchers: [],
parties: [],
products: [],
invoices: [],
treasury: [],
employees: [],
payslips: [],
settings: EMPTY_SETTINGS,
}
interface AppStoreValue {
data: AppData
loading: boolean
error: string | null
ready: boolean
reload: () => void
upsertAccount: (item: Account) => void
removeAccount: (id: string) => void
upsertVoucher: (item: Voucher) => void
removeVoucher: (id: string) => void
/** Bulk-creates vouchers (used by the live-data "صدور سند" sync); returns how many were created. */
createVouchers: (items: Voucher[]) => Promise<number>
upsertParty: (item: Party) => void
removeParty: (id: string) => void
upsertProduct: (item: Product) => void
removeProduct: (id: string) => void
upsertInvoice: (item: Invoice) => void
removeInvoice: (id: string) => void
upsertTreasury: (item: TreasuryTxn) => void
removeTreasury: (id: string) => void
upsertEmployee: (item: Employee) => void
removeEmployee: (id: string) => void
upsertPayslip: (item: Payslip) => void
removePayslip: (id: string) => void
updateSettings: (settings: CompanySettings) => void
resetAll: () => void
}
const AppStoreContext = createContext<AppStoreValue | null>(null)
const errorMessage = (err: unknown): string =>
err instanceof Error ? err.message : 'خطا در ارتباط با سرور حسابداری'
/** Strips client-generated ids; nested lines are recreated server-side. */
function serializePayload(key: CollectionKey, item: Identifiable): Record<string, unknown> {
const { id: _id, ...rest } = item as Record<string, unknown> & Identifiable
if (key === 'vouchers' || key === 'invoices') {
const lines = (rest as { lines?: Identifiable[] }).lines ?? []
return {
...rest,
lines: lines.map((line) => {
const { id: _lineId, ...lineRest } = line as Record<string, unknown> & Identifiable
return lineRest
}),
}
}
return rest
}
export function AppStoreProvider({ children }: { children: ReactNode }) {
const { connected, disconnect } = useConnection()
const [data, setData] = useState<AppData>(EMPTY_DATA)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [ready, setReady] = useState(false)
const dataRef = useRef(data)
useEffect(() => {
dataRef.current = data
}, [data])
/** On an expired/invalid session, clear the token so the app returns to login. */
const handleApiError = useCallback(
(err: unknown) => {
if (err instanceof ApiError && err.status === 401) {
disconnect()
return
}
setError(errorMessage(err))
},
[disconnect],
)
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [accounts, vouchers, parties, products, invoices, treasury, employees, payslips, settings] =
await Promise.all([
acct.get(ENDPOINT.accounts).then(asList<Account>),
acct.get(ENDPOINT.vouchers).then(asList<Voucher>),
acct.get(ENDPOINT.parties).then(asList<Party>),
acct.get(ENDPOINT.products).then(asList<Product>),
acct.get(ENDPOINT.invoices).then(asList<Invoice>),
acct.get(ENDPOINT.treasury).then(asList<TreasuryTxn>),
acct.get(ENDPOINT.employees).then(asList<Employee>),
acct.get(ENDPOINT.payslips).then(asList<Payslip>),
acct.get<CompanySettings>(ACCOUNTING_ENDPOINTS.SETTINGS),
])
setData({ accounts, vouchers, parties, products, invoices, treasury, employees, payslips, settings })
setReady(true)
} catch (err) {
setReady(false)
handleApiError(err)
} finally {
setLoading(false)
}
}, [handleApiError])
useEffect(() => {
if (connected) {
void load()
} else {
setData(EMPTY_DATA)
setReady(false)
setError(null)
}
}, [connected, load])
const upsert = useCallback(
async <K extends CollectionKey>(key: K, item: AppData[K][number]) => {
const entity = item as Identifiable
const exists = (dataRef.current[key] as Identifiable[]).some((x) => x.id === entity.id)
const payload = serializePayload(key, entity)
try {
if (exists) {
const saved = await acct.patch<AppData[K][number]>(`${ENDPOINT[key]}${entity.id}/`, payload)
setData((prev) => ({
...prev,
[key]: (prev[key] as Identifiable[]).map((x) =>
x.id === entity.id ? (saved as Identifiable) : x,
),
}))
} else {
const saved = await acct.post<AppData[K][number]>(ENDPOINT[key], payload)
setData((prev) => ({ ...prev, [key]: [...(prev[key] as Identifiable[]), saved as Identifiable] }))
}
} catch (err) {
handleApiError(err)
throw err
}
},
[handleApiError],
)
const remove = useCallback(
async (key: CollectionKey, id: string) => {
try {
await acct.delete(`${ENDPOINT[key]}${id}/`)
setData((prev) => ({ ...prev, [key]: (prev[key] as Identifiable[]).filter((x) => x.id !== id) }))
} catch (err) {
handleApiError(err)
throw err
}
},
[handleApiError],
)
const createVouchers = useCallback(async (items: Voucher[]): Promise<number> => {
if (items.length === 0) return 0
try {
const saved = await Promise.all(
items.map((v) => acct.post<Voucher>(ENDPOINT.vouchers, serializePayload('vouchers', v))),
)
setData((prev) => ({ ...prev, vouchers: [...prev.vouchers, ...saved] }))
return saved.length
} catch (err) {
handleApiError(err)
throw err
}
}, [handleApiError])
const updateSettings = useCallback(async (settings: CompanySettings) => {
try {
const saved = await acct.patch<CompanySettings>(ACCOUNTING_ENDPOINTS.SETTINGS, settings)
setData((prev) => ({ ...prev, settings: saved }))
} catch (err) {
handleApiError(err)
throw err
}
}, [handleApiError])
const resetAll = useCallback(async () => {
try {
await acct.post(ACCOUNTING_ENDPOINTS.RESET, {})
await load()
} catch (err) {
handleApiError(err)
}
}, [load, handleApiError])
const value = useMemo<AppStoreValue>(
() => ({
data,
loading,
error,
ready,
reload: () => {
void load()
},
upsertAccount: (item) => void upsert('accounts', item),
removeAccount: (id) => void remove('accounts', id),
upsertVoucher: (item) => void upsert('vouchers', item),
removeVoucher: (id) => void remove('vouchers', id),
createVouchers,
upsertParty: (item) => void upsert('parties', item),
removeParty: (id) => void remove('parties', id),
upsertProduct: (item) => void upsert('products', item),
removeProduct: (id) => void remove('products', id),
upsertInvoice: (item) => void upsert('invoices', item),
removeInvoice: (id) => void remove('invoices', id),
upsertTreasury: (item) => void upsert('treasury', item),
removeTreasury: (id) => void remove('treasury', id),
upsertEmployee: (item) => void upsert('employees', item),
removeEmployee: (id) => void remove('employees', id),
upsertPayslip: (item) => void upsert('payslips', item),
removePayslip: (id) => void remove('payslips', id),
updateSettings,
resetAll,
}),
[data, loading, error, ready, load, upsert, remove, createVouchers, updateSettings, resetAll],
)
return <AppStoreContext.Provider value={value}>{children}</AppStoreContext.Provider>
}
export function useStore(): AppStoreValue {
const ctx = useContext(AppStoreContext)
if (!ctx) {
throw new Error('useStore must be used within an AppStoreProvider')
}
return ctx
}