feat(shadcn-ui): introduce new icons for improved UI consistency
This commit is contained in:
parent
a232595a71
commit
2a39be97f4
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
99
apps/web/src/test-fixtures/workspace.ts
Normal file
99
apps/web/src/test-fixtures/workspace.ts
Normal 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",
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
160
modules/companies/src/web/components/companies-workspace.tsx
Normal file
160
modules/companies/src/web/components/companies-workspace.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
10
packages/shadcn-ui/src/lib/icons.ts
Normal file
10
packages/shadcn-ui/src/lib/icons.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export {
|
||||
BoxIcon,
|
||||
BriefcaseIcon,
|
||||
Building2Icon,
|
||||
KeyRoundIcon,
|
||||
LayersIcon,
|
||||
LayoutTemplateIcon,
|
||||
MapPinIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
Loading…
Reference in New Issue
Block a user