Files
accounting-frontend/src/pages/Vouchers.tsx

315 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
)
}