diff --git a/docs/componentes-de-interfaz.md b/docs/componentes-de-interfaz.md index 9c224e5..8c690d4 100644 --- a/docs/componentes-de-interfaz.md +++ b/docs/componentes-de-interfaz.md @@ -295,3 +295,9 @@ La cabecera permanece visible. Un formulario modal utiliza una raíz flex con al `FormSection` agrupa datos relacionados mediante título accesible y un borde discreto. Activos, recursos y plantillas lo utilizan para sus datos principales. Las características y las secciones del editor se distribuyen en dos bloques por fila cuando el contenedor dispone de al menos 900 px; los controles dentro de cada bloque mantienen una o dos columnas según el espacio. Una plantilla sin secciones puede aprovechar hasta tres columnas de características. Observaciones y ayuda ocupan toda la fila. La disposición responde al espacio del modal, no solo al ancho del navegador. El desplazamiento vertical se conserva cuando el contenido supera la altura disponible; no se ocultan campos ni se reduce artificialmente su tamaño para eliminarlo. No se introducen pestañas ni páginas nuevas sin una necesidad específica del formulario. + +## Resumen de compañía + +La pantalla `CompanyOverview` tiene composición independiente de `DashboardPage` (Inicio). Comparte tarjetas, bloques de trabajo/avisos/actividad y consultas existentes, siempre fijando el ámbito al UUID de la compañía. El encabezado «De un vistazo» precede las acciones rápidas (ticket, intervención, activo y recurso), cuatro indicadores operativos y una franja navegable de personas, departamentos y sedes. No muestra compañías activas ni el resumen por compañías. La variante compacta de `StatisticCard` mejora la jerarquía del valor y la etiqueta sin modificar la presentación de Inicio. + +La navegación de compañía empieza por Resumen, Tickets e Intervenciones; después agrupa Personas, Puestos de trabajo y Perfiles de usuario y finalmente las demás secciones. Los grupos tienen separación visual. Las operaciones de usuario permanecen en el menú superior y dejan de repetirse al pie del menú de compañía. diff --git a/modules/companies/src/common/locales/en.json b/modules/companies/src/common/locales/en.json index 7edfea1..1912e55 100644 --- a/modules/companies/src/common/locales/en.json +++ b/modules/companies/src/common/locales/en.json @@ -6,9 +6,10 @@ "routeHelp": "Check the address or return to the dashboard." }, "companyOverview": { - "title": "Company overview", + "title": "At a glance", "description": "Status, pending work and alerts for {{name}}.", - "directory": "Company information" + "directory": "People and organization", + "navigation": "Overview" }, "catalogOverview": { "globalTitle": "Global catalogs", diff --git a/modules/companies/src/common/locales/es.json b/modules/companies/src/common/locales/es.json index 1749d1c..c13f19c 100644 --- a/modules/companies/src/common/locales/es.json +++ b/modules/companies/src/common/locales/es.json @@ -6,9 +6,10 @@ "routeHelp": "Comprueba la dirección o vuelve al inicio." }, "companyOverview": { - "title": "Resumen de la compañía", + "title": "De un vistazo", "description": "Estado, trabajo pendiente y avisos de {{name}}.", - "directory": "Información de la compañía" + "directory": "Equipo y organización", + "navigation": "Resumen" }, "catalogOverview": { "globalTitle": "Catálogos globales", diff --git a/modules/companies/src/web/companies/detail/types/company-detail-props.ts b/modules/companies/src/web/companies/detail/types/company-detail-props.ts index c4ab1df..00bbe72 100644 --- a/modules/companies/src/web/companies/detail/types/company-detail-props.ts +++ b/modules/companies/src/web/companies/detail/types/company-detail-props.ts @@ -1,5 +1,3 @@ -import type { ReactNode } from "react"; - import type { CompanyTab } from "../../shared/types"; export type CompanyDetailProps = { @@ -7,7 +5,6 @@ export type CompanyDetailProps = { csrfToken: string; onBack: () => void; embedded?: boolean; - accountNavigation?: ReactNode; initialSection?: CompanyTab; section?: CompanyTab; onSectionChange?: (section: CompanyTab) => void; diff --git a/modules/companies/src/web/companies/detail/ui/company-detail.tsx b/modules/companies/src/web/companies/detail/ui/company-detail.tsx index 867a9e7..6d1c644 100644 --- a/modules/companies/src/web/companies/detail/ui/company-detail.tsx +++ b/modules/companies/src/web/companies/detail/ui/company-detail.tsx @@ -9,7 +9,9 @@ import { LayoutTemplateIcon, MailIcon, MapPinIcon, + TicketIcon, UsersIcon, + WrenchIcon, } from "@repo/rdx-ui/lib/icons"; import { Button } from "@repo/shadcn-ui/components/button"; import { useTranslation } from "react-i18next"; @@ -26,7 +28,6 @@ export function CompanyDetail(props: CompanyDetailProps) { csrfToken, onBack, embedded = false, - accountNavigation, initialSection, initialEntityId, initialCreate, @@ -39,7 +40,7 @@ export function CompanyDetail(props: CompanyDetailProps) { const { t: accessTranslation } = useTranslation("access"); const layout = embedded ? { - root: "grid min-h-[calc(100dvh-140px)] grid-cols-[minmax(0,1fr)] grid-rows-[auto_auto_1fr_auto] [grid-template-areas:'heading'_'navigation'_'main'_'account'] shell:min-h-[calc(100dvh-var(--spacing-shell-header))] shell:grid-cols-[240px_minmax(0,1fr)] shell:grid-rows-[auto_1fr_auto] shell:[grid-template-areas:'heading_main'_'navigation_main'_'account_main']", + root: "grid min-h-[calc(100dvh-140px)] grid-cols-[minmax(0,1fr)] grid-rows-[auto_auto_1fr] [grid-template-areas:'heading'_'navigation'_'main'] shell:min-h-[calc(100dvh-var(--spacing-shell-header))] shell:grid-cols-[240px_minmax(0,1fr)] shell:grid-rows-[auto_1fr] shell:[grid-template-areas:'heading_main'_'navigation_main']", heading: "flex items-center justify-between gap-3 border-b border-border p-3 [grid-area:heading] shell:block shell:px-4 shell:py-5", navigation: @@ -71,11 +72,6 @@ export function CompanyDetail(props: CompanyDetailProps) { {t("retry")} )} - {embedded && ( - - )} {company && ( <>
@@ -116,24 +112,24 @@ export function CompanyDetail(props: CompanyDetailProps) {
+ ); + })}
diff --git a/modules/companies/src/web/companies/detail/ui/company-overview-statistics.tsx b/modules/companies/src/web/companies/detail/ui/company-overview-statistics.tsx new file mode 100644 index 0000000..bc4c1b3 --- /dev/null +++ b/modules/companies/src/web/companies/detail/ui/company-overview-statistics.tsx @@ -0,0 +1,114 @@ +import { BrowserLink } from "@metis/core/client"; +import { StatisticCard } from "@repo/rdx-ui/components/statistic-card"; +import { + BriefcaseIcon, + ChevronRightIcon, + DatabaseIcon, + LaptopIcon, + MapPinIcon, + TicketIcon, + UsersIcon, + WrenchIcon, +} from "@repo/rdx-ui/lib/icons"; +import type { UseQueryResult } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; + +import type { CompanyDTO } from "../../../../common"; +import type { DashboardSummaryDTO } from "../../../../common/dashboard"; +import type { DashboardNavigation } from "../../../dashboard/shared/helpers"; +import { companySectionHref } from "../../../workspace/shared/helpers"; + +export function CompanyOverviewStatistics({ + company, + summary, + onNavigate, + onDetails, +}: { + company: CompanyDTO; + summary: UseQueryResult; + onNavigate: (destination: DashboardNavigation) => void; + onDetails: (kind: "asset" | "resource") => void; +}) { + const { t } = useTranslation("companies"); + const status = summary.isPending + ? t("loading") + : summary.isError + ? t("dashboard.blockError") + : undefined; + const indicators = [ + { + key: "tickets", + label: "openTickets", + Icon: TicketIcon, + tone: "rose", + action: () => onNavigate({ area: "tickets", companyId: company.id, pendingOnly: true }), + }, + { + key: "interventions", + label: "pendingInterventions", + Icon: WrenchIcon, + tone: "amber", + action: () => onNavigate({ area: "interventions", companyId: company.id, pendingOnly: true }), + }, + { + key: "assets", + label: "registeredAssets", + Icon: LaptopIcon, + tone: "green", + action: () => onDetails("asset"), + }, + { + key: "resources", + label: "registeredResources", + Icon: DatabaseIcon, + tone: "violet", + action: () => onDetails("resource"), + }, + ] as const; + return ( +
+
+ {indicators.map(({ key, label, Icon, tone, action }) => ( + } + key={key} + label={t(`dashboard.${label}`)} + onClick={action} + status={status} + tone={tone} + value={summary.isError ? undefined : summary.data?.[key]} + /> + ))} +
+ +
+ ); +} diff --git a/modules/companies/src/web/companies/detail/ui/company-overview.tsx b/modules/companies/src/web/companies/detail/ui/company-overview.tsx index 3603ff8..89ed986 100644 --- a/modules/companies/src/web/companies/detail/ui/company-overview.tsx +++ b/modules/companies/src/web/companies/detail/ui/company-overview.tsx @@ -1,11 +1,21 @@ import { navigateTo } from "@metis/core/client"; +import { Button } from "@repo/shadcn-ui/components/button"; import { useTranslation } from "react-i18next"; import type { CompanyDTO } from "../../../../common"; -import { DashboardPage } from "../../../dashboard/view/ui/dashboard-page"; -import { SectionNavigation } from "../../../shared/ui/section-navigation"; -import { companySectionHref, workspaceHref } from "../../../workspace/shared/helpers"; +import { DashboardQuickCreate } from "../../../dashboard/create"; +import { DashboardDetailsDialog } from "../../../dashboard/detail"; +import type { DashboardNavigation } from "../../../dashboard/shared/helpers"; +import { KIND_ICONS } from "../../../dashboard/shared/helpers"; +import { useDashboardPageController } from "../../../dashboard/view/controllers"; +import { DashboardActivityPanel } from "../../../dashboard/view/ui/dashboard-activity-panel"; +import { DashboardAttentionPanel } from "../../../dashboard/view/ui/dashboard-attention-panel"; +import { DashboardWorkPanel } from "../../../dashboard/view/ui/dashboard-work-panel"; +import { workspaceHref } from "../../../workspace/shared/helpers"; +import { CompanyOverviewStatistics } from "./company-overview-statistics"; + +/** Composición propia de compañía; comparte bloques y consultas, no la pantalla Inicio. */ export function CompanyOverview({ company, csrfToken, @@ -14,37 +24,78 @@ export function CompanyOverview({ csrfToken: string; }) { const { t } = useTranslation("companies"); + const { summary, details, setDetails, quick, setQuick } = useDashboardPageController(company.id); + const onNavigate = (destination: DashboardNavigation) => + navigateTo(workspaceHref(destination), { create: destination.create === true }); return ( -
- - - navigateTo(workspaceHref(destination), { create: destination.create === true }) - } - title={t("companyOverview.title")} +
+
+
+

+ {t("companyOverview.title")} +

+

+ {t("companyOverview.description", { name: company.name })} +

+
+ +
+ setDetails({ kind })} + onNavigate={onNavigate} + summary={summary} /> +
+
+ setDetails({ work: true })} + onNavigate={onNavigate} + /> + setDetails({ activity: true })} + onNavigate={onNavigate} + /> +
+ setDetails({ issue })} + /> +
+ {details && ( + setDetails(undefined)} + onNavigate={onNavigate} + /> + )} + {quick && ( + setQuick(undefined)} + onNavigate={onNavigate} + /> + )}
); } diff --git a/modules/companies/src/web/companies/shared/companies-page.test.tsx b/modules/companies/src/web/companies/shared/companies-page.test.tsx index f291d5b..113b8ee 100644 --- a/modules/companies/src/web/companies/shared/companies-page.test.tsx +++ b/modules/companies/src/web/companies/shared/companies-page.test.tsx @@ -1,6 +1,8 @@ import assert from "node:assert/strict"; import { test } from "node:test"; +import { assetsEnglish, assetsSpanish } from "@metis/assets/client"; +import { resourcesEnglish, resourcesSpanish } from "@metis/resources/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createInstance } from "i18next"; import type { ReactNode } from "react"; @@ -14,6 +16,7 @@ import { companiesEnglish, companiesSpanish } from "../../shared"; import { SiteForm } from "../../sites"; import { CatalogOverview } from "../../workspace/catalogs/ui/catalog-overview"; import { CompanyDetail } from "../detail"; +import { CompanyOverview } from "../detail/ui/company-overview"; import { CompaniesPage } from "../list"; import { companyKeys } from "./queries"; @@ -28,7 +31,10 @@ async function renderPage( await i18n.init({ lng: language, fallbackLng: "es", - resources: { es: { companies: companiesSpanish }, en: { companies: companiesEnglish } }, + resources: { + es: { companies: companiesSpanish, assets: assetsSpanish, resources: resourcesSpanish }, + en: { companies: companiesEnglish, assets: assetsEnglish, resources: resourcesEnglish }, + }, interpolation: { escapeValue: false }, }); const client = new QueryClient({ @@ -93,7 +99,7 @@ test("el resumen de compañía es la entrada por defecto y ofrece destinos reale }, }) ); - assert.ok(markup.includes(language === "es" ? "Resumen de la compañía" : "Company overview")); + assert.ok(markup.includes(language === "es" ? "De un vistazo" : "At a glance")); for (const section of ["people", "departments", "sites"]) { assert.ok(markup.includes(`href="/companies/${companyId}/${section}"`)); } @@ -260,3 +266,91 @@ test("asignar un departamento permite añadir el primero y conserva el selector ); assert.equal(edited.includes('aria-label="Nuevo departamento"'), false); }); + +test("el tablero de compañía tiene composición propia, acciones arriba y métricas limitadas a su ámbito", async () => { + const company = { + id: companyId, + name: "Empresa", + active: true, + people: [], + contacts: [], + departments: [], + assignments: [], + sites: [], + }; + for (const language of ["es", "en"]) { + const html = await renderPage( + language, + , + (client) => { + client.setQueryData(dashboardKeys.block("summary"), { + companies: 999, + tickets: 999, + interventions: 999, + assets: 999, + resources: 999, + }); + client.setQueryData(dashboardKeys.block("summary", companyId), { + companies: 1, + tickets: 7, + interventions: 3, + assets: 24, + resources: 11, + }); + client.setQueryData(dashboardKeys.block("work", companyId), { items: [], total: 0 }); + client.setQueryData(dashboardKeys.block("activity", companyId), { items: [], total: 0 }); + client.setQueryData(dashboardKeys.block("attention", companyId), { items: [] }); + } + ); + assert.ok(html.includes('data-slot="company-overview"')); + assert.equal(/>999<\//.test(html), false); + for (const count of [7, 3, 24, 11]) { + assert.match(html, new RegExp(`]*>${count}`)); + } + assert.equal( + html.includes(language === "es" ? "Compañías activas" : "Active companies"), + false + ); + assert.equal( + html.includes(language === "es" ? "Resumen por compañías" : "Company summary"), + false + ); + assert.equal(html.includes(language === "es" ? "Nueva compañía" : "New company"), false); + assert.ok( + html.indexOf(language === "es" ? "Nuevo ticket" : "New ticket") < + html.indexOf(language === "es" ? "Tickets abiertos" : "Open tickets") + ); + for (const section of ["people", "departments", "sites"]) { + assert.ok(html.includes(`href="/companies/${companyId}/${section}"`)); + } + } +}); + +test("el menú de compañía ordena trabajo y personas por grupos sin repetir las acciones de usuario", async () => { + const html = await renderPage( + "es", + undefined} />, + (client) => + client.setQueryData(companyKeys.detail(companyId), { + company: { + id: companyId, + name: "Empresa", + active: true, + people: [], + contacts: [], + departments: [], + assignments: [], + sites: [], + }, + }) + ); + const nav = html.match(/