246 lines
9.7 KiB
TypeScript
246 lines
9.7 KiB
TypeScript
import { useMemo } from 'react'
|
||
import { Link } from 'react-router-dom'
|
||
import { visibleVouchers } from '../accounting/voucherDedupe'
|
||
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 isSaleInvoice(invoice: ReturnType<typeof useStore>['data']['invoices'][number]): boolean {
|
||
return (
|
||
invoice.kind === 'sale' &&
|
||
(invoice.documentType ?? 'invoice') === 'invoice' &&
|
||
invoice.status !== 'draft' &&
|
||
invoice.status !== 'cancelled'
|
||
)
|
||
}
|
||
|
||
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(isSaleInvoice)
|
||
.reduce((sum, i) => sum + invoiceTotals(i).net, 0),
|
||
purchases: inMonth
|
||
.filter((i) => i.kind === 'purchase' && i.status !== 'draft')
|
||
.reduce((sum, i) => sum + invoiceTotals(i).net, 0),
|
||
})
|
||
}
|
||
return buckets
|
||
}
|
||
|
||
export function Dashboard() {
|
||
const { data } = useStore()
|
||
|
||
const metrics = useMemo(() => {
|
||
const vouchers = visibleVouchers(data.vouchers, data)
|
||
const balances = trialBalance(data.accounts, 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(isSaleInvoice)
|
||
.reduce((sum, i) => sum + invoiceTotals(i).net, 0)
|
||
const purchases = data.invoices
|
||
.filter((i) => i.kind === 'purchase' && i.status !== 'draft')
|
||
.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.documentType ?? 'invoice') === 'invoice' && i.status === 'confirmed',
|
||
)
|
||
|
||
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(isSaleInvoice).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/review" 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>
|
||
)
|
||
}
|