Ship leftover Yimaru login/personas, sidebar sections, filter modal, chart/stat clip fixes, and matching feature docs so the polish work is fully committed. Co-authored-by: Cursor <cursoragent@cursor.com>
223 lines
7.3 KiB
TypeScript
223 lines
7.3 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo } from "react";
|
|
import { useTranslations } from "next-intl";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { PageShell } from "@/components/layout/page-shell";
|
|
import { PageDashboardSkeleton } from "@/components/layout/page-loading";
|
|
import { StatCard, StatCardGrid } from "@/components/layout/stat-card";
|
|
import { StatusBadge } from "@/components/shared/status-badge";
|
|
import {
|
|
ChartCard,
|
|
CategoryPieChart,
|
|
DepartmentSpendChart,
|
|
MembersByPackageChart,
|
|
SpendTrendChart,
|
|
} from "@/components/charts/portal-charts";
|
|
import { useAuth } from "@/providers/auth-provider";
|
|
import { mockApi } from "@/lib/api/mock-client";
|
|
import { formatEtb } from "@/lib/format";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { ROLE_META } from "@/lib/auth/session";
|
|
import { Link, useRouter } from "@/i18n/routing";
|
|
import { Building2, Package, Users, Wallet } from "lucide-react";
|
|
|
|
export default function DashboardPage() {
|
|
const t = useTranslations();
|
|
const { user } = useAuth();
|
|
const router = useRouter();
|
|
const orgQ = useQuery({ queryKey: ["org"], queryFn: mockApi.getOrganisation });
|
|
const membersQ = useQuery({
|
|
queryKey: ["members", user?.activeRole],
|
|
queryFn: () => mockApi.getMembers(user!.activeRole),
|
|
enabled: !!user && user.activeRole !== "MEMBER",
|
|
});
|
|
const approvalsQ = useQuery({
|
|
queryKey: ["approvals"],
|
|
queryFn: mockApi.getApprovals,
|
|
enabled: !!user && user.activeRole !== "MEMBER",
|
|
});
|
|
const deptsQ = useQuery({
|
|
queryKey: ["departments"],
|
|
queryFn: mockApi.getDepartments,
|
|
enabled: !!user && user.activeRole !== "MEMBER",
|
|
});
|
|
const statementsQ = useQuery({
|
|
queryKey: ["statements"],
|
|
queryFn: mockApi.getStatements,
|
|
enabled: !!user && user.activeRole !== "MEMBER",
|
|
});
|
|
const spendQ = useQuery({
|
|
queryKey: ["spend"],
|
|
queryFn: mockApi.getSpend,
|
|
enabled: !!user && user.activeRole !== "MEMBER",
|
|
});
|
|
const packagesQ = useQuery({
|
|
queryKey: ["packages"],
|
|
queryFn: mockApi.getPackages,
|
|
enabled: !!user && user.activeRole !== "MEMBER",
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (user?.activeRole === "MEMBER") router.replace("/me");
|
|
}, [user, router]);
|
|
|
|
const trendData = useMemo(
|
|
() =>
|
|
(statementsQ.data ?? [])
|
|
.slice()
|
|
.sort((a, b) => a.period.localeCompare(b.period))
|
|
.map((s) => ({
|
|
period: s.period.slice(5),
|
|
covered: s.coveredEtb,
|
|
total: s.totalSpendEtb,
|
|
})),
|
|
[statementsQ.data],
|
|
);
|
|
|
|
const deptChart = useMemo(
|
|
() =>
|
|
(deptsQ.data ?? []).map((d) => ({
|
|
name: d.name.split(" ")[0] ?? d.name,
|
|
spend: d.spendEtb,
|
|
limit: d.subLimitEtb ?? 0,
|
|
})),
|
|
[deptsQ.data],
|
|
);
|
|
|
|
const categoryChart = useMemo(() => {
|
|
const map = new Map<string, number>();
|
|
for (const row of spendQ.data ?? []) {
|
|
map.set(row.category, (map.get(row.category) ?? 0) + row.coveredEtb);
|
|
}
|
|
return Array.from(map.entries()).map(([name, value]) => ({ name, value }));
|
|
}, [spendQ.data]);
|
|
|
|
const packageChart = useMemo(
|
|
() =>
|
|
(packagesQ.data ?? []).map((p) => ({
|
|
name: p.name,
|
|
members: p.memberCount,
|
|
})),
|
|
[packagesQ.data],
|
|
);
|
|
|
|
if (!user || user.activeRole === "MEMBER") return null;
|
|
|
|
const dashLoading =
|
|
orgQ.isLoading ||
|
|
membersQ.isLoading ||
|
|
approvalsQ.isLoading ||
|
|
deptsQ.isLoading ||
|
|
statementsQ.isLoading ||
|
|
spendQ.isLoading ||
|
|
packagesQ.isLoading;
|
|
|
|
const activeMembers = membersQ.data?.filter((m) => m.status === "active").length ?? 0;
|
|
const pendingApprovals =
|
|
approvalsQ.data?.filter((a) => a.status === "pending").length ?? 0;
|
|
const creditUsed = orgQ.data?.creditUsedEtb ?? 0;
|
|
const creditLimit = orgQ.data?.creditLimitEtb ?? 0;
|
|
const creditPct = creditLimit > 0 ? Math.round((creditUsed / creditLimit) * 100) : 0;
|
|
|
|
return (
|
|
<PageShell title={t("dashboard.title")} description={t("dashboard.subtitle")}>
|
|
{dashLoading ? (
|
|
<PageDashboardSkeleton />
|
|
) : (
|
|
<>
|
|
<StatCardGrid cols={4}>
|
|
<StatCard
|
|
label={t("dashboard.members")}
|
|
value={activeMembers}
|
|
hint={t("dashboard.liveHint")}
|
|
icon={<Users className="size-4" />}
|
|
/>
|
|
<StatCard
|
|
label={t("dashboard.credit")}
|
|
value={`${creditPct}%`}
|
|
hint={
|
|
orgQ.data
|
|
? `${formatEtb(creditUsed)} / ${formatEtb(creditLimit)}`
|
|
: undefined
|
|
}
|
|
icon={<Wallet className="size-4" />}
|
|
/>
|
|
<StatCard
|
|
label={t("dashboard.departments")}
|
|
value={deptsQ.data?.length ?? "—"}
|
|
icon={<Building2 className="size-4" />}
|
|
/>
|
|
<StatCard
|
|
label={t("dashboard.pendingApprovals")}
|
|
value={pendingApprovals}
|
|
icon={<Package className="size-4" />}
|
|
/>
|
|
</StatCardGrid>
|
|
|
|
<div className="grid gap-4 xl:grid-cols-2">
|
|
<ChartCard
|
|
title={t("dashboard.spendTrend")}
|
|
description={t("dashboard.spendTrendHint")}
|
|
>
|
|
<SpendTrendChart data={trendData} />
|
|
</ChartCard>
|
|
<ChartCard
|
|
title={t("dashboard.deptSpend")}
|
|
description={t("dashboard.deptSpendHint")}
|
|
>
|
|
<DepartmentSpendChart data={deptChart} />
|
|
</ChartCard>
|
|
</div>
|
|
|
|
<div className="grid gap-4 xl:grid-cols-2">
|
|
<ChartCard title={t("details.spendByCategory")}>
|
|
<CategoryPieChart data={categoryChart} />
|
|
</ChartCard>
|
|
<ChartCard title={t("dashboard.membersByPackage")}>
|
|
<MembersByPackageChart data={packageChart} />
|
|
</ChartCard>
|
|
</div>
|
|
|
|
<div className="grid gap-4 lg:grid-cols-2">
|
|
<Card className="rounded-2xl shadow-none">
|
|
<CardHeader className="flex flex-row items-center justify-between">
|
|
<CardTitle className="text-base">{t("dashboard.orgStatus")}</CardTitle>
|
|
{orgQ.data ? <StatusBadge status={orgQ.data.status} /> : null}
|
|
</CardHeader>
|
|
<CardContent className="space-y-3 text-sm">
|
|
<p className="font-medium">{orgQ.data?.tradeName}</p>
|
|
<p className="text-muted-foreground">
|
|
{user.name} · {t(ROLE_META[user.activeRole].labelKey)}
|
|
</p>
|
|
<div className="flex flex-wrap gap-2 pt-1">
|
|
<Button size="sm" variant="outline" render={<Link href="/organisation" />}>
|
|
{t("nav.organisation")}
|
|
</Button>
|
|
<Button size="sm" variant="outline" render={<Link href="/finance" />}>
|
|
{t("nav.finance")}
|
|
</Button>
|
|
<Button size="sm" variant="outline" render={<Link href="/members" />}>
|
|
{t("nav.members")}
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card className="rounded-2xl border-l-4 border-l-brand shadow-none">
|
|
<CardHeader>
|
|
<CardTitle className="text-base">{t("dashboard.guidance")}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
|
<p>{t("dashboard.guidanceHr")}</p>
|
|
<p>{t("dashboard.guidanceFinance")}</p>
|
|
<p>{t("dashboard.guidanceSuper")}</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</>
|
|
)}
|
|
</PageShell>
|
|
);
|
|
}
|