feat(shadcn-ui): introduce new icons for improved UI consistency

This commit is contained in:
David Arranz 2026-10-04 20:16:39 +02:00
parent a232595a71
commit 2a39be97f4
16 changed files with 940 additions and 197 deletions

View File

@ -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 { catalogKeys, inventoryKeys } from "@metis/assets/client";
import { companyKeys } from "@metis/companies/client";
import { identityKeys } from "@metis/identity/client"; import { identityKeys } from "@metis/identity/client";
import type { SessionResponseDTO, UserDTO } from "@metis/identity/common"; import type { SessionResponseDTO, UserDTO } from "@metis/identity/common";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@ -9,6 +11,7 @@ import { I18nextProvider } from "react-i18next";
import { App } from "./app"; import { App } from "./app";
import { createAppI18n } from "./i18n"; import { createAppI18n } from "./i18n";
import { workspaceAssets, workspaceCatalog, workspaceCompany } from "./test-fixtures/workspace";
const user: UserDTO = { const user: UserDTO = {
id: "c90b4d83-b06a-4d27-a402-e64899a2dfc2", id: "c90b4d83-b06a-4d27-a402-e64899a2dfc2",
@ -17,7 +20,11 @@ const user: UserDTO = {
active: true, active: true,
isInitialAdmin: false, isInitialAdmin: false,
}; };
async function renderPage(language: string, session?: SessionResponseDTO) { async function renderPage(
language: string,
session?: SessionResponseDTO,
seed?: (client: QueryClient) => void
) {
const { instance, initialization } = createAppI18n(language); const { instance, initialization } = createAppI18n(language);
await initialization; await initialization;
const client = new QueryClient({ const client = new QueryClient({
@ -26,6 +33,7 @@ async function renderPage(language: string, session?: SessionResponseDTO) {
if (session) { if (session) {
client.setQueryData(identityKeys.session, session); client.setQueryData(identityKeys.session, session);
} }
seed?.(client);
try { try {
return renderToStaticMarkup( return renderToStaticMarkup(
<I18nextProvider i18n={instance}> <I18nextProvider i18n={instance}>
@ -80,3 +88,32 @@ test("solo el administrador inicial ve la navegación de gestión de usuarios",
const admin = await renderPage("es", { ...snapshot, user: { ...user, isInitialAdmin: true } }); const admin = await renderPage("es", { ...snapshot, user: { ...user, isInitialAdmin: true } });
assert.ok(admin.includes(">Usuarios</button>")); assert.ok(admin.includes(">Usuarios</button>"));
}); });
test("el espacio mantiene compañía, secciones y ficha del primer activo 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"],
]) {
const html = await renderPage(
language ?? "es",
{ user, csrfToken: "a".repeat(64), setupRequired: false },
(client) => {
client.setQueryData(companyKeys.list, { companies: [workspaceCompany] });
client.setQueryData(companyKeys.detail(workspaceCompany.id), { company: workspaceCompany });
client.setQueryData(inventoryKeys.company(workspaceCompany.id), {
assets: workspaceAssets,
});
client.setQueryData(catalogKeys.scope(workspaceCompany.id), workspaceCatalog);
}
);
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.equal(html.includes("<form"), false);
assert.equal(html.includes("2026-10-04"), false);
}
});

View File

@ -1,6 +1,4 @@
import { AssetCatalogPage } from "@metis/assets/client"; import { CompaniesWorkspace } from "@metis/companies/client";
import { AccessWorkspacePage, CompaniesPage } from "@metis/companies/client";
import { HomePage } from "@metis/core/client";
import { IdentityWorkspace } from "@metis/identity/client"; import { IdentityWorkspace } from "@metis/identity/client";
import { import {
Select, Select,
@ -18,9 +16,6 @@ const LANGUAGE_OPTIONS = [
]; ];
export function App() { export function App() {
const { t: companyTranslation } = useTranslation("companies");
const { t: assetTranslation } = useTranslation("assets");
const { t: accessTranslation } = useTranslation("access");
const { t, i18n } = useTranslation("core"); const { t, i18n } = useTranslation("core");
const [languageError, setLanguageError] = useState(false); const [languageError, setLanguageError] = useState(false);
const languageLabelId = useId(); const languageLabelId = useId();
@ -28,7 +23,7 @@ export function App() {
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
<header className="border-b border-border bg-card"> <header className="border-b border-border bg-card">
<div className="mx-auto flex max-w-5xl items-center justify-between gap-6 px-6 py-5"> <div className="flex items-center justify-between gap-3 px-4 py-3">
<span className="text-xl font-semibold tracking-tight text-foreground"> <span className="text-xl font-semibold tracking-tight text-foreground">
Metis Desk<span className="ml-1 text-primary">.</span> Metis Desk<span className="ml-1 text-primary">.</span>
</span> </span>
@ -70,27 +65,11 @@ export function App() {
)} )}
</header> </header>
<IdentityWorkspace <IdentityWorkspace
pages={[ renderWorkspace={(session, accountNavigation) => (
{ <CompaniesWorkspace accountNavigation={accountNavigation} csrfToken={session.csrfToken} />
id: "catalogs", )}
label: accessTranslation("catalogTitle"),
render: (session) => (
<AccessWorkspacePage csrfToken={session.csrfToken} scope="global" />
),
},
{
id: "asset-catalog",
label: assetTranslation("title"),
render: (session) => <AssetCatalogPage csrfToken={session.csrfToken} scope="global" />,
},
{
id: "companies",
label: companyTranslation("title"),
render: (session) => <CompaniesPage csrfToken={session.csrfToken} />,
},
]}
> >
<HomePage /> {null}
</IdentityWorkspace> </IdentityWorkspace>
</div> </div>
); );

View File

@ -7,3 +7,212 @@ body {
margin: 0; margin: 0;
font-family: ui-sans-serif, system-ui, sans-serif; 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);
}
.desk-company-rail {
width: 72px;
flex: 0 0 72px;
display: flex;
align-items: center;
flex-direction: column;
gap: 12px;
padding: 16px 8px;
background: var(--muted);
border-right: 1px solid var(--border);
}
.desk-space-button {
width: 48px;
min-height: 48px;
flex-shrink: 0;
border-radius: 50%;
background: var(--background);
}
.desk-space-button[aria-pressed="true"] {
color: var(--primary-foreground);
background: var(--primary);
border-radius: 16px;
}
.desk-rail-separator {
width: 28px;
height: 1px;
background: var(--border);
}
.desk-workspace-content {
flex: 1;
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);
background: var(--sidebar);
}
.company-space-heading {
grid-area: heading;
padding: 20px 16px;
border-bottom: 1px solid var(--border);
}
.company-space-heading h1 {
font-size: 1rem;
overflow-wrap: anywhere;
}
.company-space-heading > button {
margin-top: 16px;
width: 100%;
}
.company-space-navigation {
grid-area: navigation;
display: flex;
flex-direction: column;
gap: 4px;
padding: 16px 10px;
}
.company-space-navigation > button {
justify-content: flex-start;
white-space: normal;
height: auto;
min-height: 40px;
text-align: left;
}
.company-space-navigation > button[aria-pressed="true"] {
background: var(--accent);
color: var(--accent-foreground);
}
.company-space-account {
grid-area: account;
padding: 16px 10px;
border-top: 1px solid var(--border);
}
.company-space-main {
grid-area: main;
padding: 24px;
min-width: 0;
background: var(--background);
border-left: 1px solid var(--border);
}
.company-space-breadcrumb {
padding-bottom: 16px;
margin-bottom: 24px;
border-bottom: 1px solid var(--border);
font-size: 0.875rem;
color: var(--muted-foreground);
}
.inventory-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 280px;
gap: 24px;
align-items: start;
}
.inventory-list {
min-width: 0;
}
.inventory-layout[data-detail-open="false"] {
grid-template-columns: minmax(0, 1fr);
}
.asset-side-panel {
display: grid;
gap: 24px;
padding: 20px;
min-width: 0;
background: var(--muted);
border: 1px solid var(--border);
border-radius: 12px;
}
.inventory-asset-row {
padding-inline: 12px;
border-radius: 8px;
}
.inventory-asset-selected {
background: var(--accent);
}
.inventory-asset-name {
text-align: left;
overflow-wrap: anywhere;
cursor: pointer;
}
.inventory-asset-name:hover {
text-decoration: underline;
}
.inventory-asset-name:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 4px;
border-radius: 4px;
}
.asset-mobile-action {
display: none;
}
@media (max-width: 1099px) {
.inventory-layout {
grid-template-columns: minmax(0, 1fr);
}
.asset-side-panel,
.asset-desktop-action {
display: none;
}
.asset-mobile-action {
display: inline-flex;
}
}
@media (max-width: 767px) {
.desk-workspace {
flex-direction: column;
}
.desk-company-rail {
width: 100%;
flex-basis: auto;
flex-direction: row;
flex-wrap: wrap;
border-right: 0;
border-bottom: 1px solid var(--border);
gap: 8px;
padding: 10px;
}
.desk-space-button {
width: 44px;
min-height: 44px;
}
.company-space {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto auto 1fr auto;
grid-template-areas: "heading" "navigation" "main" "account";
min-height: calc(100dvh - 140px);
}
.company-space-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px;
}
.company-space-heading > button {
margin-top: 0;
width: auto;
}
.company-space-navigation {
flex-direction: row;
flex-wrap: wrap;
padding: 10px;
gap: 6px;
}
.company-space-navigation > button {
font-size: 0.8125rem;
min-height: 40px;
}
.company-space-main {
padding: 16px;
border-left: 0;
border-top: 1px solid var(--border);
}
.company-space-breadcrumb {
margin-bottom: 16px;
}
.company-space-account {
padding: 12px;
}
}

