Initial commit: FunZone accounting frontend with Sepidar-inspired RTL modules
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
242
src/pages/Accounts.tsx
Normal file
242
src/pages/Accounts.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
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 { Account, AccountType } from '../types'
|
||||
import { createId } from '../utils/id'
|
||||
import { formatMoney, toFa } from '../utils/format'
|
||||
|
||||
const typeLabels: Record<AccountType, string> = {
|
||||
asset: 'دارایی',
|
||||
liability: 'بدهی',
|
||||
equity: 'سرمایه',
|
||||
income: 'درآمد',
|
||||
expense: 'هزینه',
|
||||
}
|
||||
|
||||
const typeTones: Record<AccountType, 'blue' | 'rose' | 'indigo' | 'green' | 'amber'> = {
|
||||
asset: 'blue',
|
||||
liability: 'rose',
|
||||
equity: 'indigo',
|
||||
income: 'green',
|
||||
expense: 'amber',
|
||||
}
|
||||
|
||||
const emptyDraft = (): Account => ({
|
||||
id: '',
|
||||
code: '',
|
||||
name: '',
|
||||
type: 'asset',
|
||||
parentId: null,
|
||||
isGroup: false,
|
||||
openingBalance: 0,
|
||||
})
|
||||
|
||||
export function Accounts() {
|
||||
const { data, upsertAccount, removeAccount } = useStore()
|
||||
const [search, setSearch] = useState('')
|
||||
const [draft, setDraft] = useState<Account | null>(null)
|
||||
const [toDelete, setToDelete] = useState<Account | null>(null)
|
||||
|
||||
const groups = useMemo(
|
||||
() => data.accounts.filter((account) => account.isGroup),
|
||||
[data.accounts],
|
||||
)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const query = search.trim().toLowerCase()
|
||||
if (!query) return data.accounts
|
||||
return data.accounts.filter(
|
||||
(account) =>
|
||||
account.name.toLowerCase().includes(query) || account.code.includes(query),
|
||||
)
|
||||
}, [data.accounts, search])
|
||||
|
||||
const handleSave = () => {
|
||||
if (!draft) return
|
||||
if (!draft.code.trim() || !draft.name.trim()) return
|
||||
upsertAccount({ ...draft, id: draft.id || createId('acc-') })
|
||||
setDraft(null)
|
||||
}
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (!toDelete) return
|
||||
removeAccount(toDelete.id)
|
||||
setToDelete(null)
|
||||
}
|
||||
|
||||
const columns: Array<Column<Account>> = [
|
||||
{
|
||||
key: 'code',
|
||||
header: 'کد حساب',
|
||||
render: (a) => <span className="font-mono text-slate-500">{toFa(a.code)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
header: 'عنوان حساب',
|
||||
render: (a) => (
|
||||
<span className={a.isGroup ? 'font-bold text-slate-800' : 'text-slate-700'}>
|
||||
{a.isGroup && <span className="ms-1 text-slate-400">▸</span>} {a.name}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
header: 'ماهیت',
|
||||
render: (a) => <Badge tone={typeTones[a.type]}>{typeLabels[a.type]}</Badge>,
|
||||
},
|
||||
{
|
||||
key: 'kind',
|
||||
header: 'نوع',
|
||||
render: (a) => (a.isGroup ? <Badge tone="slate">گروه</Badge> : <Badge tone="indigo">معین</Badge>),
|
||||
},
|
||||
{
|
||||
key: 'opening',
|
||||
header: 'مانده افتتاحیه',
|
||||
align: 'end',
|
||||
render: (a) => (a.isGroup ? '—' : <span className="tabular-nums">{formatMoney(a.openingBalance)}</span>),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
align: 'end',
|
||||
render: (a) => (
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button size="sm" variant="ghost" onClick={() => setDraft({ ...a })}>
|
||||
ویرایش
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="text-rose-600" onClick={() => setToDelete(a)}>
|
||||
حذف
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="حسابها (کدینگ)"
|
||||
subtitle="کدینگ استاندارد حسابها مطابق ساختار دارایی، بدهی، سرمایه، درآمد و هزینه"
|
||||
actions={
|
||||
<Button icon="+" onClick={() => setDraft(emptyDraft())}>
|
||||
حساب جدید
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card className="p-4">
|
||||
<Input
|
||||
placeholder="جستجو بر اساس کد یا عنوان حساب…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="mb-4 max-w-sm"
|
||||
/>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={filtered}
|
||||
rowKey={(a) => a.id}
|
||||
emptyTitle="حسابی یافت نشد"
|
||||
emptyDescription="برای شروع یک حساب جدید تعریف کنید."
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
open={draft !== null}
|
||||
title={draft?.id ? 'ویرایش حساب' : 'تعریف حساب جدید'}
|
||||
onClose={() => setDraft(null)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setDraft(null)}>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button onClick={handleSave}>ذخیره</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{draft && (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field label="کد حساب">
|
||||
<Input
|
||||
value={draft.code}
|
||||
onChange={(e) => setDraft({ ...draft, code: e.target.value })}
|
||||
placeholder="مثال: 1001"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="عنوان حساب">
|
||||
<Input
|
||||
value={draft.name}
|
||||
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||
placeholder="مثال: صندوق"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="ماهیت حساب">
|
||||
<Select
|
||||
value={draft.type}
|
||||
onChange={(e) => setDraft({ ...draft, type: e.target.value as AccountType })}
|
||||
>
|
||||
{Object.entries(typeLabels).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="حساب گروه (سرفصل)">
|
||||
<Select
|
||||
value={draft.parentId ?? ''}
|
||||
onChange={(e) => setDraft({ ...draft, parentId: e.target.value || null })}
|
||||
>
|
||||
<option value="">— بدون گروه —</option>
|
||||
{groups
|
||||
.filter((group) => group.id !== draft.id)
|
||||
.map((group) => (
|
||||
<option key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="نوع حساب">
|
||||
<Select
|
||||
value={draft.isGroup ? 'group' : 'leaf'}
|
||||
onChange={(e) => setDraft({ ...draft, isGroup: e.target.value === 'group' })}
|
||||
>
|
||||
<option value="leaf">حساب معین</option>
|
||||
<option value="group">گروه / سرفصل</option>
|
||||
</Select>
|
||||
</Field>
|
||||
{!draft.isGroup && (
|
||||
<Field label="مانده افتتاحیه (تومان)">
|
||||
<Input
|
||||
type="number"
|
||||
value={draft.openingBalance}
|
||||
onChange={(e) => setDraft({ ...draft, openingBalance: Number(e.target.value) || 0 })}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
open={toDelete !== null}
|
||||
title="حذف حساب"
|
||||
message={`آیا از حذف حساب «${toDelete?.name}» مطمئن هستید؟ این عمل قابل بازگشت نیست.`}
|
||||
onCancel={() => setToDelete(null)}
|
||||
onConfirm={confirmDelete}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user