Añadir estadísticas y navegación a la vista de resumen de compañía

This commit is contained in:
David Arranz 2026-10-09 21:01:55 +02:00
parent ef4f41493a
commit 5a7960302e
10 changed files with 368 additions and 83 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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")}
</Button>
)}
{embedded && (
<footer className="border-t border-border p-3 [grid-area:account] shell:px-2.5 shell:py-4">
{accountNavigation}
</footer>
)}
{company && (
<>
<div className={layout.heading}>
@ -116,24 +112,24 @@ export function CompanyDetail(props: CompanyDetailProps) {
<nav aria-label={t("companySections")} className={layout.navigation}>
{(
[
["overview", t("companyOverview.title"), HomeIcon],
["inventory", assetTranslation("inventory"), BoxIcon],
["resources", resourceTranslation("inventory"), LayersIcon],
["overview", t("companyOverview.navigation"), HomeIcon],
["tickets", t("tickets.title"), TicketIcon],
["interventions", t("interventions.title"), WrenchIcon],
["people", `${t("people")} (${company.people.length})`, UsersIcon],
["workstations", assetTranslation("workstations"), BriefcaseIcon],
["profiles", t("profiles.title"), UsersIcon],
["telework", t("telework.title"), KeyRoundIcon],
["remote", t("remote.title"), KeyRoundIcon],
["processes", t("processes.title"), LayoutTemplateIcon],
["intervention-templates", t("interventionTemplates.title"), LayoutTemplateIcon],
["interventions", t("interventions.title"), LayoutTemplateIcon],
["tickets", t("tickets.title"), LayoutTemplateIcon],
["people", `${t("people")} (${company.people.length})`, UsersIcon],
["inventory", assetTranslation("inventory"), BoxIcon],
["resources", resourceTranslation("inventory"), LayersIcon],
[
"departments",
`${t("departments")} (${company.departments.length})`,
BriefcaseIcon,
],
["sites", `${t("sites")} (${company.sites.length})`, MapPinIcon],
["telework", t("telework.title"), KeyRoundIcon],
["remote", t("remote.title"), KeyRoundIcon],
["processes", t("processes.title"), LayoutTemplateIcon],
["intervention-templates", t("interventionTemplates.title"), LayoutTemplateIcon],
["access", accessTranslation("title"), KeyRoundIcon],
["folders", t("folders.title"), MailIcon],
["certificates", t("certificates.title"), LayersIcon],
@ -144,28 +140,42 @@ export function CompanyDetail(props: CompanyDetailProps) {
["assets", assetTranslation("companyTitle"), LayoutTemplateIcon],
["resource-catalog", resourceTranslation("companyTitle"), LayoutTemplateIcon],
] as const
).map(([section, label, Icon]) => (
<Button
aria-pressed={tab === section}
className={
embedded
? "h-auto min-h-10 justify-start whitespace-normal text-left text-[13px] leading-[1.4285714286] aria-pressed:bg-accent aria-pressed:hover:bg-accent dark:aria-pressed:hover:bg-accent aria-pressed:text-accent-foreground shell:text-sm"
: undefined
}
key={section}
{...(props.onSectionChange
? {
nativeButton: false,
render: <BrowserLink href={companySectionHref(company.id, section)} />,
"aria-current": tab === section ? "page" : undefined,
).map((entry) => {
if (!entry) {
return null;
}
const [section, label, Icon] = entry;
return (
<div
className={
section === "people" || section === "inventory"
? "basis-full border-t border-border pt-2 mt-2 shell:basis-auto"
: "contents"
}
key={section}
>
<Button
aria-pressed={tab === section}
className={
embedded
? "h-auto min-h-10 w-full justify-start whitespace-normal text-left text-[13px] leading-[1.4285714286] aria-pressed:bg-accent aria-pressed:hover:bg-accent dark:aria-pressed:hover:bg-accent aria-pressed:text-accent-foreground shell:text-sm"
: undefined
}
: { onClick: () => setTab(section) })}
variant="ghost"
>
<Icon aria-hidden="true" className="size-4 shrink-0" />
{label}
</Button>
))}
{...(props.onSectionChange
? {
nativeButton: false,
render: <BrowserLink href={companySectionHref(company.id, section)} />,
"aria-current": tab === section ? "page" : undefined,
}
: { onClick: () => setTab(section) })}
variant="ghost"
>
<Icon aria-hidden="true" className="size-4 shrink-0" />
{label}
</Button>
</div>
);
})}
</nav>
<main className={layout.main} key={tab}>

View File

@ -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<DashboardSummaryDTO>;
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 (
<section aria-label={t("dashboard.indicators")} className="space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 2xl:grid-cols-4">
{indicators.map(({ key, label, Icon, tone, action }) => (
<StatisticCard
compact
icon={<Icon />}
key={key}
label={t(`dashboard.${label}`)}
onClick={action}
status={status}
tone={tone}
value={summary.isError ? undefined : summary.data?.[key]}
/>
))}
</div>
<nav
aria-label={t("companyOverview.directory")}
className="grid overflow-hidden rounded-xl border bg-card sm:grid-cols-3"
>
{(
[
{ section: "people", label: "people", value: company.people.length, Icon: UsersIcon },
{
section: "departments",
label: "departments",
value: company.departments.length,
Icon: BriefcaseIcon,
},
{ section: "sites", label: "sites", value: company.sites.length, Icon: MapPinIcon },
] as const
).map(({ section, label, value, Icon }) => (
<BrowserLink
className="flex items-center gap-3 border-b px-4 py-4 transition-colors last:border-0 hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring sm:border-b-0 sm:border-r"
href={companySectionHref(company.id, section)}
key={section}
>
<Icon aria-hidden="true" className="size-5 text-muted-foreground" />
<span className="text-sm font-medium">{t(label)}</span>
<strong className="ml-auto text-xl font-semibold tabular-nums">{value}</strong>
<ChevronRightIcon aria-hidden="true" className="size-4 text-muted-foreground" />
</BrowserLink>
))}
</nav>
</section>
);
}

View File

@ -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 (
<div className="space-y-6">
<SectionNavigation
items={[
{
href: companySectionHref(company.id, "people"),
title: t("people"),
value: company.people.length,
},
{
href: companySectionHref(company.id, "departments"),
title: t("departments"),
value: company.departments.length,
},
{
href: companySectionHref(company.id, "sites"),
title: t("sites"),
value: company.sites.length,
},
]}
label={t("companyOverview.directory")}
/>
<DashboardPage
companyId={company.id}
csrfToken={csrfToken}
description={t("companyOverview.description", { name: company.name })}
onNavigate={(destination) =>
navigateTo(workspaceHref(destination), { create: destination.create === true })
}
title={t("companyOverview.title")}
<div className="space-y-6" data-slot="company-overview">
<header className="space-y-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">
{t("companyOverview.title")}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{t("companyOverview.description", { name: company.name })}
</p>
</div>
<nav aria-label={t("dashboard.quickActions")} className="flex flex-wrap gap-2">
{(["ticket", "intervention", "asset", "resource"] as const).map((kind) => {
const Icon = KIND_ICONS[kind];
return (
<Button
key={kind}
onClick={() => setQuick(kind)}
size="sm"
variant={kind === "ticket" ? "default" : "outline"}
>
<Icon aria-hidden="true" className="size-4" />
{t(`dashboard.create.${kind}`)}
</Button>
);
})}
</nav>
</header>
<CompanyOverviewStatistics
company={company}
onDetails={(kind) => setDetails({ kind })}
onNavigate={onNavigate}
summary={summary}
/>
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">
<div className="min-w-0 space-y-6">
<DashboardWorkPanel
companyId={company.id}
onDetails={() => setDetails({ work: true })}
onNavigate={onNavigate}
/>
<DashboardActivityPanel
companyId={company.id}
onDetails={() => setDetails({ activity: true })}
onNavigate={onNavigate}
/>
</div>
<DashboardAttentionPanel
companyId={company.id}
onDetails={(issue) => setDetails({ issue })}
/>
</div>
{details && (
<DashboardDetailsDialog
companyId={company.id}
details={details}
onClose={() => setDetails(undefined)}
onNavigate={onNavigate}
/>
)}
{quick && (
<DashboardQuickCreate
companyId={company.id}
csrfToken={csrfToken}
kind={quick}
onClose={() => setQuick(undefined)}
onNavigate={onNavigate}
/>
)}
</div>
);
}

View File

@ -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,
<CompanyOverview company={company} csrfToken="test" />,
(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(`<strong[^>]*>${count}</strong>`));
}
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",
<CompanyDetail csrfToken="test" embedded id={companyId} onBack={() => undefined} />,
(client) =>
client.setQueryData(companyKeys.detail(companyId), {
company: {
id: companyId,
name: "Empresa",
active: true,
people: [],
contacts: [],
departments: [],
assignments: [],
sites: [],
},
})
);
const nav = html.match(/<nav aria-label="Información de la compañía"[\s\S]*?<\/nav>/)?.[0];
assert.ok(nav);
assert.ok(nav.indexOf("Resumen") < nav.indexOf("Tickets"));
assert.ok(nav.indexOf("Tickets") < nav.indexOf("Intervenciones"));
assert.ok(nav.indexOf("Intervenciones") < nav.indexOf("Personas"));
assert.ok(nav.indexOf("Personas") < nav.indexOf("Puestos de trabajo"));
assert.ok(nav.indexOf("Puestos de trabajo") < nav.indexOf("Perfiles de usuario"));
assert.equal(html.includes("[grid-area:account]"), false);
assert.equal(html.includes("Mi contraseña"), false);
});

