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 { test } from "node:test";
import { catalogKeys, inventoryKeys } from "@metis/assets/client";
import { companyKeys } from "@metis/companies/client";
import { identityKeys } from "@metis/identity/client";
import type { SessionResponseDTO, UserDTO } from "@metis/identity/common";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@ -9,6 +11,7 @@ import { I18nextProvider } from "react-i18next";
import { App } from "./app";
import { createAppI18n } from "./i18n";
import { workspaceAssets, workspaceCatalog, workspaceCompany } from "./test-fixtures/workspace";
const user: UserDTO = {
id: "c90b4d83-b06a-4d27-a402-e64899a2dfc2",
@ -17,7 +20,11 @@ const user: UserDTO = {
active: true,
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);
await initialization;
const client = new QueryClient({
@ -26,6 +33,7 @@ async function renderPage(language: string, session?: SessionResponseDTO) {
if (session) {
client.setQueryData(identityKeys.session, session);
}
seed?.(client);
try {
return renderToStaticMarkup(
<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 } });
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 { AccessWorkspacePage, CompaniesPage } from "@metis/companies/client";
import { HomePage } from "@metis/core/client";
import { CompaniesWorkspace } from "@metis/companies/client";
import { IdentityWorkspace } from "@metis/identity/client";
import {
Select,
@ -18,9 +16,6 @@ const LANGUAGE_OPTIONS = [
];
export function App() {
const { t: companyTranslation } = useTranslation("companies");
const { t: assetTranslation } = useTranslation("assets");
const { t: accessTranslation } = useTranslation("access");
const { t, i18n } = useTranslation("core");
const [languageError, setLanguageError] = useState(false);
const languageLabelId = useId();
@ -28,7 +23,7 @@ export function App() {
return (
<div className="min-h-screen bg-background">
<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">
Metis Desk<span className="ml-1 text-primary">.</span>
</span>
@ -70,27 +65,11 @@ export function App() {
)}
</header>
<IdentityWorkspace
pages={[
{
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} />,
},
]}
renderWorkspace={(session, accountNavigation) => (
<CompaniesWorkspace accountNavigation={accountNavigation} csrfToken={session.csrfToken} />
)}
>
<HomePage />
{null}
</IdentityWorkspace>
</div>
);

View File

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

View File

@ -45,7 +45,7 @@ export function AssetForm({
asset?: AssetDTO | undefined;
items: readonly CatalogItemDTO[];
lookups: InventoryLookups;
onSaved: () => void;
onSaved: (id: string) => void;
}) {
const { t } = useTranslation("assets");
const prefix = useId();
@ -94,10 +94,10 @@ export function AssetForm({
: []);
const mutation = useMutation({
mutationFn: (input: AssetInput) => inventoryApi.save(companyId, input, csrfToken, asset?.id),
onSuccess: async () => {
onSuccess: async (response) => {
await client.invalidateQueries({ queryKey: inventoryKeys.company(companyId) });
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 { assetsEnglish, assetsSpanish } from "./i18n";
export { inventoryKeys } from "./inventory-api";
export { AssetInventoryPage } from "./inventory-page";

View File

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

View File

@ -6,6 +6,8 @@ import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { AssetDTO } from "../common/inventory";
import { AssetDetail } from "./asset-detail";
import type { InventoryLookups } from "./asset-form";
import { AssetForm, InventoryFeedback } from "./asset-form";
@ -33,7 +35,10 @@ export function AssetInventoryPage({
const [search, setSearch] = useState("");
const [editor, setEditor] = 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 selected = assets.find((asset) => asset.id === selectedId) ?? assets[0];
const edited = assets.find((asset) => asset.id === editor);
const viewed = assets.find((asset) => asset.id === viewId);
const filtered = assets.filter((asset) => {
@ -48,76 +53,118 @@ export function AssetInventoryPage({
].some((value) => value.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()));
});
return (
<div className="space-y-5">
<header className="flex items-center justify-between gap-4">
<h2 className="text-xl font-semibold">{t("inventory")}</h2>
<Button disabled={!(catalog.data && query.data)} onClick={() => setEditor("new")}>
{t("newAsset")}
</Button>
</header>
<Input
aria-label={t("search")}
onChange={(event) => setSearch(event.target.value)}
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 className="inventory-layout" data-detail-open={detailOpen && Boolean(selected)}>
<div className="inventory-list space-y-5">
<header className="flex items-center justify-between gap-4">
<h2 className="text-xl font-semibold">{t("inventory")}</h2>
<div className="flex flex-wrap items-center justify-end gap-2">
{!detailOpen && (
<Button
className="asset-desktop-action"
onClick={() => setDetailOpen(true)}
variant="outline"
>
<div>
<h3 className="font-medium">{asset.values.name}</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>
{t("showAssetPanel")}
</Button>
)}
<Button disabled={!(catalog.data && query.data)} onClick={() => setEditor("new")}>
{t("newAsset")}
</Button>
</div>
</header>
<Input
aria-label={t("search")}
onChange={(event) => setSearch(event.target.value)}
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 className="flex gap-2">
<Button 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>
))}
</CardContent>
</Card>
)}
))}
</CardContent>
</Card>
)}
</div>
<AssetSelectionPanel
asset={selected}
canEdit={catalog.isSuccess}
lookups={lookups}
onClose={() => setDetailOpen(false)}
onEdit={setEditor}
onView={setViewId}
open={detailOpen}
/>
{editor && catalog.data && (
<FormDialog
cancelLabel={t("cancel")}
@ -137,7 +184,11 @@ export function AssetInventoryPage({
csrfToken={csrfToken}
items={catalog.data.items}
lookups={lookups}
onSaved={() => setEditor(undefined)}
onSaved={(id) => {
setSelectedId(id);
setDetailOpen(true);
setEditor(undefined);
}}
/>
</FormDialog>
)}
@ -159,3 +210,49 @@ export function AssetInventoryPage({
</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 },
];
export function CompaniesPage({ csrfToken }: { csrfToken: string }) {
export function CompaniesPage({
csrfToken,
onSelectCompany,
}: {
csrfToken: string;
onSelectCompany?: (id: string) => void;
}) {
const { t } = useTranslation("companies");
const query = useQuery({ queryKey: companyKeys.list, queryFn: companyApi.list });
const [selectedId, setSelectedId] = useState<string | undefined>();
@ -45,7 +51,8 @@ export function CompaniesPage({ csrfToken }: { csrfToken: string }) {
state: { globalFilter: search.trim() },
globalFilterFn: "includesString",
});
if (selectedId) {
const selectCompany = onSelectCompany ?? setSelectedId;
if (selectedId && !onSelectCompany) {
return (
<CompanyDetail
csrfToken={csrfToken}
@ -108,7 +115,7 @@ export function CompaniesPage({ csrfToken }: { csrfToken: string }) {
<TableCell>{t(company.active ? "active" : "inactive")}</TableCell>
<TableCell>
<Button
onClick={() => setSelectedId(company.id)}
onClick={() => selectCompany(company.id)}
size="sm"
variant="outline"
>
@ -134,7 +141,7 @@ export function CompaniesPage({ csrfToken }: { csrfToken: string }) {
csrfToken={csrfToken}
onCreated={(companyId) => {
setCreateOpen(false);
setSelectedId(companyId);
selectCompany(companyId);
}}
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 { Card, CardContent } from "@repo/shadcn-ui/components/card";
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 type { ReactNode } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@ -96,10 +105,14 @@ export function CompanyDetail({
id,
csrfToken,
onBack,
embedded = false,
accountNavigation,
}: {
id: string;
csrfToken: string;
onBack: () => void;
embedded?: boolean;
accountNavigation?: ReactNode;
}) {
const { t } = useTranslation("companies");
const { t: assetTranslation } = useTranslation("assets");
@ -108,13 +121,29 @@ export function CompanyDetail({
const [editOpen, setEditOpen] = useState(false);
const [tab, setTab] = useState<
"people" | "departments" | "sites" | "access" | "assets" | "inventory"
>("people");
>(embedded ? "inventory" : "people");
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 (
<div className="space-y-6">
<Button onClick={onBack} variant="outline">
{t("back")}
</Button>
<div className={layout.root}>
{!embedded && (
<Button onClick={onBack} variant="outline">
{t("back")}
</Button>
)}
{query.isPending && <p role="status">{t("loading")}</p>}
<CompanyFeedback error={query.error} />
{query.isError && (
@ -127,9 +156,10 @@ export function CompanyDetail({
{t("retry")}
</Button>
)}
{embedded && <footer className="company-space-account">{accountNavigation}</footer>}
{company && (
<>
<div className="flex flex-wrap items-start justify-between gap-4">
<div className={layout.heading}>
<div>
<h1 className="text-2xl font-semibold">{company.name}</h1>
<p className="mt-1 text-sm text-muted-foreground">
@ -155,51 +185,37 @@ export function CompanyDetail({
showTitle={false}
/>
</FormDialog>
<nav aria-label={t("companySections")} className="flex flex-wrap gap-2">
<Button
aria-pressed={tab === "people"}
onClick={() => setTab("people")}
variant={tab === "people" ? "secondary" : "outline"}
>
{t("people")} ({company.people.length})
</Button>
<Button
aria-pressed={tab === "departments"}
onClick={() => setTab("departments")}
variant={tab === "departments" ? "secondary" : "outline"}
>
{t("departments")} ({company.departments.length})
</Button>
<Button
aria-pressed={tab === "sites"}
onClick={() => setTab("sites")}
variant={tab === "sites" ? "secondary" : "outline"}
>
{t("sites")} ({company.sites.length})
</Button>
<Button
aria-pressed={tab === "access"}
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 aria-label={t("companySections")} className={layout.navigation}>
{(
[
["inventory", assetTranslation("inventory"), BoxIcon],
["people", `${t("people")} (${company.people.length})`, UsersIcon],
[
"departments",
`${t("departments")} (${company.departments.length})`,
BriefcaseIcon,
],
["sites", `${t("sites")} (${company.sites.length})`, MapPinIcon],
["access", accessTranslation("title"), KeyRoundIcon],
["assets", assetTranslation("companyTitle"), LayoutTemplateIcon],
] as const
).map(([section, label, Icon]) => (
<Button
aria-pressed={tab === section}
key={section}
onClick={() => setTab(section)}
variant="ghost"
>
<Icon aria-hidden="true" className="size-4 shrink-0" />
{label}
</Button>
))}
</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>

View File

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

View File

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

View File

@ -6,6 +6,7 @@ import {
CardHeader,
CardTitle,
} from "@repo/shadcn-ui/components/card";
import { FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { useState } from "react";
@ -24,14 +25,18 @@ export type WorkspacePage = Readonly<{
label: string;
render: (session: SessionResponseDTO) => ReactNode;
}>;
type WorkspaceRenderer = (session: SessionResponseDTO, accountNavigation: ReactNode) => ReactNode;
const EMPTY_PAGES: readonly WorkspacePage[] = [];
export function IdentityWorkspace({
children,
pages = EMPTY_PAGES,
renderWorkspace,
}: {
children: ReactNode;
pages?: readonly WorkspacePage[];
renderWorkspace?: WorkspaceRenderer | undefined;
}) {
const { t } = useTranslation("identity");
const session = useQuery({
@ -85,7 +90,7 @@ export function IdentityWorkspace({
);
}
return (
<AuthenticatedWorkspace pages={pages} snapshot={snapshot}>
<AuthenticatedWorkspace pages={pages} renderWorkspace={renderWorkspace} snapshot={snapshot}>
{children}
</AuthenticatedWorkspace>
);
@ -95,10 +100,12 @@ function AuthenticatedWorkspace({
snapshot,
children,
pages,
renderWorkspace,
}: {
snapshot: SessionResponseDTO;
children: ReactNode;
pages: readonly WorkspacePage[];
renderWorkspace?: WorkspaceRenderer | undefined;
}) {
const { t } = useTranslation("identity");
const client = useQueryClient();
@ -116,6 +123,19 @@ function AuthenticatedWorkspace({
if (!snapshot.user) {
return null;
}
if (renderWorkspace) {
return (
<CustomWorkspace
logoutError={logout.error}
logoutPending={logout.isPending}
onLogout={() => logout.mutate()}
page={page}
renderWorkspace={renderWorkspace}
setPage={setPage}
snapshot={snapshot}
/>
);
}
return (
<>
<nav aria-label={t("accountNavigation")} className="border-b bg-card">
@ -157,7 +177,7 @@ function AuthenticatedWorkspace({
<FormFeedback error={logout.error} />
</div>
)}
{page === "home" || (page === "users" && !snapshot.user.isInitialAdmin) ? (
{isHomePage(page, snapshot.user.isInitialAdmin) ? (
children
) : (
<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";