diff --git a/README.md b/README.md index a568673..61b0538 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/apps/server/src/dashboard.integration.ts b/apps/server/src/dashboard.integration.ts new file mode 100644 index 0000000..a3522d3 --- /dev/null +++ b/apps/server/src/dashboard.integration.ts @@ -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; +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" + ); + } +} diff --git a/apps/server/src/identity.test.ts b/apps/server/src/identity.test.ts index 87832b6..265a502 100644 --- a/apps/server/src/identity.test.ts +++ b/apps/server/src/identity.test.ts @@ -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"); diff --git a/apps/server/src/infrastructure/http/identity-runtime.ts b/apps/server/src/infrastructure/http/identity-runtime.ts index 9a3f089..521e43e 100644 --- a/apps/server/src/infrastructure/http/identity-runtime.ts +++ b/apps/server/src/infrastructure/http/identity-runtime.ts @@ -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(), diff --git a/apps/web/src/app.test.tsx b/apps/web/src/app.test.tsx index 62e1df3..a82a936 100644 --- a/apps/web/src/app.test.tsx +++ b/apps/web/src/app.test.tsx @@ -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")); }); -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(" { + 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")); + 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", + }); +}); diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index bf1e12a..3117ef5 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -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 ( -
-
-
- - Metis Desk. - -
- {t("language")} - -
-
- {languageError && ( -

- {t("languageError")} -

- )} -
+
+
+ {t("language")} + +
+ + {languageError && ( +

+ {t("languageError")} +

+ )} +
+ ); +} + +export function App() { + const session = useQuery({ + queryKey: identityKeys.session, + queryFn: identityApi.session, + retry: false, + }); + return ( +
+ {!session.data?.user && ( +
+ Metis Desk + +
+ )} ( - + renderWorkspace={(snapshot, accountNavigation) => ( + } + userName={snapshot.user?.name ?? ""} + /> )} > {null} diff --git a/apps/web/src/global.css b/apps/web/src/global.css index 1ec7c5f..15df732 100644 --- a/apps/web/src/global.css +++ b/apps/web/src/global.css @@ -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); diff --git a/docs/componentes-de-interfaz.md b/docs/componentes-de-interfaz.md index feba31e..65359ca 100644 --- a/docs/componentes-de-interfaz.md +++ b/docs/componentes-de-interfaz.md @@ -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. diff --git a/docs/dashboard-inicio.md b/docs/dashboard-inicio.md new file mode 100644 index 0000000..622d068 --- /dev/null +++ b/docs/dashboard-inicio.md @@ -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. diff --git a/modules/assets/src/api/infrastructure/persistence/asset-dashboard-reader.ts b/modules/assets/src/api/infrastructure/persistence/asset-dashboard-reader.ts new file mode 100644 index 0000000..c9b944f --- /dev/null +++ b/modules/assets/src/api/infrastructure/persistence/asset-dashboard-reader.ts @@ -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( + `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( + "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 } } + ); + } +} diff --git a/modules/assets/src/api/module.ts b/modules/assets/src/api/module.ts index 1943544..6f3d389 100644 --- a/modules/assets/src/api/module.ts +++ b/modules/assets/src/api/module.ts @@ -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); diff --git a/modules/assets/src/web/inventory-page.tsx b/modules/assets/src/web/inventory-page.tsx index cdfa39e..5121bae 100644 --- a/modules/assets/src/web/inventory-page.tsx +++ b/modules/assets/src/web/inventory-page.tsx @@ -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(); + const [editor, setEditor] = useState(initialCreate ? "new" : undefined); const [viewId, setViewId] = useState(); - const [selectedId, setSelectedId] = useState(); + const [selectedId, setSelectedId] = useState(initialId); const [detailOpen, setDetailOpen] = useState(true); const assets = query.data?.assets ?? []; const selected = assets.find((asset) => asset.id === selectedId) ?? assets[0]; diff --git a/modules/companies/src/api/application/dashboard.use-cases.ts b/modules/companies/src/api/application/dashboard.use-cases.ts new file mode 100644 index 0000000..990a8d6 --- /dev/null +++ b/modules/companies/src/api/application/dashboard.use-cases.ts @@ -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; +} +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; + read(block: DashboardBlock, query: DashboardDetailsQuery): Promise; +} + +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)); + } +} diff --git a/modules/companies/src/api/infrastructure/http/dashboard.routes.ts b/modules/companies/src/api/infrastructure/http/dashboard.routes.ts new file mode 100644 index 0000000..31dbd38 --- /dev/null +++ b/modules/companies/src/api/infrastructure/http/dashboard.routes.ts @@ -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); +} diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize-dashboard.repository.ts b/modules/companies/src/api/infrastructure/persistence/sequelize-dashboard.repository.ts new file mode 100644 index 0000000..a1f04a1 --- /dev/null +++ b/modules/companies/src/api/infrastructure/persistence/sequelize-dashboard.repository.ts @@ -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( + sql: string, + query: DashboardDetailsQuery, + extra: Record = {} + ) { + return this.database.query(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( + `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( + `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( + `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( + `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 } + ); + } + } + } +} diff --git a/modules/companies/src/api/module.ts b/modules/companies/src/api/module.ts index f6afe81..005bf87 100644 --- a/modules/companies/src/api/module.ts +++ b/modules/companies/src/api/module.ts @@ -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[0]) => + registerDashboardRoutes(app, dashboard, authenticate), + }; + }, initializeProfiles: async ( inventory: IWorkProfileInventoryService & IProcessAssetInventoryService & diff --git a/modules/companies/src/common/dashboard.ts b/modules/companies/src/common/dashboard.ts new file mode 100644 index 0000000..1342b7e --- /dev/null +++ b/modules/companies/src/common/dashboard.ts @@ -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; +export type DashboardEntriesDTO = z.infer; +export type DashboardSummaryDTO = z.infer; +export type DashboardCompanyDTO = z.infer; +export type DashboardIssue = z.infer; +export type DashboardDetailsQuery = z.infer; diff --git a/modules/companies/src/common/index.ts b/modules/companies/src/common/index.ts index 0bfe07f..1583cd8 100644 --- a/modules/companies/src/common/index.ts +++ b/modules/companies/src/common/index.ts @@ -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"; diff --git a/modules/companies/src/common/locales/en.json b/modules/companies/src/common/locales/en.json index 9e2e35e..6ad7e1e 100644 --- a/modules/companies/src/common/locales/en.json +++ b/modules/companies/src/common/locales/en.json @@ -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": { diff --git a/modules/companies/src/common/locales/es.json b/modules/companies/src/common/locales/es.json index 55fe32c..208b2d4 100644 --- a/modules/companies/src/common/locales/es.json +++ b/modules/companies/src/common/locales/es.json @@ -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": { diff --git a/modules/companies/src/web/components/companies-page.tsx b/modules/companies/src/web/components/companies-page.tsx index 00db288..bd560dc 100644 --- a/modules/companies/src/web/components/companies-page.tsx +++ b/modules/companies/src/web/components/companies-page.tsx @@ -29,14 +29,22 @@ const COMPANY_COLUMNS: ColumnDef[] = [ 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(); - 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 (
+ + {companyId && ( + + {t("tickets.company")}:{" "} + {query.data?.companies.find((item) => item.id === companyId)?.name} + + )} +
+ } + filtersApplied={[activeOnly, companyId, search].some(Boolean)} items={companies} locale={i18n.language} + onClearFilters={() => { + setActiveOnly(false); + setCompanyId(undefined); + setSearch(""); + }} onSearchChange={setSearch} search={search} searchPlaceholder={t("searchPlaceholder")} diff --git a/modules/companies/src/web/components/companies-workspace.tsx b/modules/companies/src/web/components/companies-workspace.tsx index 8768f4d..021b17a 100644 --- a/modules/companies/src/web/components/companies-workspace.tsx +++ b/modules/companies/src/web/components/companies-workspace.tsx @@ -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 (
- +
+
+
+
+ + +
+ + + + {userName} + {coreTranslation("shell.supportWorkspace")} + + +
+ {accountNavigation} + {languageSwitcher} +
+
+
+ + {accountNavigation} + {languageSwitcher} + {coreTranslation("workspace")} {query.isPending && (

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

-
-

{t(workspaceTitle(current.area))}

-

{t("supportWorkspace")}

-
- -
{accountNavigation}
-
+
+
@@ -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 ; + return ( + + ); case "tickets": - return ; + return ( + + ); case "directory": - return ; + return ( + + ); case "catalogs": return ; case "company": return null; + case "home": + return ; } } diff --git a/modules/companies/src/web/components/company-detail.tsx b/modules/companies/src/web/components/company-detail.tsx index f684b7f..e14cccd 100644 --- a/modules/companies/src/web/components/company-detail.tsx +++ b/modules/companies/src/web/components/company-detail.tsx @@ -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(embedded ? "inventory" : "people"); + const [tab, setTab] = useState(initialSection ?? (embedded ? "inventory" : "people")); const company = query.data?.company; const layout = embedded ? { @@ -281,7 +287,13 @@ export function CompanyDetail({
{embedded &&
{company.name}
} - +
)} @@ -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 ; case "people": - return ; + return ; case "departments": return ; case "sites": @@ -322,7 +338,9 @@ function CompanySection({ ); case "profiles": - return ; + return ( + + ); case "certificates": return ; case "applications": @@ -336,12 +354,21 @@ function CompanySection({ case "workstations": return ; case "inventory": - return ; + return ( + + ); case "resources": return ( resource.values.integration?.kind === "mailbox" ? ( diff --git a/modules/companies/src/web/components/company-inventory.tsx b/modules/companies/src/web/components/company-inventory.tsx index 71a53c5..ff44887 100644 --- a/modules/companies/src/web/components/company-inventory.tsx +++ b/modules/companies/src/web/components/company-inventory.tsx @@ -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 ( <> [ + "dashboard", + block, + companyId ?? "global", + page, + kind, + ], +}; +async function request( + block: string, + signal: AbortSignal, + query: Omit & { 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; +} +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, + }; +} diff --git a/modules/companies/src/web/components/dashboard-block.tsx b/modules/companies/src/web/components/dashboard-block.tsx new file mode 100644 index 0000000..0f94202 --- /dev/null +++ b/modules/companies/src/web/components/dashboard-block.tsx @@ -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 ( +

+ {t("loading")} +

+ ); + } + if (query.isError) { + return ( +

+ {t("dashboard.blockError")} +

+ ); + } + if (empty) { + return

{emptyText}

; + } + return children; +} diff --git a/modules/companies/src/web/components/dashboard-details-dialog.tsx b/modules/companies/src/web/components/dashboard-details-dialog.tsx new file mode 100644 index 0000000..b6bbd39 --- /dev/null +++ b/modules/companies/src/web/components/dashboard-details-dialog.tsx @@ -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 ( + { + if (!open) { + onClose(); + } + }} + open + title={title} + wide + > + {details.companies ? ( + + item.name, + render: (item) => ( + + ), + }, + { + 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} + /> + + ) : ( + <> + +

{range}

+
+ + + + + + + {details.activity && } + {details.activity && } + {details.work && } + + + + {query.data?.items.map((item) => ( + + + + {details.activity && ( + + )} + {details.activity && } + {details.work && ( + + )} + + ))} + +
{title}
{t("name")}{t("tickets.company")}{t("dashboard.recordedAt")}{t("dashboard.recordedBy")}{t("tickets.state")}
+ + {item.companyName} + {item.recordedAt + ? new Intl.DateTimeFormat(i18n.language, { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(item.recordedAt)) + : "—"} + {item.actorName ?? "—"} + {t( + (item.kind === "ticket" ? "tickets.states." : "interventions.states.") + + item.state + )} +
+
+
+ {!details.activity && query.isSuccess && ( +
+ {range} +
+ + + {labels.page} {page} + + +
+
+ )} + + )} +
+ ); +} + +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"; +} diff --git a/modules/companies/src/web/components/dashboard-page.tsx b/modules/companies/src/web/components/dashboard-page.tsx new file mode 100644 index 0000000..22757af --- /dev/null +++ b/modules/companies/src/web/components/dashboard-page.tsx @@ -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 ( +
+
+
+

{t("dashboard.title")}

+

{t("dashboard.description")}

+
+
+ ({ value: item.id, label: item.name })) ?? + []), + ]} + placeholder={t("interventions.allCompanies")} + value={companyId} + /> + {summary.isSuccess && ( +

+

+ )} +
+
+
+ {kpis.map(({ key, label, Icon, tone, action }) => ( + } + key={key} + label={t(`dashboard.${label}`)} + onClick={action} + status={statStatus} + tone={tone} + value={summary.isError ? undefined : summary.data?.[key]} + /> + ))} +
+
+ setDetails({ work: true })} + icon={} + title={t("dashboard.pendingWork")} + > + +
+ + + + + {[ + t("dashboard.type"), + t("tickets.reference"), + t("tickets.subject"), + t("tickets.company"), + t("tickets.priority"), + t("tickets.state"), + ].map((label) => ( + + ))} + + + + {work.data?.items.slice(0, 6).map((item) => { + const Icon = KIND_ICONS[item.kind]; + return ( + + + + + + + + + ); + })} + +
{t("dashboard.pendingWork")}
+ {label} +
+ + + {item.name}{item.companyName} + {item.priority ? ( + + {t(`tickets.priorities.${item.priority}`)} + + ) : ( + "—" + )} + + + {t( + (item.kind === "ticket" + ? "tickets.states." + : "interventions.states.") + item.state + )} + +
+
+
+
+ } + title={t("dashboard.attention")} + > + +
    + {attention.data?.items.map((item) => { + const Icon = ISSUE_ICONS[item.kind]; + return ( +
  • + +
  • + ); + })} +
+
+
+
+
+ setDetails({ activity: true })} + icon={} + title={t("dashboard.recentActivity")} + > + +
    + {activity.data?.items.slice(0, 4).map((item) => { + const Icon = KIND_ICONS[item.kind]; + return ( +
  1. + +
  2. + ); + })} +
+
+
+ setDetails({ companies: true })} + icon={} + title={t("dashboard.companySummary")} + > + +
+ + + + + {[ + t("tickets.company"), + t("dashboard.openTickets"), + t("dashboard.pendingInterventions"), + t("dashboard.registeredAssets"), + t("people"), + ].map((label) => ( + + ))} + + + + {totals.data?.items.slice(0, 5).map((item) => ( + + + + + + + + ))} + +
{t("dashboard.companySummary")}
+ {label} +
+ + + + + + + + + +
+
+
+
+
+ +
+ {(["ticket", "intervention", "company", "asset", "resource"] as const).map((kind) => { + const Icon = kind === "company" ? Building2Icon : KIND_ICONS[kind]; + return ( + + ); + })} +
+
+ {details && ( + setDetails(undefined)} + onNavigate={onNavigate} + /> + )} + {quick && ( + setQuick(undefined)} + onNavigate={onNavigate} + /> + )} +
+ ); +} + +function DashboardPanel({ + title, + icon, + action, + children, +}: { + title: string; + icon?: ReactNode; + action?: () => void; + children: ReactNode; +}) { + const { t } = useTranslation("companies"); + return ( +
+
+

+ {icon && ( + + )} + {title} +

+ {action && ( + + )} +
+ {children} +
+ ); +} diff --git a/modules/companies/src/web/components/dashboard-quick-create.tsx b/modules/companies/src/web/components/dashboard-quick-create.tsx new file mode 100644 index 0000000..a835aae --- /dev/null +++ b/modules/companies/src/web/components/dashboard-quick-create.tsx @@ -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 ( + <> + { + if (!open) { + onClose(); + } + }} + open + title={t(`dashboard.create.${kind}`)} + wide={kind === "ticket" || kind === "intervention"} + > + {kind === "company" && ( + + )} + {kind === "ticket" && ( + + {tickets.data && ( + + )} + + )} + {kind === "intervention" && ( + + {interventions.data && ( + + )} + + )} + {(kind === "asset" || kind === "resource") && ( + + 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} + /> +

{t("dashboard.chooseCompany")}

+ +
+ )} +
+ {addingCompany && ( + + { + setSelectedCompany(id); + setAddingCompany(false); + }} + showTitle={false} + /> + + )} + + ); +} diff --git a/modules/companies/src/web/components/people-section.tsx b/modules/companies/src/web/components/people-section.tsx index f8f96af..cdacc9c 100644 --- a/modules/companies/src/web/components/people-section.tsx +++ b/modules/companies/src/web/components/people-section.tsx @@ -207,12 +207,20 @@ function PersonDetails({
); } -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); diff --git a/modules/companies/src/web/interventions/interventions-page.tsx b/modules/companies/src/web/interventions/interventions-page.tsx index 06ae6b3..19fce5c 100644 --- a/modules/companies/src/web/interventions/interventions-page.tsx +++ b/modules/companies/src/web/interventions/interventions-page.tsx @@ -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(); const [view, setView] = useState(); - 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 (
- {" "} + {" "} {!companyId && ( {" "} } - 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 && } + {currentView && query.data && ( + + )}
); @@ -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); +} diff --git a/modules/companies/src/web/profiles/work-profiles-page.tsx b/modules/companies/src/web/profiles/work-profiles-page.tsx index 747c116..4d22828 100644 --- a/modules/companies/src/web/profiles/work-profiles-page.tsx +++ b/modules/companies/src/web/profiles/work-profiles-page.tsx @@ -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(); + const [configuration, setConfiguration] = useState( + initialId ? { kind: "environment", profileId: initialId } : undefined + ); const [editor, setEditor] = useState(); const [view, setView] = useState(); const [search, setSearch] = useState(""); diff --git a/modules/companies/src/web/tickets/tickets-page.tsx b/modules/companies/src/web/tickets/tickets-page.tsx index 9d66088..3758ee8 100644 --- a/modules/companies/src/web/tickets/tickets-page.tsx +++ b/modules/companies/src/web/tickets/tickets-page.tsx @@ -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(); const [view, setView] = useState(); const [interventionTicket, setInterventionTicket] = useState(); - 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 (
- {" "} + {" "} {!companyId && ( {" "} } - 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 && } + {currentView && query.data && }
); } + +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); +} diff --git a/modules/core/src/common/locales/en.json b/modules/core/src/common/locales/en.json index 1565e55..69d3894 100644 --- a/modules/core/src/common/locales/en.json +++ b/modules/core/src/common/locales/en.json @@ -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" } } diff --git a/modules/core/src/common/locales/es.json b/modules/core/src/common/locales/es.json index dfa7e94..07a6864 100644 --- a/modules/core/src/common/locales/es.json +++ b/modules/core/src/common/locales/es.json @@ -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" } } diff --git a/modules/identity/src/web/index.ts b/modules/identity/src/web/index.ts index 1eb1136..d0825de 100644 --- a/modules/identity/src/web/index.ts +++ b/modules/identity/src/web/index.ts @@ -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"; diff --git a/modules/resources/src/api/infrastructure/persistence/resource-dashboard-reader.ts b/modules/resources/src/api/infrastructure/persistence/resource-dashboard-reader.ts new file mode 100644 index 0000000..eb28c0e --- /dev/null +++ b/modules/resources/src/api/infrastructure/persistence/resource-dashboard-reader.ts @@ -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( + `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( + "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 } } + ); + } +} diff --git a/modules/resources/src/api/module.ts b/modules/resources/src/api/module.ts index 6ad6f6d..c9ba745 100644 --- a/modules/resources/src/api/module.ts +++ b/modules/resources/src/api/module.ts @@ -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[0]) => { registerResourceCatalogRoutes(app, catalog, authenticate); diff --git a/modules/resources/src/web/resources-page.tsx b/modules/resources/src/web/resources-page.tsx index 31b50ea..4df10b1 100644 --- a/modules/resources/src/web/resources-page.tsx +++ b/modules/resources/src/web/resources-page.tsx @@ -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(); - const [viewId, setViewId] = useState(); + const [editor, setEditor] = useState(() => initialEditor(initialCreate)); + const [viewId, setViewId] = useState(initialId); const query = useQuery({ queryKey: inventoryKeys.company(companyId), queryFn: () => inventoryApi.get(companyId), @@ -183,3 +187,7 @@ export function ResourcesPage({
); } + +function initialEditor(create: boolean) { + return create ? "new" : undefined; +} diff --git a/packages/shadcn-ui/src/components/statistic-card.tsx b/packages/shadcn-ui/src/components/statistic-card.tsx new file mode 100644 index 0000000..7d85a7b --- /dev/null +++ b/packages/shadcn-ui/src/components/statistic-card.tsx @@ -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 ( + + ); +} diff --git a/packages/shadcn-ui/src/lib/icons.ts b/packages/shadcn-ui/src/lib/icons.ts index de3f364..ad936bf 100644 --- a/packages/shadcn-ui/src/lib/icons.ts +++ b/packages/shadcn-ui/src/lib/icons.ts @@ -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";