diff --git a/apps/web/src/app.test.tsx b/apps/web/src/app.test.tsx index 2d484ed..2716d76 100644 --- a/apps/web/src/app.test.tsx +++ b/apps/web/src/app.test.tsx @@ -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( @@ -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")); }); + +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("
-
+
Metis Desk. @@ -70,27 +65,11 @@ export function App() { )}
( - - ), - }, - { - id: "asset-catalog", - label: assetTranslation("title"), - render: (session) => , - }, - { - id: "companies", - label: companyTranslation("title"), - render: (session) => , - }, - ]} + renderWorkspace={(session, accountNavigation) => ( + + )} > - + {null} ); diff --git a/apps/web/src/global.css b/apps/web/src/global.css index ed1f63d..bf68a89 100644 --- a/apps/web/src/global.css +++ b/apps/web/src/global.css @@ -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; + } +} diff --git a/apps/web/src/test-fixtures/workspace.ts b/apps/web/src/test-fixtures/workspace.ts new file mode 100644 index 0000000..acd3c65 --- /dev/null +++ b/apps/web/src/test-fixtures/workspace.ts @@ -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", + }, + ], + }; +}); diff --git a/modules/assets/src/web/asset-detail.tsx b/modules/assets/src/web/asset-detail.tsx index b0b14e9..e18b975 100644 --- a/modules/assets/src/web/asset-detail.tsx +++ b/modules/assets/src/web/asset-detail.tsx @@ -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 (
-

{t("snapshotNotice")}

-
+ {!compact &&

{t("snapshotNotice")}

} +
{t("template")}
{asset.specification.templateName}
@@ -48,44 +56,48 @@ export function AssetDetail({ asset, lookups }: { asset: AssetDTO; lookups: Inve {asset.values.notes && (

{asset.values.notes}

)} -

- {t("history")} ({asset.history.length}) -

-
    - {[...asset.history].reverse().map((revision) => ( -
  1. -

    - {new Intl.DateTimeFormat(i18n.language, { - dateStyle: "medium", - timeStyle: "short", - }).format(new Date(revision.recordedAt))}{" "} - · {t("recordedBy", { name: revision.actorName })} -

    -

    - {revision.values.name} · {t(revision.values.status)} -

    -

    - {t("site")}: {nameOf(revision.values.siteId, lookups.sites)} · {t("person")}:{" "} - {nameOf(revision.values.personId, lookups.people)} -

    -
    - {t("characteristics")} -
    - {asset.specification.fields.map((field) => ( -
    -
    {field.name}
    -
    {display(revision.values.characteristics[field.fieldId])}
    -
    - ))} -
    - {revision.values.notes && ( -

    {revision.values.notes}

    - )} -
    -
  2. - ))} -
-

ID: {asset.id}

+ {!compact && ( + <> +

+ {t("history")} ({asset.history.length}) +

+
    + {[...asset.history].reverse().map((revision) => ( +
  1. +

    + {new Intl.DateTimeFormat(i18n.language, { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(revision.recordedAt))}{" "} + · {t("recordedBy", { name: revision.actorName })} +

    +

    + {revision.values.name} · {t(revision.values.status)} +

    +

    + {t("site")}: {nameOf(revision.values.siteId, lookups.sites)} · {t("person")}:{" "} + {nameOf(revision.values.personId, lookups.people)} +

    +
    + {t("characteristics")} +
    + {asset.specification.fields.map((field) => ( +
    +
    {field.name}
    +
    {display(revision.values.characteristics[field.fieldId])}
    +
    + ))} +
    + {revision.values.notes && ( +

    {revision.values.notes}

    + )} +
    +
  2. + ))} +
+ + )} + {!compact &&

ID: {asset.id}

}
); } diff --git a/modules/assets/src/web/asset-form.tsx b/modules/assets/src/web/asset-form.tsx index 4fa85d7..634d311 100644 --- a/modules/assets/src/web/asset-form.tsx +++ b/modules/assets/src/web/asset-form.tsx @@ -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); } }, }); diff --git a/modules/assets/src/web/index.ts b/modules/assets/src/web/index.ts index 0642538..da172f0 100644 --- a/modules/assets/src/web/index.ts +++ b/modules/assets/src/web/index.ts @@ -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"; diff --git a/modules/assets/src/web/inventory-i18n.ts b/modules/assets/src/web/inventory-i18n.ts index 32fa0ba..3cee41a 100644 --- a/modules/assets/src/web/inventory-i18n.ts +++ b/modules/assets/src/web/inventory-i18n.ts @@ -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: diff --git a/modules/assets/src/web/inventory-page.tsx b/modules/assets/src/web/inventory-page.tsx index 83627e7..8c08231 100644 --- a/modules/assets/src/web/inventory-page.tsx +++ b/modules/assets/src/web/inventory-page.tsx @@ -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(); const [viewId, setViewId] = useState(); + const [selectedId, setSelectedId] = useState(); + 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 ( -
-
-

{t("inventory")}