View File

@ -70,7 +70,6 @@ export function CompaniesWorkspace(props: CompaniesWorkspaceProps) {
)}
{current.area === "company" ? (
<CompanyDetail
accountNavigation={accountNavigation}
csrfToken={csrfToken}
embedded
id={current.id}

View File

@ -23,7 +23,9 @@ export function StatisticCard({
tone = "teal",
onClick,
status,
compact = false,
}: {
compact?: boolean;
icon: ReactNode;
label: string;
value?: number | undefined;
@ -33,7 +35,7 @@ export function StatisticCard({
}) {
return (
<button
className={`flex min-h-28 items-start gap-3 rounded-xl border border-border p-4 text-left shadow-sm transition-colors hover:border-primary focus-visible:outline-2 focus-visible:outline-ring ${TONES[tone]}`}
className={`flex items-start gap-3 rounded-xl border border-border text-left transition-colors hover:border-primary focus-visible:outline-2 focus-visible:outline-ring ${compact ? "min-h-28 p-5" : "min-h-28 p-4 shadow-sm"} ${TONES[tone]}`}
onClick={onClick}
type="button"
>
@ -44,8 +46,18 @@ export function StatisticCard({
{icon}
</span>
<span className="min-w-0">
<span className="block text-xs font-medium text-foreground">{label}</span>
<strong className="mt-2 block text-3xl font-semibold text-foreground">
<span
className={
compact
? "block text-sm font-medium text-foreground"
: "block text-xs font-medium text-foreground"
}
>
{label}
</span>
<strong
className={`mt-2 block font-semibold tabular-nums text-foreground ${compact ? "text-4xl tracking-tight" : "text-3xl"}`}
>
{value ?? "—"}
</strong>
{status && (