metis-desk/modules/companies/src/web/components/dashboard-page.tsx
David bada13a50e feat(dashboard): implement dashboard schemas and API integration
- Added Zod schemas for dashboard queries, entries, summaries, and companies.
- Created dashboard API functions for fetching summary, work, attention, activity, and companies data.
- Developed dashboard components including BlockState, DashboardDetailsDialog, and DashboardPage for rendering dashboard information.
- Implemented quick create functionality for companies, tickets, interventions, assets, and resources.
- Introduced a new ResourceDashboardReader for querying resource-related data.
- Added a StatisticCard component for displaying key performance indicators in the dashboard.
2026-10-08 13:27:27 +02:00

517 lines
18 KiB
TypeScript

import { Button } from "@repo/shadcn-ui/components/button";
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
import { StatisticCard } from "@repo/shadcn-ui/components/statistic-card";
import {
BoxIcon,
Building2Icon,
ChevronRightIcon,
ClockIcon,
DatabaseIcon,
FileKeyIcon,
LaptopIcon,
TicketIcon,
TriangleAlertIcon,
UserRoundXIcon,
UsersIcon,
WrenchIcon,
} from "@repo/shadcn-ui/lib/icons";
import { useQuery } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { DashboardIssue } from "../../common/dashboard";
import { companyApi, companyKeys } from "../api/company-api";
import {
type DashboardNavigation,
dashboardApi,
dashboardKeys,
entryDestination,
} from "./dashboard-api";
import { BlockState } from "./dashboard-block";
import { DashboardDetailsDialog } from "./dashboard-details-dialog";
import { DashboardQuickCreate } from "./dashboard-quick-create";
const ISSUE_ICONS = {
certificate: FileKeyIcon,
license: FileKeyIcon,
profile: UsersIcon,
missing_site: BoxIcon,
inactive_person: UserRoundXIcon,
};
const KIND_ICONS = {
ticket: TicketIcon,
intervention: WrenchIcon,
asset: LaptopIcon,
resource: DatabaseIcon,
person: UsersIcon,
profile: UsersIcon,
};
export function DashboardPage({
onNavigate,
csrfToken,
}: {
onNavigate: (destination: DashboardNavigation) => void;
csrfToken: string;
}) {
const { t, i18n } = useTranslation("companies");
const [companyId, setCompanyId] = useState("");
const scope = companyId || undefined;
const [details, setDetails] = useState<{
kind?: "asset" | "resource";
issue?: DashboardIssue;
work?: boolean;
activity?: boolean;
companies?: boolean;
}>();
const [quick, setQuick] = useState<
"company" | "ticket" | "intervention" | "asset" | "resource"
>();
const companies = useQuery({ queryKey: companyKeys.list, queryFn: companyApi.list });
const summary = useQuery({
queryKey: dashboardKeys.block("summary", scope),
queryFn: ({ signal }) => dashboardApi.summary(signal, scope),
staleTime: 0,
});
const work = useQuery({
queryKey: dashboardKeys.block("work", scope),
queryFn: ({ signal }) => dashboardApi.work(signal, scope),
staleTime: 0,
});
const attention = useQuery({
queryKey: dashboardKeys.block("attention", scope),
queryFn: ({ signal }) => dashboardApi.attention(signal, scope),
staleTime: 0,
});
const activity = useQuery({
queryKey: dashboardKeys.block("activity", scope),
queryFn: ({ signal }) => dashboardApi.activity(signal, scope),
staleTime: 0,
});
const totals = useQuery({
queryKey: dashboardKeys.block("companies", scope),
queryFn: ({ signal }) => dashboardApi.companies(signal, scope),
staleTime: 0,
});
const date = (value: string | null) =>
value
? new Intl.DateTimeFormat(i18n.language, { dateStyle: "medium", timeStyle: "short" }).format(
new Date(value)
)
: "—";
const statStatus = summary.isPending
? t("loading")
: summary.isError
? t("dashboard.blockError")
: undefined;
const kpis = [
{
key: "companies",
label: "activeCompanies",
Icon: Building2Icon,
tone: "teal",
action: () => onNavigate({ area: "directory", companyId: scope, activeOnly: true }),
},
{
key: "tickets",
label: "openTickets",
Icon: TicketIcon,
tone: "rose",
action: () => onNavigate({ area: "tickets", companyId: scope, pendingOnly: true }),
},
{
key: "interventions",
label: "pendingInterventions",
Icon: WrenchIcon,
tone: "amber",
action: () => onNavigate({ area: "interventions", companyId: scope, pendingOnly: true }),
},
{
key: "assets",
label: "registeredAssets",
Icon: LaptopIcon,
tone: "green",
action: () => setDetails({ kind: "asset" }),
},
{
key: "resources",
label: "registeredResources",
Icon: DatabaseIcon,
tone: "violet",
action: () => setDetails({ kind: "resource" }),
},
] as const;
return (
<div className="dashboard-page space-y-6">
<header className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-3xl font-semibold tracking-tight">{t("dashboard.title")}</h1>
<p className="mt-1 text-sm text-muted-foreground">{t("dashboard.description")}</p>
</div>
<div className="w-full sm:w-64 space-y-2">
<SelectWithAction
id="dashboard-company"
label={t("tickets.company")}
onValueChange={setCompanyId}
options={[
...(companies.data?.companies.map((item) => ({ value: item.id, label: item.name })) ??
[]),
]}
placeholder={t("interventions.allCompanies")}
value={companyId}
/>
{summary.isSuccess && (
<p className="text-xs text-muted-foreground flex items-center gap-2">
<span aria-hidden="true" className="size-2 rounded-full bg-primary" />
{t("dashboard.connected")}
</p>
)}
</div>
</header>
<section aria-label={t("dashboard.indicators")} className="dashboard-kpis">
{kpis.map(({ key, label, Icon, tone, action }) => (
<StatisticCard
icon={<Icon />}
key={key}
label={t(`dashboard.${label}`)}
onClick={action}
status={statStatus}
tone={tone}
value={summary.isError ? undefined : summary.data?.[key]}
/>
))}
</section>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)]">
<DashboardPanel
action={() => setDetails({ work: true })}
icon={<TicketIcon />}
title={t("dashboard.pendingWork")}
>
<BlockState
empty={!work.data?.items.length}
emptyText={t("dashboard.noPendingWork")}
query={work}
>
<div className="overflow-x-auto">
<table>
<caption className="sr-only">{t("dashboard.pendingWork")}</caption>
<thead>
<tr>
{[
t("dashboard.type"),
t("tickets.reference"),
t("tickets.subject"),
t("tickets.company"),
t("tickets.priority"),
t("tickets.state"),
].map((label) => (
<th key={label} scope="col">
{label}
</th>
))}
</tr>
</thead>
<tbody>
{work.data?.items.slice(0, 6).map((item) => {
const Icon = KIND_ICONS[item.kind];
return (
<tr key={item.id}>
<td>
<Icon aria-hidden="true" className="size-5 text-primary" />
<span className="sr-only">{t(`dashboard.types.${item.kind}`)}</span>
</td>
<td>
<button
className="dashboard-link"
onClick={() => onNavigate(entryDestination(item))}
type="button"
>
{item.reference}
</button>
</td>
<td>{item.name}</td>
<td>{item.companyName}</td>
<td>
{item.priority ? (
<span className="dashboard-badge" data-state={item.priority}>
{t(`tickets.priorities.${item.priority}`)}
</span>
) : (
"—"
)}
</td>
<td>
<span className="dashboard-badge" data-state={item.state}>
{t(
(item.kind === "ticket"
? "tickets.states."
: "interventions.states.") + item.state
)}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</BlockState>
</DashboardPanel>
<DashboardPanel
icon={<TriangleAlertIcon className="text-destructive" />}
title={t("dashboard.attention")}
>
<BlockState
empty={!attention.data?.items.length}
emptyText={t("dashboard.noAttention")}
query={attention}
>
<ul className="divide-y">
{attention.data?.items.map((item) => {
const Icon = ISSUE_ICONS[item.kind];
return (
<li key={item.kind}>
<button
className="dashboard-alert"
onClick={() => setDetails({ issue: item.kind })}
type="button"
>
<Icon aria-hidden="true" className="size-5" />
<strong
className={
item.count
? "text-amber-600 dark:text-amber-400"
: "text-muted-foreground"
}
>
{item.count}
</strong>
<span>{t(`dashboard.issues.${item.kind}`)}</span>
<ChevronRightIcon aria-hidden="true" className="size-4 shrink-0" />
</button>
</li>
);
})}
</ul>
</BlockState>
</DashboardPanel>
</div>
<div className="grid gap-6 xl:grid-cols-2">
<DashboardPanel
action={() => setDetails({ activity: true })}
icon={<ClockIcon />}
title={t("dashboard.recentActivity")}
>
<BlockState
empty={!activity.data?.items.length}
emptyText={t("dashboard.noRecentActivity")}
query={activity}
>
<ol className="dashboard-timeline">
{activity.data?.items.slice(0, 4).map((item) => {
const Icon = KIND_ICONS[item.kind];
return (
<li key={item.eventId ?? item.id}>
<time
className="text-xs text-muted-foreground"
dateTime={item.recordedAt ?? undefined}
>
{date(item.recordedAt)}
</time>
<Icon aria-hidden="true" className="size-5 text-primary shrink-0" />
<div>
<button
className="dashboard-link"
onClick={() => onNavigate(entryDestination(item))}
type="button"
>
{t("dashboard.recordUpdated", { name: item.reference ?? item.name })}
</button>
<p className="text-xs text-muted-foreground">
{item.companyName}
{item.actorName ? ` · ${item.actorName}` : ""}
</p>
</div>
</li>
);
})}
</ol>
</BlockState>
</DashboardPanel>
<DashboardPanel
action={() => setDetails({ companies: true })}
icon={<Building2Icon />}
title={t("dashboard.companySummary")}
>
<BlockState
empty={!totals.data?.items.length}
emptyText={t("noCompanies")}
query={totals}
>
<div className="overflow-x-auto">
<table>
<caption className="sr-only">{t("dashboard.companySummary")}</caption>
<thead>
<tr>
{[
t("tickets.company"),
t("dashboard.openTickets"),
t("dashboard.pendingInterventions"),
t("dashboard.registeredAssets"),
t("people"),
].map((label) => (
<th key={label} scope="col">
{label}
</th>
))}
</tr>
</thead>
<tbody>
{totals.data?.items.slice(0, 5).map((item) => (
<tr key={item.id}>
<td>
<button
className="dashboard-link"
onClick={() =>
onNavigate({ area: "company", id: item.id, section: "inventory" })
}
type="button"
>
{item.name}
</button>
</td>
<td>
<button
aria-label={`${t("dashboard.openTickets")} · ${item.name}: ${item.tickets}`}
className="dashboard-link text-destructive"
onClick={() =>
onNavigate({ area: "tickets", companyId: item.id, pendingOnly: true })
}
type="button"
>
{item.tickets}
</button>
</td>
<td>
<button
aria-label={`${t("dashboard.pendingInterventions")} · ${item.name}: ${item.interventions}`}
className="dashboard-link text-amber-600 dark:text-amber-400"
onClick={() =>
onNavigate({
area: "interventions",
companyId: item.id,
pendingOnly: true,
})
}
type="button"
>
{item.interventions}
</button>
</td>
<td>
<button
className="dashboard-link"
onClick={() =>
onNavigate({ area: "company", id: item.id, section: "inventory" })
}
type="button"
>
<span className="sr-only">
{t("dashboard.registeredAssets")} · {item.name}:{" "}
</span>
{item.assets}
</button>
</td>
<td>
<button
className="dashboard-link"
onClick={() =>
onNavigate({ area: "company", id: item.id, section: "people" })
}
type="button"
>
<span className="sr-only">
{t("people")} · {item.name}:{" "}
</span>
{item.people}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</BlockState>
</DashboardPanel>
</div>
<DashboardPanel title={t("dashboard.quickActions")}>
<div className="flex flex-wrap gap-3">
{(["ticket", "intervention", "company", "asset", "resource"] as const).map((kind) => {
const Icon = kind === "company" ? Building2Icon : KIND_ICONS[kind];
return (
<Button key={kind} onClick={() => setQuick(kind)} variant="outline">
<Icon aria-hidden="true" className="size-4" />
{t(`dashboard.create.${kind}`)}
</Button>
);
})}
</div>
</DashboardPanel>
{details && (
<DashboardDetailsDialog
companyId={scope}
details={details}
onClose={() => setDetails(undefined)}
onNavigate={onNavigate}
/>
)}
{quick && (
<DashboardQuickCreate
companyId={scope}
csrfToken={csrfToken}
kind={quick}
onClose={() => setQuick(undefined)}
onNavigate={onNavigate}
/>
)}
</div>
);
}
function DashboardPanel({
title,
icon,
action,
children,
}: {
title: string;
icon?: ReactNode;
action?: () => void;
children: ReactNode;
}) {
const { t } = useTranslation("companies");
return (
<section aria-label={title} className="dashboard-panel min-w-0">
<div className="mb-4 flex items-center justify-between gap-3">
<h2 className="flex items-center gap-3">
{icon && (
<span aria-hidden="true" className="[&_svg]:size-5">
{icon}
</span>
)}
{title}
</h2>
{action && (
<Button className="text-primary shrink-0" onClick={action} size="sm" variant="ghost">
{t("dashboard.viewAll")}
<ChevronRightIcon aria-hidden="true" className="size-4" />
</Button>
)}
</div>
{children}
</section>
);
}