- -
- setSearch(event.target.value)} - placeholder={t("search")} - value={search} - /> - {(query.isPending || catalog.isPending) &&

{t("loading")}

} - - - {(query.isError || catalog.isError) && ( - - )} - {query.data && ( - - - {!filtered.length && ( -

- {t(assets.length ? "noResults" : "noAssets")} -

- )} - {filtered.map((asset) => ( -
+
+
+

{t("inventory")}

+
+ {!detailOpen && ( + + )} + +
+
+ setSearch(event.target.value)} + placeholder={t("search")} + value={search} + /> + {(query.isPending || catalog.isPending) &&

{t("loading")}

} + + + {(query.isError || catalog.isError) && ( + + )} + {query.data && ( + + + {!filtered.length && ( +

+ {t(assets.length ? "noResults" : "noAssets")} +

+ )} + {filtered.map((asset) => ( +
+
+

+ +

+

+ {asset.specification.templateName} · {t(asset.values.status)} +

+

+ {lookups.sites.find((site) => site.id === asset.values.siteId)?.name ?? + t("noAssignment")}{" "} + ·{" "} + {lookups.people.find((person) => person.id === asset.values.personId)?.name ?? + t("noAssignment")} +

+
+
+ + +
-
- - -
-
- ))} - - - )} + ))} + + + )} +
+ setDetailOpen(false)} + onEdit={setEditor} + onView={setViewId} + open={detailOpen} + /> {editor && catalog.data && ( setEditor(undefined)} + onSaved={(id) => { + setSelectedId(id); + setDetailOpen(true); + setEditor(undefined); + }} /> )} @@ -159,3 +210,49 @@ export function AssetInventoryPage({
); } + +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 ( + + ); +} diff --git a/modules/companies/src/web/components/companies-page.tsx b/modules/companies/src/web/components/companies-page.tsx index 394aa30..3920bfe 100644 --- a/modules/companies/src/web/components/companies-page.tsx +++ b/modules/companies/src/web/components/companies-page.tsx @@ -30,7 +30,13 @@ const COMPANY_COLUMNS: ColumnDef[] = [ { 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(); @@ -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 ( {t(company.active ? "active" : "inactive")} + +
+ {companies.map((company) => ( + + ))} + +
+ {coreTranslation("workspace")} + {query.isPending && ( +

+ {t("loading")} +

+ )} + {query.isError && ( +
+ + +
+ )} + {current.area === "company" ? ( + setLocation({ area: "directory" })} + /> + ) : ( +
+
+

+ {t(current.area === "catalogs" ? "globalCatalogs" : "title")} +

+

{t("supportWorkspace")}

+
+ +
{accountNavigation}
+
+ {current.area === "directory" ? ( + + ) : catalog === "access" ? ( + + ) : ( + + )} +
+
+ )} +
+
+ ); +} diff --git a/modules/companies/src/web/components/company-detail.tsx b/modules/companies/src/web/components/company-detail.tsx index a0a2669..01bcb74 100644 --- a/modules/companies/src/web/components/company-detail.tsx +++ b/modules/companies/src/web/components/company-detail.tsx @@ -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 ( -
- +
+ {!embedded && ( + + )} {query.isPending &&

{t("loading")}

} {query.isError && ( @@ -127,9 +156,10 @@ export function CompanyDetail({ {t("retry")} )} + {embedded &&
{accountNavigation}
} {company && ( <> -
+

{company.name}

@@ -155,51 +185,37 @@ export function CompanyDetail({ showTitle={false} /> -

diff --git a/modules/companies/src/web/i18n.ts b/modules/companies/src/web/i18n.ts index 9a99a0f..d775a45 100644 --- a/modules/companies/src/web/i18n.ts +++ b/modules/companies/src/web/i18n.ts @@ -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", diff --git a/modules/companies/src/web/index.ts b/modules/companies/src/web/index.ts index 7978cdb..ee1ed93 100644 --- a/modules/companies/src/web/index.ts +++ b/modules/companies/src/web/index.ts @@ -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"; diff --git a/modules/identity/src/web/components/identity-workspace.tsx b/modules/identity/src/web/components/identity-workspace.tsx index 55b84fb..9f09051 100644 --- a/modules/identity/src/web/components/identity-workspace.tsx +++ b/modules/identity/src/web/components/identity-workspace.tsx @@ -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 ( - + {children} ); @@ -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 ( + logout.mutate()} + page={page} + renderWorkspace={renderWorkspace} + setPage={setPage} + snapshot={snapshot} + /> + ); + } return ( <>
)} - {page === "home" || (page === "users" && !snapshot.user.isInitialAdmin) ? ( + {isHomePage(page, snapshot.user.isInitialAdmin) ? ( children ) : (
@@ -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, +
+

{snapshot.user.name}

+
+ {snapshot.user.isInitialAdmin && ( + + )} + + +
+ +
+ )} + { + if (!open) { + setPage("home"); + } + }} + open={page === "users" && snapshot.user.isInitialAdmin} + title={t("users")} + wide + > + + + { + if (!open) { + setPage("home"); + } + }} + open={page === "password"} + title={t("changePassword")} + > + + + + ); +} + +function isHomePage(page: string, isInitialAdmin: boolean) { + return page === "home" || (page === "users" && !isInitialAdmin); +} diff --git a/packages/shadcn-ui/src/lib/icons.ts b/packages/shadcn-ui/src/lib/icons.ts new file mode 100644 index 0000000..bce6e98 --- /dev/null +++ b/packages/shadcn-ui/src/lib/icons.ts @@ -0,0 +1,10 @@ +export { + BoxIcon, + BriefcaseIcon, + Building2Icon, + KeyRoundIcon, + LayersIcon, + LayoutTemplateIcon, + MapPinIcon, + UsersIcon, +} from "lucide-react";