Initial commit: FunZone accounting frontend with Sepidar-inspired RTL modules
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
302
src/store/AppStore.tsx
Normal file
302
src/store/AppStore.tsx
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user