315 lines
11 KiB
TypeScript
315 lines
11 KiB
TypeScript
import { useMemo, useState } from 'react'
|
||
import { useStore } from '../store/AppStore'
|
||
import {
|
||
Badge,
|
||
Button,
|
||
Card,
|
||
ConfirmDialog,
|
||
DataTable,
|
||
Field,
|
||
Input,
|
||
JalaliDateInput,
|
||
Modal,
|
||
PageHeader,
|
||
Select,
|
||
type Column,
|
||
} from '../components/ui'
|
||
import type { Voucher, VoucherLine } from '../types'
|
||
import { createId, nextNumber } from '../utils/id'
|
||
import { formatDate, formatMoney, todayISO, 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: todayISO(),
|
||
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: 'شماره',
|
||
headerClassName: 'w-[4.5rem]',
|
||
className: 'w-[4.5rem] whitespace-nowrap',
|
||
render: (v) => <span className="font-mono">{toFa(v.number)}</span>,
|
||
},
|
||
{
|
||
key: 'date',
|
||
header: 'تاریخ',
|
||
headerClassName: 'w-[6.5rem]',
|
||
className: 'w-[6.5rem] whitespace-nowrap tabular-nums',
|
||
render: (v) => formatDate(v.date),
|
||
},
|
||
{
|
||
key: 'desc',
|
||
header: 'شرح',
|
||
truncate: true,
|
||
className: 'min-w-0',
|
||
render: (v) => (
|
||
<span className="block truncate text-slate-700" title={v.description || undefined}>
|
||
{v.description || '—'}
|
||
</span>
|
||
),
|
||
},
|
||
{
|
||
key: 'amount',
|
||
header: 'مبلغ',
|
||
align: 'end',
|
||
headerClassName: 'w-[7.5rem]',
|
||
className: 'w-[7.5rem] whitespace-nowrap tabular-nums',
|
||
render: (v) => formatMoney(voucherBalance(v).debit),
|
||
},
|
||
{
|
||
key: 'status',
|
||
header: 'وضعیت',
|
||
sticky: { left: '9rem' },
|
||
headerClassName: 'w-[6.5rem]',
|
||
className: 'w-[6.5rem] whitespace-nowrap',
|
||
render: (v) =>
|
||
v.status === 'posted' ? <Badge tone="green">ثبت قطعی</Badge> : <Badge tone="amber">پیشنویس</Badge>,
|
||
},
|
||
{
|
||
key: 'actions',
|
||
header: '',
|
||
align: 'end',
|
||
sticky: { left: '0' },
|
||
headerClassName: 'w-[9rem]',
|
||
className: 'w-[9rem] whitespace-nowrap',
|
||
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="تاریخ">
|
||
<JalaliDateInput value={draft.date} onChange={(date) => setDraft({ ...draft, date })} />
|
||
</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>
|
||
)
|
||
}
|