View File

@ -0,0 +1,99 @@
import type { AssetDTO, CatalogResponseDTO } from "@metis/assets/common";
import type { CompanyDTO } from "@metis/companies/common";
export const workspaceCompany: CompanyDTO = {
id: "0199b200-0000-7000-8000-000000000001",
name: "Clínica Norte · prueba",
active: true,
contacts: [],
people: [
{
id: "0199b200-0000-7000-8000-000000000002",
companyId: "0199b200-0000-7000-8000-000000000001",
name: "Ana López",
position: "Administración",
email: null,
phone: null,
active: true,
isContact: true,
},
],
sites: [
{
id: "0199b200-0000-7000-8000-000000000003",
companyId: "0199b200-0000-7000-8000-000000000001",
name: "Sede central",
address: null,
},
],
departments: [],
assignments: [],
};
const fieldId = "0199b200-0000-7000-8000-000000000004";
const templateId = "0199b200-0000-7000-8000-000000000005";
export const workspaceCatalog: CatalogResponseDTO = {
items: [
{
id: fieldId,
companyId: null,
builtin: true,
active: true,
effectiveActive: true,
overridden: false,
bindings: [],
values: {
kind: "field",
name: "Marca",
description: "",
type: "text",
unit: "",
choices: [],
},
},
{
id: templateId,
companyId: null,
builtin: true,
active: true,
effectiveActive: true,
overridden: false,
bindings: [{ id: "0199b200-0000-7000-8000-000000000006", fieldId, required: false }],
values: {
kind: "template",
name: "Portátil",
description: "",
fields: [{ fieldId, required: false }],
},
},
],
};
export const workspaceAssets: AssetDTO[] = ["PORT-ANA-01", "PORT-RESERVA"].map((name, index) => {
const values: AssetDTO["values"] = {
name,
templateId,
siteId: workspaceCompany.sites[0]?.id ?? null,
personId: index === 0 ? (workspaceCompany.people[0]?.id ?? null) : null,
status: index === 0 ? "in_use" : "available",
notes: "",
characteristics: { [fieldId]: "Lenovo" },
};
return {
id: `0199b200-0000-7000-8000-00000000000${index + 7}`,
companyId: workspaceCompany.id,
specification: {
templateId,
templateName: "Portátil",
fields: [{ fieldId, name: "Marca", type: "text", unit: "", choices: [], required: false }],
},
values,
history: [
{
id: `0199b200-0000-7000-8000-00000000001${index}`,
values,
actorId: "0199b200-0000-7000-8000-000000000011",
actorName: "Técnico de prueba",
recordedAt: "2026-10-04T10:00:00.000Z",
},
],
};
});

