Añadir estadísticas y navegación a la vista de resumen de compañía
This commit is contained in:
parent
ef4f41493a
commit
5a7960302e
@ -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.
|
`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.
|
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.
|
||||||
|
|||||||
@ -6,9 +6,10 @@
|
|||||||
"routeHelp": "Check the address or return to the dashboard."
|
"routeHelp": "Check the address or return to the dashboard."
|
||||||
},
|
},
|
||||||
"companyOverview": {
|
"companyOverview": {
|
||||||
"title": "Company overview",
|
"title": "At a glance",
|
||||||
"description": "Status, pending work and alerts for {{name}}.",
|
"description": "Status, pending work and alerts for {{name}}.",
|
||||||
"directory": "Company information"
|
"directory": "People and organization",
|
||||||
|
"navigation": "Overview"
|
||||||
},
|
},
|
||||||
"catalogOverview": {
|
"catalogOverview": {
|
||||||
"globalTitle": "Global catalogs",
|
"globalTitle": "Global catalogs",
|
||||||
|
|||||||
@ -6,9 +6,10 @@
|
|||||||
"routeHelp": "Comprueba la dirección o vuelve al inicio."
|
"routeHelp": "Comprueba la dirección o vuelve al inicio."
|
||||||
},
|
},
|
||||||
"companyOverview": {
|
"companyOverview": {
|
||||||
"title": "Resumen de la compañía",
|
"title": "De un vistazo",
|
||||||
"description": "Estado, trabajo pendiente y avisos de {{name}}.",
|
"description": "Estado, trabajo pendiente y avisos de {{name}}.",
|
||||||
"directory": "Información de la compañía"
|
"directory": "Equipo y organización",
|
||||||
|
"navigation": "Resumen"
|
||||||
},
|
},
|
||||||
"catalogOverview": {
|
"catalogOverview": {
|
||||||
"globalTitle": "Catálogos globales",
|
"globalTitle": "Catálogos globales",
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
import type { CompanyTab } from "../../shared/types";
|
import type { CompanyTab } from "../../shared/types";
|
||||||
|
|
||||||
export type CompanyDetailProps = {
|
export type CompanyDetailProps = {
|
||||||
@ -7,7 +5,6 @@ export type CompanyDetailProps = {
|
|||||||
csrfToken: string;
|
csrfToken: string;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
embedded?: boolean;
|
embedded?: boolean;
|
||||||
accountNavigation?: ReactNode;
|
|
||||||
initialSection?: CompanyTab;
|
initialSection?: CompanyTab;
|
||||||
section?: CompanyTab;
|
section?: CompanyTab;
|
||||||
onSectionChange?: (section: CompanyTab) => void;
|
onSectionChange?: (section: CompanyTab) => void;
|
||||||
|
|||||||
@ -9,7 +9,9 @@ import {
|
|||||||
LayoutTemplateIcon,
|
LayoutTemplateIcon,
|
||||||
MailIcon,
|
MailIcon,
|
||||||
MapPinIcon,
|
MapPinIcon,
|
||||||
|
TicketIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
|
WrenchIcon,
|
||||||
} from "@repo/rdx-ui/lib/icons";
|
} from "@repo/rdx-ui/lib/icons";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -26,7 +28,6 @@ export function CompanyDetail(props: CompanyDetailProps) {
|
|||||||
csrfToken,
|
csrfToken,
|
||||||
onBack,
|
onBack,
|
||||||
embedded = false,
|
embedded = false,
|
||||||
accountNavigation,
|
|
||||||
initialSection,
|
initialSection,
|
||||||
initialEntityId,
|
initialEntityId,
|
||||||
initialCreate,
|
initialCreate,
|
||||||
@ -39,7 +40,7 @@ export function CompanyDetail(props: CompanyDetailProps) {
|
|||||||
const { t: accessTranslation } = useTranslation("access");
|
const { t: accessTranslation } = useTranslation("access");
|
||||||
const layout = embedded
|
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:
|
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",
|
"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:
|
navigation:
|
||||||
@ -71,11 +72,6 @@ export function CompanyDetail(props: CompanyDetailProps) {
|
|||||||
{t("retry")}
|
{t("retry")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{embedded && (
|
|
||||||
<footer className="border-t border-border p-3 [grid-area:account] shell:px-2.5 shell:py-4">
|
|
||||||
{accountNavigation}
|
|
||||||
</footer>
|
|
||||||
)}
|
|
||||||
{company && (
|
{company && (
|
||||||
<>
|
<>
|
||||||
<div className={layout.heading}>
|
<div className={layout.heading}>
|
||||||
@ -116,24 +112,24 @@ export function CompanyDetail(props: CompanyDetailProps) {
|
|||||||
<nav aria-label={t("companySections")} className={layout.navigation}>
|
<nav aria-label={t("companySections")} className={layout.navigation}>
|
||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
["overview", t("companyOverview.title"), HomeIcon],
|
["overview", t("companyOverview.navigation"), HomeIcon],
|
||||||
["inventory", assetTranslation("inventory"), BoxIcon],
|
["tickets", t("tickets.title"), TicketIcon],
|
||||||
["resources", resourceTranslation("inventory"), LayersIcon],
|
["interventions", t("interventions.title"), WrenchIcon],
|
||||||
|
["people", `${t("people")} (${company.people.length})`, UsersIcon],
|
||||||
["workstations", assetTranslation("workstations"), BriefcaseIcon],
|
["workstations", assetTranslation("workstations"), BriefcaseIcon],
|
||||||
["profiles", t("profiles.title"), UsersIcon],
|
["profiles", t("profiles.title"), UsersIcon],
|
||||||
["telework", t("telework.title"), KeyRoundIcon],
|
["inventory", assetTranslation("inventory"), BoxIcon],
|
||||||
["remote", t("remote.title"), KeyRoundIcon],
|
["resources", resourceTranslation("inventory"), LayersIcon],
|
||||||
["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],
|
|
||||||
[
|
[
|
||||||
"departments",
|
"departments",
|
||||||
`${t("departments")} (${company.departments.length})`,
|
`${t("departments")} (${company.departments.length})`,
|
||||||
BriefcaseIcon,
|
BriefcaseIcon,
|
||||||
],
|
],
|
||||||
["sites", `${t("sites")} (${company.sites.length})`, MapPinIcon],
|
["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],
|
["access", accessTranslation("title"), KeyRoundIcon],
|
||||||
["folders", t("folders.title"), MailIcon],
|
["folders", t("folders.title"), MailIcon],
|
||||||
["certificates", t("certificates.title"), LayersIcon],
|
["certificates", t("certificates.title"), LayersIcon],
|
||||||
@ -144,28 +140,42 @@ export function CompanyDetail(props: CompanyDetailProps) {
|
|||||||
["assets", assetTranslation("companyTitle"), LayoutTemplateIcon],
|
["assets", assetTranslation("companyTitle"), LayoutTemplateIcon],
|
||||||
["resource-catalog", resourceTranslation("companyTitle"), LayoutTemplateIcon],
|
["resource-catalog", resourceTranslation("companyTitle"), LayoutTemplateIcon],
|
||||||
] as const
|
] as const
|
||||||
).map(([section, label, Icon]) => (
|
).map((entry) => {
|
||||||
<Button
|
if (!entry) {
|
||||||
aria-pressed={tab === section}
|
return null;
|
||||||
className={
|
}
|
||||||
embedded
|
const [section, label, Icon] = entry;
|
||||||
? "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"
|
return (
|
||||||
: undefined
|
<div
|
||||||
}
|
className={
|
||||||
key={section}
|
section === "people" || section === "inventory"
|
||||||
{...(props.onSectionChange
|
? "basis-full border-t border-border pt-2 mt-2 shell:basis-auto"
|
||||||
? {
|
: "contents"
|
||||||
nativeButton: false,
|
}
|
||||||
render: <BrowserLink href={companySectionHref(company.id, section)} />,
|
key={section}
|
||||||
"aria-current": tab === section ? "page" : undefined,
|
>
|
||||||
|
<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) })}
|
{...(props.onSectionChange
|
||||||
variant="ghost"
|
? {
|
||||||
>
|
nativeButton: false,
|
||||||
<Icon aria-hidden="true" className="size-4 shrink-0" />
|
render: <BrowserLink href={companySectionHref(company.id, section)} />,
|
||||||
{label}
|
"aria-current": tab === section ? "page" : undefined,
|
||||||
</Button>
|
}
|
||||||
))}
|
: { onClick: () => setTab(section) })}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<Icon aria-hidden="true" className="size-4 shrink-0" />
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main className={layout.main} key={tab}>
|
<main className={layout.main} key={tab}>
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,11 +1,21 @@
|
|||||||
import { navigateTo } from "@metis/core/client";
|
import { navigateTo } from "@metis/core/client";
|
||||||
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { CompanyDTO } from "../../../../common";
|
import type { CompanyDTO } from "../../../../common";
|
||||||
import { DashboardPage } from "../../../dashboard/view/ui/dashboard-page";
|
import { DashboardQuickCreate } from "../../../dashboard/create";
|
||||||
import { SectionNavigation } from "../../../shared/ui/section-navigation";
|
import { DashboardDetailsDialog } from "../../../dashboard/detail";
|
||||||
import { companySectionHref, workspaceHref } from "../../../workspace/shared/helpers";
|
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({
|
export function CompanyOverview({
|
||||||
company,
|
company,
|
||||||
csrfToken,
|
csrfToken,
|
||||||
@ -14,37 +24,78 @@ export function CompanyOverview({
|
|||||||
csrfToken: string;
|
csrfToken: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("companies");
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6" data-slot="company-overview">
|
||||||
<SectionNavigation
|
<header className="space-y-4">
|
||||||
items={[
|
<div>
|
||||||
{
|
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||||
href: companySectionHref(company.id, "people"),
|
{t("companyOverview.title")}
|
||||||
title: t("people"),
|
</h1>
|
||||||
value: company.people.length,
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
},
|
{t("companyOverview.description", { name: company.name })}
|
||||||
{
|
</p>
|
||||||
href: companySectionHref(company.id, "departments"),
|
</div>
|
||||||
title: t("departments"),
|
<nav aria-label={t("dashboard.quickActions")} className="flex flex-wrap gap-2">
|
||||||
value: company.departments.length,
|
{(["ticket", "intervention", "asset", "resource"] as const).map((kind) => {
|
||||||
},
|
const Icon = KIND_ICONS[kind];
|
||||||
{
|
return (
|
||||||
href: companySectionHref(company.id, "sites"),
|
<Button
|
||||||
title: t("sites"),
|
key={kind}
|
||||||
value: company.sites.length,
|
onClick={() => setQuick(kind)}
|
||||||
},
|
size="sm"
|
||||||
]}
|
variant={kind === "ticket" ? "default" : "outline"}
|
||||||
label={t("companyOverview.directory")}
|
>
|
||||||
/>
|
<Icon aria-hidden="true" className="size-4" />
|
||||||
<DashboardPage
|
{t(`dashboard.create.${kind}`)}
|
||||||
companyId={company.id}
|
</Button>
|
||||||
csrfToken={csrfToken}
|
);
|
||||||
description={t("companyOverview.description", { name: company.name })}
|
})}
|
||||||
onNavigate={(destination) =>
|
</nav>
|
||||||
navigateTo(workspaceHref(destination), { create: destination.create === true })
|
</header>
|
||||||
}
|
<CompanyOverviewStatistics
|
||||||
title={t("companyOverview.title")}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { test } from "node:test";
|
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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { createInstance } from "i18next";
|
import { createInstance } from "i18next";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
@ -14,6 +16,7 @@ import { companiesEnglish, companiesSpanish } from "../../shared";
|
|||||||
import { SiteForm } from "../../sites";
|
import { SiteForm } from "../../sites";
|
||||||
import { CatalogOverview } from "../../workspace/catalogs/ui/catalog-overview";
|
import { CatalogOverview } from "../../workspace/catalogs/ui/catalog-overview";
|
||||||
import { CompanyDetail } from "../detail";
|
import { CompanyDetail } from "../detail";
|
||||||
|
import { CompanyOverview } from "../detail/ui/company-overview";
|
||||||
import { CompaniesPage } from "../list";
|
import { CompaniesPage } from "../list";
|
||||||
|
|
||||||
import { companyKeys } from "./queries";
|
import { companyKeys } from "./queries";
|
||||||
@ -28,7 +31,10 @@ async function renderPage(
|
|||||||
await i18n.init({
|
await i18n.init({
|
||||||
lng: language,
|
lng: language,
|
||||||
fallbackLng: "es",
|
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 },
|
interpolation: { escapeValue: false },
|
||||||
});
|
});
|
||||||
const client = new QueryClient({
|
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"]) {
|
for (const section of ["people", "departments", "sites"]) {
|
||||||
assert.ok(markup.includes(`href="/companies/${companyId}/${section}"`));
|
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);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@ -70,7 +70,6 @@ export function CompaniesWorkspace(props: CompaniesWorkspaceProps) {
|
|||||||
)}
|
)}
|
||||||
{current.area === "company" ? (
|
{current.area === "company" ? (
|
||||||
<CompanyDetail
|
<CompanyDetail
|
||||||
accountNavigation={accountNavigation}
|
|
||||||
csrfToken={csrfToken}
|
csrfToken={csrfToken}
|
||||||
embedded
|
embedded
|
||||||
id={current.id}
|
id={current.id}
|
||||||
|
|||||||
@ -23,7 +23,9 @@ export function StatisticCard({
|
|||||||
tone = "teal",
|
tone = "teal",
|
||||||
onClick,
|
onClick,
|
||||||
status,
|
status,
|
||||||
|
compact = false,
|
||||||
}: {
|
}: {
|
||||||
|
compact?: boolean;
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
label: string;
|
label: string;
|
||||||
value?: number | undefined;
|
value?: number | undefined;
|
||||||
@ -33,7 +35,7 @@ export function StatisticCard({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<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}
|
onClick={onClick}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
@ -44,8 +46,18 @@ export function StatisticCard({
|
|||||||
{icon}
|
{icon}
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
<span className="block text-xs font-medium text-foreground">{label}</span>
|
<span
|
||||||
<strong className="mt-2 block text-3xl font-semibold text-foreground">
|
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 ?? "—"}
|
{value ?? "—"}
|
||||||
</strong>
|
</strong>
|
||||||
{status && (
|
{status && (
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user