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 11:27:27 +00:00
|
|
|
|
import { Button } from "@repo/shadcn-ui/components/button";
|
|
|
|
|
|
import { EntityTable } from "@repo/shadcn-ui/components/entity-list";
|
|
|
|
|
|
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
|
|
|
|
|
import { entityListLabels } from "@repo/shadcn-ui/lib/entity-list";
|
|
|
|
|
|
import { useQuery } from "@tanstack/react-query";
|
|
|
|
|
|
import { useState } from "react";
|
|
|
|
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
|
|
|
|
|
|
|
|
import type { DashboardIssue } from "../../common/dashboard";
|
|
|
|
|
|
|
2026-10-08 14:43:34 +00:00
|
|
|
|
import type { DashboardNavigation } from "./dashboard-api";
|
|
|
|
|
|
import { dashboardApi, dashboardKeys, entryDestination } from "./dashboard-api";
|
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 11:27:27 +00:00
|
|
|
|
import { BlockState } from "./dashboard-block";
|
|
|
|
|
|
|
|
|
|
|
|
export function DashboardDetailsDialog({
|
|
|
|
|
|
companyId,
|
|
|
|
|
|
details,
|
|
|
|
|
|
onClose,
|
|
|
|
|
|
onNavigate,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
companyId?: string | undefined;
|
|
|
|
|
|
details: {
|
|
|
|
|
|
kind?: "asset" | "resource";
|
|
|
|
|
|
issue?: DashboardIssue;
|
|
|
|
|
|
work?: boolean;
|
|
|
|
|
|
activity?: boolean;
|
|
|
|
|
|
companies?: boolean;
|
|
|
|
|
|
};
|
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
|
onNavigate: (destination: DashboardNavigation) => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const { t, i18n } = useTranslation("companies");
|
|
|
|
|
|
const [page, setPage] = useState(1);
|
|
|
|
|
|
const block = details.work ? "work" : details.activity ? "activity" : "details";
|
|
|
|
|
|
const query = useQuery({
|
|
|
|
|
|
queryKey: dashboardKeys.block(block, companyId, page, details.issue ?? details.kind),
|
|
|
|
|
|
enabled: !details.companies,
|
|
|
|
|
|
queryFn: ({ signal }) => {
|
|
|
|
|
|
if (details.work) {
|
|
|
|
|
|
return dashboardApi.work(signal, companyId, page);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (details.activity) {
|
|
|
|
|
|
return dashboardApi.activity(signal, companyId);
|
|
|
|
|
|
}
|
|
|
|
|
|
return dashboardApi.details(signal, {
|
|
|
|
|
|
companyId,
|
|
|
|
|
|
page,
|
|
|
|
|
|
kind: details.kind,
|
|
|
|
|
|
issue: details.issue,
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
const companies = useQuery({
|
|
|
|
|
|
queryKey: dashboardKeys.block("companies", companyId),
|
|
|
|
|
|
enabled: Boolean(details.companies),
|
|
|
|
|
|
queryFn: ({ signal }) => dashboardApi.companies(signal, companyId),
|
|
|
|
|
|
});
|
|
|
|
|
|
const title = t(detailsTitle(details));
|
|
|
|
|
|
const labels = entityListLabels(i18n.language);
|
|
|
|
|
|
const total = query.data?.total ?? 0;
|
|
|
|
|
|
const range = labels.range(
|
|
|
|
|
|
total ? (page - 1) * 10 + 1 : 0,
|
|
|
|
|
|
Math.min(page * 10, total),
|
|
|
|
|
|
total,
|
|
|
|
|
|
t("dashboard.records")
|
|
|
|
|
|
);
|
|
|
|
|
|
return (
|
|
|
|
|
|
<FormDialog
|
|
|
|
|
|
cancelLabel={t("cancel")}
|
|
|
|
|
|
onOpenChange={(open) => {
|
|
|
|
|
|
if (!open) {
|
|
|
|
|
|
onClose();
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
open
|
|
|
|
|
|
title={title}
|
|
|
|
|
|
wide
|
|
|
|
|
|
>
|
|
|
|
|
|
{details.companies ? (
|
|
|
|
|
|
<BlockState
|
|
|
|
|
|
empty={!companies.data?.items.length}
|
|
|
|
|
|
emptyText={t("noCompanies")}
|
|
|
|
|
|
query={companies}
|
|
|
|
|
|
>
|
|
|
|
|
|
<EntityTable
|
|
|
|
|
|
columns={[
|
|
|
|
|
|
{
|
|
|
|
|
|
id: "name",
|
|
|
|
|
|
label: t("name"),
|
|
|
|
|
|
value: (item) => item.name,
|
|
|
|
|
|
render: (item) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="dashboard-link"
|
|
|
|
|
|
onClick={() =>
|
|
|
|
|
|
onNavigate({ area: "company", id: item.id, section: "inventory" })
|
|
|
|
|
|
}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
>
|
|
|
|
|
|
{item.name}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: "tickets",
|
|
|
|
|
|
label: t("dashboard.openTickets"),
|
|
|
|
|
|
value: (item) => item.tickets,
|
|
|
|
|
|
render: (item) => item.tickets,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: "interventions",
|
|
|
|
|
|
label: t("dashboard.pendingInterventions"),
|
|
|
|
|
|
value: (item) => item.interventions,
|
|
|
|
|
|
render: (item) => item.interventions,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: "assets",
|
|
|
|
|
|
label: t("dashboard.registeredAssets"),
|
|
|
|
|
|
value: (item) => item.assets,
|
|
|
|
|
|
render: (item) => item.assets,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: "people",
|
|
|
|
|
|
label: t("people"),
|
|
|
|
|
|
value: (item) => item.people,
|
|
|
|
|
|
render: (item) => item.people,
|
|
|
|
|
|
},
|
|
|
|
|
|
]}
|
|
|
|
|
|
entityLabel={t("title")}
|
|
|
|
|
|
items={companies.data?.items ?? []}
|
|
|
|
|
|
locale={i18n.language}
|
|
|
|
|
|
title={title}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</BlockState>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<BlockState
|
|
|
|
|
|
empty={!query.data?.items.length}
|
|
|
|
|
|
emptyText={t("dashboard.noMatches")}
|
|
|
|
|
|
query={query}
|
|
|
|
|
|
>
|
|
|
|
|
|
<p className="text-sm text-muted-foreground">{range}</p>
|
|
|
|
|
|
<div className="overflow-x-auto dashboard-panel !p-0 !border-0 !shadow-none">
|
|
|
|
|
|
<table>
|
|
|
|
|
|
<caption className="sr-only">{title}</caption>
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th scope="col">{t("name")}</th>
|
|
|
|
|
|
<th scope="col">{t("tickets.company")}</th>
|
|
|
|
|
|
{details.activity && <th scope="col">{t("dashboard.recordedAt")}</th>}
|
|
|
|
|
|
{details.activity && <th scope="col">{t("dashboard.recordedBy")}</th>}
|
|
|
|
|
|
{details.work && <th scope="col">{t("tickets.state")}</th>}
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
{query.data?.items.map((item) => (
|
|
|
|
|
|
<tr key={item.eventId ?? item.id}>
|
|
|
|
|
|
<td>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="dashboard-link"
|
|
|
|
|
|
onClick={() => onNavigate(entryDestination(item))}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
>
|
|
|
|
|
|
{item.reference ? `${item.reference} · ` : ""}
|
|
|
|
|
|
{item.name}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td>{item.companyName}</td>
|
|
|
|
|
|
{details.activity && (
|
|
|
|
|
|
<td>
|
|
|
|
|
|
{item.recordedAt
|
|
|
|
|
|
? new Intl.DateTimeFormat(i18n.language, {
|
|
|
|
|
|
dateStyle: "medium",
|
|
|
|
|
|
timeStyle: "short",
|
|
|
|
|
|
}).format(new Date(item.recordedAt))
|
|
|
|
|
|
: "—"}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{details.activity && <td>{item.actorName ?? "—"}</td>}
|
|
|
|
|
|
{details.work && (
|
|
|
|
|
|
<td>
|
|
|
|
|
|
{t(
|
|
|
|
|
|
(item.kind === "ticket" ? "tickets.states." : "interventions.states.") +
|
|
|
|
|
|
item.state
|
|
|
|
|
|
)}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</BlockState>
|
|
|
|
|
|
{!details.activity && query.isSuccess && (
|
|
|
|
|
|
<footer className="flex flex-wrap items-center justify-between gap-3 border-t pt-4 text-sm">
|
|
|
|
|
|
<span>{range}</span>
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
aria-label={labels.previous}
|
|
|
|
|
|
disabled={page === 1}
|
|
|
|
|
|
onClick={() => setPage(page - 1)}
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
>
|
|
|
|
|
|
‹
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<span>
|
|
|
|
|
|
{labels.page} {page}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
aria-label={labels.next}
|
|
|
|
|
|
disabled={page * 10 >= total}
|
|
|
|
|
|
onClick={() => setPage(page + 1)}
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
>
|
|
|
|
|
|
›
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</footer>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</FormDialog>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function detailsTitle(details: {
|
|
|
|
|
|
issue?: DashboardIssue;
|
|
|
|
|
|
kind?: "asset" | "resource";
|
|
|
|
|
|
work?: boolean;
|
|
|
|
|
|
activity?: boolean;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
if (details.issue) {
|
|
|
|
|
|
return `dashboard.issues.${details.issue}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (details.kind === "asset") {
|
|
|
|
|
|
return "dashboard.registeredAssets";
|
|
|
|
|
|
}
|
|
|
|
|
|
if (details.kind === "resource") {
|
|
|
|
|
|
return "dashboard.registeredResources";
|
|
|
|
|
|
}
|
|
|
|
|
|
if (details.work) {
|
|
|
|
|
|
return "dashboard.pendingWork";
|
|
|
|
|
|
}
|
|
|
|
|
|
if (details.activity) {
|
|
|
|
|
|
return "dashboard.recentActivity";
|
|
|
|
|
|
}
|
|
|
|
|
|
return "dashboard.companySummary";
|
|
|
|
|
|
}
|