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:
David Arranz 2026-10-08 13:27:27 +02:00
parent 3f01d5ecf1
commit bada13a50e
41 changed files with 3247 additions and 269 deletions

View File

@ -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)

View 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"
);
}
}

View File

@ -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");

View File

@ -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(),

View File

@ -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",
});
});

View File

@ -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}

View File

@ -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);

View File

@ -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
View 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.

View File

@ -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 } }
);
}
}

View File

@ -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);

View File

@ -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];

View 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));
}
}

View File

@ -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);
}

View File

@ -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 }
);
}
}
}
}

View File

@ -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 &

View 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>;

View File

@ -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";

View File

@ -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": {

View File

@ -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": {

View File

@ -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")}

View File

@ -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} />;
}
}

View File

@ -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} />

View File

@ -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={{

View 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,
};
}

View 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;
}

View File

@ -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";
}

View 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>
);
}

View 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>
)}
</>
);
}

View File

@ -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);

View File

@ -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);
}

View File

@ -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("");

View File

@ -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);
}

View File

@ -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"
}
}

View File

@ -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"
}
}

View File

@ -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";

View File

@ -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 } }
);
}
}

View File

@ -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);

View File

@ -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;
}

View 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>
);
}

View File

@ -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";