Files
accounting-frontend/src/pages/Dashboard.tsx
2026-07-11 16:53:03 +03:30

246 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
)
}