- 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.
517 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|