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

283
src/pages/Vouchers.tsx Normal file
View File

@@ -0,0 +1,283 @@
import { useMemo, useState } from 'react'
import { useStore } from '../store/AppStore'
import {
Badge,
Button,
Card,
ConfirmDialog,
DataTable,
Field,
Input,
Modal,
PageHeader,
Select,
type Column,
} from '../components/ui'
import type { Voucher, VoucherLine } from '../types'
import { createId, nextNumber } from '../utils/id'
import { formatDate, formatMoney, toFa } from '../utils/format'
import { voucherBalance } from '../utils/accounting'
const newLine = (): VoucherLine => ({
id: createId('vl-'),
accountId: '',
description: '',
debit: 0,
credit: 0,
})
function createDraft(items: Voucher[]): Voucher {
return {
id: '',
number: nextNumber(items),
date: new Date().toISOString().slice(0, 10),
description: '',
status: 'draft',
lines: [newLine(), newLine()],
}
}
export function Vouchers() {
const { data, upsertVoucher, removeVoucher } = useStore()
const [draft, setDraft] = useState<Voucher | null>(null)
const [toDelete, setToDelete] = useState<Voucher | null>(null)
const leafAccounts = useMemo(
() => data.accounts.filter((account) => !account.isGroup),
[data.accounts],
)
const accountName = (id: string) => leafAccounts.find((a) => a.id === id)?.name ?? '—'
const sorted = useMemo(
() => [...data.vouchers].sort((a, b) => b.date.localeCompare(a.date) || b.number - a.number),
[data.vouchers],
)
const balance = draft ? voucherBalance(draft) : null
const updateLine = (lineId: string, patch: Partial<VoucherLine>) => {
if (!draft) return
setDraft({
...draft,
lines: draft.lines.map((line) => (line.id === lineId ? { ...line, ...patch } : line)),
})
}
const handleSave = (status: Voucher['status']) => {
if (!draft) return
const cleanLines = draft.lines.filter((line) => line.accountId && (line.debit > 0 || line.credit > 0))
if (cleanLines.length < 2) return
const candidate: Voucher = { ...draft, lines: cleanLines, status }
if (status === 'posted' && !voucherBalance(candidate).balanced) return
upsertVoucher({ ...candidate, id: candidate.id || createId('v-') })
setDraft(null)
}
const columns: Array<Column<Voucher>> = [
{ key: 'number', header: 'شماره', render: (v) => <span className="font-mono">{toFa(v.number)}</span> },
{ key: 'date', header: 'تاریخ', render: (v) => formatDate(v.date) },
{ key: 'desc', header: 'شرح', render: (v) => <span className="text-slate-700">{v.description || '—'}</span> },
{
key: 'amount',
header: 'مبلغ',
align: 'end',
render: (v) => <span className="tabular-nums">{formatMoney(voucherBalance(v).debit)}</span>,
},
{
key: 'status',
header: 'وضعیت',
render: (v) =>
v.status === 'posted' ? <Badge tone="green">ثبت قطعی</Badge> : <Badge tone="amber">پیشنویس</Badge>,
},
{
key: 'actions',
header: '',
align: 'end',
render: (v) => (
<div className="flex justify-end gap-1">
<Button size="sm" variant="ghost" onClick={() => setDraft({ ...v, lines: v.lines.map((l) => ({ ...l })) })}>
ویرایش
</Button>
<Button size="sm" variant="ghost" className="text-rose-600" onClick={() => setToDelete(v)}>
حذف
</Button>
</div>
),
},
]
return (
<div className="space-y-6">
<PageHeader
title="اسناد حسابداری"
subtitle="ثبت اسناد دوطرفه با کنترل تراز بدهکار و بستانکار"
actions={
<Button icon="" onClick={() => setDraft(createDraft(data.vouchers))}>
سند جدید
</Button>
}
/>
<Card className="p-4">
<DataTable
columns={columns}
rows={sorted}
rowKey={(v) => v.id}
emptyTitle="سندی ثبت نشده است"
emptyDescription="اولین سند حسابداری خود را ایجاد کنید."
/>
</Card>
<Modal
open={draft !== null}
title={draft?.id ? `ویرایش سند #${toFa(draft.number)}` : 'سند حسابداری جدید'}
onClose={() => setDraft(null)}
size="xl"
footer={
<>
<div className="me-auto flex items-center gap-4 text-sm">
<span className="text-slate-500">
بدهکار: <span className="tabular-nums font-semibold text-slate-700">{formatMoney(balance?.debit ?? 0)}</span>
</span>
<span className="text-slate-500">
بستانکار: <span className="tabular-nums font-semibold text-slate-700">{formatMoney(balance?.credit ?? 0)}</span>
</span>
{balance?.balanced ? (
<Badge tone="green">تراز است</Badge>
) : (
<Badge tone="rose">اختلاف {formatMoney(Math.abs((balance?.debit ?? 0) - (balance?.credit ?? 0)))}</Badge>
)}
</div>
<Button variant="secondary" onClick={() => setDraft(null)}>
انصراف
</Button>
<Button variant="secondary" onClick={() => handleSave('draft')}>
ذخیره پیشنویس
</Button>
<Button variant="success" disabled={!balance?.balanced} onClick={() => handleSave('posted')}>
ثبت قطعی
</Button>
</>
}
>
{draft && (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Field label="شماره سند">
<Input
type="number"
value={draft.number}
onChange={(e) => setDraft({ ...draft, number: Number(e.target.value) || 0 })}
/>
</Field>
<Field label="تاریخ">
<Input type="date" value={draft.date} onChange={(e) => setDraft({ ...draft, date: e.target.value })} />
</Field>
<Field label="شرح سند">
<Input value={draft.description} onChange={(e) => setDraft({ ...draft, description: e.target.value })} />
</Field>
</div>
<div className="overflow-x-auto rounded-xl border border-slate-200">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-500">
<tr>
<th className="px-3 py-2 text-start font-medium">حساب</th>
<th className="px-3 py-2 text-start font-medium">شرح ردیف</th>
<th className="px-3 py-2 text-end font-medium">بدهکار</th>
<th className="px-3 py-2 text-end font-medium">بستانکار</th>
<th className="px-3 py-2" />
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{draft.lines.map((line) => (
<tr key={line.id}>
<td className="px-2 py-1.5">
<Select
value={line.accountId}
onChange={(e) => updateLine(line.id, { accountId: e.target.value })}
>
<option value="">انتخاب حساب</option>
{leafAccounts.map((account) => (
<option key={account.id} value={account.id}>
{account.code} - {account.name}
</option>
))}
</Select>
</td>
<td className="px-2 py-1.5">
<Input
value={line.description}
onChange={(e) => updateLine(line.id, { description: e.target.value })}
placeholder="شرح"
/>
</td>
<td className="px-2 py-1.5">
<Input
type="number"
className="text-end"
value={line.debit || ''}
onChange={(e) =>
updateLine(line.id, { debit: Number(e.target.value) || 0, credit: 0 })
}
/>
</td>
<td className="px-2 py-1.5">
<Input
type="number"
className="text-end"
value={line.credit || ''}
onChange={(e) =>
updateLine(line.id, { credit: Number(e.target.value) || 0, debit: 0 })
}
/>
</td>
<td className="px-2 py-1.5 text-center">
<button
onClick={() =>
setDraft({ ...draft, lines: draft.lines.filter((l) => l.id !== line.id) })
}
className="text-slate-400 transition hover:text-rose-600"
aria-label="حذف ردیف"
disabled={draft.lines.length <= 2}
>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Button
size="sm"
variant="secondary"
icon=""
onClick={() => setDraft({ ...draft, lines: [...draft.lines, newLine()] })}
>
افزودن ردیف
</Button>
{draft.lines.some((l) => l.accountId) && (
<p className="text-xs text-slate-400">
حسابهای انتخابشده: {draft.lines.filter((l) => l.accountId).map((l) => accountName(l.accountId)).join('، ')}
</p>
)}
</div>
)}
</Modal>
<ConfirmDialog
open={toDelete !== null}
title="حذف سند"
message={`سند شماره ${toFa(toDelete?.number ?? 0)} حذف شود؟`}
onCancel={() => setToDelete(null)}
onConfirm={() => {
if (toDelete) removeVoucher(toDelete.id)
setToDelete(null)
}}
/>
</div>
)
}