View File

@ -3,7 +3,15 @@ import { useTranslation } from "react-i18next";
import type { AssetDTO } from "../common/inventory"; import type { AssetDTO } from "../common/inventory";
import type { InventoryLookups } from "./asset-form"; import type { InventoryLookups } from "./asset-form";
export function AssetDetail({ asset, lookups }: { asset: AssetDTO; lookups: InventoryLookups }) { export function AssetDetail({
asset,
lookups,
compact = false,
}: {
asset: AssetDTO;
lookups: InventoryLookups;
compact?: boolean;
}) {
const { t, i18n } = useTranslation("assets"); const { t, i18n } = useTranslation("assets");
const nameOf = (id: string | null, options: readonly { id: string; name: string }[]) => const nameOf = (id: string | null, options: readonly { id: string; name: string }[]) =>
id ? (options.find((option) => option.id === id)?.name ?? id) : t("noAssignment"); id ? (options.find((option) => option.id === id)?.name ?? id) : t("noAssignment");
@ -15,8 +23,8 @@ export function AssetDetail({ asset, lookups }: { asset: AssetDTO; lookups: Inve
: String(value); : String(value);
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<p className="text-sm text-muted-foreground">{t("snapshotNotice")}</p> {!compact && <p className="text-sm text-muted-foreground">{t("snapshotNotice")}</p>}
<dl className="grid gap-3 sm:grid-cols-2"> <dl className={compact ? "grid gap-4" : "grid gap-3 sm:grid-cols-2"}>
<div> <div>
<dt className="text-sm text-muted-foreground">{t("template")}</dt> <dt className="text-sm text-muted-foreground">{t("template")}</dt>
<dd>{asset.specification.templateName}</dd> <dd>{asset.specification.templateName}</dd>
@ -48,44 +56,48 @@ export function AssetDetail({ asset, lookups }: { asset: AssetDTO; lookups: Inve
{asset.values.notes && ( {asset.values.notes && (
<p className="whitespace-pre-wrap break-words">{asset.values.notes}</p> <p className="whitespace-pre-wrap break-words">{asset.values.notes}</p>
)} )}
<h3 className="font-medium"> {!compact && (
{t("history")} ({asset.history.length}) <>
</h3> <h3 className="font-medium">
<ol className="space-y-4"> {t("history")} ({asset.history.length})
{[...asset.history].reverse().map((revision) => ( </h3>
<li className="space-y-2 rounded-md border p-3 text-sm" key={revision.id}> <ol className="space-y-4">
<p className="font-medium"> {[...asset.history].reverse().map((revision) => (
{new Intl.DateTimeFormat(i18n.language, { <li className="space-y-2 rounded-md border p-3 text-sm" key={revision.id}>
dateStyle: "medium", <p className="font-medium">
timeStyle: "short", {new Intl.DateTimeFormat(i18n.language, {
}).format(new Date(revision.recordedAt))}{" "} dateStyle: "medium",
· {t("recordedBy", { name: revision.actorName })} timeStyle: "short",
</p> }).format(new Date(revision.recordedAt))}{" "}
<p> · {t("recordedBy", { name: revision.actorName })}
{revision.values.name} · {t(revision.values.status)} </p>
</p> <p>
<p> {revision.values.name} · {t(revision.values.status)}
{t("site")}: {nameOf(revision.values.siteId, lookups.sites)} · {t("person")}:{" "} </p>
{nameOf(revision.values.personId, lookups.people)} <p>
</p> {t("site")}: {nameOf(revision.values.siteId, lookups.sites)} · {t("person")}:{" "}
<details> {nameOf(revision.values.personId, lookups.people)}
<summary className="cursor-pointer">{t("characteristics")}</summary> </p>
<dl className="mt-2 grid gap-2 sm:grid-cols-2"> <details>
{asset.specification.fields.map((field) => ( <summary className="cursor-pointer">{t("characteristics")}</summary>
<div key={field.fieldId}> <dl className="mt-2 grid gap-2 sm:grid-cols-2">
<dt className="text-muted-foreground">{field.name}</dt> {asset.specification.fields.map((field) => (
<dd>{display(revision.values.characteristics[field.fieldId])}</dd> <div key={field.fieldId}>
</div> <dt className="text-muted-foreground">{field.name}</dt>
))} <dd>{display(revision.values.characteristics[field.fieldId])}</dd>
</dl> </div>
{revision.values.notes && ( ))}
<p className="mt-2 whitespace-pre-wrap break-words">{revision.values.notes}</p> </dl>
)} {revision.values.notes && (
</details> <p className="mt-2 whitespace-pre-wrap break-words">{revision.values.notes}</p>
</li> )}
))} </details>
</ol> </li>
<p className="text-xs text-muted-foreground">ID: {asset.id}</p> ))}
</ol>
</>
)}
{!compact && <p className="break-all text-xs text-muted-foreground">ID: {asset.id}</p>}
</div> </div>
); );
} }

View File

@ -45,7 +45,7 @@ export function AssetForm({
asset?: AssetDTO | undefined; asset?: AssetDTO | undefined;
items: readonly CatalogItemDTO[]; items: readonly CatalogItemDTO[];
lookups: InventoryLookups; lookups: InventoryLookups;
onSaved: () => void; onSaved: (id: string) => void;
}) { }) {
const { t } = useTranslation("assets"); const { t } = useTranslation("assets");
const prefix = useId(); const prefix = useId();
@ -94,10 +94,10 @@ export function AssetForm({
: []); : []);
const mutation = useMutation({ const mutation = useMutation({
mutationFn: (input: AssetInput) => inventoryApi.save(companyId, input, csrfToken, asset?.id), mutationFn: (input: AssetInput) => inventoryApi.save(companyId, input, csrfToken, asset?.id),
onSuccess: async () => { onSuccess: async (response) => {
await client.invalidateQueries({ queryKey: inventoryKeys.company(companyId) }); await client.invalidateQueries({ queryKey: inventoryKeys.company(companyId) });
if (mounted.current) { if (mounted.current) {
onSaved(); onSaved(response.asset.id);
} }
}, },
}); });

View File

@ -1,3 +1,5 @@
export { catalogKeys } from "./catalog-api";
export { AssetCatalogPage } from "./catalog-page"; export { AssetCatalogPage } from "./catalog-page";
export { assetsEnglish, assetsSpanish } from "./i18n"; export { assetsEnglish, assetsSpanish } from "./i18n";
export { inventoryKeys } from "./inventory-api";
export { AssetInventoryPage } from "./inventory-page"; export { AssetInventoryPage } from "./inventory-page";

View File

@ -1,4 +1,8 @@
export const inventorySpanish = { export const inventorySpanish = {
assetPanel: "Ficha del activo seleccionado",
closeAssetPanel: "Cerrar ficha lateral",
showAssetPanel: "Mostrar ficha lateral",
inventory: "Activos", inventory: "Activos",
chooseValue: "Selecciona una opción", chooseValue: "Selecciona una opción",
relatedNotice: relatedNotice:
@ -50,6 +54,10 @@ export const inventorySpanish = {
}, },
}; };
export const inventoryEnglish = { export const inventoryEnglish = {
assetPanel: "Selected asset details",
closeAssetPanel: "Close details panel",
showAssetPanel: "Show details panel",
inventory: "Assets", inventory: "Assets",
chooseValue: "Choose an option", chooseValue: "Choose an option",
relatedNotice: relatedNotice:

View File

@ -6,6 +6,8 @@ import { useQuery } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { AssetDTO } from "../common/inventory";
import { AssetDetail } from "./asset-detail"; import { AssetDetail } from "./asset-detail";
import type { InventoryLookups } from "./asset-form"; import type { InventoryLookups } from "./asset-form";
import { AssetForm, InventoryFeedback } from "./asset-form"; import { AssetForm, InventoryFeedback } from "./asset-form";
@ -33,7 +35,10 @@ export function AssetInventoryPage({
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [editor, setEditor] = useState<string | undefined>(); const [editor, setEditor] = useState<string | undefined>();
const [viewId, setViewId] = useState<string | undefined>(); const [viewId, setViewId] = useState<string | undefined>();
const [selectedId, setSelectedId] = useState<string>();
const [detailOpen, setDetailOpen] = useState(true);
const assets = query.data?.assets ?? []; const assets = query.data?.assets ?? [];
const selected = assets.find((asset) => asset.id === selectedId) ?? assets[0];
const edited = assets.find((asset) => asset.id === editor); const edited = assets.find((asset) => asset.id === editor);
const viewed = assets.find((asset) => asset.id === viewId); const viewed = assets.find((asset) => asset.id === viewId);
const filtered = assets.filter((asset) => { const filtered = assets.filter((asset) => {
@ -48,76 +53,118 @@ export function AssetInventoryPage({
].some((value) => value.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())); ].some((value) => value.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()));
}); });
return ( return (
<div className="space-y-5"> <div className="inventory-layout" data-detail-open={detailOpen && Boolean(selected)}>
<header className="flex items-center justify-between gap-4"> <div className="inventory-list space-y-5">
<h2 className="text-xl font-semibold">{t("inventory")}</h2> <header className="flex items-center justify-between gap-4">
<Button disabled={!(catalog.data && query.data)} onClick={() => setEditor("new")}> <h2 className="text-xl font-semibold">{t("inventory")}</h2>
{t("newAsset")} <div className="flex flex-wrap items-center justify-end gap-2">
</Button> {!detailOpen && (
</header> <Button
<Input className="asset-desktop-action"
aria-label={t("search")} onClick={() => setDetailOpen(true)}
onChange={(event) => setSearch(event.target.value)} variant="outline"
placeholder={t("search")}
value={search}
/>
{(query.isPending || catalog.isPending) && <p role="status">{t("loading")}</p>}
<InventoryFeedback error={query.error} />
<CatalogError error={catalog.error} />
{(query.isError || catalog.isError) && (
<Button
onClick={() => {
Promise.all([query.refetch(), catalog.refetch()]).catch(() => undefined);
}}
variant="outline"
>
{t("retry")}
</Button>
)}
{query.data && (
<Card>
<CardContent className="divide-y pt-3">
{!filtered.length && (
<p className="py-4 text-muted-foreground">
{t(assets.length ? "noResults" : "noAssets")}
</p>
)}
{filtered.map((asset) => (
<div
className="flex flex-wrap items-center justify-between gap-3 py-4"
key={asset.id}
> >
<div> {t("showAssetPanel")}
<h3 className="font-medium">{asset.values.name}</h3> </Button>
<p className="text-sm text-muted-foreground"> )}
{asset.specification.templateName} · {t(asset.values.status)} <Button disabled={!(catalog.data && query.data)} onClick={() => setEditor("new")}>
</p> {t("newAsset")}
<p className="text-sm text-muted-foreground"> </Button>
{lookups.sites.find((site) => site.id === asset.values.siteId)?.name ?? </div>
t("noAssignment")}{" "} </header>
·{" "} <Input
{lookups.people.find((person) => person.id === asset.values.personId)?.name ?? aria-label={t("search")}
t("noAssignment")} onChange={(event) => setSearch(event.target.value)}
</p> placeholder={t("search")}
value={search}
/>
{(query.isPending || catalog.isPending) && <p role="status">{t("loading")}</p>}
<InventoryFeedback error={query.error} />
<CatalogError error={catalog.error} />
{(query.isError || catalog.isError) && (
<Button
onClick={() => {
Promise.all([query.refetch(), catalog.refetch()]).catch(() => undefined);
}}
variant="outline"
>
{t("retry")}
</Button>
)}
{query.data && (
<Card>
<CardContent className="divide-y pt-3">
{!filtered.length && (
<p className="py-4 text-muted-foreground">
{t(assets.length ? "noResults" : "noAssets")}
</p>
)}
{filtered.map((asset) => (
<div
className={`inventory-asset-row flex flex-wrap items-center justify-between gap-3 py-4 ${selectedRowClass(asset.id, selected?.id, detailOpen)}`}
key={asset.id}
>
<div>
<h3 className="font-medium">
<button
aria-pressed={selected?.id === asset.id && detailOpen}
className="inventory-asset-name"
onClick={() => {
setSelectedId(asset.id);
setDetailOpen(true);
if (window.matchMedia("(max-width: 1099px)").matches) {
setViewId(asset.id);
}
}}
type="button"
>
{asset.values.name}
</button>
</h3>
<p className="text-sm text-muted-foreground">
{asset.specification.templateName} · {t(asset.values.status)}
</p>
<p className="text-sm text-muted-foreground">
{lookups.sites.find((site) => site.id === asset.values.siteId)?.name ??
t("noAssignment")}{" "}
·{" "}
{lookups.people.find((person) => person.id === asset.values.personId)?.name ??
t("noAssignment")}
</p>
</div>
<div className="flex gap-2">
<Button
className="asset-mobile-action"
onClick={() => setViewId(asset.id)}
size="sm"
variant="outline"
>
{t("viewAsset")}
</Button>
<Button
disabled={!catalog.data}
onClick={() => setEditor(asset.id)}
size="sm"
variant="outline"
>
{t("edit")}
</Button>
</div>
</div> </div>
<div className="flex gap-2"> ))}
<Button onClick={() => setViewId(asset.id)} size="sm" variant="outline"> </CardContent>
{t("viewAsset")} </Card>
</Button> )}
<Button </div>
disabled={!catalog.data} <AssetSelectionPanel
onClick={() => setEditor(asset.id)} asset={selected}
size="sm" canEdit={catalog.isSuccess}
variant="outline" lookups={lookups}
> onClose={() => setDetailOpen(false)}
{t("edit")} onEdit={setEditor}
</Button> onView={setViewId}
</div> open={detailOpen}
</div> />
))}
</CardContent>
</Card>
)}
{editor && catalog.data && ( {editor && catalog.data && (
<FormDialog <FormDialog
cancelLabel={t("cancel")} cancelLabel={t("cancel")}
@ -137,7 +184,11 @@ export function AssetInventoryPage({
csrfToken={csrfToken} csrfToken={csrfToken}
items={catalog.data.items} items={catalog.data.items}
lookups={lookups} lookups={lookups}
onSaved={() => setEditor(undefined)} onSaved={(id) => {
setSelectedId(id);
setDetailOpen(true);
setEditor(undefined);
}}
/> />
</FormDialog> </FormDialog>
)} )}
@ -159,3 +210,49 @@ export function AssetInventoryPage({
</div> </div>
); );
} }
function selectedRowClass(id: string, selectedId: string | undefined, detailOpen: boolean) {
return id === selectedId && detailOpen ? "inventory-asset-selected" : "";
}
function AssetSelectionPanel({
asset,
open,
lookups,
canEdit,
onClose,
onView,
onEdit,
}: {
asset: AssetDTO | undefined;
open: boolean;
lookups: InventoryLookups;
canEdit: boolean;
onClose: () => void;
onView: (id: string) => void;
onEdit: (id: string) => void;
}) {
const { t } = useTranslation("assets");
if (!(asset && open)) {
return null;
}
return (
<aside aria-label={t("assetPanel")} className="asset-side-panel">
<header className="flex items-start justify-between gap-3">
<h2 className="min-w-0 break-words text-lg font-semibold">{asset.values.name}</h2>
<Button aria-label={t("closeAssetPanel")} onClick={onClose} size="sm" variant="ghost">
×
</Button>
</header>
<AssetDetail asset={asset} compact lookups={lookups} />
<div className="flex flex-wrap gap-2">
<Button onClick={() => onView(asset.id)} variant="outline">
{t("viewAsset")}
</Button>
<Button disabled={!canEdit} onClick={() => onEdit(asset.id)} variant="outline">
{t("edit")}
</Button>
</div>
</aside>
);
}

View File

@ -30,7 +30,13 @@ const COMPANY_COLUMNS: ColumnDef<CompanySummaryDTO>[] = [
{ accessorKey: "active", enableGlobalFilter: false }, { accessorKey: "active", enableGlobalFilter: false },
]; ];
export function CompaniesPage({ csrfToken }: { csrfToken: string }) { export function CompaniesPage({
csrfToken,
onSelectCompany,
}: {
csrfToken: string;
onSelectCompany?: (id: string) => void;
}) {
const { t } = useTranslation("companies"); const { t } = useTranslation("companies");
const query = useQuery({ queryKey: companyKeys.list, queryFn: companyApi.list }); const query = useQuery({ queryKey: companyKeys.list, queryFn: companyApi.list });
const [selectedId, setSelectedId] = useState<string | undefined>(); const [selectedId, setSelectedId] = useState<string | undefined>();
@ -45,7 +51,8 @@ export function CompaniesPage({ csrfToken }: { csrfToken: string }) {
state: { globalFilter: search.trim() }, state: { globalFilter: search.trim() },
globalFilterFn: "includesString", globalFilterFn: "includesString",
}); });
if (selectedId) { const selectCompany = onSelectCompany ?? setSelectedId;
if (selectedId && !onSelectCompany) {
return ( return (
<CompanyDetail <CompanyDetail
csrfToken={csrfToken} csrfToken={csrfToken}
@ -108,7 +115,7 @@ export function CompaniesPage({ csrfToken }: { csrfToken: string }) {
<TableCell>{t(company.active ? "active" : "inactive")}</TableCell> <TableCell>{t(company.active ? "active" : "inactive")}</TableCell>
<TableCell> <TableCell>
<Button <Button
onClick={() => setSelectedId(company.id)} onClick={() => selectCompany(company.id)}
size="sm" size="sm"
variant="outline" variant="outline"
> >
@ -134,7 +141,7 @@ export function CompaniesPage({ csrfToken }: { csrfToken: string }) {
csrfToken={csrfToken} csrfToken={csrfToken}
onCreated={(companyId) => { onCreated={(companyId) => {
setCreateOpen(false); setCreateOpen(false);
setSelectedId(companyId); selectCompany(companyId);
}} }}
showTitle={false} showTitle={false}
/> />

View File

@ -0,0 +1,160 @@
import { AssetCatalogPage } from "@metis/assets/client";
import { Button } from "@repo/shadcn-ui/components/button";
import { Building2Icon, LayersIcon } 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 { AccessWorkspacePage } from "../access/access-workspace";
import { companyApi, companyKeys } from "../api/company-api";
import { CompaniesPage } from "./companies-page";
import { CompanyDetail } from "./company-detail";
import { CompanyFeedback } from "./company-feedback";
type WorkspaceLocation =
| { area: "company"; id: string }
| { area: "directory" }
| { area: "catalogs" };
export function CompaniesWorkspace({
csrfToken,
accountNavigation,
}: {
csrfToken: string;
accountNavigation: ReactNode;
}) {
const { t } = useTranslation("companies");
const { t: coreTranslation } = useTranslation("core");
const { t: assetTranslation } = useTranslation("assets");
const { t: accessTranslation } = useTranslation("access");
const query = useQuery({ queryKey: companyKeys.list, queryFn: companyApi.list });
const [location, setLocation] = useState<WorkspaceLocation>();
const [catalog, setCatalog] = useState<"access" | "assets">("access");
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 });
return (
<div className="desk-workspace">
<nav aria-label={t("companySpaces")} className="desk-company-rail">
<Button
aria-label={t("globalCatalogs")}
aria-pressed={current.area === "catalogs"}
className="desk-space-button"
onClick={() => setLocation({ area: "catalogs" })}
title={t("globalCatalogs")}
variant="ghost"
>
<LayersIcon aria-hidden="true" className="size-5" />
</Button>
<Button
aria-label={t("title")}
aria-pressed={current.area === "directory"}
className="desk-space-button"
onClick={() => setLocation({ area: "directory" })}
title={t("title")}
variant="ghost"
>
<Building2Icon aria-hidden="true" className="size-5" />
</Button>
<div className="desk-rail-separator" />
{companies.map((company) => (
<Button
aria-label={company.name}
aria-pressed={current.area === "company" && current.id === company.id}
className="desk-space-button"
key={company.id}
onClick={() => selectCompany(company.id)}
title={company.name}
variant="ghost"
>
{company.name
.trim()
.split(/\s+/)
.slice(0, 2)
.map((word) => word[0])
.join("")
.toLocaleUpperCase()}
</Button>
))}
</nav>
<div className="desk-workspace-content">
<span className="sr-only">{coreTranslation("workspace")}</span>
{query.isPending && (
<p className="p-6" role="status">
{t("loading")}
</p>
)}
{query.isError && (
<div className="space-y-4 p-6">
<CompanyFeedback error={query.error} />
<Button
onClick={() => {
query.refetch().catch(() => undefined);
}}
variant="outline"
>
{t("retry")}
</Button>
</div>
)}
{current.area === "company" ? (
<CompanyDetail
accountNavigation={accountNavigation}
csrfToken={csrfToken}
embedded
id={current.id}
key={current.id}
onBack={() => setLocation({ area: "directory" })}
/>
) : (
<div className="company-space">
<div className="company-space-heading">
<h1 className="text-lg font-semibold">
{t(current.area === "catalogs" ? "globalCatalogs" : "title")}
</h1>
<p className="text-sm text-muted-foreground">{t("supportWorkspace")}</p>
</div>
<nav aria-label={t("workspaceSections")} className="company-space-navigation">
{current.area === "catalogs" ? (
<>
<Button
aria-pressed={catalog === "access"}
onClick={() => setCatalog("access")}
variant="ghost"
>
{accessTranslation("catalogTitle")}
</Button>
<Button
aria-pressed={catalog === "assets"}
onClick={() => setCatalog("assets")}
variant="ghost"
>
{assetTranslation("title")}
</Button>
</>
) : (
<Button aria-pressed variant="ghost">
{t("title")}
</Button>
)}
</nav>
<footer className="company-space-account">{accountNavigation}</footer>
<main className="company-space-main">
{current.area === "directory" ? (
<CompaniesPage csrfToken={csrfToken} onSelectCompany={selectCompany} />
) : catalog === "access" ? (
<AccessWorkspacePage csrfToken={csrfToken} scope="global" />
) : (
<AssetCatalogPage csrfToken={csrfToken} scope="global" />
)}
</main>
</div>
)}
</div>
</div>
);
}

View File

@ -2,7 +2,16 @@ import { AssetCatalogPage } from "@metis/assets/client";
import { Button } from "@repo/shadcn-ui/components/button"; import { Button } from "@repo/shadcn-ui/components/button";
import { Card, CardContent } from "@repo/shadcn-ui/components/card"; import { Card, CardContent } from "@repo/shadcn-ui/components/card";
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog"; import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import {
BoxIcon,
BriefcaseIcon,
KeyRoundIcon,
LayoutTemplateIcon,
MapPinIcon,
UsersIcon,
} from "@repo/shadcn-ui/lib/icons";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -96,10 +105,14 @@ export function CompanyDetail({
id, id,
csrfToken, csrfToken,
onBack, onBack,
embedded = false,
accountNavigation,
}: { }: {
id: string; id: string;
csrfToken: string; csrfToken: string;
onBack: () => void; onBack: () => void;
embedded?: boolean;
accountNavigation?: ReactNode;
}) { }) {
const { t } = useTranslation("companies"); const { t } = useTranslation("companies");
const { t: assetTranslation } = useTranslation("assets"); const { t: assetTranslation } = useTranslation("assets");
@ -108,13 +121,29 @@ export function CompanyDetail({
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [tab, setTab] = useState< const [tab, setTab] = useState<
"people" | "departments" | "sites" | "access" | "assets" | "inventory" "people" | "departments" | "sites" | "access" | "assets" | "inventory"
>("people"); >(embedded ? "inventory" : "people");
const company = query.data?.company; const company = query.data?.company;
const layout = embedded
? {
root: "company-space",
heading: "company-space-heading",
navigation: "company-space-navigation",
main: "company-space-main",
}
: {
root: "space-y-6",
heading: "flex flex-wrap items-start justify-between gap-4",
navigation: "flex flex-wrap gap-2",
main: undefined,
};
return ( return (
<div className="space-y-6"> <div className={layout.root}>
<Button onClick={onBack} variant="outline"> {!embedded && (
{t("back")} <Button onClick={onBack} variant="outline">
</Button> {t("back")}
</Button>
)}
{query.isPending && <p role="status">{t("loading")}</p>} {query.isPending && <p role="status">{t("loading")}</p>}
<CompanyFeedback error={query.error} /> <CompanyFeedback error={query.error} />
{query.isError && ( {query.isError && (
@ -127,9 +156,10 @@ export function CompanyDetail({
{t("retry")} {t("retry")}
</Button> </Button>
)} )}
{embedded && <footer className="company-space-account">{accountNavigation}</footer>}
{company && ( {company && (
<> <>
<div className="flex flex-wrap items-start justify-between gap-4"> <div className={layout.heading}>
<div> <div>
<h1 className="text-2xl font-semibold">{company.name}</h1> <h1 className="text-2xl font-semibold">{company.name}</h1>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
@ -155,51 +185,37 @@ export function CompanyDetail({
showTitle={false} showTitle={false}
/> />
</FormDialog> </FormDialog>
<nav aria-label={t("companySections")} className="flex flex-wrap gap-2"> <nav aria-label={t("companySections")} className={layout.navigation}>
<Button {(
aria-pressed={tab === "people"} [
onClick={() => setTab("people")} ["inventory", assetTranslation("inventory"), BoxIcon],
variant={tab === "people" ? "secondary" : "outline"} ["people", `${t("people")} (${company.people.length})`, UsersIcon],
> [
{t("people")} ({company.people.length}) "departments",
</Button> `${t("departments")} (${company.departments.length})`,
<Button BriefcaseIcon,
aria-pressed={tab === "departments"} ],
onClick={() => setTab("departments")} ["sites", `${t("sites")} (${company.sites.length})`, MapPinIcon],
variant={tab === "departments" ? "secondary" : "outline"} ["access", accessTranslation("title"), KeyRoundIcon],
> ["assets", assetTranslation("companyTitle"), LayoutTemplateIcon],
{t("departments")} ({company.departments.length}) ] as const
</Button> ).map(([section, label, Icon]) => (
<Button <Button
aria-pressed={tab === "sites"} aria-pressed={tab === section}
onClick={() => setTab("sites")} key={section}
variant={tab === "sites" ? "secondary" : "outline"} onClick={() => setTab(section)}
> variant="ghost"
{t("sites")} ({company.sites.length}) >
</Button> <Icon aria-hidden="true" className="size-4 shrink-0" />
<Button {label}
aria-pressed={tab === "access"} </Button>
onClick={() => setTab("access")} ))}
variant={tab === "access" ? "secondary" : "outline"}
>
{accessTranslation("title")}
</Button>
<Button
aria-pressed={tab === "assets"}
onClick={() => setTab("assets")}
variant={tab === "assets" ? "secondary" : "outline"}
>
{assetTranslation("companyTitle")}
</Button>
<Button
aria-pressed={tab === "inventory"}
onClick={() => setTab("inventory")}
variant={tab === "inventory" ? "secondary" : "outline"}
>
{assetTranslation("inventory")}
</Button>
</nav> </nav>
<CompanySection company={company} csrfToken={csrfToken} tab={tab} />
<main className={layout.main} key={tab}>
{embedded && <div className="company-space-breadcrumb">{company.name}</div>}
<CompanySection company={company} csrfToken={csrfToken} tab={tab} />
</main>
</> </>
)} )}
</div> </div>

View File

@ -1,4 +1,9 @@
export const companiesSpanish = { export const companiesSpanish = {
companySpaces: "Espacios de compañías",
globalCatalogs: "Catálogos globales",
supportWorkspace: "Tu espacio de soporte",
workspaceSections: "Secciones del espacio",
people: "Personas", people: "Personas",
newPerson: "Nueva persona", newPerson: "Nueva persona",
editPerson: "Editar persona", editPerson: "Editar persona",
@ -108,6 +113,11 @@ export const companiesSpanish = {
}, },
}; };
export const companiesEnglish = { export const companiesEnglish = {
companySpaces: "Company spaces",
globalCatalogs: "Global catalogs",
supportWorkspace: "Your support workspace",
workspaceSections: "Workspace sections",
people: "People", people: "People",
newPerson: "New person", newPerson: "New person",
editPerson: "Edit person", editPerson: "Edit person",

View File

@ -2,4 +2,5 @@ export { AccessWorkspacePage } from "./access/access-workspace";
export { accessEnglish, accessSpanish } from "./access/i18n"; export { accessEnglish, accessSpanish } from "./access/i18n";
export { companyKeys } from "./api/company-api"; export { companyKeys } from "./api/company-api";
export { CompaniesPage } from "./components/companies-page"; export { CompaniesPage } from "./components/companies-page";
export { CompaniesWorkspace } from "./components/companies-workspace";
export { companiesEnglish, companiesSpanish } from "./i18n"; export { companiesEnglish, companiesSpanish } from "./i18n";

View File

@ -6,6 +6,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@repo/shadcn-ui/components/card"; } from "@repo/shadcn-ui/components/card";
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useState } from "react"; import { useState } from "react";
@ -24,14 +25,18 @@ export type WorkspacePage = Readonly<{
label: string; label: string;
render: (session: SessionResponseDTO) => ReactNode; render: (session: SessionResponseDTO) => ReactNode;
}>; }>;
type WorkspaceRenderer = (session: SessionResponseDTO, accountNavigation: ReactNode) => ReactNode;
const EMPTY_PAGES: readonly WorkspacePage[] = []; const EMPTY_PAGES: readonly WorkspacePage[] = [];
export function IdentityWorkspace({ export function IdentityWorkspace({
children, children,
pages = EMPTY_PAGES, pages = EMPTY_PAGES,
renderWorkspace,
}: { }: {
children: ReactNode; children: ReactNode;
pages?: readonly WorkspacePage[]; pages?: readonly WorkspacePage[];
renderWorkspace?: WorkspaceRenderer | undefined;
}) { }) {
const { t } = useTranslation("identity"); const { t } = useTranslation("identity");
const session = useQuery({ const session = useQuery({
@ -85,7 +90,7 @@ export function IdentityWorkspace({
); );
} }
return ( return (
<AuthenticatedWorkspace pages={pages} snapshot={snapshot}> <AuthenticatedWorkspace pages={pages} renderWorkspace={renderWorkspace} snapshot={snapshot}>
{children} {children}
</AuthenticatedWorkspace> </AuthenticatedWorkspace>
); );
@ -95,10 +100,12 @@ function AuthenticatedWorkspace({
snapshot, snapshot,
children, children,
pages, pages,
renderWorkspace,
}: { }: {
snapshot: SessionResponseDTO; snapshot: SessionResponseDTO;
children: ReactNode; children: ReactNode;
pages: readonly WorkspacePage[]; pages: readonly WorkspacePage[];
renderWorkspace?: WorkspaceRenderer | undefined;
}) { }) {
const { t } = useTranslation("identity"); const { t } = useTranslation("identity");
const client = useQueryClient(); const client = useQueryClient();
@ -116,6 +123,19 @@ function AuthenticatedWorkspace({
if (!snapshot.user) { if (!snapshot.user) {
return null; return null;
} }
if (renderWorkspace) {
return (
<CustomWorkspace
logoutError={logout.error}
logoutPending={logout.isPending}
onLogout={() => logout.mutate()}
page={page}
renderWorkspace={renderWorkspace}
setPage={setPage}
snapshot={snapshot}
/>
);
}
return ( return (
<> <>
<nav aria-label={t("accountNavigation")} className="border-b bg-card"> <nav aria-label={t("accountNavigation")} className="border-b bg-card">
@ -157,7 +177,7 @@ function AuthenticatedWorkspace({
<FormFeedback error={logout.error} /> <FormFeedback error={logout.error} />
</div> </div>
)} )}
{page === "home" || (page === "users" && !snapshot.user.isInitialAdmin) ? ( {isHomePage(page, snapshot.user.isInitialAdmin) ? (
children children
) : ( ) : (
<main className="mx-auto max-w-5xl px-6 py-8"> <main className="mx-auto max-w-5xl px-6 py-8">
@ -177,3 +197,79 @@ function AuthenticatedWorkspace({
</> </>
); );
} }
function CustomWorkspace({
snapshot,
renderWorkspace,
page,
setPage,
logoutPending,
logoutError,
onLogout,
}: {
snapshot: SessionResponseDTO;
renderWorkspace: WorkspaceRenderer;
page: string;
setPage: (page: string) => void;
logoutPending: boolean;
logoutError: Error | null;
onLogout: () => void;
}) {
const { t } = useTranslation("identity");
if (!snapshot.user) {
return null;
}
return (
<>
{renderWorkspace(
snapshot,
<div className="space-y-3">
<p className="truncate text-sm font-medium">{snapshot.user.name}</p>
<div className="flex flex-wrap gap-1">
{snapshot.user.isInitialAdmin && (
<Button onClick={() => setPage("users")} size="sm" variant="ghost">
{t("users")}
</Button>
)}
<Button onClick={() => setPage("password")} size="sm" variant="ghost">
{t("changePassword")}
</Button>
<Button disabled={logoutPending} onClick={onLogout} size="sm" variant="ghost">
{t("logout")}
</Button>
</div>
<FormFeedback error={logoutError} />
</div>
)}
<FormDialog
cancelLabel={t("cancel")}
onOpenChange={(open) => {
if (!open) {
setPage("home");
}
}}
open={page === "users" && snapshot.user.isInitialAdmin}
title={t("users")}
wide
>
<UserManagement csrfToken={snapshot.csrfToken} />
</FormDialog>
<FormDialog
cancelLabel={t("cancel")}
onOpenChange={(open) => {
if (!open) {
setPage("home");
}
}}
open={page === "password"}
title={t("changePassword")}
>
<PasswordForm csrfToken={snapshot.csrfToken} />
</FormDialog>
</>
);
}
function isHomePage(page: string, isInitialAdmin: boolean) {
return page === "home" || (page === "users" && !isInitialAdmin);
}

View File

@ -0,0 +1,10 @@
export {
BoxIcon,
BriefcaseIcon,
Building2Icon,
KeyRoundIcon,
LayersIcon,
LayoutTemplateIcon,
MapPinIcon,
UsersIcon,
} from "lucide-react";