Initial commit: FunZone accounting frontend with Sepidar-inspired RTL modules
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
232
src/pages/Dashboard.tsx
Normal file
232
src/pages/Dashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user