feat(dashboard): real data — metrics, charts, top customers, recent transactions
Dashboard is no longer mock data. Single getDashboardData(tenantId) server
query computes everything in one pass.
New aggregator (lib/appwrite/dashboard-queries.ts):
- Pulls customers, invoices, finance_entries, tasks, services in parallel.
- Derives:
* metrics: totalCustomers, activeCustomers, monthIncome,
prevMonthIncome (for delta), outstanding (unpaid invoice total),
overdueCount, openTasks, urgentTasks
* monthlyIncome: 12-month income+expense series for area chart
* topCustomers: 5 highest-grossing customers by paid invoice total
* recentTransactions: 8 newest finance entries
* topServices: 5 services by aggregate unit price (placeholder, will
refine when we have invoice line analytics)
* newCustomersMonthly: 6-month new customer count for bar chart
Components (dashboard/components/):
- Metrics: 4 cards with trend indicator on income (delta vs previous
month), warning tone on overdue invoices and urgent tasks.
- IncomeChart: Recharts Area chart, dual income/expense series with
gradient fills, Turkish month labels.
- TopCustomers: ranked list with progress bars relative to top earner.
- RecentTransactions: list with type badge, signed amount, link to
/finance for full list.
- CustomerGrowth: BarChart of new customers per month (last 6).
- QuickActions: 4 buttons linking to /customers, /invoices, /calendar,
/tasks (replaced template's New User/Add Product/etc).
Layout: 4 metric cards row, then income chart + top customers (2-col),
then recent transactions + customer growth (2-col).
Removed:
- src/app/(dashboard)/dashboard-2/ (was the demo page; same components
re-exported into the real /dashboard from there. Now /dashboard owns
its components.)
- 'Dashboard 2' entry from CommandSearch; replaced with our actual
module list (Müşteriler / Hizmetler / Yazılımlarımız / Takvim /
Görevler / Gelir-Gider / Faturalar).
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowDownRight,
|
||||
ArrowUpRight,
|
||||
CheckSquare,
|
||||
Receipt,
|
||||
Users,
|
||||
Wallet,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { formatTRY } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import type { DashboardData } from "@/lib/appwrite/dashboard-queries";
|
||||
|
||||
function delta(current: number, previous: number): { pct: number; positive: boolean } | null {
|
||||
if (previous === 0) {
|
||||
if (current === 0) return null;
|
||||
return { pct: 100, positive: true };
|
||||
}
|
||||
const pct = ((current - previous) / previous) * 100;
|
||||
return { pct: Math.abs(pct), positive: pct >= 0 };
|
||||
}
|
||||
|
||||
export function Metrics({ data }: { data: DashboardData["metrics"] }) {
|
||||
const incomeDelta = delta(data.monthIncome, data.prevMonthIncome);
|
||||
|
||||
const cards = [
|
||||
{
|
||||
label: "Müşteriler",
|
||||
value: String(data.totalCustomers),
|
||||
sub: `${data.activeCustomers} aktif`,
|
||||
icon: Users,
|
||||
tone: "default",
|
||||
},
|
||||
{
|
||||
label: "Bu ayki gelir",
|
||||
value: formatTRY(data.monthIncome),
|
||||
sub: incomeDelta
|
||||
? `${incomeDelta.positive ? "+" : "−"}${incomeDelta.pct.toFixed(1)}% önceki ay`
|
||||
: "Geçen ay veri yok",
|
||||
icon: Wallet,
|
||||
tone: "income",
|
||||
trend: incomeDelta,
|
||||
},
|
||||
{
|
||||
label: "Bekleyen tahsilat",
|
||||
value: formatTRY(data.outstanding),
|
||||
sub:
|
||||
data.overdueCount > 0
|
||||
? `${data.overdueCount} vadesi geçmiş`
|
||||
: "Vadesi geçmiş yok",
|
||||
icon: Receipt,
|
||||
tone: data.overdueCount > 0 ? "warning" : "default",
|
||||
},
|
||||
{
|
||||
label: "Açık görevler",
|
||||
value: String(data.openTasks),
|
||||
sub:
|
||||
data.urgentTasks > 0 ? `${data.urgentTasks} acil` : "Acil görev yok",
|
||||
icon: CheckSquare,
|
||||
tone: data.urgentTasks > 0 ? "warning" : "default",
|
||||
},
|
||||
];
|
||||
|
||||
const toneClass: Record<string, string> = {
|
||||
default: "text-muted-foreground",
|
||||
income: "text-emerald-600 dark:text-emerald-400",
|
||||
warning: "text-amber-600 dark:text-amber-400",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2 @5xl:grid-cols-4">
|
||||
{cards.map((c) => {
|
||||
const Icon = c.icon;
|
||||
return (
|
||||
<Card key={c.label}>
|
||||
<CardContent className="flex items-start justify-between p-5">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{c.label}
|
||||
</p>
|
||||
<p className="mt-2 text-2xl font-semibold tabular-nums">{c.value}</p>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1 flex items-center gap-1 text-xs",
|
||||
c.tone === "warning" && data.overdueCount + data.urgentTasks > 0
|
||||
? "text-amber-600 dark:text-amber-400"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{c.trend &&
|
||||
(c.trend.positive ? (
|
||||
<ArrowUpRight className="text-emerald-600 dark:text-emerald-400 size-3" />
|
||||
) : (
|
||||
<ArrowDownRight className="text-red-600 dark:text-red-400 size-3" />
|
||||
))}
|
||||
{c.tone === "warning" && data.overdueCount + data.urgentTasks > 0 && (
|
||||
<AlertCircle className="size-3" />
|
||||
)}
|
||||
{c.sub}
|
||||
</p>
|
||||
</div>
|
||||
<Icon className={cn("size-5", toneClass[c.tone])} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user