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.
This commit is contained in:
parent
3f01d5ecf1
commit
bada13a50e
@ -15,6 +15,7 @@ Aplicación interna para soporte técnico a pequeñas empresas.
|
||||
- [Perfiles de trabajo](docs/perfiles-de-trabajo.md)
|
||||
- [Buzones y correo por perfil](docs/correos-en-dispositivos.md)
|
||||
- [Componentes de interfaz](docs/componentes-de-interfaz.md)
|
||||
- [Dashboard de inicio](docs/dashboard-inicio.md)
|
||||
- [Traducciones de la interfaz](docs/traducciones.md)
|
||||
- [Alcance y siguientes entregas](docs/plan-de-desarrollo.md)
|
||||
|
||||
|
||||
227
apps/server/src/dashboard.integration.ts
Normal file
227
apps/server/src/dashboard.integration.ts
Normal file
@ -0,0 +1,227 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import {
|
||||
CompanyResponseSchema,
|
||||
DashboardAttentionSchema,
|
||||
DashboardCompaniesSchema,
|
||||
DashboardEntriesSchema,
|
||||
DashboardSummarySchema,
|
||||
InterventionListSchema,
|
||||
RemoteConnectionResponseSchema,
|
||||
TicketListSchema,
|
||||
} from "@metis/companies/common";
|
||||
import { CatalogResponseSchema, ResourceResponseSchema } from "@metis/resources/common";
|
||||
|
||||
type Request = (path: string, body?: unknown) => Promise<Response>;
|
||||
export async function verifyDashboard(request: Request, companyId: string, otherId: string) {
|
||||
async function get(path: string) {
|
||||
const response = await request(path);
|
||||
assert.equal(response.status, 200, await response.clone().text());
|
||||
return response.json();
|
||||
}
|
||||
const summary = DashboardSummarySchema.parse(await get("/dashboard/summary"));
|
||||
const companies = DashboardCompaniesSchema.parse(await get("/dashboard/companies"));
|
||||
assert.equal(
|
||||
summary.assets,
|
||||
companies.items.reduce((sum, item) => sum + item.assets, 0)
|
||||
);
|
||||
assert.equal(
|
||||
summary.resources,
|
||||
companies.items.reduce((sum, item) => sum + item.resources, 0)
|
||||
);
|
||||
const tickets = TicketListSchema.parse(await get("/tickets"));
|
||||
const interventions = InterventionListSchema.parse(await get("/interventions"));
|
||||
assert.equal(
|
||||
summary.tickets,
|
||||
tickets.tickets.filter((item) => !["resolved", "cancelled"].includes(item.values.state)).length
|
||||
);
|
||||
assert.equal(
|
||||
summary.interventions,
|
||||
interventions.interventions.filter(
|
||||
(item) => !["finished", "cancelled"].includes(item.values.state)
|
||||
).length
|
||||
);
|
||||
for (const scope of [companyId, otherId]) {
|
||||
const scoped = DashboardSummarySchema.parse(await get(`/dashboard/summary?companyId=${scope}`));
|
||||
const row = companies.items.find((item) => item.id === scope);
|
||||
assert.ok(row);
|
||||
assert.equal(scoped.assets, row.assets);
|
||||
assert.equal(scoped.resources, row.resources);
|
||||
for (const block of [
|
||||
"work",
|
||||
"activity",
|
||||
"details?kind=asset",
|
||||
"details?kind=resource",
|
||||
"details?issue=certificate",
|
||||
"details?issue=license",
|
||||
"details?issue=profile",
|
||||
"details?issue=missing_site",
|
||||
"details?issue=inactive_person",
|
||||
]) {
|
||||
const result = DashboardEntriesSchema.parse(
|
||||
await get(`/dashboard/${block}${block.includes("?") ? "&" : "?"}companyId=${scope}`)
|
||||
);
|
||||
assert.ok(result.items.every((item) => item.companyId === scope));
|
||||
assert.ok(result.items.length <= 10);
|
||||
}
|
||||
}
|
||||
const work = DashboardEntriesSchema.parse(await get("/dashboard/work"));
|
||||
assert.equal(work.total, summary.tickets + summary.interventions);
|
||||
assert.ok(
|
||||
work.items.every((item) => !["resolved", "cancelled", "finished"].includes(item.state ?? ""))
|
||||
);
|
||||
const priorityRank = (value: string | null) =>
|
||||
["urgent", "high", "normal", "low"].indexOf(value ?? "normal");
|
||||
for (let index = 1; index < work.items.length; index++) {
|
||||
const previous = work.items[index - 1];
|
||||
const current = work.items[index];
|
||||
assert.ok(previous && current);
|
||||
assert.ok(priorityRank(previous.priority) <= priorityRank(current.priority));
|
||||
if (priorityRank(previous.priority) === priorityRank(current.priority)) {
|
||||
assert.ok((previous.recordedAt ?? "") <= (current.recordedAt ?? ""));
|
||||
}
|
||||
}
|
||||
const activity = DashboardEntriesSchema.parse(await get("/dashboard/activity"));
|
||||
assert.equal(new Set(activity.items.map((item) => item.eventId)).size, activity.items.length);
|
||||
assert.ok(activity.items.every((item) => item.eventId));
|
||||
assert.equal(activity.total, activity.items.length);
|
||||
const attention = DashboardAttentionSchema.parse(await get("/dashboard/attention"));
|
||||
assert.equal(attention.items.length, 5);
|
||||
for (const alert of attention.items) {
|
||||
const details = DashboardEntriesSchema.parse(
|
||||
await get(`/dashboard/details?issue=${alert.kind}`)
|
||||
);
|
||||
assert.equal(alert.count, details.total);
|
||||
}
|
||||
const beyond = DashboardEntriesSchema.parse(
|
||||
await get("/dashboard/details?kind=asset&page=100000")
|
||||
);
|
||||
assert.equal(beyond.items.length, 0);
|
||||
assert.equal(beyond.total, summary.assets);
|
||||
for (const path of [
|
||||
"/dashboard/summary?companyId=bad",
|
||||
"/dashboard/details?page=0",
|
||||
"/dashboard/details?issue=unknown",
|
||||
"/dashboard/summary?unknown=true",
|
||||
]) {
|
||||
assert.equal((await request(path)).status, 400);
|
||||
}
|
||||
assert.equal((await request(`/dashboard/summary?companyId=${randomUUID()}`)).status, 404);
|
||||
}
|
||||
|
||||
export async function verifyDashboardBoundaries(request: Request) {
|
||||
const created = await request("/companies", { name: "Dashboard vacío", active: true });
|
||||
assert.equal(created.status, 201, await created.clone().text());
|
||||
const companyId = CompanyResponseSchema.parse(await created.json()).company.id;
|
||||
const empty = await request(`/dashboard/summary?companyId=${companyId}`);
|
||||
const zero = DashboardSummarySchema.parse(await empty.json());
|
||||
assert.deepEqual(zero, { companies: 1, tickets: 0, interventions: 0, assets: 0, resources: 0 });
|
||||
const catalogResponse = await request("/resource-catalog/global");
|
||||
const catalog = CatalogResponseSchema.parse(await catalogResponse.json());
|
||||
const expiry = catalog.items.find((item) => item.builtinKey === "field:expires");
|
||||
assert.ok(expiry);
|
||||
const date = (offset: number) =>
|
||||
new Date(Date.now() + offset * 86400000).toISOString().slice(0, 10);
|
||||
for (const [issue, key] of [
|
||||
["certificate", "certificate"],
|
||||
["license", "software_license"],
|
||||
]) {
|
||||
const template = catalog.items.find((item) => item.builtinKey === `template:${key}`);
|
||||
assert.ok(template);
|
||||
const expected: string[] = [];
|
||||
for (const [offset, active] of [
|
||||
[0, true],
|
||||
[30, true],
|
||||
[31, true],
|
||||
[-1, true],
|
||||
[5, false],
|
||||
[null, true],
|
||||
] as const) {
|
||||
const response = await request(`/resources/${companyId}`, {
|
||||
values: {
|
||||
name: `Dashboard ${key} ${offset} ${active}`,
|
||||
templateId: template.id,
|
||||
active,
|
||||
notes: "",
|
||||
characteristics: offset === null ? {} : { [expiry.id]: date(offset) },
|
||||
},
|
||||
});
|
||||
assert.equal(response.status, 201, await response.clone().text());
|
||||
const resource = ResourceResponseSchema.parse(await response.json()).resource;
|
||||
if (active && (offset === 0 || offset === 30)) {
|
||||
expected.push(resource.id);
|
||||
}
|
||||
}
|
||||
const response = await request(`/dashboard/details?companyId=${companyId}&issue=${issue}`);
|
||||
assert.equal(response.status, 200, await response.clone().text());
|
||||
const result = DashboardEntriesSchema.parse(await response.json());
|
||||
assert.equal(result.total, 2);
|
||||
assert.deepEqual(new Set(result.items.map((item) => item.id)), new Set(expected));
|
||||
}
|
||||
const first = DashboardEntriesSchema.parse(
|
||||
await (await request(`/dashboard/details?companyId=${companyId}&kind=resource`)).json()
|
||||
);
|
||||
const second = DashboardEntriesSchema.parse(
|
||||
await (await request(`/dashboard/details?companyId=${companyId}&kind=resource&page=2`)).json()
|
||||
);
|
||||
assert.equal(first.total, 12);
|
||||
assert.equal(first.items.length, 10);
|
||||
assert.equal(second.items.length, 2);
|
||||
assert.equal(second.total, 12);
|
||||
assert.equal(new Set([...first.items, ...second.items].map((item) => item.id)).size, 12);
|
||||
}
|
||||
|
||||
export async function verifyDashboardInactiveAccess(request: Request, companyId: string) {
|
||||
const personValues = {
|
||||
name: "Dashboard acceso personal",
|
||||
position: null,
|
||||
email: null,
|
||||
phone: null,
|
||||
active: true,
|
||||
isContact: false,
|
||||
};
|
||||
const created = await request(`/companies/${companyId}/people`, personValues);
|
||||
assert.equal(created.status, 201, await created.clone().text());
|
||||
const company = CompanyResponseSchema.parse(await created.json()).company;
|
||||
const person = company.people.find((item) => item.name === personValues.name);
|
||||
assert.ok(person);
|
||||
const values = {
|
||||
personId: person.id,
|
||||
sourceKind: "personal_computer",
|
||||
sourceAssetId: null,
|
||||
targetKind: "network",
|
||||
targetId: null,
|
||||
siteId: null,
|
||||
method: "VPN",
|
||||
requiresVpn: false,
|
||||
state: "configured",
|
||||
notes: null,
|
||||
};
|
||||
const response = await request(`/remote-connections/${companyId}`, { values });
|
||||
assert.equal(response.status, 201, await response.clone().text());
|
||||
const connection = RemoteConnectionResponseSchema.parse(await response.json()).connection;
|
||||
const deactivated = await request(`/companies/${companyId}/people/${person.id}/update`, {
|
||||
...personValues,
|
||||
active: false,
|
||||
});
|
||||
assert.equal(deactivated.status, 200, await deactivated.clone().text());
|
||||
async function count() {
|
||||
const response = await request(
|
||||
`/dashboard/details?companyId=${companyId}&issue=inactive_person`
|
||||
);
|
||||
assert.equal(response.status, 200, await response.clone().text());
|
||||
return DashboardEntriesSchema.parse(await response.json());
|
||||
}
|
||||
assert.ok((await count()).items.some((item) => item.id === person.id));
|
||||
for (const state of ["removal_pending", "removed"]) {
|
||||
const updated = await request(`/remote-connections/${companyId}/${connection.id}/update`, {
|
||||
values: { ...values, state },
|
||||
});
|
||||
assert.equal(updated.status, 200, await updated.clone().text());
|
||||
assert.equal(
|
||||
(await count()).items.some((item) => item.id === person.id),
|
||||
state !== "removed"
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -28,6 +28,11 @@ import { verifyAssets } from "./assets.integration";
|
||||
import { verifyBrands } from "./brands.integration";
|
||||
import { verifyCertificates } from "./certificates.integration";
|
||||
import { createDatabase, readDatabaseEnvironment } from "./config/database";
|
||||
import {
|
||||
verifyDashboard,
|
||||
verifyDashboardBoundaries,
|
||||
verifyDashboardInactiveAccess,
|
||||
} from "./dashboard.integration";
|
||||
import { createIdentityRuntime } from "./infrastructure/http/identity-runtime";
|
||||
import { verifyInterventionTemplates } from "./intervention-templates.integration";
|
||||
import { verifyInterventions } from "./interventions.integration";
|
||||
@ -382,6 +387,13 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
|
||||
const anonymousCompanyBrowser = browser();
|
||||
await anonymousCompanyBrowser.session();
|
||||
await expectError(await anonymousCompanyBrowser.request("/companies"), 401, "UNAUTHENTICATED");
|
||||
for (const block of ["summary", "work", "attention", "activity", "companies", "details"]) {
|
||||
await expectError(
|
||||
await anonymousCompanyBrowser.request(`/dashboard/${block}`),
|
||||
401,
|
||||
"UNAUTHENTICATED"
|
||||
);
|
||||
}
|
||||
const companyCreated = await operator.request("/companies", {
|
||||
name: "Compañía de pruebas",
|
||||
active: true,
|
||||
@ -852,6 +864,9 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
|
||||
secondCompany.id,
|
||||
database
|
||||
);
|
||||
await verifyDashboardBoundaries((...args) => operator.request(...args));
|
||||
await verifyDashboardInactiveAccess((...args) => operator.request(...args), secondCompany.id);
|
||||
await verifyDashboard((...args) => operator.request(...args), customer.id, secondCompany.id);
|
||||
// El entorno normal bloquea las fichas antiguas; el modo de fixtures anterior no se usa aquí.
|
||||
const migratedRuntime = await createIdentityRuntime(database, environment);
|
||||
const migratedServer = createApp(() => undefined, migratedRuntime).listen(0, "127.0.0.1");
|
||||
|
||||
@ -29,6 +29,10 @@ export async function createIdentityRuntime(
|
||||
resources.services.inventory
|
||||
);
|
||||
await resources.migrateLegacy();
|
||||
const dashboard = companies.initializeDashboard(
|
||||
assets.services.dashboard,
|
||||
resources.services.dashboard
|
||||
);
|
||||
const SequelizeStore = connectSequelize(session.Store);
|
||||
const store = new SequelizeStore({
|
||||
db: database,
|
||||
@ -56,6 +60,7 @@ export async function createIdentityRuntime(
|
||||
resources.register(app);
|
||||
mail.register(app);
|
||||
profiles.register(app);
|
||||
dashboard.register(app);
|
||||
},
|
||||
middleware,
|
||||
stop: () => store.stopExpiringSessions(),
|
||||
|
||||
@ -9,6 +9,11 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
|
||||
import {
|
||||
dashboardKeys,
|
||||
entryDestination,
|
||||
} from "../../../modules/companies/src/web/components/dashboard-api";
|
||||
|
||||
import { App } from "./app";
|
||||
import { createAppI18n } from "./i18n";
|
||||
import { workspaceAssets, workspaceCatalog, workspaceCompany } from "./test-fixtures/workspace";
|
||||
@ -28,7 +33,9 @@ async function renderPage(
|
||||
const { instance, initialization } = createAppI18n(language);
|
||||
await initialization;
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, staleTime: Number.POSITIVE_INFINITY } },
|
||||
defaultOptions: {
|
||||
queries: { retry: false, retryOnMount: false, staleTime: Number.POSITIVE_INFINITY },
|
||||
},
|
||||
});
|
||||
if (session) {
|
||||
client.setQueryData(identityKeys.session, session);
|
||||
@ -89,7 +96,7 @@ test("solo el administrador inicial ve la navegación de gestión de usuarios",
|
||||
assert.ok(admin.includes(">Usuarios</button>"));
|
||||
});
|
||||
|
||||
test("el espacio mantiene compañía, secciones y ficha del primer activo sin formularios visibles", async () => {
|
||||
test("el espacio abre Inicio tras el login y mantiene acceso a las compañías sin formularios visibles", async () => {
|
||||
for (const [language, spaces, panel] of [
|
||||
["es", "Espacios de compañías", "Ficha del activo seleccionado"],
|
||||
["en", "Company spaces", "Selected asset details"],
|
||||
@ -109,13 +116,90 @@ test("el espacio mantiene compañía, secciones y ficha del primer activo sin fo
|
||||
}
|
||||
);
|
||||
assert.ok(html.includes(`aria-label="${spaces}"`));
|
||||
assert.ok(html.includes(`aria-label="${panel}"`));
|
||||
assert.ok(html.includes("PORT-ANA-01"));
|
||||
assert.ok(html.includes("PORT-RESERVA"));
|
||||
assert.ok(html.includes("Ana López"));
|
||||
assert.ok(html.includes("Lenovo"));
|
||||
assert.ok(html.includes(language === "es" ? "Ficha e historial" : "Details and history"));
|
||||
assert.ok(html.includes(language === "es" ? "Trabajo pendiente" : "Pending work"));
|
||||
assert.ok(html.includes(language === "es" ? "Resumen por compañías" : "Company summary"));
|
||||
assert.ok(html.includes(`aria-label="${language === "es" ? "Inicio" : "Home"}"`));
|
||||
assert.equal(html.includes(`aria-label="${panel}"`), false);
|
||||
assert.ok(
|
||||
html.includes(language === "es" ? "Buscar en Metis Desk..." : "Search Metis Desk...")
|
||||
);
|
||||
assert.ok(html.includes(language === "es" ? "Configuración" : "Settings"));
|
||||
assert.equal(html.includes("shell.catalogs"), false);
|
||||
assert.ok(html.includes(user.name));
|
||||
assert.equal(html.includes("<form"), false);
|
||||
assert.equal(html.includes("2026-10-04"), false);
|
||||
}
|
||||
});
|
||||
|
||||
test("el dashboard distingue carga, vacío y fallo parcial sin reemplazar métricas desconocidas por cero", async () => {
|
||||
const session = { user, csrfToken: "a".repeat(64), setupRequired: false };
|
||||
for (const language of ["es", "en"]) {
|
||||
const loading = await renderPage(language, session);
|
||||
assert.ok(loading.includes("—"));
|
||||
assert.ok(loading.includes('role="status"'));
|
||||
const empty = await renderPage(language, session, (client) => {
|
||||
client.setQueryData(companyKeys.list, { companies: [] });
|
||||
client.setQueryData(dashboardKeys.block("summary"), {
|
||||
companies: 0,
|
||||
tickets: 0,
|
||||
interventions: 0,
|
||||
assets: 0,
|
||||
resources: 0,
|
||||
});
|
||||
for (const block of ["work", "activity", "companies"]) {
|
||||
client.setQueryData(dashboardKeys.block(block), { items: [], total: 0 });
|
||||
}
|
||||
client.setQueryData(dashboardKeys.block("attention"), { items: [] });
|
||||
});
|
||||
assert.ok(
|
||||
empty.includes(
|
||||
language === "es"
|
||||
? "No hay tickets ni intervenciones pendientes."
|
||||
: "There are no pending tickets or interventions."
|
||||
)
|
||||
);
|
||||
assert.ok(!empty.includes('role="alert"'));
|
||||
const partial = await renderPage(language, session, (client) => {
|
||||
client.setQueryData(dashboardKeys.block("summary"), {
|
||||
companies: 1,
|
||||
tickets: 23,
|
||||
interventions: 0,
|
||||
assets: 5,
|
||||
resources: 6,
|
||||
});
|
||||
const query = client
|
||||
.getQueryCache()
|
||||
.build(client, { queryKey: dashboardKeys.block("activity") });
|
||||
query.setState({ status: "error", error: new Error("failed"), fetchStatus: "idle" });
|
||||
});
|
||||
assert.ok(partial.includes('role="alert"'));
|
||||
assert.ok(partial.includes(">23</strong>"));
|
||||
assert.ok(!partial.includes("dashboard.blockError"));
|
||||
}
|
||||
});
|
||||
test("los enlaces del dashboard mantienen la compañía y la ficha exacta", () => {
|
||||
const item = { id: "entry", companyId: "company" };
|
||||
assert.deepEqual(entryDestination({ ...item, kind: "ticket" }), {
|
||||
area: "tickets",
|
||||
companyId: "company",
|
||||
id: "entry",
|
||||
});
|
||||
assert.deepEqual(entryDestination({ ...item, kind: "asset" }), {
|
||||
area: "company",
|
||||
id: "company",
|
||||
section: "inventory",
|
||||
entityId: "entry",
|
||||
});
|
||||
assert.deepEqual(entryDestination({ ...item, kind: "resource" }), {
|
||||
area: "company",
|
||||
id: "company",
|
||||
section: "resources",
|
||||
entityId: "entry",
|
||||
});
|
||||
assert.deepEqual(entryDestination({ ...item, kind: "profile" }), {
|
||||
area: "company",
|
||||
id: "company",
|
||||
section: "profiles",
|
||||
entityId: "entry",
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { CompaniesWorkspace } from "@metis/companies/client";
|
||||
import { IdentityWorkspace } from "@metis/identity/client";
|
||||
import { IdentityWorkspace, identityApi, identityKeys } from "@metis/identity/client";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@ -7,10 +7,11 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@repo/shadcn-ui/components/select";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useId, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function App() {
|
||||
function LanguageSwitcher() {
|
||||
const { t, i18n } = useTranslation("core");
|
||||
const languageOptions = ["es", "en"].map((value) => ({
|
||||
value,
|
||||
@ -20,52 +21,69 @@ export function App() {
|
||||
const languageLabelId = useId();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<header className="border-b border-border bg-card">
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-3">
|
||||
<span className="text-xl font-semibold tracking-tight text-foreground">
|
||||
Metis Desk<span className="ml-1 text-primary">.</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<span id={languageLabelId}>{t("language")}</span>
|
||||
<Select
|
||||
items={languageOptions}
|
||||
onValueChange={(language) => {
|
||||
if (language !== "es" && language !== "en") {
|
||||
return;
|
||||
}
|
||||
setLanguageError(false);
|
||||
i18n
|
||||
.changeLanguage(language)
|
||||
.then(() => {
|
||||
document.documentElement.lang = i18n.resolvedLanguage ?? "es";
|
||||
})
|
||||
.catch(() => setLanguageError(true));
|
||||
}}
|
||||
value={i18n.resolvedLanguage ?? "es"}
|
||||
>
|
||||
<SelectTrigger aria-labelledby={languageLabelId} className="min-w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{languageOptions.map((language) => (
|
||||
<SelectItem key={language.value} value={language.value}>
|
||||
{language.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{languageError && (
|
||||
<p className="px-6 pb-4 text-destructive" role="alert">
|
||||
{t("languageError")}
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
<div className="desk-language">
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<span id={languageLabelId}>{t("language")}</span>
|
||||
<Select
|
||||
items={languageOptions}
|
||||
onValueChange={(language) => {
|
||||
if (language !== "es" && language !== "en") {
|
||||
return;
|
||||
}
|
||||
setLanguageError(false);
|
||||
i18n
|
||||
.changeLanguage(language)
|
||||
.then(() => {
|
||||
document.documentElement.lang = i18n.resolvedLanguage ?? "es";
|
||||
})
|
||||
.catch(() => setLanguageError(true));
|
||||
}}
|
||||
value={i18n.resolvedLanguage ?? "es"}
|
||||
>
|
||||
<SelectTrigger aria-labelledby={languageLabelId} className="min-w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{languageOptions.map((language) => (
|
||||
<SelectItem key={language.value} value={language.value}>
|
||||
{language.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{languageError && (
|
||||
<p className="text-destructive" role="alert">
|
||||
{t("languageError")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const session = useQuery({
|
||||
queryKey: identityKeys.session,
|
||||
queryFn: identityApi.session,
|
||||
retry: false,
|
||||
});
|
||||
return (
|
||||
<div className="desk-app">
|
||||
{!session.data?.user && (
|
||||
<header className="desk-login-header">
|
||||
<strong>Metis Desk</strong>
|
||||
<LanguageSwitcher />
|
||||
</header>
|
||||
)}
|
||||
<IdentityWorkspace
|
||||
renderWorkspace={(session, accountNavigation) => (
|
||||
<CompaniesWorkspace accountNavigation={accountNavigation} csrfToken={session.csrfToken} />
|
||||
renderWorkspace={(snapshot, accountNavigation) => (
|
||||
<CompaniesWorkspace
|
||||
accountNavigation={accountNavigation}
|
||||
csrfToken={snapshot.csrfToken}
|
||||
languageSwitcher={<LanguageSwitcher />}
|
||||
userName={snapshot.user?.name ?? ""}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{null}
|
||||
|
||||
@ -8,49 +8,297 @@ body {
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* Espacios de compañías: contexto persistente y área de trabajo independiente. */
|
||||
.desk-workspace {
|
||||
display: flex;
|
||||
min-height: calc(100dvh - 65px);
|
||||
/* Tokens del shell aislados de los colores semánticos de shadcn/ui. */
|
||||
:root {
|
||||
--desk-app-background: #000;
|
||||
--desk-content-background: #f7f9fb;
|
||||
--desk-sidebar-background: #0e282c;
|
||||
--desk-sidebar-top: #0c252a;
|
||||
--desk-sidebar-bottom: #102c30;
|
||||
--desk-sidebar-text: #f4f7f7;
|
||||
--desk-sidebar-muted: rgb(255 255 255 / 82%);
|
||||
--desk-accent: #00d2c1;
|
||||
--desk-active-start: rgb(0 188 177 / 40%);
|
||||
--desk-active-end: rgb(0 128 130 / 45%);
|
||||
--desk-navy: #0c2850;
|
||||
--desk-muted-navy: #607895;
|
||||
--desk-border: #d1d9e2;
|
||||
--desk-radius-sm: 8px;
|
||||
--desk-radius-md: 10px;
|
||||
--desk-radius-shell: 20px;
|
||||
--desk-sidebar-width: 228px;
|
||||
--desk-header-height: 88px;
|
||||
--desk-search-height: 41px;
|
||||
--desk-nav-height: 48px;
|
||||
--desk-avatar-size: 35px;
|
||||
}
|
||||
.desk-company-rail {
|
||||
width: 72px;
|
||||
flex: 0 0 72px;
|
||||
.desk-app {
|
||||
min-height: 100dvh;
|
||||
background: var(--desk-content-background);
|
||||
}
|
||||
.desk-login-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 30px;
|
||||
}
|
||||
.desk-language {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.desk-login-header .desk-language {
|
||||
margin-top: 0;
|
||||
}
|
||||
.desk-workspace {
|
||||
display: flex;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
border-radius: var(--desk-radius-shell);
|
||||
background: var(--desk-app-background);
|
||||
}
|
||||
body:has(.desk-workspace) {
|
||||
background: var(--desk-app-background);
|
||||
}
|
||||
.desk-sidebar {
|
||||
width: var(--desk-sidebar-width);
|
||||
flex: 0 0 var(--desk-sidebar-width);
|
||||
overflow-y: auto;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
var(--desk-sidebar-top),
|
||||
var(--desk-sidebar-background) 45%,
|
||||
var(--desk-sidebar-bottom)
|
||||
);
|
||||
color: var(--desk-sidebar-text);
|
||||
}
|
||||
.desk-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
padding: 31px 0 0 30px;
|
||||
white-space: nowrap;
|
||||
font-size: 21px;
|
||||
font-weight: 650;
|
||||
line-height: 30px;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
.desk-brand svg {
|
||||
width: 32px;
|
||||
height: 30px;
|
||||
color: var(--desk-accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.desk-company-rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px 8px;
|
||||
background: var(--muted);
|
||||
border-right: 1px solid var(--border);
|
||||
gap: 2px;
|
||||
padding: 28px 13px 24px 26px;
|
||||
}
|
||||
.desk-space-button {
|
||||
width: 48px;
|
||||
min-height: 48px;
|
||||
width: 100%;
|
||||
min-height: var(--desk-nav-height);
|
||||
height: var(--desk-nav-height);
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--background);
|
||||
justify-content: flex-start;
|
||||
gap: 19px;
|
||||
padding: 0 12px 0 14px;
|
||||
border: 0;
|
||||
border-left: 2px solid transparent;
|
||||
border-radius: var(--desk-radius-sm);
|
||||
color: var(--desk-sidebar-text);
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
.desk-space-button > svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
stroke-width: 1.6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.desk-space-button > span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.desk-space-button > .desk-nav-chevron {
|
||||
margin-left: auto;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
.desk-space-button:hover {
|
||||
background: rgb(255 255 255 / 6%);
|
||||
color: white;
|
||||
}
|
||||
.desk-space-button[aria-pressed="true"] {
|
||||
color: var(--primary-foreground);
|
||||
background: var(--primary);
|
||||
border-radius: 16px;
|
||||
color: white;
|
||||
border-left-color: var(--desk-accent);
|
||||
background: linear-gradient(90deg, var(--desk-active-start), var(--desk-active-end));
|
||||
}
|
||||
.desk-rail-separator {
|
||||
width: 28px;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
background: rgb(255 255 255 / 12%);
|
||||
margin: 26px -3px 25px -5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.desk-catalog-navigation {
|
||||
display: grid;
|
||||
padding-left: 18px;
|
||||
}
|
||||
.desk-catalog-navigation > button {
|
||||
height: auto;
|
||||
min-height: 38px;
|
||||
justify-content: flex-start;
|
||||
white-space: normal;
|
||||
text-align: left;
|
||||
color: var(--desk-sidebar-muted);
|
||||
}
|
||||
.desk-catalog-navigation > button[aria-pressed="true"] {
|
||||
background: rgb(255 255 255 / 8%);
|
||||
color: white;
|
||||
}
|
||||
.desk-workspace-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
background: var(--desk-content-background);
|
||||
}
|
||||
.desk-global-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
min-height: var(--desk-header-height);
|
||||
padding: 31px 30px 16px 17px;
|
||||
color: var(--desk-navy);
|
||||
}
|
||||
.desk-search {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.desk-search > svg {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 11px;
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
color: #1d3e6e;
|
||||
pointer-events: none;
|
||||
}
|
||||
.desk-search > input {
|
||||
height: var(--desk-search-height);
|
||||
padding-left: 43px;
|
||||
border-radius: var(--desk-radius-sm);
|
||||
border-color: var(--desk-border);
|
||||
background: rgb(255 255 255 / 65%);
|
||||
box-shadow: none;
|
||||
font-size: 14px;
|
||||
color: var(--desk-navy);
|
||||
}
|
||||
.desk-search > input::placeholder {
|
||||
color: var(--desk-muted-navy);
|
||||
}
|
||||
.desk-search-results,
|
||||
.desk-profile-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
z-index: 30;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--desk-border);
|
||||
border-radius: var(--desk-radius-md);
|
||||
background: var(--desk-content-background);
|
||||
box-shadow: 0 3px 12px rgb(12 40 80 / 8%);
|
||||
}
|
||||
.desk-search-results {
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
display: grid;
|
||||
}
|
||||
.desk-search-results > button {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.desk-search-results p {
|
||||
font-size: 12px;
|
||||
color: var(--desk-muted-navy);
|
||||
padding: 8px;
|
||||
}
|
||||
.desk-notifications {
|
||||
display: flex;
|
||||
flex: 0 0 24px;
|
||||
}
|
||||
.desk-notifications svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
stroke-width: 1.6;
|
||||
}
|
||||
.desk-profile {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.desk-profile summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
.desk-profile summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.desk-profile summary > svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.desk-avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--desk-avatar-size);
|
||||
height: var(--desk-avatar-size);
|
||||
border-radius: 50%;
|
||||
background: #d9e8e9;
|
||||
color: #0e282c;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.desk-profile-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.25;
|
||||
max-width: 150px;
|
||||
}
|
||||
.desk-profile-copy strong {
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.desk-profile-copy > span {
|
||||
font-size: 12px;
|
||||
color: var(--desk-muted-navy);
|
||||
}
|
||||
.desk-profile-menu {
|
||||
right: 0;
|
||||
width: 300px;
|
||||
}
|
||||
.desk-profile summary:focus-visible,
|
||||
.desk-space-button:focus-visible {
|
||||
outline: 2px solid var(--desk-accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.desk-main-content {
|
||||
padding: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
.company-space {
|
||||
display: grid;
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
grid-template-rows: auto 1fr auto;
|
||||
grid-template-areas: "heading main" "navigation main" "account main";
|
||||
min-height: calc(100dvh - 65px);
|
||||
min-height: calc(100dvh - var(--desk-header-height));
|
||||
background: var(--sidebar);
|
||||
}
|
||||
.company-space-heading {
|
||||
@ -103,6 +351,137 @@ body {
|
||||
font-size: 0.875rem;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.dashboard-page {
|
||||
max-width: 1440px;
|
||||
}
|
||||
.dashboard-kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.dashboard-kpi,
|
||||
.dashboard-panel {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--card);
|
||||
box-shadow: 0 1px 2px color-mix(in srgb, var(--foreground) 5%, transparent);
|
||||
}
|
||||
.dashboard-kpi {
|
||||
min-height: 144px;
|
||||
padding: 20px;
|
||||
text-align: left;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.dashboard-kpi:not(:disabled):hover {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.dashboard-kpi strong {
|
||||
color: var(--foreground);
|
||||
font-size: 2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.dashboard-kpi-icon {
|
||||
color: var(--primary);
|
||||
}
|
||||
.dashboard-kpi-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.dashboard-panel {
|
||||
padding: 20px;
|
||||
}
|
||||
.dashboard-panel h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.dashboard-panel table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.dashboard-panel th,
|
||||
.dashboard-panel td {
|
||||
padding: 10px 8px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: top;
|
||||
}
|
||||
.dashboard-panel th {
|
||||
color: var(--muted-foreground);
|
||||
font-weight: 500;
|
||||
}
|
||||
.dashboard-link {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
}
|
||||
.dashboard-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.dashboard-link:focus-visible,
|
||||
.dashboard-alert:focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.dashboard-alert {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
padding: 16px 0;
|
||||
text-align: left;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.dashboard-alert strong {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.dashboard-alert > span {
|
||||
flex: 1;
|
||||
}
|
||||
.dashboard-badge {
|
||||
display: inline-block;
|
||||
border-radius: 999px;
|
||||
background: var(--muted);
|
||||
padding: 3px 10px;
|
||||
white-space: nowrap;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.dashboard-badge[data-state="urgent"],
|
||||
.dashboard-badge[data-state="high"] {
|
||||
background: color-mix(in srgb, var(--destructive) 12%, var(--card));
|
||||
color: var(--destructive);
|
||||
}
|
||||
.dashboard-badge[data-state="in_progress"] {
|
||||
background: color-mix(in srgb, var(--primary) 12%, var(--card));
|
||||
color: var(--primary);
|
||||
}
|
||||
.dashboard-timeline {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
.dashboard-timeline li {
|
||||
display: grid;
|
||||
grid-template-columns: 110px 24px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.dashboard-timeline li {
|
||||
grid-template-columns: 24px minmax(0, 1fr);
|
||||
}
|
||||
.dashboard-timeline time {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
@media (max-width: 1199px) {
|
||||
.dashboard-kpis {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
.inventory-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 280px;
|
||||
@ -163,22 +542,69 @@ body {
|
||||
}
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.desk-workspace {
|
||||
flex-direction: column;
|
||||
.dashboard-kpis {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.desk-company-rail {
|
||||
.dashboard-kpi,
|
||||
.dashboard-panel {
|
||||
padding: 16px;
|
||||
}
|
||||
.desk-workspace {
|
||||
height: auto;
|
||||
min-height: 100dvh;
|
||||
flex-direction: column;
|
||||
border-radius: 0;
|
||||
}
|
||||
.desk-sidebar {
|
||||
width: 100%;
|
||||
flex-basis: auto;
|
||||
}
|
||||
.desk-brand {
|
||||
padding: 20px;
|
||||
}
|
||||
.desk-company-rail {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
gap: 6px;
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
.desk-space-button {
|
||||
width: 44px;
|
||||
min-height: 44px;
|
||||
width: auto;
|
||||
min-height: 42px;
|
||||
height: 42px;
|
||||
padding: 0 10px;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.desk-space-button > svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.desk-rail-separator {
|
||||
flex-basis: 100%;
|
||||
margin: 6px 0;
|
||||
}
|
||||
.desk-catalog-navigation {
|
||||
flex-basis: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
.desk-global-header {
|
||||
gap: 14px;
|
||||
min-height: 72px;
|
||||
padding: 16px;
|
||||
}
|
||||
.desk-profile-copy {
|
||||
display: none;
|
||||
}
|
||||
.desk-profile summary {
|
||||
gap: 6px;
|
||||
}
|
||||
.desk-profile-menu {
|
||||
width: min(300px, calc(100vw - 32px));
|
||||
}
|
||||
.desk-main-content {
|
||||
padding: 16px;
|
||||
}
|
||||
.company-space {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
|
||||
@ -113,3 +113,28 @@ BrandSelect, dentro del módulo assets, adapta SelectWithAction al catálogo rem
|
||||
## Acciones de fila ancladas
|
||||
|
||||
EntityRowActions muestra un botón icon-sm con EllipsisIcon, etiqueta accesible y título traducidos, sin texto visible repetido. EntityTable reserva la identidad de columna actions para operaciones: siempre visible, sin búsqueda/ordenación y fijada a la derecha mediante columnPinning de TanStack Table. EntityTableContent renderiza primero las columnas de datos y después la columna anclada, con cabecera/celdas sticky right-0, alineación derecha, anchura compacta y fondo opaco para evitar solapamientos al desplazar la tabla. Todos los listados consumidores heredan este comportamiento.
|
||||
|
||||
## Tarjetas de indicadores
|
||||
|
||||
StatisticCard (`@repo/shadcn-ui/components/statistic-card`) representa un indicador con etiqueta, valor, icono y tinte discreto. Admite carga/error, evita sustituir datos desconocidos por cero y utiliza un botón con foco visible cuando hay navegación. No depende de DTO ni endpoints. Inicio la reutiliza para sus cinco KPI; la organización de bloques y sus estados permanece local al dashboard.
|
||||
|
||||
## Shell global
|
||||
|
||||
`App` conserva el acceso y el selector de idioma. `CompaniesWorkspace` compone sidebar,
|
||||
header global y contenido con los destinos y estado de navegación existentes. El
|
||||
header solo aparece en sesiones autenticadas. Los catálogos se seleccionan en la
|
||||
sidebar; la navegación interna de cada compañía permanece en `CompanyDetail`.
|
||||
|
||||
Los tokens `--desk-*` de `apps/web/src/global.css` aíslan la paleta del shell de los
|
||||
colores semánticos del resto del producto: superficie negra exterior, contenido
|
||||
#F7F9FB, sidebar #0E282C, acento turquesa, texto y borde azul, radios 8/10/20px,
|
||||
sidebar 228px, header 88px, búsqueda 41px, fila de navegación 48px y avatar 35px.
|
||||
En móvil se conserva la navegación horizontal y envuelta, sin introducir drawer.
|
||||
|
||||
El buscador usa Input de shadcn/ui y filtra compañías cargadas y destinos globales;
|
||||
Escape limpia y los resultados se recorren mediante Tab. No existe un servicio de
|
||||
búsqueda transversal de entidades. La campana no muestra avisos ficticios: no existe
|
||||
servicio de notificaciones. El perfil usa disclosure nativo con foco visible y
|
||||
conserva las acciones de identidad y selección de idioma. No hay primitives Avatar
|
||||
ni DropdownMenu instalados; el avatar muestra iniciales del usuario real, sin
|
||||
inventar foto ni cargo. Configuración abre un FormDialog con las acciones existentes.
|
||||
|
||||
53
docs/dashboard-inicio.md
Normal file
53
docs/dashboard-inicio.md
Normal file
@ -0,0 +1,53 @@
|
||||
# Dashboard de inicio
|
||||
|
||||
Inicio es la pantalla inicial después del acceso. Mantiene el layout de compañías como espacios, el usuario, el idioma y los formularios existentes. El selector permite consultar todas las compañías o una concreta; su alcance se aplica a todos los bloques y a las altas rápidas.
|
||||
|
||||
## Composición y navegación
|
||||
|
||||
Cinco tarjetas KPI preceden al trabajo pendiente, los avisos, la actividad reciente, el resumen de compañías y las acciones rápidas. Se utilizan los tokens y la tipografía existentes. En escritorio, trabajo/avisos ocupan dos tercios/un tercio; la segunda fila reparte actividad y compañías. En móvil se apilan los bloques y las tablas se desplazan dentro de su contenedor.
|
||||
|
||||
Las tarjetas son botones accesibles. Compañías abre el directorio filtrado por activas; tickets e intervenciones abren sus listados con “Solo trabajo pendiente”, un filtro visible y eliminable. Activos y recursos abren un listado global o por compañía paginado en el servidor. Cada registro abre su ficha concreta dentro del workspace. El resumen limita la vista a cinco compañías y ofrece la tabla completa con búsqueda, columnas y paginación.
|
||||
|
||||
Las cinco altas reutilizan CompanyForm, TicketForm, InterventionForm, AssetForm y ResourceForm. Activos y recursos solicitan primero una compañía cuando no está seleccionada. Se conserva la ayuda antes del pie, Guardar/Cancelar y SelectWithAction. No se añaden registros de ejemplo.
|
||||
|
||||
## Definición de indicadores
|
||||
|
||||
| Indicador | Regla |
|
||||
| --- | --- |
|
||||
| Compañías activas | Compañías con active verdadero. |
|
||||
| Tickets abiertos | open, in_progress y waiting; excluye resolved y cancelled. |
|
||||
| Intervenciones pendientes | pending e in_progress; excluye finished y cancelled. |
|
||||
| Activos registrados | Todos los activos persistidos, incluidos los retirados. |
|
||||
| Recursos registrados | Todos los recursos persistidos, incluidos los inactivos. |
|
||||
| Personas por compañía | Todas las personas registradas, incluidas las inactivas. |
|
||||
|
||||
Los contadores corresponden al conjunto completo, nunca a las filas visibles. Trabajo pendiente muestra seis entradas y “Ver todos” abre páginas de diez. Primero se ordena por prioridad urgent, high, normal, low y después por la primera revisión (más antiguo primero), con ID como desempate. Una intervención hereda la prioridad del ticket vinculado; sin ticket se ordena como normal. Prioridad y estado se presentan por separado.
|
||||
|
||||
## Avisos comprobables
|
||||
|
||||
- Certificados y licencias activos cuya fecha de caducidad está entre hoy y hoy + 30 días, incluidos los extremos. La fecha de referencia es UTC. Se identifican por stockKey de las plantillas de serie certificate/software_license y del campo expires, nunca por nombres. No se incluyen recursos sin fecha, inactivos, ya caducados o plantillas personalizadas cuyo significado no está declarado.
|
||||
- Perfiles activos que requieren revisión porque su persona está inactiva, su activo retirado o su cuenta ya no es válida para ese perfil (cuenta inactiva, técnica, caducada, sistema/entorno desactivado o sin vinculación vigente a la persona). Los campos opcionales vacíos no constituyen errores.
|
||||
- Activos sin sede asignada, incluidos los retirados, porque el listado representa la ausencia de esa referencia.
|
||||
- Personas inactivas con activos no retirados asignados, perfiles activos, vinculaciones de cuentas aún pendientes/configuradas/pendientes de retirar (aunque haya cerrado su periodo), necesidades vigentes, conexiones remotas y configuraciones de correo/aplicaciones/certificados/carpetas/impresoras sin retirar, o autorización de teletrabajo activa no caducada.
|
||||
|
||||
Estos avisos representan información registrada pendiente de revisión; no verifican permisos efectivos en servidores externos ni ejecutan retiradas. Cada contador abre el conjunto que lo origina. Los accesos personales registrados también se contemplan a través de conexiones remotas y configuraciones de correo.
|
||||
|
||||
## Fuentes y capas
|
||||
|
||||
GET /api/dashboard/summary, /work, /attention, /activity, /companies y /details son consultas autenticadas de lectura. Admiten companyId; work/details admiten page, details admite kind o issue. Un alcance inexistente devuelve 404; parámetros desconocidos o inválidos devuelven 400. Los contratos se validan con Zod.
|
||||
|
||||
DashboardUseCases coordina el repositorio de lectura en Application. La implementación Sequelize y el SQL están en Infrastructure. Assets y Resources ofrecen proyecciones públicas acotadas para contadores, detalles e historial; no se reconstruyen ni descargan inventarios de todas las compañías en el navegador. No se crea ninguna entidad de negocio ni migración de datos.
|
||||
|
||||
La actividad combina las seis revisiones más recientes de tickets, intervenciones, activos y recursos y presenta las seis más recientes del conjunto (cuatro en Inicio). Cada evento utiliza el UUID de su revisión. Se muestra “Revisión registrada”, fecha, entidad, compañía y autor cuando existe; no se deduce una acción concreta a partir de un simple cambio. Este bloque no constituye un historial global completo ni incluye todavía todos los historiales del dominio. “Ver todos” muestra este conjunto reciente ampliado.
|
||||
|
||||
## Carga, vacío, error y actualización
|
||||
|
||||
Cada bloque tiene una consulta independiente con cancelación y clave por alcance. Un fallo parcial no oculta los demás. Loading usa role=status; error usa role=alert; vacío tiene un texto propio. Los KPI pendientes o fallidos muestran un guion, nunca un cero ficticio. La conexión disponible indica que la consulta de resumen ha respondido correctamente; no es un monitor de red permanente.
|
||||
|
||||
TanStack Query actualiza las consultas al volver a montarlas/enfocarlas y las altas rápidas invalidan las claves dashboard. No existe polling permanente ni notificación push. Todos los textos están en los JSON es/en del módulo Companies.
|
||||
|
||||
## Componentes y verificación
|
||||
|
||||
StatisticCard pertenece a @repo/shadcn-ui y solo acepta presentación y callbacks, sin DTO ni endpoints. DashboardPanel y BlockState son componentes locales: organizan los bloques y gestionan sus estados sin acoplar otros módulos al dashboard.
|
||||
|
||||
La integración HTTP con la BD de pruebas comprueba totales completos, exclusión de estados finales, alcance de compañías, avisos/detalles consistentes, paginación, contratos, parámetros inválidos y autorización. Las pruebas de renderizado comprueban idiomas, vacío, carga y errores parciales. Las comprobaciones visuales se hacen en desarrollo sin crear datos de negocio.
|
||||
@ -0,0 +1,49 @@
|
||||
import { QueryTypes, type Sequelize } from "sequelize";
|
||||
|
||||
type Entry = {
|
||||
eventId?: string;
|
||||
id: string;
|
||||
companyId: string;
|
||||
companyName: string;
|
||||
name: string;
|
||||
recordedAt: string | null;
|
||||
actorName: string | null;
|
||||
};
|
||||
|
||||
/** Proyección de lectura acotada; no reconstruye inventarios ni agregados. */
|
||||
export class AssetDashboardReader {
|
||||
constructor(private readonly database: Sequelize) {}
|
||||
stats(companyId?: string) {
|
||||
return this.database.query<{ companyId: string; count: number }>(
|
||||
"SELECT companyId,COUNT(*) AS count FROM assets WHERE (:companyId IS NULL OR companyId=:companyId) GROUP BY companyId",
|
||||
{ type: QueryTypes.SELECT, replacements: { companyId: companyId ?? null } }
|
||||
);
|
||||
}
|
||||
async entries(companyId?: string, issue?: string, page = 1) {
|
||||
const where =
|
||||
"WHERE (:companyId IS NULL OR a.companyId=:companyId)" +
|
||||
(issue === "missing_site" ? " AND a.siteId IS NULL" : "");
|
||||
const replacements = { companyId: companyId ?? null, offset: (page - 1) * 10 };
|
||||
const counts = await this.database.query<{ total: number }>(
|
||||
`SELECT COUNT(*) AS total FROM assets a ${where}`,
|
||||
{ type: QueryTypes.SELECT, replacements }
|
||||
);
|
||||
const items = await this.database.query<Entry>(
|
||||
`SELECT a.id,a.companyId,c.name AS companyName,JSON_UNQUOTE(JSON_EXTRACT(a.\`values\`,'$.name')) AS name,NULL AS recordedAt,NULL AS actorName FROM assets a JOIN companies c ON c.id=a.companyId ${where} ORDER BY name,a.id LIMIT 10 OFFSET :offset`,
|
||||
{ type: QueryTypes.SELECT, replacements }
|
||||
);
|
||||
return { total: Number(counts[0]?.total ?? 0), items };
|
||||
}
|
||||
inactivePeople(companyId?: string) {
|
||||
return this.database.query<{ id: string }>(
|
||||
"SELECT DISTINCT p.id FROM assets a JOIN company_contacts p ON p.id=a.personId AND p.companyId=a.companyId WHERE p.active=0 AND JSON_UNQUOTE(JSON_EXTRACT(a.`values`,'$.status')) <> 'retired' AND (:companyId IS NULL OR a.companyId=:companyId)",
|
||||
{ type: QueryTypes.SELECT, replacements: { companyId: companyId ?? null } }
|
||||
);
|
||||
}
|
||||
activity(companyId?: string) {
|
||||
return this.database.query<Entry>(
|
||||
"SELECT a.id,r.id AS eventId,a.companyId,c.name AS companyName,JSON_UNQUOTE(JSON_EXTRACT(a.`values`,'$.name')) AS name,r.recordedAt,u.name AS actorName FROM asset_revisions r JOIN assets a ON a.id=r.assetId JOIN companies c ON c.id=a.companyId LEFT JOIN users u ON u.id=r.actorId WHERE (:companyId IS NULL OR a.companyId=:companyId) ORDER BY r.recordedAt DESC,r.id DESC LIMIT 6",
|
||||
{ type: QueryTypes.SELECT, replacements: { companyId: companyId ?? null } }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -22,6 +22,7 @@ import {
|
||||
migrateAssetCatalog,
|
||||
} from "./infrastructure/persistence/asset-catalog.model";
|
||||
import { AssetCatalogPersistenceMapper } from "./infrastructure/persistence/asset-catalog-persistence.mapper";
|
||||
import { AssetDashboardReader } from "./infrastructure/persistence/asset-dashboard-reader";
|
||||
import { migrateRelationships } from "./infrastructure/persistence/asset-relationship.model";
|
||||
import { importLegacyBrands, migrateBrands } from "./infrastructure/persistence/brand-migration";
|
||||
import { SequelizeAssetRepository } from "./infrastructure/persistence/sequelize-asset.repository";
|
||||
@ -61,6 +62,7 @@ export async function createAssetModule(database: Sequelize, authenticate: Reque
|
||||
const useCases = new AssetCatalogUseCases(repository);
|
||||
return {
|
||||
services: {
|
||||
dashboard: new AssetDashboardReader(database),
|
||||
inventory: {
|
||||
workstations: async (companyId: UniqueID) => {
|
||||
const result = await assetRepository.get(companyId);
|
||||
|
||||
@ -25,10 +25,14 @@ export function AssetInventoryPage({
|
||||
companyId,
|
||||
csrfToken,
|
||||
lookups,
|
||||
initialId,
|
||||
initialCreate = false,
|
||||
}: {
|
||||
companyId: string;
|
||||
csrfToken: string;
|
||||
lookups: InventoryLookups;
|
||||
initialId?: string | undefined;
|
||||
initialCreate?: boolean | undefined;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation("assets");
|
||||
const query = useQuery({
|
||||
@ -40,9 +44,9 @@ export function AssetInventoryPage({
|
||||
queryFn: () => catalogApi.get(companyId),
|
||||
});
|
||||
const [search, setSearch] = useState("");
|
||||
const [editor, setEditor] = useState<string | undefined>();
|
||||
const [editor, setEditor] = useState<string | undefined>(initialCreate ? "new" : undefined);
|
||||
const [viewId, setViewId] = useState<string | undefined>();
|
||||
const [selectedId, setSelectedId] = useState<string>();
|
||||
const [selectedId, setSelectedId] = useState<string | undefined>(initialId);
|
||||
const [detailOpen, setDetailOpen] = useState(true);
|
||||
const assets = query.data?.assets ?? [];
|
||||
const selected = assets.find((asset) => asset.id === selectedId) ?? assets[0];
|
||||
|
||||
52
modules/companies/src/api/application/dashboard.use-cases.ts
Normal file
52
modules/companies/src/api/application/dashboard.use-cases.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { DashboardDetailsQuery, DashboardEntryDTO } from "../../common/dashboard";
|
||||
import { CompanyError } from "../domain/company-error";
|
||||
|
||||
export type InventoryEntry = Pick<
|
||||
DashboardEntryDTO,
|
||||
"id" | "companyId" | "companyName" | "name" | "recordedAt" | "actorName" | "eventId"
|
||||
>;
|
||||
export interface IInventoryDashboardReader {
|
||||
stats(companyId?: string): Promise<{ companyId: string; count: number }[]>;
|
||||
entries(
|
||||
companyId?: string,
|
||||
issue?: string,
|
||||
page?: number
|
||||
): Promise<{ items: InventoryEntry[]; total: number }>;
|
||||
activity(companyId?: string): Promise<InventoryEntry[]>;
|
||||
}
|
||||
export interface IAssetDashboardReader extends IInventoryDashboardReader {
|
||||
inactivePeople(companyId?: string): Promise<{ id: string }[]>;
|
||||
}
|
||||
|
||||
export type DashboardBlock =
|
||||
| "summary"
|
||||
| "work"
|
||||
| "attention"
|
||||
| "activity"
|
||||
| "companies"
|
||||
| "details";
|
||||
export interface IDashboardRepository {
|
||||
companyExists(id: string): Promise<boolean>;
|
||||
read(block: DashboardBlock, query: DashboardDetailsQuery): Promise<unknown>;
|
||||
}
|
||||
|
||||
export class DashboardUseCases {
|
||||
constructor(private readonly repository: IDashboardRepository) {}
|
||||
async read(block: DashboardBlock, query: DashboardDetailsQuery) {
|
||||
let normalized = query;
|
||||
if (query.companyId) {
|
||||
const id = UniqueID.create(query.companyId);
|
||||
if (!id.isSuccess) {
|
||||
return Result.fail(new CompanyError("INVALID_REQUEST"));
|
||||
}
|
||||
if (!(await this.repository.companyExists(id.data.toPrimitive()))) {
|
||||
return Result.fail(new CompanyError("COMPANY_NOT_FOUND"));
|
||||
}
|
||||
normalized = { ...query, companyId: id.data.toPrimitive() };
|
||||
}
|
||||
return Result.ok(await this.repository.read(block, normalized));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
import type { Application, RequestHandler } from "express";
|
||||
import { Router } from "express";
|
||||
|
||||
import {
|
||||
DashboardAttentionSchema,
|
||||
DashboardCompaniesSchema,
|
||||
DashboardDetailsQuerySchema,
|
||||
DashboardEntriesSchema,
|
||||
DashboardQuerySchema,
|
||||
DashboardSummarySchema,
|
||||
} from "../../../common/dashboard";
|
||||
import type { DashboardUseCases } from "../../application/dashboard.use-cases";
|
||||
|
||||
export function registerDashboardRoutes(
|
||||
app: Application,
|
||||
useCases: DashboardUseCases,
|
||||
authenticate: RequestHandler
|
||||
) {
|
||||
const router = Router();
|
||||
for (const [block, schema] of [
|
||||
["summary", DashboardSummarySchema],
|
||||
["work", DashboardEntriesSchema],
|
||||
["attention", DashboardAttentionSchema],
|
||||
["activity", DashboardEntriesSchema],
|
||||
["companies", DashboardCompaniesSchema],
|
||||
["details", DashboardEntriesSchema],
|
||||
] as const) {
|
||||
router.get(`/${block}`, (request, response, next) => {
|
||||
const parsed = (
|
||||
block === "details" || block === "work" ? DashboardDetailsQuerySchema : DashboardQuerySchema
|
||||
).safeParse(request.query);
|
||||
if (!parsed.success) {
|
||||
response
|
||||
.status(400)
|
||||
.json({ code: "INVALID_REQUEST", requestId: response.getHeader("x-request-id") });
|
||||
return;
|
||||
}
|
||||
useCases
|
||||
.read(block, { page: 1, ...parsed.data })
|
||||
.then((result) => {
|
||||
if (!result.isSuccess) {
|
||||
response
|
||||
.status(result.error.code === "COMPANY_NOT_FOUND" ? 404 : 400)
|
||||
.json({ code: result.error.code, requestId: response.getHeader("x-request-id") });
|
||||
return;
|
||||
}
|
||||
response.json(schema.parse(result.data));
|
||||
})
|
||||
.catch(next);
|
||||
});
|
||||
}
|
||||
app.use("/api/dashboard", authenticate, router);
|
||||
}
|
||||
@ -0,0 +1,257 @@
|
||||
import { QueryTypes, type Sequelize } from "sequelize";
|
||||
|
||||
import type { DashboardDetailsQuery, DashboardEntryDTO } from "../../../common/dashboard";
|
||||
import type {
|
||||
DashboardBlock,
|
||||
IAssetDashboardReader,
|
||||
IDashboardRepository,
|
||||
IInventoryDashboardReader,
|
||||
InventoryEntry,
|
||||
} from "../../application/dashboard.use-cases";
|
||||
|
||||
const OPEN_TICKET =
|
||||
"JSON_UNQUOTE(JSON_EXTRACT(t.`values`,'$.state')) IN ('open','in_progress','waiting')";
|
||||
const PENDING_INTERVENTION =
|
||||
"JSON_UNQUOTE(JSON_EXTRACT(i.`values`,'$.state')) IN ('pending','in_progress')";
|
||||
const PROFILE_REVIEW = `JSON_EXTRACT(p.\`values\`,'$.active')=true AND (person.active=0 OR JSON_UNQUOTE(JSON_EXTRACT(a.\`values\`,'$.status'))='retired' OR (p.accountId IS NOT NULL AND (
|
||||
account.id IS NULL OR JSON_EXTRACT(account.\`values\`,'$.active')<>true OR JSON_UNQUOTE(JSON_EXTRACT(account.\`values\`,'$.usage'))='technical'
|
||||
OR JSON_UNQUOTE(JSON_EXTRACT(account.\`values\`,'$.expiresOn')) < :today
|
||||
OR NOT EXISTS (SELECT 1 FROM access_items s WHERE s.id=JSON_UNQUOTE(JSON_EXTRACT(account.\`values\`,'$.systemId')) AND s.kind='system' AND JSON_EXTRACT(s.\`values\`,'$.active')=true)
|
||||
OR (NULLIF(JSON_UNQUOTE(JSON_EXTRACT(account.\`values\`,'$.environmentId')),'null') IS NOT NULL AND NOT EXISTS (SELECT 1 FROM access_items e WHERE e.id=JSON_UNQUOTE(JSON_EXTRACT(account.\`values\`,'$.environmentId')) AND e.kind='environment' AND JSON_EXTRACT(e.\`values\`,'$.active')=true))
|
||||
OR NOT EXISTS (SELECT 1 FROM access_items link WHERE link.kind='account_person' AND JSON_UNQUOTE(JSON_EXTRACT(link.\`values\`,'$.personId'))=p.personId AND JSON_UNQUOTE(JSON_EXTRACT(link.\`values\`,'$.accountId'))=p.accountId AND JSON_UNQUOTE(JSON_EXTRACT(link.\`values\`,'$.startDate'))<=:today AND (NULLIF(JSON_UNQUOTE(JSON_EXTRACT(link.\`values\`,'$.endDate')),'null') IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(link.\`values\`,'$.endDate'))>:today) AND JSON_UNQUOTE(JSON_EXTRACT(link.\`values\`,'$.configuration')) IN ('pending','configured'))
|
||||
)))`;
|
||||
|
||||
export class SequelizeDashboardRepository implements IDashboardRepository {
|
||||
constructor(
|
||||
private readonly database: Sequelize,
|
||||
private readonly assets: IAssetDashboardReader,
|
||||
private readonly resources: IInventoryDashboardReader
|
||||
) {}
|
||||
private select<T extends object>(
|
||||
sql: string,
|
||||
query: DashboardDetailsQuery,
|
||||
extra: Record<string, string | number | null | string[]> = {}
|
||||
) {
|
||||
return this.database.query<T>(sql, {
|
||||
type: QueryTypes.SELECT,
|
||||
replacements: {
|
||||
companyId: query.companyId ?? null,
|
||||
today: new Date().toISOString().slice(0, 10),
|
||||
offset: (query.page - 1) * 10,
|
||||
...extra,
|
||||
},
|
||||
});
|
||||
}
|
||||
async companyExists(id: string) {
|
||||
const rows = await this.select<{ id: string }>("SELECT id FROM companies WHERE id=:companyId", {
|
||||
companyId: id,
|
||||
page: 1,
|
||||
});
|
||||
return rows.length > 0;
|
||||
}
|
||||
private async companies(query: DashboardDetailsQuery) {
|
||||
const [rows, assets, resources] = await Promise.all([
|
||||
this.select<{
|
||||
id: string;
|
||||
name: string;
|
||||
active: number;
|
||||
tickets: number;
|
||||
interventions: number;
|
||||
people: number;
|
||||
}>(
|
||||
`SELECT c.id,c.name,c.active,(SELECT COUNT(*) FROM tickets t WHERE t.companyId=c.id AND ${OPEN_TICKET}) AS tickets,(SELECT COUNT(*) FROM interventions i WHERE i.companyId=c.id AND ${PENDING_INTERVENTION}) AS interventions,(SELECT COUNT(*) FROM company_contacts p WHERE p.companyId=c.id) AS people FROM companies c WHERE (:companyId IS NULL OR c.id=:companyId) ORDER BY c.name,c.id`,
|
||||
query
|
||||
),
|
||||
this.assets.stats(query.companyId),
|
||||
this.resources.stats(query.companyId),
|
||||
]);
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
companies: Number(Boolean(row.active)),
|
||||
tickets: Number(row.tickets),
|
||||
interventions: Number(row.interventions),
|
||||
people: Number(row.people),
|
||||
assets: Number(assets.find((item) => item.companyId === row.id)?.count ?? 0),
|
||||
resources: Number(resources.find((item) => item.companyId === row.id)?.count ?? 0),
|
||||
}));
|
||||
}
|
||||
private workQuery() {
|
||||
return `SELECT t.id,t.companyId,c.name AS companyName,'ticket' AS kind,t.reference,JSON_UNQUOTE(JSON_EXTRACT(t.\`values\`,'$.subject')) AS name,JSON_UNQUOTE(JSON_EXTRACT(t.\`values\`,'$.state')) AS state,JSON_UNQUOTE(JSON_EXTRACT(t.\`values\`,'$.priority')) AS priority,(SELECT MIN(recordedAt) FROM ticket_revisions WHERE ticketId=t.id) AS recordedAt,NULL AS actorName FROM tickets t JOIN companies c ON c.id=t.companyId WHERE ${OPEN_TICKET} AND (:companyId IS NULL OR t.companyId=:companyId)
|
||||
UNION ALL SELECT i.id,i.companyId,c.name,'intervention',i.reference,JSON_UNQUOTE(JSON_EXTRACT(i.\`values\`,'$.subject')),JSON_UNQUOTE(JSON_EXTRACT(i.\`values\`,'$.state')),JSON_UNQUOTE(JSON_EXTRACT(t.\`values\`,'$.priority')),(SELECT MIN(recordedAt) FROM intervention_revisions WHERE interventionId=i.id),NULL FROM interventions i JOIN companies c ON c.id=i.companyId LEFT JOIN tickets t ON t.id=i.ticketId WHERE ${PENDING_INTERVENTION} AND (:companyId IS NULL OR i.companyId=:companyId)`;
|
||||
}
|
||||
private async work(query: DashboardDetailsQuery) {
|
||||
const counts = await this.select<{ total: number }>(
|
||||
`SELECT COUNT(*) AS total FROM (${this.workQuery()}) work`,
|
||||
query
|
||||
);
|
||||
const items = await this.select<DashboardEntryDTO>(
|
||||
`SELECT * FROM (${this.workQuery()}) work ORDER BY FIELD(COALESCE(priority,'normal'),'urgent','high','normal','low'),recordedAt,id LIMIT 10 OFFSET :offset`,
|
||||
query
|
||||
);
|
||||
return { items, total: Number(counts[0]?.total ?? 0) };
|
||||
}
|
||||
private async issues(query: DashboardDetailsQuery) {
|
||||
if (query.issue === "profile") {
|
||||
const from =
|
||||
"FROM work_profiles p JOIN companies c ON c.id=p.companyId JOIN company_contacts person ON person.id=p.personId JOIN assets a ON a.id=p.assetId LEFT JOIN access_items account ON account.id=p.accountId";
|
||||
const where = `WHERE (:companyId IS NULL OR p.companyId=:companyId) AND ${PROFILE_REVIEW}`;
|
||||
const count = await this.select<{ total: number }>(
|
||||
`SELECT COUNT(*) AS total ${from} ${where}`,
|
||||
query
|
||||
);
|
||||
const items = await this.select<InventoryEntry>(
|
||||
`SELECT p.id,p.companyId,c.name AS companyName,JSON_UNQUOTE(JSON_EXTRACT(p.\`values\`,'$.name')) AS name,NULL AS recordedAt,NULL AS actorName ${from} ${where} ORDER BY name,p.id LIMIT 10 OFFSET :offset`,
|
||||
query
|
||||
);
|
||||
return {
|
||||
items: items.map((item) => ({
|
||||
...item,
|
||||
kind: "profile" as const,
|
||||
reference: null,
|
||||
state: null,
|
||||
priority: null,
|
||||
})),
|
||||
total: Number(count[0]?.total ?? 0),
|
||||
};
|
||||
}
|
||||
if (query.issue === "inactive_person") {
|
||||
const assets = await this.assets.inactivePeople(query.companyId);
|
||||
const ids = assets.map((item) => item.id);
|
||||
const profileConfigurations = [
|
||||
"application_configurations",
|
||||
"certificate_configurations",
|
||||
"folder_mappings",
|
||||
"printer_configurations",
|
||||
]
|
||||
.map(
|
||||
(table) =>
|
||||
`EXISTS (SELECT 1 FROM ${table} cfg JOIN work_profiles w ON w.id=cfg.profileId WHERE cfg.companyId=p.companyId AND w.personId=p.id AND JSON_UNQUOTE(JSON_EXTRACT(cfg.\`values\`,'$.state')) NOT IN ('removed','never_configured'))`
|
||||
)
|
||||
.join(" OR ");
|
||||
const where = `WHERE p.active=0 AND (:companyId IS NULL OR p.companyId=:companyId) AND (p.id IN (:ids) OR EXISTS (SELECT 1 FROM work_profiles w WHERE w.personId=p.id AND JSON_EXTRACT(w.\`values\`,'$.active')=true) OR EXISTS (SELECT 1 FROM access_items link JOIN access_workspaces ws ON ws.id=link.workspaceId WHERE ws.companyId=p.companyId AND JSON_UNQUOTE(JSON_EXTRACT(link.\`values\`,'$.personId'))=p.id AND ((link.kind='need' AND (NULLIF(JSON_UNQUOTE(JSON_EXTRACT(link.\`values\`,'$.endDate')),'null') IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(link.\`values\`,'$.endDate'))>:today)) OR (link.kind='account_person' AND JSON_UNQUOTE(JSON_EXTRACT(link.\`values\`,'$.configuration')) IN ('pending','configured','removal_pending'))))
|
||||
OR EXISTS (SELECT 1 FROM remote_connections r WHERE r.companyId=p.companyId AND r.personId=p.id AND JSON_UNQUOTE(JSON_EXTRACT(r.\`values\`,'$.state')) NOT IN ('removed','never_configured'))
|
||||
OR EXISTS (SELECT 1 FROM mail_configurations m LEFT JOIN work_profiles w ON w.id=m.profileId WHERE m.companyId=p.companyId AND (m.personId=p.id OR w.personId=p.id) AND JSON_UNQUOTE(JSON_EXTRACT(m.\`values\`,'$.state')) NOT IN ('removed','never_configured'))
|
||||
OR ${profileConfigurations}
|
||||
OR EXISTS (SELECT 1 FROM telework_authorizations ta WHERE ta.companyId=p.companyId AND ta.personId=p.id AND JSON_EXTRACT(ta.\`values\`,'$.active')=true AND (NULLIF(JSON_UNQUOTE(JSON_EXTRACT(ta.\`values\`,'$.expiresOn')),'null') IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(ta.\`values\`,'$.expiresOn'))>=:today)))`;
|
||||
const extra = { ids: ids.length ? ids : [""] };
|
||||
const count = await this.select<{ total: number }>(
|
||||
`SELECT COUNT(*) AS total FROM company_contacts p ${where}`,
|
||||
query,
|
||||
extra
|
||||
);
|
||||
const items = await this.select<InventoryEntry>(
|
||||
`SELECT p.id,p.companyId,c.name AS companyName,p.name,NULL AS recordedAt,NULL AS actorName FROM company_contacts p JOIN companies c ON c.id=p.companyId ${where} ORDER BY p.name,p.id LIMIT 10 OFFSET :offset`,
|
||||
query,
|
||||
extra
|
||||
);
|
||||
return {
|
||||
items: items.map((item) => ({
|
||||
...item,
|
||||
kind: "person" as const,
|
||||
reference: null,
|
||||
state: null,
|
||||
priority: null,
|
||||
})),
|
||||
total: Number(count[0]?.total ?? 0),
|
||||
};
|
||||
}
|
||||
const kind = query.issue === "missing_site" || query.kind === "asset" ? "asset" : "resource";
|
||||
const data = await (kind === "asset" ? this.assets : this.resources).entries(
|
||||
query.companyId,
|
||||
query.issue,
|
||||
query.page
|
||||
);
|
||||
return {
|
||||
total: data.total,
|
||||
items: data.items.map((item) => ({
|
||||
...item,
|
||||
kind,
|
||||
reference: null,
|
||||
state: null,
|
||||
priority: null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
private async activity(query: DashboardDetailsQuery) {
|
||||
const items: DashboardEntryDTO[] = [];
|
||||
for (const [table, revisions, foreignKey, kind] of [
|
||||
["tickets", "ticket_revisions", "ticketId", "ticket"],
|
||||
["interventions", "intervention_revisions", "interventionId", "intervention"],
|
||||
] as const) {
|
||||
items.push(
|
||||
...(await this.select<DashboardEntryDTO>(
|
||||
`SELECT t.id,h.id AS eventId,t.companyId,c.name AS companyName,'${kind}' AS kind,t.reference,JSON_UNQUOTE(JSON_EXTRACT(h.\`values\`,'$.subject')) AS name,JSON_UNQUOTE(JSON_EXTRACT(h.\`values\`,'$.state')) AS state,NULL AS priority,h.recordedAt,u.name AS actorName FROM ${revisions} h JOIN ${table} t ON t.id=h.${foreignKey} JOIN companies c ON c.id=t.companyId LEFT JOIN users u ON u.id=h.actorId WHERE (:companyId IS NULL OR t.companyId=:companyId) ORDER BY h.recordedAt DESC,h.id DESC LIMIT 6`,
|
||||
query
|
||||
))
|
||||
);
|
||||
}
|
||||
for (const [service, kind] of [
|
||||
[this.assets, "asset"],
|
||||
[this.resources, "resource"],
|
||||
] as const) {
|
||||
items.push(
|
||||
...(await service.activity(query.companyId)).map((item) => ({
|
||||
...item,
|
||||
kind,
|
||||
reference: null,
|
||||
state: null,
|
||||
priority: null,
|
||||
}))
|
||||
);
|
||||
}
|
||||
return {
|
||||
total: Math.min(items.length, 6),
|
||||
items: items
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(b.recordedAt ?? "").localeCompare(a.recordedAt ?? "") || a.id.localeCompare(b.id)
|
||||
)
|
||||
.slice(0, 6),
|
||||
};
|
||||
}
|
||||
async read(block: DashboardBlock, query: DashboardDetailsQuery) {
|
||||
switch (block) {
|
||||
case "work":
|
||||
return this.work(query);
|
||||
case "activity":
|
||||
return this.activity(query);
|
||||
case "details":
|
||||
return this.issues(query);
|
||||
case "attention": {
|
||||
const kinds = [
|
||||
"certificate",
|
||||
"license",
|
||||
"profile",
|
||||
"missing_site",
|
||||
"inactive_person",
|
||||
] as const;
|
||||
return {
|
||||
items: await Promise.all(
|
||||
kinds.map(async (kind) => ({
|
||||
kind,
|
||||
count: (await this.issues({ ...query, issue: kind })).total,
|
||||
}))
|
||||
),
|
||||
};
|
||||
}
|
||||
case "companies": {
|
||||
const items = await this.companies(query);
|
||||
return { total: items.length, items };
|
||||
}
|
||||
case "summary": {
|
||||
const items = await this.companies(query);
|
||||
return items.reduce(
|
||||
(sum, item) => ({
|
||||
companies: sum.companies + item.companies,
|
||||
tickets: sum.tickets + item.tickets,
|
||||
interventions: sum.interventions + item.interventions,
|
||||
assets: sum.assets + item.assets,
|
||||
resources: sum.resources + item.resources,
|
||||
}),
|
||||
{ companies: 0, tickets: 0, interventions: 0, assets: 0, resources: 0 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -7,6 +7,11 @@ import { ApplicationConfigurationUseCases } from "./application/application-conf
|
||||
import { CertificateConfigurationUseCases } from "./application/certificate-configuration.use-cases";
|
||||
import { CompanyUseCases } from "./application/company.use-cases";
|
||||
import { CompanyInputMapper } from "./application/company-input.mapper";
|
||||
import type {
|
||||
IAssetDashboardReader,
|
||||
IInventoryDashboardReader,
|
||||
} from "./application/dashboard.use-cases";
|
||||
import { DashboardUseCases } from "./application/dashboard.use-cases";
|
||||
import { FolderMappingUseCases } from "./application/folder-mapping.use-cases";
|
||||
import { InterventionUseCases } from "./application/intervention.use-cases";
|
||||
import { InterventionTemplateUseCases } from "./application/intervention-template.use-cases";
|
||||
@ -26,6 +31,7 @@ import { registerAccessRoutes } from "./infrastructure/http/access.routes";
|
||||
import { registerApplicationConfigurationRoutes } from "./infrastructure/http/application-configuration.routes";
|
||||
import { registerCertificateConfigurationRoutes } from "./infrastructure/http/certificate-configuration.routes";
|
||||
import { registerCompanyRoutes } from "./infrastructure/http/company.routes";
|
||||
import { registerDashboardRoutes } from "./infrastructure/http/dashboard.routes";
|
||||
import { registerFolderMappingRoutes } from "./infrastructure/http/folder-mapping.routes";
|
||||
import { registerInterventionRoutes } from "./infrastructure/http/intervention.routes";
|
||||
import { registerInterventionTemplateRoutes } from "./infrastructure/http/intervention-template.routes";
|
||||
@ -60,6 +66,7 @@ import { migrateRemoteConnections } from "./infrastructure/persistence/remote-co
|
||||
import { SequelizeApplicationConfigurationRepository } from "./infrastructure/persistence/sequelize-application-configuration.repository";
|
||||
import { SequelizeCertificateConfigurationRepository } from "./infrastructure/persistence/sequelize-certificate-configuration.repository";
|
||||
import { SequelizeCompanyRepository } from "./infrastructure/persistence/sequelize-company.repository";
|
||||
import { SequelizeDashboardRepository } from "./infrastructure/persistence/sequelize-dashboard.repository";
|
||||
import { SequelizeFolderMappingRepository } from "./infrastructure/persistence/sequelize-folder-mapping.repository";
|
||||
import { SequelizeInterventionRepository } from "./infrastructure/persistence/sequelize-intervention.repository";
|
||||
import { SequelizeInterventionTemplateRepository } from "./infrastructure/persistence/sequelize-intervention-template.repository";
|
||||
@ -98,6 +105,15 @@ export async function createCompanyModule(
|
||||
);
|
||||
const useCases = new CompanyUseCases(repository, new CompanyInputMapper());
|
||||
return {
|
||||
initializeDashboard: (assets: IAssetDashboardReader, resources: IInventoryDashboardReader) => {
|
||||
const dashboard = new DashboardUseCases(
|
||||
new SequelizeDashboardRepository(database, assets, resources)
|
||||
);
|
||||
return {
|
||||
register: (app: Parameters<typeof registerDashboardRoutes>[0]) =>
|
||||
registerDashboardRoutes(app, dashboard, authenticate),
|
||||
};
|
||||
},
|
||||
initializeProfiles: async (
|
||||
inventory: IWorkProfileInventoryService &
|
||||
IProcessAssetInventoryService &
|
||||
|
||||
59
modules/companies/src/common/dashboard.ts
Normal file
59
modules/companies/src/common/dashboard.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const DashboardQuerySchema = z.strictObject({ companyId: z.uuid().optional() });
|
||||
export const DashboardIssueSchema = z.enum([
|
||||
"certificate",
|
||||
"license",
|
||||
"profile",
|
||||
"missing_site",
|
||||
"inactive_person",
|
||||
]);
|
||||
export const DashboardEntrySchema = z.strictObject({
|
||||
id: z.uuid(),
|
||||
eventId: z.uuid().optional(),
|
||||
companyId: z.uuid(),
|
||||
companyName: z.string(),
|
||||
name: z.string(),
|
||||
kind: z.enum(["asset", "resource", "profile", "person", "ticket", "intervention"]),
|
||||
reference: z.string().nullable(),
|
||||
state: z.string().nullable(),
|
||||
priority: z.string().nullable(),
|
||||
recordedAt: z.string().nullable(),
|
||||
actorName: z.string().nullable(),
|
||||
});
|
||||
export const DashboardEntriesSchema = z.strictObject({
|
||||
items: z.array(DashboardEntrySchema),
|
||||
total: z.number().int().nonnegative(),
|
||||
});
|
||||
export const DashboardSummarySchema = z.strictObject({
|
||||
companies: z.number().int().nonnegative(),
|
||||
tickets: z.number().int().nonnegative(),
|
||||
interventions: z.number().int().nonnegative(),
|
||||
assets: z.number().int().nonnegative(),
|
||||
resources: z.number().int().nonnegative(),
|
||||
});
|
||||
export const DashboardCompanySchema = DashboardSummarySchema.extend({
|
||||
id: z.uuid(),
|
||||
name: z.string(),
|
||||
people: z.number().int().nonnegative(),
|
||||
});
|
||||
export const DashboardCompaniesSchema = z.strictObject({
|
||||
items: z.array(DashboardCompanySchema),
|
||||
total: z.number().int().nonnegative(),
|
||||
});
|
||||
export const DashboardAttentionSchema = z.strictObject({
|
||||
items: z.array(
|
||||
z.strictObject({ kind: DashboardIssueSchema, count: z.number().int().nonnegative() })
|
||||
),
|
||||
});
|
||||
export const DashboardDetailsQuerySchema = DashboardQuerySchema.extend({
|
||||
kind: z.enum(["asset", "resource"]).optional(),
|
||||
issue: DashboardIssueSchema.optional(),
|
||||
page: z.coerce.number().int().min(1).max(100000).default(1),
|
||||
});
|
||||
export type DashboardEntryDTO = z.infer<typeof DashboardEntrySchema>;
|
||||
export type DashboardEntriesDTO = z.infer<typeof DashboardEntriesSchema>;
|
||||
export type DashboardSummaryDTO = z.infer<typeof DashboardSummarySchema>;
|
||||
export type DashboardCompanyDTO = z.infer<typeof DashboardCompanySchema>;
|
||||
export type DashboardIssue = z.infer<typeof DashboardIssueSchema>;
|
||||
export type DashboardDetailsQuery = z.infer<typeof DashboardDetailsQuerySchema>;
|
||||
@ -1,5 +1,6 @@
|
||||
export * from "./application-configurations";
|
||||
export * from "./certificate-configurations";
|
||||
export * from "./dashboard";
|
||||
export * from "./dto/request/access.request.dto";
|
||||
export * from "./dto/request/company.request.dto";
|
||||
export * from "./dto/request/people.request.dto";
|
||||
|
||||
@ -697,6 +697,58 @@
|
||||
},
|
||||
"companySpaces": "Company spaces",
|
||||
"globalCatalogs": "Global catalogs",
|
||||
"dashboard": {
|
||||
"title": "Home",
|
||||
"description": "Summary of activity and situations that need attention",
|
||||
"indicators": "Activity indicators",
|
||||
"activeCompanies": "Active companies",
|
||||
"openTickets": "Open tickets",
|
||||
"pendingInterventions": "Pending interventions",
|
||||
"registeredAssets": "Registered assets",
|
||||
"registeredResources": "Registered resources",
|
||||
"pendingWork": "Pending work",
|
||||
"viewAll": "View all",
|
||||
"partialWorkError": "Part of the pending work could not be loaded.",
|
||||
"noPendingWork": "There are no pending tickets or interventions.",
|
||||
"type": "Type",
|
||||
"types": {
|
||||
"ticket": "Ticket",
|
||||
"intervention": "Intervention"
|
||||
},
|
||||
"attention": "Situations that need attention",
|
||||
"attentionUnavailable": "This version does not calculate alerts without a verifiable aggregate query.",
|
||||
"recentActivity": "Recent activity",
|
||||
"recentActivityDescription": "The latest recorded change was on {{date}}.",
|
||||
"noRecentActivity": "There is no recorded activity yet.",
|
||||
"blockError": "This section could not be loaded. Return to Home to retry.",
|
||||
"connected": "Connection available",
|
||||
"noAttention": "There are no pending alerts.",
|
||||
"noMatches": "There are no records for this selection.",
|
||||
"records": "records",
|
||||
"companySummary": "Company summary",
|
||||
"quickActions": "Quick actions",
|
||||
"recordUpdated": "Recorded revision of {{name}}",
|
||||
"chooseCompany": "Select the company where you want to register the item.",
|
||||
"continue": "Continue",
|
||||
"pendingFilter": "Pending work only",
|
||||
"activeOnly": "Active companies only",
|
||||
"recordedAt": "Change date",
|
||||
"recordedBy": "Recorded by",
|
||||
"issues": {
|
||||
"certificate": "Certificates expiring in the next 30 days",
|
||||
"license": "Licenses expiring in the next 30 days",
|
||||
"profile": "User profiles with references awaiting review",
|
||||
"missing_site": "Assets without an assigned site",
|
||||
"inactive_person": "Inactive people with assignments awaiting review"
|
||||
},
|
||||
"create": {
|
||||
"ticket": "New ticket",
|
||||
"intervention": "New intervention",
|
||||
"company": "New company",
|
||||
"asset": "New asset",
|
||||
"resource": "New resource"
|
||||
}
|
||||
},
|
||||
"supportWorkspace": "Your support workspace",
|
||||
"workspaceSections": "Workspace sections",
|
||||
"people": "People",
|
||||
@ -796,6 +848,11 @@
|
||||
"UNAUTHENTICATED": "Your session expired. Sign in again.",
|
||||
"FORBIDDEN": "The operation could not be authorized. Reload the page.",
|
||||
"INTERNAL_ERROR": "The operation could not be completed. Try again."
|
||||
},
|
||||
"shell": {
|
||||
"catalogs": "Catalogs",
|
||||
"tickets": "Tickets",
|
||||
"interventions": "Interventions"
|
||||
}
|
||||
},
|
||||
"access": {
|
||||
|
||||
@ -697,6 +697,58 @@
|
||||
},
|
||||
"companySpaces": "Espacios de compañías",
|
||||
"globalCatalogs": "Catálogos globales",
|
||||
"dashboard": {
|
||||
"title": "Inicio",
|
||||
"description": "Resumen de la actividad y las situaciones que requieren atención",
|
||||
"indicators": "Indicadores de actividad",
|
||||
"activeCompanies": "Compañías activas",
|
||||
"openTickets": "Tickets abiertos",
|
||||
"pendingInterventions": "Intervenciones pendientes",
|
||||
"registeredAssets": "Activos registrados",
|
||||
"registeredResources": "Recursos registrados",
|
||||
"pendingWork": "Trabajo pendiente",
|
||||
"viewAll": "Ver todos",
|
||||
"partialWorkError": "No se ha podido cargar una parte del trabajo pendiente.",
|
||||
"noPendingWork": "No hay tickets ni intervenciones pendientes.",
|
||||
"type": "Tipo",
|
||||
"types": {
|
||||
"ticket": "Ticket",
|
||||
"intervention": "Intervención"
|
||||
},
|
||||
"attention": "Situaciones que requieren atención",
|
||||
"attentionUnavailable": "Esta versión no calcula avisos para los que aún no existe una consulta agregada verificable.",
|
||||
"recentActivity": "Actividad reciente",
|
||||
"recentActivityDescription": "El último cambio registrado fue el {{date}}.",
|
||||
"noRecentActivity": "Todavía no hay actividad registrada.",
|
||||
"blockError": "No se ha podido cargar este bloque. Vuelve a entrar en Inicio para reintentarlo.",
|
||||
"connected": "Conexión disponible",
|
||||
"noAttention": "No hay avisos pendientes.",
|
||||
"noMatches": "No hay registros para esta selección.",
|
||||
"records": "registros",
|
||||
"companySummary": "Resumen por compañías",
|
||||
"quickActions": "Acciones rápidas",
|
||||
"recordUpdated": "Revisión registrada de {{name}}",
|
||||
"chooseCompany": "Selecciona la compañía en la que quieres registrar el elemento.",
|
||||
"continue": "Continuar",
|
||||
"pendingFilter": "Solo trabajo pendiente",
|
||||
"activeOnly": "Solo compañías activas",
|
||||
"recordedAt": "Fecha del cambio",
|
||||
"recordedBy": "Registrado por",
|
||||
"issues": {
|
||||
"certificate": "Certificados que caducan en los próximos 30 días",
|
||||
"license": "Licencias que caducan en los próximos 30 días",
|
||||
"profile": "Perfiles de usuario con referencias pendientes de revisión",
|
||||
"missing_site": "Activos sin sede asignada",
|
||||
"inactive_person": "Personas inactivas con asignaciones pendientes de revisión"
|
||||
},
|
||||
"create": {
|
||||
"ticket": "Nuevo ticket",
|
||||
"intervention": "Nueva intervención",
|
||||
"company": "Nueva compañía",
|
||||
"asset": "Nuevo activo",
|
||||
"resource": "Nuevo recurso"
|
||||
}
|
||||
},
|
||||
"supportWorkspace": "Tu espacio de soporte",
|
||||
"workspaceSections": "Secciones del espacio",
|
||||
"people": "Personas",
|
||||
@ -796,6 +848,11 @@
|
||||
"UNAUTHENTICATED": "La sesión ha caducado. Vuelve a entrar.",
|
||||
"FORBIDDEN": "No se ha podido autorizar la operación. Recarga la página.",
|
||||
"INTERNAL_ERROR": "No se ha podido completar la operación. Inténtalo de nuevo."
|
||||
},
|
||||
"shell": {
|
||||
"catalogs": "Catálogos",
|
||||
"tickets": "Tickets",
|
||||
"interventions": "Intervenciones"
|
||||
}
|
||||
},
|
||||
"access": {
|
||||
|
||||
@ -29,14 +29,22 @@ const COMPANY_COLUMNS: ColumnDef<CompanySummaryDTO>[] = [
|
||||
export function CompaniesPage({
|
||||
csrfToken,
|
||||
onSelectCompany,
|
||||
initialActiveOnly = false,
|
||||
initialCompanyId,
|
||||
initialCreate = false,
|
||||
}: {
|
||||
csrfToken: string;
|
||||
onSelectCompany?: (id: string) => void;
|
||||
initialActiveOnly?: boolean | undefined;
|
||||
initialCompanyId?: string | undefined;
|
||||
initialCreate?: boolean | undefined;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation("companies");
|
||||
const query = useQuery({ queryKey: companyKeys.list, queryFn: companyApi.list });
|
||||
const [selectedId, setSelectedId] = useState<string | undefined>();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [companyId, setCompanyId] = useState(initialCompanyId);
|
||||
const [activeOnly, setActiveOnly] = useState(initialActiveOnly);
|
||||
const [createOpen, setCreateOpen] = useState(initialCreate);
|
||||
const [search, setSearch] = useState("");
|
||||
const _id = useId();
|
||||
const table = useReactTable({
|
||||
@ -58,7 +66,12 @@ export function CompaniesPage({
|
||||
/>
|
||||
);
|
||||
}
|
||||
const companies = table.getRowModel().rows.map((row) => row.original);
|
||||
const companies = table
|
||||
.getRowModel()
|
||||
.rows.map((row) => row.original)
|
||||
.filter(
|
||||
(company) => (!activeOnly || company.active) && (!companyId || company.id === companyId)
|
||||
);
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<EntityListHeader
|
||||
@ -125,8 +138,32 @@ export function CompaniesPage({
|
||||
},
|
||||
]}
|
||||
emptyMessage={t("empty")}
|
||||
filters={
|
||||
<div className="flex flex-wrap gap-3 text-sm">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
checked={activeOnly}
|
||||
onChange={(event) => setActiveOnly(event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
{t("dashboard.activeOnly")}
|
||||
</label>
|
||||
{companyId && (
|
||||
<span>
|
||||
{t("tickets.company")}:{" "}
|
||||
{query.data?.companies.find((item) => item.id === companyId)?.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
filtersApplied={[activeOnly, companyId, search].some(Boolean)}
|
||||
items={companies}
|
||||
locale={i18n.language}
|
||||
onClearFilters={() => {
|
||||
setActiveOnly(false);
|
||||
setCompanyId(undefined);
|
||||
setSearch("");
|
||||
}}
|
||||
onSearchChange={setSearch}
|
||||
search={search}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
|
||||
@ -1,7 +1,20 @@
|
||||
import { AssetCatalogPage, BrandCatalogPage } from "@metis/assets/client";
|
||||
import { ResourceCatalogPage } from "@metis/resources/client";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Building2Icon, LayersIcon } from "@repo/shadcn-ui/lib/icons";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import {
|
||||
BellIcon,
|
||||
Building2Icon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
DatabaseIcon,
|
||||
HomeIcon,
|
||||
SearchIcon,
|
||||
SettingsIcon,
|
||||
TicketIcon,
|
||||
WrenchIcon,
|
||||
} from "@repo/shadcn-ui/lib/icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
@ -16,20 +29,21 @@ import { TicketsPage } from "../tickets/tickets-page";
|
||||
import { CompaniesPage } from "./companies-page";
|
||||
import { CompanyDetail } from "./company-detail";
|
||||
import { CompanyFeedback } from "./company-feedback";
|
||||
import type { DashboardNavigation } from "./dashboard-api";
|
||||
import { DashboardPage } from "./dashboard-page";
|
||||
|
||||
type WorkspaceLocation =
|
||||
| { area: "company"; id: string }
|
||||
| { area: "tickets" }
|
||||
| { area: "interventions" }
|
||||
| { area: "directory" }
|
||||
| { area: "catalogs" };
|
||||
type WorkspaceLocation = { area: "home" } | DashboardNavigation | { area: "catalogs" };
|
||||
|
||||
export function CompaniesWorkspace({
|
||||
csrfToken,
|
||||
accountNavigation,
|
||||
userName,
|
||||
languageSwitcher,
|
||||
}: {
|
||||
csrfToken: string;
|
||||
accountNavigation: ReactNode;
|
||||
userName: string;
|
||||
languageSwitcher: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
const { t: coreTranslation } = useTranslation("core");
|
||||
@ -41,76 +55,222 @@ export function CompaniesWorkspace({
|
||||
const [catalog, setCatalog] = useState<
|
||||
"access" | "assets" | "brands" | "resources" | "intervention-templates"
|
||||
>("access");
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const companies = query.data?.companies ?? [];
|
||||
const current =
|
||||
location ?? (companies[0] ? { area: "company", id: companies[0].id } : { area: "directory" });
|
||||
const selectCompany = (id: string) => setLocation({ area: "company", id });
|
||||
const current = location ?? { area: "home" };
|
||||
const selectCompany = (id: string) => setLocation({ area: "company", id, section: "inventory" });
|
||||
|
||||
return (
|
||||
<div className="desk-workspace">
|
||||
<nav aria-label={t("companySpaces")} className="desk-company-rail">
|
||||
<Button
|
||||
aria-label={t("globalCatalogs")}
|
||||
aria-pressed={current.area === "catalogs"}
|
||||
className="desk-space-button"
|
||||
onClick={() => setLocation({ area: "catalogs" })}
|
||||
title={t("globalCatalogs")}
|
||||
variant="ghost"
|
||||
>
|
||||
<LayersIcon aria-hidden="true" className="size-5" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={t("title")}
|
||||
aria-pressed={current.area === "directory"}
|
||||
className="desk-space-button"
|
||||
onClick={() => setLocation({ area: "directory" })}
|
||||
title={t("title")}
|
||||
variant="ghost"
|
||||
>
|
||||
<Building2Icon aria-hidden="true" className="size-5" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={t("tickets.global")}
|
||||
aria-pressed={current.area === "tickets"}
|
||||
className="desk-space-button"
|
||||
onClick={() => setLocation({ area: "tickets" })}
|
||||
title={t("tickets.global")}
|
||||
variant="ghost"
|
||||
>
|
||||
T
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={t("interventions.global")}
|
||||
aria-pressed={current.area === "interventions"}
|
||||
className="desk-space-button"
|
||||
onClick={() => setLocation({ area: "interventions" })}
|
||||
title={t("interventions.global")}
|
||||
variant="ghost"
|
||||
>
|
||||
I
|
||||
</Button>
|
||||
<div className="desk-rail-separator" />
|
||||
{companies.map((company) => (
|
||||
<aside className="desk-sidebar">
|
||||
<div className="desk-brand">
|
||||
<svg aria-hidden="true" viewBox="0 0 36 30">
|
||||
<path
|
||||
d="M5 24 13 7l9 17 9-18"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="7"
|
||||
/>
|
||||
</svg>
|
||||
<span>Metis Desk</span>
|
||||
</div>
|
||||
<nav aria-label={t("companySpaces")} className="desk-company-rail">
|
||||
{(
|
||||
[
|
||||
["home", "dashboard.title", HomeIcon],
|
||||
["directory", "title", Building2Icon],
|
||||
["catalogs", "shell.catalogs", DatabaseIcon],
|
||||
["tickets", "shell.tickets", TicketIcon],
|
||||
["interventions", "shell.interventions", WrenchIcon],
|
||||
] as const
|
||||
).map(([area, label, Icon]) => (
|
||||
<Button
|
||||
aria-label={t(label)}
|
||||
aria-pressed={current.area === area}
|
||||
className="desk-space-button"
|
||||
key={area}
|
||||
onClick={() => setLocation({ area })}
|
||||
variant="ghost"
|
||||
>
|
||||
<Icon aria-hidden="true" />
|
||||
<span>{t(label)}</span>
|
||||
{area === "catalogs" && (
|
||||
<ChevronRightIcon aria-hidden="true" className="desk-nav-chevron" />
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
{current.area === "catalogs" && (
|
||||
<nav aria-label={t("globalCatalogs")} className="desk-catalog-navigation">
|
||||
<>
|
||||
<Button
|
||||
aria-pressed={catalog === "access"}
|
||||
onClick={() => setCatalog("access")}
|
||||
variant="ghost"
|
||||
>
|
||||
{accessTranslation("catalogTitle")}
|
||||
</Button>
|
||||
<Button
|
||||
aria-pressed={catalog === "assets"}
|
||||
onClick={() => setCatalog("assets")}
|
||||
variant="ghost"
|
||||
>
|
||||
{assetTranslation("title")}
|
||||
</Button>
|
||||
<Button
|
||||
aria-pressed={catalog === "intervention-templates"}
|
||||
onClick={() => setCatalog("intervention-templates")}
|
||||
variant="ghost"
|
||||
>
|
||||
{t("interventionTemplates.title")}
|
||||
</Button>
|
||||
<Button
|
||||
aria-pressed={catalog === "brands"}
|
||||
onClick={() => setCatalog("brands")}
|
||||
variant="ghost"
|
||||
>
|
||||
{assetTranslation("brands.title")}
|
||||
</Button>
|
||||
<Button
|
||||
aria-pressed={catalog === "resources"}
|
||||
onClick={() => setCatalog("resources")}
|
||||
variant="ghost"
|
||||
>
|
||||
{resourceTranslation("title")}
|
||||
</Button>
|
||||
</>
|
||||
</nav>
|
||||
)}
|
||||
<Button
|
||||
aria-label={company.name}
|
||||
aria-pressed={current.area === "company" && current.id === company.id}
|
||||
className="desk-space-button"
|
||||
key={company.id}
|
||||
onClick={() => selectCompany(company.id)}
|
||||
title={company.name}
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
variant="ghost"
|
||||
>
|
||||
{company.name
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.slice(0, 2)
|
||||
.map((word) => word[0])
|
||||
.join("")
|
||||
.toLocaleUpperCase()}
|
||||
<SettingsIcon aria-hidden="true" />
|
||||
<span>{coreTranslation("shell.settings")}</span>
|
||||
</Button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="desk-rail-separator" />
|
||||
{companies.map((company) => (
|
||||
<Button
|
||||
aria-label={company.name}
|
||||
aria-pressed={current.area === "company" && current.id === company.id}
|
||||
className="desk-space-button"
|
||||
key={company.id}
|
||||
onClick={() => selectCompany(company.id)}
|
||||
title={company.name}
|
||||
variant="ghost"
|
||||
>
|
||||
<Building2Icon aria-hidden="true" />
|
||||
<span>{company.name}</span>
|
||||
</Button>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
<div className="desk-workspace-content">
|
||||
<header className="desk-global-header">
|
||||
<div className="desk-search">
|
||||
<SearchIcon aria-hidden="true" />
|
||||
<Input
|
||||
aria-label={coreTranslation("shell.search")}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
setSearch("");
|
||||
}
|
||||
}}
|
||||
placeholder={coreTranslation("shell.search")}
|
||||
value={search}
|
||||
/>
|
||||
{search.trim() && (
|
||||
<section
|
||||
aria-label={coreTranslation("shell.results")}
|
||||
className="desk-search-results"
|
||||
>
|
||||
{companies
|
||||
.filter((company) =>
|
||||
company.name.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())
|
||||
)
|
||||
.map((company) => (
|
||||
<Button
|
||||
key={company.id}
|
||||
onClick={() => {
|
||||
selectCompany(company.id);
|
||||
setSearch("");
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
{company.name}
|
||||
</Button>
|
||||
))}
|
||||
{(
|
||||
[
|
||||
["home", "dashboard.title"],
|
||||
["directory", "title"],
|
||||
["catalogs", "shell.catalogs"],
|
||||
["tickets", "tickets.global"],
|
||||
["interventions", "interventions.global"],
|
||||
] as const
|
||||
)
|
||||
.filter(([, label]) =>
|
||||
t(label).toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())
|
||||
)
|
||||
.map(([area, label]) => (
|
||||
<Button
|
||||
key={area}
|
||||
onClick={() => {
|
||||
setLocation({ area });
|
||||
setSearch("");
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
{t(label)}
|
||||
</Button>
|
||||
))}
|
||||
<p>{coreTranslation("shell.searchScope")}</p>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
aria-label={coreTranslation("shell.notificationsUnavailable")}
|
||||
className="desk-notifications"
|
||||
role="img"
|
||||
title={coreTranslation("shell.notificationsUnavailable")}
|
||||
>
|
||||
<BellIcon aria-hidden="true" />
|
||||
</span>
|
||||
<details className="desk-profile">
|
||||
<summary aria-label={coreTranslation("shell.profile")}>
|
||||
<span aria-hidden="true" className="desk-avatar">
|
||||
{userName
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.slice(0, 2)
|
||||
.map((word) => word[0])
|
||||
.join("")}
|
||||
</span>
|
||||
<span className="desk-profile-copy">
|
||||
<strong>{userName}</strong>
|
||||
<span>{coreTranslation("shell.supportWorkspace")}</span>
|
||||
</span>
|
||||
<ChevronDownIcon aria-hidden="true" />
|
||||
</summary>
|
||||
<div className="desk-profile-menu">
|
||||
{accountNavigation}
|
||||
{languageSwitcher}
|
||||
</div>
|
||||
</details>
|
||||
</header>
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
onOpenChange={setSettingsOpen}
|
||||
open={settingsOpen}
|
||||
title={coreTranslation("shell.settings")}
|
||||
>
|
||||
{accountNavigation}
|
||||
{languageSwitcher}
|
||||
</FormDialog>
|
||||
<span className="sr-only">{coreTranslation("workspace")}</span>
|
||||
{query.isPending && (
|
||||
<p className="p-6" role="status">
|
||||
@ -136,66 +296,22 @@ export function CompaniesWorkspace({
|
||||
csrfToken={csrfToken}
|
||||
embedded
|
||||
id={current.id}
|
||||
initialCreate={current.create}
|
||||
initialEntityId={current.entityId}
|
||||
initialSection={current.section}
|
||||
key={current.id}
|
||||
onBack={() => setLocation({ area: "directory" })}
|
||||
/>
|
||||
) : (
|
||||
<div className="company-space">
|
||||
<div className="company-space-heading">
|
||||
<h1 className="text-lg font-semibold">{t(workspaceTitle(current.area))}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t("supportWorkspace")}</p>
|
||||
</div>
|
||||
<nav aria-label={t("workspaceSections")} className="company-space-navigation">
|
||||
{current.area === "catalogs" ? (
|
||||
<>
|
||||
<Button
|
||||
aria-pressed={catalog === "access"}
|
||||
onClick={() => setCatalog("access")}
|
||||
variant="ghost"
|
||||
>
|
||||
{accessTranslation("catalogTitle")}
|
||||
</Button>
|
||||
<Button
|
||||
aria-pressed={catalog === "assets"}
|
||||
onClick={() => setCatalog("assets")}
|
||||
variant="ghost"
|
||||
>
|
||||
{assetTranslation("title")}
|
||||
</Button>
|
||||
<Button
|
||||
aria-pressed={catalog === "intervention-templates"}
|
||||
onClick={() => setCatalog("intervention-templates")}
|
||||
variant="ghost"
|
||||
>
|
||||
{t("interventionTemplates.title")}
|
||||
</Button>
|
||||
<Button
|
||||
aria-pressed={catalog === "brands"}
|
||||
onClick={() => setCatalog("brands")}
|
||||
variant="ghost"
|
||||
>
|
||||
{assetTranslation("brands.title")}
|
||||
</Button>
|
||||
<Button
|
||||
aria-pressed={catalog === "resources"}
|
||||
onClick={() => setCatalog("resources")}
|
||||
variant="ghost"
|
||||
>
|
||||
{resourceTranslation("title")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button aria-pressed variant="ghost">
|
||||
{t(workspaceTitle(current.area))}
|
||||
</Button>
|
||||
)}
|
||||
</nav>
|
||||
<footer className="company-space-account">{accountNavigation}</footer>
|
||||
<main className="company-space-main">
|
||||
<div className="desk-global-content">
|
||||
<main className="desk-main-content">
|
||||
<WorkspaceContent
|
||||
area={current.area}
|
||||
catalog={catalog}
|
||||
csrfToken={csrfToken}
|
||||
key={JSON.stringify(current)}
|
||||
navigation={current}
|
||||
onNavigate={setLocation}
|
||||
onSelectCompany={selectCompany}
|
||||
/>
|
||||
</main>
|
||||
@ -227,39 +343,69 @@ function GlobalCatalogContent({
|
||||
}
|
||||
}
|
||||
|
||||
function workspaceTitle(area: WorkspaceLocation["area"]) {
|
||||
switch (area) {
|
||||
case "catalogs":
|
||||
return "globalCatalogs";
|
||||
case "interventions":
|
||||
return "interventions.global";
|
||||
case "tickets":
|
||||
return "tickets.global";
|
||||
default:
|
||||
return "title";
|
||||
}
|
||||
}
|
||||
function WorkspaceContent({
|
||||
area,
|
||||
navigation,
|
||||
catalog,
|
||||
csrfToken,
|
||||
onNavigate,
|
||||
onSelectCompany,
|
||||
}: {
|
||||
area: WorkspaceLocation["area"];
|
||||
navigation: WorkspaceLocation;
|
||||
catalog: "access" | "assets" | "brands" | "resources" | "intervention-templates";
|
||||
csrfToken: string;
|
||||
onNavigate: (destination: DashboardNavigation) => void;
|
||||
onSelectCompany: (id: string) => void;
|
||||
}) {
|
||||
switch (area) {
|
||||
case "interventions":
|
||||
return <InterventionsPage csrfToken={csrfToken} />;
|
||||
return (
|
||||
<InterventionsPage
|
||||
csrfToken={csrfToken}
|
||||
{...(navigation.area === "interventions"
|
||||
? {
|
||||
companyId: navigation.companyId,
|
||||
initialId: navigation.id,
|
||||
initialCreate: navigation.create,
|
||||
initialPendingOnly: navigation.pendingOnly,
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
);
|
||||
case "tickets":
|
||||
return <TicketsPage csrfToken={csrfToken} />;
|
||||
return (
|
||||
<TicketsPage
|
||||
csrfToken={csrfToken}
|
||||
{...(navigation.area === "tickets"
|
||||
? {
|
||||
companyId: navigation.companyId,
|
||||
initialId: navigation.id,
|
||||
initialCreate: navigation.create,
|
||||
initialPendingOnly: navigation.pendingOnly,
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
);
|
||||
case "directory":
|
||||
return <CompaniesPage csrfToken={csrfToken} onSelectCompany={onSelectCompany} />;
|
||||
return (
|
||||
<CompaniesPage
|
||||
csrfToken={csrfToken}
|
||||
onSelectCompany={onSelectCompany}
|
||||
{...(navigation.area === "directory"
|
||||
? {
|
||||
initialActiveOnly: navigation.activeOnly,
|
||||
initialCompanyId: navigation.companyId,
|
||||
initialCreate: navigation.create,
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
);
|
||||
case "catalogs":
|
||||
return <GlobalCatalogContent catalog={catalog} csrfToken={csrfToken} />;
|
||||
case "company":
|
||||
return null;
|
||||
case "home":
|
||||
return <DashboardPage csrfToken={csrfToken} onNavigate={onNavigate} />;
|
||||
}
|
||||
}
|
||||
|
||||
@ -160,12 +160,18 @@ export function CompanyDetail({
|
||||
onBack,
|
||||
embedded = false,
|
||||
accountNavigation,
|
||||
initialSection,
|
||||
initialEntityId,
|
||||
initialCreate,
|
||||
}: {
|
||||
id: string;
|
||||
csrfToken: string;
|
||||
onBack: () => void;
|
||||
embedded?: boolean;
|
||||
accountNavigation?: ReactNode;
|
||||
initialSection?: CompanyTab;
|
||||
initialEntityId?: string | undefined;
|
||||
initialCreate?: boolean | undefined;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
const { t: resourceTranslation } = useTranslation("resources");
|
||||
@ -173,7 +179,7 @@ export function CompanyDetail({
|
||||
const { t: accessTranslation } = useTranslation("access");
|
||||
const query = useQuery({ queryKey: companyKeys.detail(id), queryFn: () => companyApi.get(id) });
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [tab, setTab] = useState<CompanyTab>(embedded ? "inventory" : "people");
|
||||
const [tab, setTab] = useState<CompanyTab>(initialSection ?? (embedded ? "inventory" : "people"));
|
||||
const company = query.data?.company;
|
||||
const layout = embedded
|
||||
? {
|
||||
@ -281,7 +287,13 @@ export function CompanyDetail({
|
||||
|
||||
<main className={layout.main} key={tab}>
|
||||
{embedded && <div className="company-space-breadcrumb">{company.name}</div>}
|
||||
<CompanySection company={company} csrfToken={csrfToken} tab={tab} />
|
||||
<CompanySection
|
||||
company={company}
|
||||
csrfToken={csrfToken}
|
||||
initialCreate={tab === initialSection ? initialCreate : false}
|
||||
initialEntityId={tab === initialSection ? initialEntityId : undefined}
|
||||
tab={tab}
|
||||
/>
|
||||
</main>
|
||||
</>
|
||||
)}
|
||||
@ -293,10 +305,14 @@ function CompanySection({
|
||||
company,
|
||||
csrfToken,
|
||||
tab,
|
||||
initialEntityId,
|
||||
initialCreate,
|
||||
}: {
|
||||
company: CompanyDTO;
|
||||
csrfToken: string;
|
||||
tab: CompanyTab;
|
||||
initialEntityId?: string | undefined;
|
||||
initialCreate?: boolean | undefined;
|
||||
}) {
|
||||
switch (tab) {
|
||||
case "intervention-templates":
|
||||
@ -312,7 +328,7 @@ function CompanySection({
|
||||
case "telework":
|
||||
return <TeleworkAuthorizationsPage company={company} csrfToken={csrfToken} />;
|
||||
case "people":
|
||||
return <PeopleSection company={company} csrfToken={csrfToken} />;
|
||||
return <PeopleSection company={company} csrfToken={csrfToken} initialId={initialEntityId} />;
|
||||
case "departments":
|
||||
return <DepartmentSection company={company} csrfToken={csrfToken} />;
|
||||
case "sites":
|
||||
@ -322,7 +338,9 @@ function CompanySection({
|
||||
<AccessWorkspacePage csrfToken={csrfToken} people={company.people} scope={company.id} />
|
||||
);
|
||||
case "profiles":
|
||||
return <WorkProfilesPage company={company} csrfToken={csrfToken} />;
|
||||
return (
|
||||
<WorkProfilesPage company={company} csrfToken={csrfToken} initialId={initialEntityId} />
|
||||
);
|
||||
case "certificates":
|
||||
return <CertificateConfigurationsPage company={company} csrfToken={csrfToken} />;
|
||||
case "applications":
|
||||
@ -336,12 +354,21 @@ function CompanySection({
|
||||
case "workstations":
|
||||
return <CompanyInventory company={company} csrfToken={csrfToken} workstations />;
|
||||
case "inventory":
|
||||
return <CompanyInventory company={company} csrfToken={csrfToken} />;
|
||||
return (
|
||||
<CompanyInventory
|
||||
company={company}
|
||||
csrfToken={csrfToken}
|
||||
initialCreate={initialCreate}
|
||||
initialId={initialEntityId}
|
||||
/>
|
||||
);
|
||||
case "resources":
|
||||
return (
|
||||
<ResourcesPage
|
||||
companyId={company.id}
|
||||
csrfToken={csrfToken}
|
||||
initialCreate={initialCreate}
|
||||
initialId={initialEntityId}
|
||||
relatedContent={(resource) =>
|
||||
resource.values.integration?.kind === "mailbox" ? (
|
||||
<MailboxRelations company={company} csrfToken={csrfToken} mailboxId={resource.id} />
|
||||
|
||||
@ -11,10 +11,14 @@ export function CompanyInventory({
|
||||
company,
|
||||
csrfToken,
|
||||
workstations = false,
|
||||
initialId,
|
||||
initialCreate,
|
||||
}: {
|
||||
company: CompanyDTO;
|
||||
csrfToken: string;
|
||||
workstations?: boolean;
|
||||
initialId?: string | undefined;
|
||||
initialCreate?: boolean | undefined;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
const { t: assetTranslation } = useTranslation("assets");
|
||||
@ -26,6 +30,7 @@ export function CompanyInventory({
|
||||
return (
|
||||
<>
|
||||
<Page
|
||||
{...(workstations ? {} : { initialId, initialCreate })}
|
||||
companyId={company.id}
|
||||
csrfToken={csrfToken}
|
||||
lookups={{
|
||||
|
||||
99
modules/companies/src/web/components/dashboard-api.ts
Normal file
99
modules/companies/src/web/components/dashboard-api.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import {
|
||||
DashboardAttentionSchema,
|
||||
DashboardCompaniesSchema,
|
||||
type DashboardDetailsQuery,
|
||||
DashboardEntriesSchema,
|
||||
DashboardSummarySchema,
|
||||
} from "../../common/dashboard";
|
||||
|
||||
export const dashboardKeys = {
|
||||
block: (block: string, companyId?: string | undefined, page = 1, kind = "") => [
|
||||
"dashboard",
|
||||
block,
|
||||
companyId ?? "global",
|
||||
page,
|
||||
kind,
|
||||
],
|
||||
};
|
||||
async function request(
|
||||
block: string,
|
||||
signal: AbortSignal,
|
||||
query: Omit<DashboardDetailsQuery, "page"> & { page?: number } = {}
|
||||
) {
|
||||
const parameters = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value !== undefined) {
|
||||
parameters.set(key, String(value));
|
||||
}
|
||||
}
|
||||
const response = await fetch(`/api/dashboard/${block}?${parameters}`, {
|
||||
signal,
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("DASHBOARD_UNAVAILABLE");
|
||||
}
|
||||
return response.json() as Promise<unknown>;
|
||||
}
|
||||
export const dashboardApi = {
|
||||
summary: async (signal: AbortSignal, companyId?: string | undefined) =>
|
||||
DashboardSummarySchema.parse(await request("summary", signal, { companyId })),
|
||||
work: async (signal: AbortSignal, companyId?: string | undefined, page = 1) =>
|
||||
DashboardEntriesSchema.parse(await request("work", signal, { companyId, page })),
|
||||
attention: async (signal: AbortSignal, companyId?: string | undefined) =>
|
||||
DashboardAttentionSchema.parse(await request("attention", signal, { companyId })),
|
||||
activity: async (signal: AbortSignal, companyId?: string | undefined) =>
|
||||
DashboardEntriesSchema.parse(await request("activity", signal, { companyId })),
|
||||
companies: async (signal: AbortSignal, companyId?: string | undefined) =>
|
||||
DashboardCompaniesSchema.parse(await request("companies", signal, { companyId })),
|
||||
details: async (signal: AbortSignal, query: DashboardDetailsQuery) =>
|
||||
DashboardEntriesSchema.parse(await request("details", signal, query)),
|
||||
};
|
||||
export type DashboardNavigation =
|
||||
| {
|
||||
area: "directory";
|
||||
companyId?: string | undefined;
|
||||
activeOnly?: boolean;
|
||||
create?: boolean | undefined;
|
||||
}
|
||||
| {
|
||||
area: "tickets" | "interventions";
|
||||
companyId?: string | undefined;
|
||||
id?: string | undefined;
|
||||
pendingOnly?: boolean | undefined;
|
||||
create?: boolean | undefined;
|
||||
}
|
||||
| {
|
||||
area: "company";
|
||||
id: string;
|
||||
section: "inventory" | "resources" | "profiles" | "people";
|
||||
entityId?: string | undefined;
|
||||
create?: boolean | undefined;
|
||||
};
|
||||
|
||||
export function entryDestination(item: {
|
||||
kind: string;
|
||||
companyId: string;
|
||||
id: string;
|
||||
}): DashboardNavigation {
|
||||
if (item.kind === "ticket" || item.kind === "intervention") {
|
||||
return {
|
||||
area: item.kind === "ticket" ? "tickets" : "interventions",
|
||||
companyId: item.companyId,
|
||||
id: item.id,
|
||||
};
|
||||
}
|
||||
return {
|
||||
area: "company",
|
||||
id: item.companyId,
|
||||
section:
|
||||
item.kind === "asset"
|
||||
? "inventory"
|
||||
: item.kind === "resource"
|
||||
? "resources"
|
||||
: item.kind === "profile"
|
||||
? "profiles"
|
||||
: "people",
|
||||
entityId: item.id,
|
||||
};
|
||||
}
|
||||
35
modules/companies/src/web/components/dashboard-block.tsx
Normal file
35
modules/companies/src/web/components/dashboard-block.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type QueryState = { isPending: boolean; isError: boolean };
|
||||
export function BlockState({
|
||||
query,
|
||||
empty,
|
||||
emptyText,
|
||||
children,
|
||||
}: {
|
||||
query: QueryState;
|
||||
empty: boolean;
|
||||
emptyText: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
if (query.isPending) {
|
||||
return (
|
||||
<p className="py-6 text-sm text-muted-foreground" role="status">
|
||||
{t("loading")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (query.isError) {
|
||||
return (
|
||||
<p className="py-6 text-sm text-destructive" role="alert">
|
||||
{t("dashboard.blockError")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (empty) {
|
||||
return <p className="py-6 text-sm text-muted-foreground">{emptyText}</p>;
|
||||
}
|
||||
return children;
|
||||
}
|
||||
@ -0,0 +1,253 @@
|
||||
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";
|
||||
|
||||
import {
|
||||
type DashboardNavigation,
|
||||
dashboardApi,
|
||||
dashboardKeys,
|
||||
entryDestination,
|
||||
} from "./dashboard-api";
|
||||
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";
|
||||
}
|
||||
516
modules/companies/src/web/components/dashboard-page.tsx
Normal file
516
modules/companies/src/web/components/dashboard-page.tsx
Normal file
@ -0,0 +1,516 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
143
modules/companies/src/web/components/dashboard-quick-create.tsx
Normal file
143
modules/companies/src/web/components/dashboard-quick-create.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { companyApi, companyKeys } from "../api/company-api";
|
||||
import { interventionApi, interventionKeys } from "../interventions/intervention-api";
|
||||
import { InterventionForm } from "../interventions/intervention-form";
|
||||
import { ticketApi, ticketKeys } from "../tickets/ticket-api";
|
||||
import { TicketForm } from "../tickets/ticket-form";
|
||||
|
||||
import { CompanyForm } from "./company-form";
|
||||
import type { DashboardNavigation } from "./dashboard-api";
|
||||
import { BlockState } from "./dashboard-block";
|
||||
|
||||
export function DashboardQuickCreate({
|
||||
kind,
|
||||
companyId,
|
||||
csrfToken,
|
||||
onClose,
|
||||
onNavigate,
|
||||
}: {
|
||||
kind: "company" | "ticket" | "intervention" | "asset" | "resource";
|
||||
companyId?: string | undefined;
|
||||
csrfToken: string;
|
||||
onClose: () => void;
|
||||
onNavigate: (destination: DashboardNavigation) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
const client = useQueryClient();
|
||||
const [selectedCompany, setSelectedCompany] = useState(companyId ?? "");
|
||||
const [addingCompany, setAddingCompany] = useState(false);
|
||||
const companies = useQuery({ queryKey: companyKeys.list, queryFn: companyApi.list });
|
||||
const tickets = useQuery({
|
||||
queryKey: ticketKeys.list(companyId),
|
||||
queryFn: () => ticketApi.list(companyId),
|
||||
enabled: kind === "ticket",
|
||||
});
|
||||
const interventions = useQuery({
|
||||
queryKey: interventionKeys.list(companyId),
|
||||
queryFn: () => interventionApi.list(companyId),
|
||||
enabled: kind === "intervention",
|
||||
});
|
||||
const saved = () => {
|
||||
client.invalidateQueries({ queryKey: ["dashboard"] }).catch(() => undefined);
|
||||
onClose();
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions={kind !== "asset" && kind !== "resource"}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
open
|
||||
title={t(`dashboard.create.${kind}`)}
|
||||
wide={kind === "ticket" || kind === "intervention"}
|
||||
>
|
||||
{kind === "company" && (
|
||||
<CompanyForm csrfToken={csrfToken} onSaved={saved} showTitle={false} />
|
||||
)}
|
||||
{kind === "ticket" && (
|
||||
<BlockState empty={false} emptyText="" query={tickets}>
|
||||
{tickets.data && (
|
||||
<TicketForm
|
||||
csrfToken={csrfToken}
|
||||
data={tickets.data}
|
||||
onSaved={saved}
|
||||
{...(companyId ? { scope: companyId } : {})}
|
||||
/>
|
||||
)}
|
||||
</BlockState>
|
||||
)}
|
||||
{kind === "intervention" && (
|
||||
<BlockState empty={false} emptyText="" query={interventions}>
|
||||
{interventions.data && (
|
||||
<InterventionForm
|
||||
csrfToken={csrfToken}
|
||||
data={interventions.data}
|
||||
onSaved={saved}
|
||||
{...(companyId ? { scope: companyId } : {})}
|
||||
/>
|
||||
)}
|
||||
</BlockState>
|
||||
)}
|
||||
{(kind === "asset" || kind === "resource") && (
|
||||
<BlockState empty={false} emptyText="" query={companies}>
|
||||
<SelectWithAction
|
||||
action={{ label: t("newCompany"), onClick: () => setAddingCompany(true) }}
|
||||
id="dashboard-create-company"
|
||||
label={t("tickets.company")}
|
||||
onValueChange={setSelectedCompany}
|
||||
options={
|
||||
companies.data?.companies
|
||||
.filter((item) => item.active)
|
||||
.map((item) => ({ value: item.id, label: item.name })) ?? []
|
||||
}
|
||||
placeholder={t("dashboard.chooseCompany")}
|
||||
value={selectedCompany}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">{t("dashboard.chooseCompany")}</p>
|
||||
<Button
|
||||
disabled={!selectedCompany}
|
||||
onClick={() =>
|
||||
onNavigate({
|
||||
area: "company",
|
||||
id: selectedCompany,
|
||||
section: kind === "asset" ? "inventory" : "resources",
|
||||
create: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("dashboard.continue")}
|
||||
</Button>
|
||||
</BlockState>
|
||||
)}
|
||||
</FormDialog>
|
||||
{addingCompany && (
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
onOpenChange={setAddingCompany}
|
||||
open
|
||||
title={t("newCompany")}
|
||||
>
|
||||
<CompanyForm
|
||||
csrfToken={csrfToken}
|
||||
onCreated={(id) => {
|
||||
setSelectedCompany(id);
|
||||
setAddingCompany(false);
|
||||
}}
|
||||
showTitle={false}
|
||||
/>
|
||||
</FormDialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -207,12 +207,20 @@ function PersonDetails({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function PeopleSection({ company, csrfToken }: { company: CompanyDTO; csrfToken: string }) {
|
||||
export function PeopleSection({
|
||||
company,
|
||||
csrfToken,
|
||||
initialId,
|
||||
}: {
|
||||
company: CompanyDTO;
|
||||
csrfToken: string;
|
||||
initialId?: string | undefined;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation("companies");
|
||||
const filterId = useId();
|
||||
const [selection, setSelection] = useState<
|
||||
{ mode: "new" | "detail" | "edit"; id?: string } | undefined
|
||||
>();
|
||||
>(initialId ? { mode: "detail", id: initialId } : undefined);
|
||||
const [search, setSearch] = useState("");
|
||||
const [contactsOnly, setContactsOnly] = useState(false);
|
||||
const selected = company.people.find((item) => item.id === selection?.id);
|
||||
|
||||
@ -28,12 +28,14 @@ export type InterventionFilters = {
|
||||
};
|
||||
export function filterInterventions(
|
||||
items: readonly InterventionDTO[],
|
||||
filters: InterventionFilters
|
||||
filters: InterventionFilters,
|
||||
pendingOnly = false
|
||||
) {
|
||||
const search = filters.search.trim().toLocaleLowerCase();
|
||||
return items
|
||||
.filter(
|
||||
(item) =>
|
||||
!(pendingOnly && ["finished", "cancelled"].includes(item.values.state)) &&
|
||||
(!filters.company || item.companyId === filters.company) &&
|
||||
(!filters.state || item.values.state === filters.state) &&
|
||||
matchesPlannedDate(item.values.plannedDate, filters.dateFrom, filters.dateTo) &&
|
||||
@ -53,9 +55,15 @@ export function filterInterventions(
|
||||
export function InterventionsPage({
|
||||
companyId,
|
||||
csrfToken,
|
||||
initialId,
|
||||
initialCreate = false,
|
||||
initialPendingOnly = false,
|
||||
}: {
|
||||
companyId?: string;
|
||||
companyId?: string | undefined;
|
||||
csrfToken: string;
|
||||
initialId?: string | undefined;
|
||||
initialCreate?: boolean | undefined;
|
||||
initialPendingOnly?: boolean | undefined;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation("companies");
|
||||
const query = useQuery({
|
||||
@ -70,16 +78,14 @@ export function InterventionsPage({
|
||||
dateTo: "",
|
||||
assignee: "",
|
||||
});
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pendingOnly, setPendingOnly] = useState(initialPendingOnly);
|
||||
const [initialViewId, setInitialViewId] = useState(initialId);
|
||||
const [open, setOpen] = useState(initialCreate);
|
||||
const [edit, setEdit] = useState<InterventionDTO | undefined>();
|
||||
const [view, setView] = useState<InterventionDTO | undefined>();
|
||||
const items = filterInterventions(query.data?.interventions ?? [], filters);
|
||||
const date = (value: string | undefined) =>
|
||||
value
|
||||
? new Intl.DateTimeFormat(i18n.language, { dateStyle: "medium", timeStyle: "short" }).format(
|
||||
new Date(value)
|
||||
)
|
||||
: "";
|
||||
const currentView = selectedDetail(view, query.data?.interventions, initialViewId);
|
||||
const items = filterInterventions(query.data?.interventions ?? [], filters, pendingOnly);
|
||||
const date = (value: string | undefined) => formatDate(value, i18n.language);
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<EntityListHeader
|
||||
@ -248,7 +254,14 @@ export function InterventionsPage({
|
||||
filterKey={JSON.stringify(filters)}
|
||||
filters={
|
||||
<>
|
||||
{" "}
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
checked={pendingOnly}
|
||||
onChange={(event) => setPendingOnly(event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
{t("dashboard.pendingFilter")}
|
||||
</label>{" "}
|
||||
{!companyId && (
|
||||
<SelectWithAction
|
||||
id="interventions-company-filter"
|
||||
@ -286,13 +299,21 @@ export function InterventionsPage({
|
||||
/>{" "}
|
||||
</>
|
||||
}
|
||||
filtersApplied={Object.values(filters).some(Boolean)}
|
||||
filtersApplied={[pendingOnly, ...Object.values(filters)].some(Boolean)}
|
||||
items={items}
|
||||
loading={query.isPending}
|
||||
locale={i18n.language}
|
||||
onClearFilters={() =>
|
||||
setFilters({ search: "", company: "", state: "", assignee: "", dateFrom: "", dateTo: "" })
|
||||
}
|
||||
onClearFilters={() => {
|
||||
setPendingOnly(false);
|
||||
setFilters({
|
||||
search: "",
|
||||
company: "",
|
||||
state: "",
|
||||
assignee: "",
|
||||
dateFrom: "",
|
||||
dateTo: "",
|
||||
});
|
||||
}}
|
||||
onSearchChange={(search) => setFilters({ ...filters, search })}
|
||||
search={filters.search}
|
||||
searchPlaceholder={t("interventions.search")}
|
||||
@ -323,13 +344,16 @@ export function InterventionsPage({
|
||||
onOpenChange={(value) => {
|
||||
if (!value) {
|
||||
setView(undefined);
|
||||
setInitialViewId(undefined);
|
||||
}
|
||||
}}
|
||||
open={Boolean(view)}
|
||||
open={Boolean(currentView)}
|
||||
title={t("interventions.details")}
|
||||
wide
|
||||
>
|
||||
{view && query.data && <InterventionDetail data={query.data} intervention={view} />}
|
||||
{currentView && query.data && (
|
||||
<InterventionDetail data={query.data} intervention={currentView} />
|
||||
)}
|
||||
</FormDialog>
|
||||
</div>
|
||||
);
|
||||
@ -344,3 +368,19 @@ function matchesPlannedDate(date: string | null, from: string, to: string) {
|
||||
}
|
||||
return (!from || date >= from) && (!to || date <= to);
|
||||
}
|
||||
|
||||
function formatDate(value: string | undefined, language: string) {
|
||||
return value
|
||||
? new Intl.DateTimeFormat(language, { dateStyle: "medium", timeStyle: "short" }).format(
|
||||
new Date(value)
|
||||
)
|
||||
: "";
|
||||
}
|
||||
|
||||
function selectedDetail(
|
||||
view: InterventionDTO | undefined,
|
||||
items: InterventionDTO[] | undefined,
|
||||
id: string | undefined
|
||||
) {
|
||||
return view ?? items?.find((item) => item.id === id);
|
||||
}
|
||||
|
||||
@ -38,12 +38,16 @@ type ProfileSelection = {
|
||||
export function WorkProfilesPage({
|
||||
company,
|
||||
csrfToken,
|
||||
initialId,
|
||||
}: {
|
||||
company: CompanyDTO;
|
||||
csrfToken: string;
|
||||
initialId?: string | undefined;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation("companies");
|
||||
const [configuration, setConfiguration] = useState<ProfileSelection>();
|
||||
const [configuration, setConfiguration] = useState<ProfileSelection | undefined>(
|
||||
initialId ? { kind: "environment", profileId: initialId } : undefined
|
||||
);
|
||||
const [editor, setEditor] = useState<string>();
|
||||
const [view, setView] = useState<string>();
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
@ -26,11 +26,16 @@ export type TicketFilters = {
|
||||
priority: string;
|
||||
assignee: string;
|
||||
};
|
||||
export function filterTickets(items: readonly TicketDTO[], filters: TicketFilters) {
|
||||
export function filterTickets(
|
||||
items: readonly TicketDTO[],
|
||||
filters: TicketFilters,
|
||||
pendingOnly = false
|
||||
) {
|
||||
const search = filters.search.trim().toLocaleLowerCase();
|
||||
return items
|
||||
.filter(
|
||||
(item) =>
|
||||
!(pendingOnly && ["resolved", "cancelled"].includes(item.values.state)) &&
|
||||
(!filters.company || item.companyId === filters.company) &&
|
||||
(!filters.state || item.values.state === filters.state) &&
|
||||
(!filters.priority || item.values.priority === filters.priority) &&
|
||||
@ -47,7 +52,19 @@ export function filterTickets(items: readonly TicketDTO[], filters: TicketFilter
|
||||
(b.history.at(-1)?.recordedAt ?? "").localeCompare(a.history.at(-1)?.recordedAt ?? "")
|
||||
);
|
||||
}
|
||||
export function TicketsPage({ companyId, csrfToken }: { companyId?: string; csrfToken: string }) {
|
||||
export function TicketsPage({
|
||||
companyId,
|
||||
csrfToken,
|
||||
initialId,
|
||||
initialCreate = false,
|
||||
initialPendingOnly = false,
|
||||
}: {
|
||||
companyId?: string | undefined;
|
||||
csrfToken: string;
|
||||
initialId?: string | undefined;
|
||||
initialCreate?: boolean | undefined;
|
||||
initialPendingOnly?: boolean | undefined;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation("companies");
|
||||
const query = useQuery({
|
||||
queryKey: ticketKeys.list(companyId),
|
||||
@ -60,17 +77,15 @@ export function TicketsPage({ companyId, csrfToken }: { companyId?: string; csrf
|
||||
priority: "",
|
||||
assignee: "",
|
||||
});
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pendingOnly, setPendingOnly] = useState(initialPendingOnly);
|
||||
const [initialViewId, setInitialViewId] = useState(initialId);
|
||||
const [open, setOpen] = useState(initialCreate);
|
||||
const [edit, setEdit] = useState<TicketDTO | undefined>();
|
||||
const [view, setView] = useState<TicketDTO | undefined>();
|
||||
const [interventionTicket, setInterventionTicket] = useState<TicketDTO>();
|
||||
const items = filterTickets(query.data?.tickets ?? [], filters);
|
||||
const date = (value: string | undefined) =>
|
||||
value
|
||||
? new Intl.DateTimeFormat(i18n.language, { dateStyle: "medium", timeStyle: "short" }).format(
|
||||
new Date(value)
|
||||
)
|
||||
: "";
|
||||
const currentView = selectedDetail(view, query.data?.tickets, initialViewId);
|
||||
const items = filterTickets(query.data?.tickets ?? [], filters, pendingOnly);
|
||||
const date = (value: string | undefined) => formatDate(value, i18n.language);
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<EntityListHeader
|
||||
@ -211,7 +226,14 @@ export function TicketsPage({ companyId, csrfToken }: { companyId?: string; csrf
|
||||
filterKey={JSON.stringify(filters)}
|
||||
filters={
|
||||
<>
|
||||
{" "}
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
checked={pendingOnly}
|
||||
onChange={(event) => setPendingOnly(event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
{t("dashboard.pendingFilter")}
|
||||
</label>{" "}
|
||||
{!companyId && (
|
||||
<SelectWithAction
|
||||
id="tickets-company-filter"
|
||||
@ -260,13 +282,14 @@ export function TicketsPage({ companyId, csrfToken }: { companyId?: string; csrf
|
||||
/>{" "}
|
||||
</>
|
||||
}
|
||||
filtersApplied={Object.values(filters).some(Boolean)}
|
||||
filtersApplied={[pendingOnly, ...Object.values(filters)].some(Boolean)}
|
||||
items={items}
|
||||
loading={query.isPending}
|
||||
locale={i18n.language}
|
||||
onClearFilters={() =>
|
||||
setFilters({ search: "", company: "", state: "", priority: "", assignee: "" })
|
||||
}
|
||||
onClearFilters={() => {
|
||||
setPendingOnly(false);
|
||||
setFilters({ search: "", company: "", state: "", priority: "", assignee: "" });
|
||||
}}
|
||||
onSearchChange={(search) => setFilters({ ...filters, search })}
|
||||
search={filters.search}
|
||||
searchPlaceholder={t("tickets.search")}
|
||||
@ -317,14 +340,31 @@ export function TicketsPage({ companyId, csrfToken }: { companyId?: string; csrf
|
||||
onOpenChange={(value) => {
|
||||
if (!value) {
|
||||
setView(undefined);
|
||||
setInitialViewId(undefined);
|
||||
}
|
||||
}}
|
||||
open={Boolean(view)}
|
||||
open={Boolean(currentView)}
|
||||
title={t("tickets.details")}
|
||||
wide
|
||||
>
|
||||
{view && query.data && <TicketDetail data={query.data} ticket={view} />}
|
||||
{currentView && query.data && <TicketDetail data={query.data} ticket={currentView} />}
|
||||
</FormDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(value: string | undefined, language: string) {
|
||||
return value
|
||||
? new Intl.DateTimeFormat(language, { dateStyle: "medium", timeStyle: "short" }).format(
|
||||
new Date(value)
|
||||
)
|
||||
: "";
|
||||
}
|
||||
|
||||
function selectedDetail(
|
||||
view: TicketDTO | undefined,
|
||||
items: TicketDTO[] | undefined,
|
||||
id: string | undefined
|
||||
) {
|
||||
return view ?? items?.find((item) => item.id === id);
|
||||
}
|
||||
|
||||
@ -16,5 +16,14 @@
|
||||
"languageNames": {
|
||||
"es": "Español",
|
||||
"en": "English"
|
||||
},
|
||||
"shell": {
|
||||
"settings": "Settings",
|
||||
"search": "Search Metis Desk...",
|
||||
"results": "Search results",
|
||||
"searchScope": "Search available companies and sections.",
|
||||
"notificationsUnavailable": "Notifications: service not yet available",
|
||||
"profile": "User menu",
|
||||
"supportWorkspace": "Support workspace"
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,5 +16,14 @@
|
||||
"languageNames": {
|
||||
"es": "Español",
|
||||
"en": "English"
|
||||
},
|
||||
"shell": {
|
||||
"settings": "Configuración",
|
||||
"search": "Buscar en Metis Desk...",
|
||||
"results": "Resultados de búsqueda",
|
||||
"searchScope": "Buscar en compañías y secciones disponibles.",
|
||||
"notificationsUnavailable": "Notificaciones: servicio todavía no disponible",
|
||||
"profile": "Menú de usuario",
|
||||
"supportWorkspace": "Espacio de soporte"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
export { identityKeys } from "./api/identity-api";
|
||||
export { identityApi, identityKeys } from "./api/identity-api";
|
||||
export { IdentityWorkspace } from "./components/identity-workspace";
|
||||
export { identityEnglish, identitySpanish } from "./i18n";
|
||||
|
||||
@ -0,0 +1,57 @@
|
||||
import { QueryTypes, type Sequelize } from "sequelize";
|
||||
|
||||
type Entry = {
|
||||
eventId?: string;
|
||||
id: string;
|
||||
companyId: string;
|
||||
companyName: string;
|
||||
name: string;
|
||||
recordedAt: string | null;
|
||||
actorName: string | null;
|
||||
};
|
||||
|
||||
/** Consulta definiciones por stockKey, nunca por nombres editables. */
|
||||
export class ResourceDashboardReader {
|
||||
constructor(private readonly database: Sequelize) {}
|
||||
stats(companyId?: string) {
|
||||
return this.database.query<{ companyId: string; count: number }>(
|
||||
"SELECT companyId,COUNT(*) AS count FROM resources WHERE (:companyId IS NULL OR companyId=:companyId) GROUP BY companyId",
|
||||
{ type: QueryTypes.SELECT, replacements: { companyId: companyId ?? null } }
|
||||
);
|
||||
}
|
||||
async entries(companyId?: string, issue?: string, page = 1) {
|
||||
const expiry = issue === "certificate" || issue === "license";
|
||||
const join = expiry
|
||||
? " JOIN resource_definitions d ON d.id=r.templateId JOIN resource_definitions f ON f.stockKey='field:expires'"
|
||||
: "";
|
||||
const where =
|
||||
"WHERE (:companyId IS NULL OR r.companyId=:companyId)" +
|
||||
(expiry
|
||||
? " AND d.stockKey=:stockKey AND JSON_EXTRACT(r.`values`,'$.active')=true AND JSON_UNQUOTE(JSON_EXTRACT(r.`values`,CONCAT('$.characteristics.\"',f.id,'\"'))) BETWEEN :today AND :until"
|
||||
: "");
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const until = new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10);
|
||||
const replacements = {
|
||||
companyId: companyId ?? null,
|
||||
stockKey: issue === "certificate" ? "template:certificate" : "template:software_license",
|
||||
today,
|
||||
until,
|
||||
offset: (page - 1) * 10,
|
||||
};
|
||||
const counts = await this.database.query<{ total: number }>(
|
||||
`SELECT COUNT(*) AS total FROM resources r ${join} ${where}`,
|
||||
{ type: QueryTypes.SELECT, replacements }
|
||||
);
|
||||
const items = await this.database.query<Entry>(
|
||||
`SELECT r.id,r.companyId,c.name AS companyName,JSON_UNQUOTE(JSON_EXTRACT(r.\`values\`,'$.name')) AS name,NULL AS recordedAt,NULL AS actorName FROM resources r JOIN companies c ON c.id=r.companyId ${join} ${where} ORDER BY name,r.id LIMIT 10 OFFSET :offset`,
|
||||
{ type: QueryTypes.SELECT, replacements }
|
||||
);
|
||||
return { total: Number(counts[0]?.total ?? 0), items };
|
||||
}
|
||||
activity(companyId?: string) {
|
||||
return this.database.query<Entry>(
|
||||
"SELECT r.id,h.id AS eventId,r.companyId,c.name AS companyName,JSON_UNQUOTE(JSON_EXTRACT(r.`values`,'$.name')) AS name,h.recordedAt,u.name AS actorName FROM resource_revisions h JOIN resources r ON r.id=h.resourceId JOIN companies c ON c.id=r.companyId LEFT JOIN users u ON u.id=h.actorId WHERE (:companyId IS NULL OR r.companyId=:companyId) ORDER BY h.recordedAt DESC,h.id DESC LIMIT 6",
|
||||
{ type: QueryTypes.SELECT, replacements: { companyId: companyId ?? null } }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,7 @@ import {
|
||||
migrateResourceCatalog,
|
||||
} from "./infrastructure/persistence/resource-catalog.model";
|
||||
import { ResourceCatalogPersistenceMapper } from "./infrastructure/persistence/resource-catalog-persistence.mapper";
|
||||
import { ResourceDashboardReader } from "./infrastructure/persistence/resource-dashboard-reader";
|
||||
import { SequelizeResourceRepository } from "./infrastructure/persistence/sequelize-resource.repository";
|
||||
import { SequelizeResourceCatalogRepository } from "./infrastructure/persistence/sequelize-resource-catalog.repository";
|
||||
|
||||
@ -40,7 +41,10 @@ export async function createResourceModule(database: Sequelize, authenticate: Re
|
||||
const inventoryRepository = new SequelizeResourceRepository(database);
|
||||
const inventory = new ResourceUseCases(inventoryRepository);
|
||||
return {
|
||||
services: { inventory: new ResourceInventoryService(inventoryRepository) },
|
||||
services: {
|
||||
inventory: new ResourceInventoryService(inventoryRepository),
|
||||
dashboard: new ResourceDashboardReader(database),
|
||||
},
|
||||
migrateLegacy: () => migrateLegacyResources(database),
|
||||
register: (app: Parameters<typeof registerResourceCatalogRoutes>[0]) => {
|
||||
registerResourceCatalogRoutes(app, catalog, authenticate);
|
||||
|
||||
@ -23,16 +23,20 @@ export function ResourcesPage({
|
||||
companyId,
|
||||
csrfToken,
|
||||
relatedContent,
|
||||
initialId,
|
||||
initialCreate = false,
|
||||
}: {
|
||||
companyId: string;
|
||||
csrfToken: string;
|
||||
relatedContent?: (resource: ResourceDTO) => ReactNode;
|
||||
initialId?: string | undefined;
|
||||
initialCreate?: boolean | undefined;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation("resources");
|
||||
const _searchId = useId();
|
||||
const [search, setSearch] = useState("");
|
||||
const [editor, setEditor] = useState<string>();
|
||||
const [viewId, setViewId] = useState<string>();
|
||||
const [editor, setEditor] = useState<string | undefined>(() => initialEditor(initialCreate));
|
||||
const [viewId, setViewId] = useState<string | undefined>(initialId);
|
||||
const query = useQuery({
|
||||
queryKey: inventoryKeys.company(companyId),
|
||||
queryFn: () => inventoryApi.get(companyId),
|
||||
@ -183,3 +187,7 @@ export function ResourcesPage({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function initialEditor(create: boolean) {
|
||||
return create ? "new" : undefined;
|
||||
}
|
||||
|
||||
62
packages/shadcn-ui/src/components/statistic-card.tsx
Normal file
62
packages/shadcn-ui/src/components/statistic-card.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const TONES = {
|
||||
teal: "bg-primary/5",
|
||||
rose: "bg-destructive/5",
|
||||
amber: "bg-amber-500/5",
|
||||
green: "bg-emerald-500/5",
|
||||
violet: "bg-violet-500/5",
|
||||
};
|
||||
|
||||
const ICON_TONES = {
|
||||
teal: "text-primary",
|
||||
rose: "text-destructive",
|
||||
amber: "text-amber-700 dark:text-amber-400",
|
||||
green: "text-emerald-700 dark:text-emerald-400",
|
||||
violet: "text-violet-700 dark:text-violet-400",
|
||||
};
|
||||
|
||||
export function StatisticCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
tone = "teal",
|
||||
onClick,
|
||||
status,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
value?: number | undefined;
|
||||
tone?: "teal" | "rose" | "amber" | "green" | "violet";
|
||||
onClick: () => void;
|
||||
status?: string | undefined;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={`flex min-h-28 items-start gap-3 rounded-xl border border-border p-4 text-left shadow-sm transition-colors hover:border-primary focus-visible:outline-2 focus-visible:outline-ring ${TONES[tone]}`}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`flex size-10 shrink-0 items-center justify-center rounded-full bg-background/60 [&_svg]:size-5 ${ICON_TONES[tone]}`}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-xs font-medium text-foreground">{label}</span>
|
||||
<strong className="mt-2 block text-3xl font-semibold text-foreground">
|
||||
{value ?? "—"}
|
||||
</strong>
|
||||
{status && (
|
||||
<small className="block mt-1 text-muted-foreground" role="status">
|
||||
{status}
|
||||
</small>
|
||||
)}
|
||||
</span>
|
||||
<span aria-hidden="true" className="ml-auto self-center text-lg text-primary">
|
||||
›
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@ -1,11 +1,26 @@
|
||||
export {
|
||||
BellIcon,
|
||||
BoxIcon,
|
||||
BriefcaseIcon,
|
||||
Building2Icon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
ClockIcon,
|
||||
DatabaseIcon,
|
||||
FileKeyIcon,
|
||||
HomeIcon,
|
||||
KeyRoundIcon,
|
||||
LaptopIcon,
|
||||
LayersIcon,
|
||||
LayoutTemplateIcon,
|
||||
MailIcon,
|
||||
MapPinIcon,
|
||||
SearchIcon,
|
||||
SettingsIcon,
|
||||
ShieldCheckIcon,
|
||||
TicketIcon,
|
||||
TriangleAlertIcon,
|
||||
UserRoundXIcon,
|
||||
UsersIcon,
|
||||
WrenchIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
Loading…
Reference in New Issue
Block a user