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