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

242
src/pages/Accounts.tsx Normal file
View 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>
)
}