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

232
src/pages/Dashboard.tsx Normal file
View File

@@ -0,0 +1,232 @@
import { useMemo } from 'react'
import { Link } from 'react-router-dom'
import { useStore } from '../store/AppStore'
import { Badge, Card, PageHeader, StatCard } from '../components/ui'
import { formatMoney, formatMoneyWithUnit, toFa } from '../utils/format'
import {
balanceSheet,
incomeStatement,
invoiceTotals,
trialBalance,
} from '../utils/accounting'
interface MonthBucket {
label: string
sales: number
purchases: number
}
function monthlyBuckets(
invoices: ReturnType<typeof useStore>['data']['invoices'],
): MonthBucket[] {
const formatter = new Intl.DateTimeFormat('fa-IR-u-ca-persian', { month: 'short' })
const buckets: MonthBucket[] = []
const now = new Date()
for (let offset = 5; offset >= 0; offset -= 1) {
const date = new Date(now.getFullYear(), now.getMonth() - offset, 1)
const key = `${date.getFullYear()}-${date.getMonth()}`
const inMonth = invoices.filter((invoice) => {
const d = new Date(invoice.date)
return `${d.getFullYear()}-${d.getMonth()}` === key
})
buckets.push({
label: formatter.format(date),
sales: inMonth
.filter((i) => i.kind === 'sale')
.reduce((sum, i) => sum + invoiceTotals(i).net, 0),
purchases: inMonth
.filter((i) => i.kind === 'purchase')
.reduce((sum, i) => sum + invoiceTotals(i).net, 0),
})
}
return buckets
}
export function Dashboard() {
const { data } = useStore()
const metrics = useMemo(() => {
const balances = trialBalance(data.accounts, data.vouchers)
const liquidity = balances
.filter((b) => ['1001', '1002', '1003'].includes(b.account.code))
.reduce((sum, b) => sum + b.balance, 0)
const sales = data.invoices
.filter((i) => i.kind === 'sale')
.reduce((sum, i) => sum + invoiceTotals(i).net, 0)
const purchases = data.invoices
.filter((i) => i.kind === 'purchase')
.reduce((sum, i) => sum + invoiceTotals(i).net, 0)
const income = incomeStatement(balances)
const sheet = balanceSheet(balances)
const lowStock = data.products.filter((p) => p.stock <= p.reorderLevel)
const unpaidSales = data.invoices.filter((i) => i.kind === 'sale' && i.status !== 'paid')
return { liquidity, sales, purchases, income, sheet, lowStock, unpaidSales }
}, [data])
const buckets = useMemo(() => monthlyBuckets(data.invoices), [data.invoices])
const chartMax = Math.max(1, ...buckets.flatMap((b) => [b.sales, b.purchases]))
return (
<div className="space-y-6">
<PageHeader
title="داشبورد مدیریتی"
subtitle="نمای کلی از وضعیت مالی مجموعه"
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard
label="موجودی نقد و بانک"
value={formatMoney(metrics.liquidity)}
hint="صندوق و حساب‌های بانکی"
icon="💰"
tone="brand"
/>
<StatCard
label="فروش کل"
value={formatMoney(metrics.sales)}
hint={`${toFa(data.invoices.filter((i) => i.kind === 'sale').length)} فاکتور فروش`}
icon="🧾"
tone="green"
/>
<StatCard
label="خرید کل"
value={formatMoney(metrics.purchases)}
hint={`${toFa(data.invoices.filter((i) => i.kind === 'purchase').length)} فاکتور خرید`}
icon="🛒"
tone="amber"
/>
<StatCard
label="سود (زیان) دوره"
value={formatMoney(metrics.income.profit)}
hint="درآمد منهای هزینه"
icon={metrics.income.profit >= 0 ? '📈' : '📉'}
tone={metrics.income.profit >= 0 ? 'green' : 'rose'}
/>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<Card className="p-5 lg:col-span-2">
<div className="mb-5 flex items-center justify-between">
<h2 className="text-lg font-bold text-slate-800">روند فروش و خرید (۶ ماه اخیر)</h2>
<div className="flex items-center gap-4 text-xs text-slate-500">
<span className="flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded-full bg-brand-500" /> فروش
</span>
<span className="flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded-full bg-amber-400" /> خرید
</span>
</div>
</div>
<div className="flex h-56 items-end justify-between gap-3">
{buckets.map((bucket) => (
<div key={bucket.label} className="flex flex-1 flex-col items-center gap-2">
<div className="flex h-full w-full items-end justify-center gap-1">
<div
className="w-1/2 rounded-t-md bg-brand-500 transition-all"
style={{ height: `${(bucket.sales / chartMax) * 100}%` }}
title={formatMoney(bucket.sales)}
/>
<div
className="w-1/2 rounded-t-md bg-amber-400 transition-all"
style={{ height: `${(bucket.purchases / chartMax) * 100}%` }}
title={formatMoney(bucket.purchases)}
/>
</div>
<span className="text-xs text-slate-500">{bucket.label}</span>
</div>
))}
</div>
</Card>
<Card className="p-5">
<h2 className="mb-4 text-lg font-bold text-slate-800">ترازنامه فشرده</h2>
<dl className="space-y-3 text-sm">
<Row label="دارایی‌ها" value={metrics.sheet.assets} tone="text-slate-800" />
<Row label="بدهی‌ها" value={metrics.sheet.liabilities} tone="text-rose-600" />
<Row label="حقوق صاحبان سهام" value={metrics.sheet.equity} tone="text-slate-800" />
<Row label="سود انباشته دوره" value={metrics.sheet.retainedEarnings} tone="text-emerald-600" />
<div className="border-t border-slate-100 pt-3">
<Row
label="جمع بدهی و سرمایه"
value={metrics.sheet.liabilities + metrics.sheet.equity + metrics.sheet.retainedEarnings}
tone="text-brand-700 font-bold"
/>
</div>
</dl>
</Card>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<Card className="p-5">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-bold text-slate-800">هشدار موجودی انبار</h2>
<Link to="/inventory" className="text-sm font-medium text-brand-600 hover:underline">
مدیریت انبار
</Link>
</div>
{metrics.lowStock.length === 0 ? (
<p className="py-6 text-center text-sm text-slate-400">موجودی همه کالاها در حد مطلوب است.</p>
) : (
<ul className="space-y-2">
{metrics.lowStock.map((product) => (
<li
key={product.id}
className="flex items-center justify-between rounded-lg bg-rose-50 px-3 py-2 text-sm"
>
<span className="font-medium text-slate-700">{product.name}</span>
<Badge tone="rose">
موجودی: {toFa(product.stock)} {product.unit}
</Badge>
</li>
))}
</ul>
)}
</Card>
<Card className="p-5">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-bold text-slate-800">فاکتورهای فروش تسویهنشده</h2>
<Link to="/sales" className="text-sm font-medium text-brand-600 hover:underline">
مشاهده فروش
</Link>
</div>
{metrics.unpaidSales.length === 0 ? (
<p className="py-6 text-center text-sm text-slate-400">همه فاکتورها تسویه شدهاند.</p>
) : (
<ul className="space-y-2">
{metrics.unpaidSales.slice(0, 5).map((invoice) => {
const party = data.parties.find((p) => p.id === invoice.partyId)
return (
<li
key={invoice.id}
className="flex items-center justify-between rounded-lg bg-slate-50 px-3 py-2 text-sm"
>
<span className="font-medium text-slate-700">
فاکتور #{toFa(invoice.number)} · {party?.name ?? '—'}
</span>
<span className="tabular-nums text-slate-600">
{formatMoneyWithUnit(invoiceTotals(invoice).net)}
</span>
</li>
)
})}
</ul>
)}
</Card>
</div>
</div>
)
}
function Row({ label, value, tone }: { label: string; value: number; tone: string }) {
return (
<div className="flex items-center justify-between">
<dt className="text-slate-500">{label}</dt>
<dd className={`tabular-nums ${tone}`}>{formatMoney(value)}</dd>
</div>
)
}