Añadir secciones de detalle y mejorar la presentación de activos y recursos

This commit is contained in:
David Arranz 2026-10-09 21:18:40 +02:00
parent 5a7960302e
commit d7eb91ee68
16 changed files with 259 additions and 76 deletions

View File

@ -301,3 +301,11 @@ El desplazamiento vertical se conserva cuando el contenido supera la altura disp
La pantalla `CompanyOverview` tiene composición independiente de `DashboardPage` (Inicio). Comparte tarjetas, bloques de trabajo/avisos/actividad y consultas existentes, siempre fijando el ámbito al UUID de la compañía. El encabezado «De un vistazo» precede las acciones rápidas (ticket, intervención, activo y recurso), cuatro indicadores operativos y una franja navegable de personas, departamentos y sedes. No muestra compañías activas ni el resumen por compañías. La variante compacta de `StatisticCard` mejora la jerarquía del valor y la etiqueta sin modificar la presentación de Inicio. La pantalla `CompanyOverview` tiene composición independiente de `DashboardPage` (Inicio). Comparte tarjetas, bloques de trabajo/avisos/actividad y consultas existentes, siempre fijando el ámbito al UUID de la compañía. El encabezado «De un vistazo» precede las acciones rápidas (ticket, intervención, activo y recurso), cuatro indicadores operativos y una franja navegable de personas, departamentos y sedes. No muestra compañías activas ni el resumen por compañías. La variante compacta de `StatisticCard` mejora la jerarquía del valor y la etiqueta sin modificar la presentación de Inicio.
La navegación de compañía empieza por Resumen, Tickets e Intervenciones; después agrupa Personas, Puestos de trabajo y Perfiles de usuario y finalmente las demás secciones. Los grupos tienen separación visual. Las operaciones de usuario permanecen en el menú superior y dejan de repetirse al pie del menú de compañía. La navegación de compañía empieza por Resumen, Tickets e Intervenciones; después agrupa Personas, Puestos de trabajo y Perfiles de usuario y finalmente las demás secciones. Los grupos tienen separación visual. Las operaciones de usuario permanecen en el menú superior y dejan de repetirse al pie del menú de compañía.
## Fichas de consulta y resumen lateral
Las secciones de consulta evitan recuadros anidados y rellenos laterales: usan títulos compactos y un separador tenue entre grupos. Las agrupaciones de características siguen este mismo criterio en lectura; los formularios conservan su organización propia.
`DetailSection` ofrece agrupación plegable nativa (`details/summary`), abierta inicialmente, accesible por teclado. `DetailField` devuelve una fila semántica `dt/dd` para incluir en un `dl`: etiqueta y valor se alinean horizontalmente cuando el contenedor dispone de al menos 240 px y se apilan en espacios menores. El consumidor aporta los enlaces; el componente común les aplica cursor de enlace, color semántico y subrayado al pasar el puntero. Los valores ausentes permanecen como texto.
La ficha lateral de activos utiliza un ancho adaptable entre 320 y 400 px, acciones debajo del título y bloques de identificación, ubicación/asignación, características, observaciones, relaciones y asistencia. Conserva las secciones capturadas de la plantilla, incluyendo la compatibilidad con especificaciones antiguas sin agrupación; no modifica valores ni instantáneas. Las relaciones llevan a destinos existentes: persona y activo relacionado a su ficha; sede, puesto y plantilla a sus listados/catálogos actuales. La vista completa e historial mantienen sus funciones. Las fichas de recursos reutilizan las filas de características. No se crean nuevas fichas laterales ni nuevas rutas.

View File

@ -303,5 +303,10 @@
"formLayout": { "formLayout": {
"identity": "Identification and assignment", "identity": "Identification and assignment",
"template": "Template details" "template": "Template details"
},
"detailSections": {
"identification": "Identification",
"assignment": "Location and assignment",
"notes": "Notes"
} }
} }

View File

@ -303,5 +303,10 @@
"formLayout": { "formLayout": {
"identity": "Identificación y asignación", "identity": "Identificación y asignación",
"template": "Datos de la plantilla" "template": "Datos de la plantilla"
},
"detailSections": {
"identification": "Identificación",
"assignment": "Ubicación y asignación",
"notes": "Observaciones"
} }
} }

View File

@ -1,4 +1,5 @@
export type AssetRelationshipsProps = { export type AssetRelationshipsProps = {
compact?: boolean;
companyId: string; companyId: string;
csrfToken: string; csrfToken: string;
assetId: string; assetId: string;

View File

@ -1,3 +1,4 @@
import { BrowserLink } from "@metis/core/client";
import { Button } from "@repo/shadcn-ui/components/button"; import { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -8,7 +9,7 @@ import { useAssetRelationshipsController } from "../controllers";
import type { AssetRelationshipsProps } from "../types"; import type { AssetRelationshipsProps } from "../types";
export function AssetRelationships(props: AssetRelationshipsProps) { export function AssetRelationships(props: AssetRelationshipsProps) {
const { companyId, csrfToken, assetId } = props; const { companyId, csrfToken, assetId, compact = false } = props;
const { query, adding, setAdding, setEndingId, ending, active, ended, dateOf } = const { query, adding, setAdding, setEndingId, ending, active, ended, dateOf } =
useAssetRelationshipsController(props); useAssetRelationshipsController(props);
const { t } = useTranslation("assets"); const { t } = useTranslation("assets");
@ -20,7 +21,12 @@ export function AssetRelationships(props: AssetRelationshipsProps) {
<li className="space-y-2 rounded-md border p-3 text-sm" key={link.id}> <li className="space-y-2 rounded-md border p-3 text-sm" key={link.id}>
<p className="font-medium"> <p className="font-medium">
{source ? link.values.name : link.values.inverseName} ·{" "} {source ? link.values.name : link.values.inverseName} ·{" "}
{nameOf(source ? link.values.targetId : link.values.sourceId)} <BrowserLink
className="cursor-pointer text-primary underline-offset-4 hover:underline"
href={`/companies/${companyId}/assets/${source ? link.values.targetId : link.values.sourceId}`}
>
{nameOf(source ? link.values.targetId : link.values.sourceId)}
</BrowserLink>
</p> </p>
{link.values.notes && ( {link.values.notes && (
<p className="whitespace-pre-wrap break-words">{link.values.notes}</p> <p className="whitespace-pre-wrap break-words">{link.values.notes}</p>
@ -49,7 +55,7 @@ export function AssetRelationships(props: AssetRelationshipsProps) {
return ( return (
<section aria-label={t("relations")} className="space-y-3"> <section aria-label={t("relations")} className="space-y-3">
<header className="flex flex-wrap items-center justify-between gap-2"> <header className="flex flex-wrap items-center justify-between gap-2">
<h3 className="font-medium">{t("relations")}</h3> {!compact && <h3 className="font-medium">{t("relations")}</h3>}
<Button onClick={() => setAdding(true)} size="sm" variant="outline"> <Button onClick={() => setAdding(true)} size="sm" variant="outline">
{t("linkAsset")} {t("linkAsset")}
</Button> </Button>

View File

@ -1,3 +1,6 @@
import { BrowserLink } from "@metis/core/client";
import { DetailField } from "@repo/rdx-ui/components/detail-field";
import { DetailSection } from "@repo/rdx-ui/components/detail-section";
import { FieldSections } from "@repo/rdx-ui/components/field-sections"; import { FieldSections } from "@repo/rdx-ui/components/field-sections";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -22,49 +25,78 @@ export function AssetDetail(props: AssetDetailProps) {
? t(value ? "yes" : "no") ? t(value ? "yes" : "no")
: String(value); : String(value);
return ( return (
<div className="space-y-5"> <div className="space-y-2">
{!compact && <p className="text-sm text-muted-foreground">{t("snapshotNotice")}</p>} {!compact && <p className="text-sm text-muted-foreground">{t("snapshotNotice")}</p>}
<dl className={compact ? "grid gap-4" : "grid gap-3 sm:grid-cols-2"}> <DetailSection title={t("detailSections.identification")}>
<div> <dl>
<dt className="text-sm text-muted-foreground">{t("template")}</dt> <DetailField label={t("template")}>
<dd>{asset.specification.templateName}</dd> <BrowserLink href={`/companies/${asset.companyId}/catalogs/assets?tab=templates`}>
</div> {asset.specification.templateName}
<div> </BrowserLink>
<dt className="text-sm text-muted-foreground">{t("status")}</dt> </DetailField>
<dd>{t(asset.values.status)}</dd> <DetailField label={t("status")}>{t(asset.values.status)}</DetailField>
</div> </dl>
<div> </DetailSection>
<dt className="text-sm text-muted-foreground">{t("site")}</dt> <DetailSection title={t("detailSections.assignment")}>
<dd>{nameOf(asset.values.siteId, lookups.sites)}</dd> <dl>
</div> <DetailField label={t("site")}>
<div> {asset.values.siteId ? (
<dt className="text-sm text-muted-foreground">{t("person")}</dt> <BrowserLink href={`/companies/${asset.companyId}/sites`}>
<dd>{nameOf(asset.values.personId, lookups.people)}</dd> {nameOf(asset.values.siteId, lookups.sites)}
</div> </BrowserLink>
<div> ) : (
<dt className="text-sm text-muted-foreground">{t("workstationInAsset")}</dt> t("noAssignment")
<dd>{workstations?.map((item) => item.values.name).join(" · ") || t("noWorkstation")}</dd> )}
</div> </DetailField>
</dl> <DetailField label={t("person")}>
<FieldSections {asset.values.personId ? (
items={asset.specification.fields} <BrowserLink href={`/companies/${asset.companyId}/people/${asset.values.personId}`}>
renderItem={(field) => ( {nameOf(asset.values.personId, lookups.people)}
<div key={field.fieldId}> </BrowserLink>
<dt className="text-sm text-muted-foreground"> ) : (
{field.name} t("noAssignment")
{field.unit ? ` (${field.unit})` : ""} )}
</dt> </DetailField>
<dd className="whitespace-pre-wrap break-words"> <DetailField label={t("workstationInAsset")}>
{workstations?.length
? workstations.map((item) => (
<BrowserLink
className="block"
href={`/companies/${asset.companyId}/workstations`}
key={item.id}
>
{item.values.name}
</BrowserLink>
))
: t("noWorkstation")}
</DetailField>
</dl>
</DetailSection>
<DetailSection title={t("characteristics")}>
<FieldSections
items={asset.specification.fields}
renderItem={(field) => (
<DetailField
key={field.fieldId}
label={
<>
{field.name}
{field.unit ? ` (${field.unit})` : ""}
</>
}
>
{display(asset.values.characteristics[field.fieldId])} {display(asset.values.characteristics[field.fieldId])}
</dd> </DetailField>
</div> )}
)} sections={asset.specification.sections}
sections={asset.specification.sections} ungroupedLabel={t("sections.ungrouped")}
ungroupedLabel={t("sections.ungrouped")} values
values />
/> </DetailSection>
{asset.values.notes && ( {asset.values.notes && (
<p className="whitespace-pre-wrap break-words">{asset.values.notes}</p> <DetailSection title={t("detailSections.notes")}>
<p className="whitespace-pre-wrap break-words text-sm">{asset.values.notes}</p>
</DetailSection>
)} )}
{children} {children}
{!compact && ( {!compact && (

View File

@ -1,3 +1,5 @@
import { DetailSection } from "@repo/rdx-ui/components/detail-section";
import { XIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button"; import { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -36,31 +38,41 @@ export function AssetSelectionPanel({
return ( return (
<aside <aside
aria-label={t("assetPanel")} aria-label={t("assetPanel")}
className="hidden min-w-0 grid-cols-[minmax(0,1fr)] gap-6 rounded-panel border border-border bg-muted p-5 [overflow-wrap:anywhere] inventory:grid" className="@container hidden min-w-0 grid-cols-[minmax(0,1fr)] gap-4 rounded-panel border border-border bg-card p-4 [overflow-wrap:anywhere] inventory:grid"
> >
<header className="flex items-start justify-between gap-3"> <header className="flex items-start justify-between gap-3">
<h2 className="min-w-0 break-words text-lg font-semibold">{asset.values.name}</h2> <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 aria-label={t("closeAssetPanel")} onClick={onClose} size="icon-sm" variant="ghost">
× <XIcon aria-hidden="true" className="size-4" />
</Button> </Button>
</header> </header>
<AssetDetail asset={asset} compact lookups={lookups}>
<AssetRelationships assetId={asset.id} companyId={companyId} csrfToken={csrfToken} />
<AssetSupport
assetId={asset.id}
companyId={companyId}
csrfToken={csrfToken}
retired={asset.values.status === "retired"}
/>
</AssetDetail>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button onClick={() => onView(asset.id)} variant="outline"> <Button onClick={() => onView(asset.id)} size="sm" variant="outline">
{t("viewAsset")} {t("viewAsset")}
</Button> </Button>
<Button disabled={!canEdit} onClick={() => onEdit(asset.id)} variant="outline"> <Button disabled={!canEdit} onClick={() => onEdit(asset.id)} size="sm" variant="outline">
{t("edit")} {t("edit")}
</Button> </Button>
</div> </div>
<AssetDetail asset={asset} compact lookups={lookups}>
<DetailSection title={t("relations")}>
<AssetRelationships
assetId={asset.id}
compact
companyId={companyId}
csrfToken={csrfToken}
/>
</DetailSection>
<DetailSection title={t("support.title")}>
<AssetSupport
assetId={asset.id}
compact
companyId={companyId}
csrfToken={csrfToken}
retired={asset.values.status === "retired"}
/>
</DetailSection>
</AssetDetail>
</aside> </aside>
); );
} }

View File

@ -47,7 +47,7 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
} }
return ( return (
<div <div
className="grid grid-cols-[minmax(0,1fr)] items-start gap-6 inventory:grid-cols-[minmax(0,1fr)_280px] inventory:data-[detail-open=false]:grid-cols-[minmax(0,1fr)]" className="grid grid-cols-[minmax(0,1fr)] items-start gap-6 inventory:grid-cols-[minmax(0,1fr)_clamp(320px,24vw,400px)] inventory:data-[detail-open=false]:grid-cols-[minmax(0,1fr)]"
data-detail-open={detailOpen && Boolean(selected)} data-detail-open={detailOpen && Boolean(selected)}
data-slot="inventory-layout" data-slot="inventory-layout"
> >

View File

@ -13,6 +13,7 @@ import { buildCatalogResponse } from "../../../api/application/asset-catalog-res
import { AssetCatalog } from "../../../api/domain/asset-catalog.aggregate"; import { AssetCatalog } from "../../../api/domain/asset-catalog.aggregate";
import { seedAssetCatalog } from "../../../api/domain/asset-catalog-seed"; import { seedAssetCatalog } from "../../../api/domain/asset-catalog-seed";
import type { RelationshipDTO } from "../../../common"; import type { RelationshipDTO } from "../../../common";
import { AssetSchema } from "../../../common/inventory";
import { AssetRelationships } from "../../asset-relationships"; import { AssetRelationships } from "../../asset-relationships";
import { catalogKeys } from "../../catalog/shared/api"; import { catalogKeys } from "../../catalog/shared/api";
import { CatalogRelationForm } from "../../catalog-relations"; import { CatalogRelationForm } from "../../catalog-relations";
@ -20,6 +21,7 @@ import { CatalogTemplateForm } from "../../catalog-templates";
import { assetsEnglish, assetsSpanish } from "../../shared"; import { assetsEnglish, assetsSpanish } from "../../shared";
import { inventoryKeys } from "../../shared/queries"; import { inventoryKeys } from "../../shared/queries";
import { WorkstationForm, WorkstationsPage } from "../../workstations"; import { WorkstationForm, WorkstationsPage } from "../../workstations";
import { AssetSelectionPanel } from "../detail/ui/asset-selection-panel";
import { AssetInventoryPage } from "../list"; import { AssetInventoryPage } from "../list";
import { AssetForm } from "./ui"; import { AssetForm } from "./ui";
@ -218,3 +220,80 @@ test("Puestos de trabajo: altas rápidas y ayuda inferior; un activo anidado no
assert.ok(!nested.includes(`aria-label="${label}"`)); assert.ok(!nested.includes(`aria-label="${label}"`));
} }
}); });
test("la ficha lateral agrupa información y ofrece acciones superiores y enlaces reales", async () => {
const fieldId = UniqueID.generateNewID().toPrimitive();
const personId = UniqueID.generateNewID().toPrimitive();
const siteId = UniqueID.generateNewID().toPrimitive();
const asset = AssetSchema.parse({
id: UniqueID.generateNewID().toPrimitive(),
companyId,
specification: {
templateId: fieldId,
templateName: "Impresora",
fields: [
{ fieldId, name: "Número de serie", type: "text", unit: "", choices: [], required: false },
],
},
values: {
name: "Impresora de administración",
templateId: fieldId,
siteId,
personId,
status: "in_use",
notes: "Uso compartido",
characteristics: { [fieldId]: "ABC123" },
},
history: [
{
id: fieldId,
actorId: personId,
actorName: "Técnico",
recordedAt: "2026-10-09T10:00:00.000Z",
values: {
name: "Impresora de administración",
templateId: fieldId,
siteId,
personId,
status: "in_use",
notes: "Uso compartido",
characteristics: { [fieldId]: "ABC123" },
},
},
],
});
const html = await render(
"es",
<AssetSelectionPanel
asset={asset}
canEdit
companyId={companyId}
csrfToken="test"
lookups={{
...lookups,
people: [{ id: personId, name: "Ana", active: true }],
sites: [{ id: siteId, name: "Fábrica" }],
}}
onClose={() => undefined}
onEdit={() => undefined}
onView={() => undefined}
open
/>
);
assert.ok(html.indexOf("Ficha e historial") < html.indexOf("Identificación"));
assert.ok(html.indexOf(">Editar<") < html.indexOf("Identificación"));
for (const group of [
"Identificación",
"Ubicación y asignación",
"Características",
"Observaciones",
]) {
assert.ok(html.includes(group));
}
assert.ok(html.includes("<summary"));
assert.ok(html.includes('open=""'));
assert.ok(html.includes(`href="/companies/${companyId}/people/${personId}"`));
assert.ok(html.includes(`href="/companies/${companyId}/sites"`));
assert.ok(html.includes(`href="/companies/${companyId}/catalogs/assets?tab=templates"`));
assert.ok(html.includes("ABC123"));
});

View File

@ -1,4 +1,5 @@
export type AssetSupportProps = { export type AssetSupportProps = {
compact?: boolean;
companyId: string; companyId: string;
assetId: string; assetId: string;
csrfToken: string; csrfToken: string;

View File

@ -9,7 +9,7 @@ import { useAssetSupportController } from "../controllers";
import type { AssetSupportProps } from "../types"; import type { AssetSupportProps } from "../types";
export function AssetSupport(props: AssetSupportProps) { export function AssetSupport(props: AssetSupportProps) {
const { companyId, assetId, csrfToken, retired = false } = props; const { companyId, assetId, csrfToken, retired = false, compact = false } = props;
const { const {
query, query,
open, open,
@ -27,9 +27,9 @@ export function AssetSupport(props: AssetSupportProps) {
} = useAssetSupportController(props); } = useAssetSupportController(props);
const { t } = useTranslation("assets"); const { t } = useTranslation("assets");
return ( return (
<section className="space-y-3 rounded-md border p-4"> <section className={compact ? "space-y-3" : "space-y-3 rounded-md border p-4"}>
<header className="flex flex-wrap items-center justify-between gap-3"> <header className="flex flex-wrap items-center justify-between gap-3">
<h3 className="font-semibold">{t("support.title")}</h3> {!compact && <h3 className="font-semibold">{t("support.title")}</h3>}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button onClick={() => setToolsOpen(true)} size="sm" variant="outline"> <Button onClick={() => setToolsOpen(true)} size="sm" variant="outline">
{t("support.tools")} {t("support.tools")}

View File

@ -1,3 +1,4 @@
import { DetailField } from "@repo/rdx-ui/components/detail-field";
import { FieldSections } from "@repo/rdx-ui/components/field-sections"; import { FieldSections } from "@repo/rdx-ui/components/field-sections";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -35,15 +36,17 @@ export function ResourceDetail({
<FieldSections <FieldSections
items={resource.specification.fields} items={resource.specification.fields}
renderItem={(field) => ( renderItem={(field) => (
<div key={field.fieldId}> <DetailField
<dt className="text-sm text-muted-foreground"> key={field.fieldId}
{field.name} label={
{field.unit ? ` (${field.unit})` : ""} <>
</dt> {field.name}
<dd className="whitespace-pre-wrap break-words"> {field.unit ? ` (${field.unit})` : ""}
{display(resource.values.characteristics[field.fieldId])} </>
</dd> }
</div> >
{display(resource.values.characteristics[field.fieldId])}
</DetailField>
)} )}
sections={resource.specification.sections} sections={resource.specification.sections}
ungroupedLabel={t("sections.ungrouped")} ungroupedLabel={t("sections.ungrouped")}

View File

@ -0,0 +1,13 @@
import type { ReactNode } from "react";
/** Etiqueta y valor responden al ancho de su ficha, no al ancho total del navegador. */
export function DetailField({ label, children }: { label: ReactNode; children: ReactNode }) {
return (
<div className="grid min-w-0 gap-1 border-b border-border/60 py-2.5 first:pt-0 last:border-0 last:pb-0 @min-[240px]:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] @min-[240px]:gap-3">
<dt className="text-xs leading-5 text-muted-foreground">{label}</dt>
<dd className="min-w-0 whitespace-pre-wrap break-words text-sm font-medium leading-5 [&_a]:cursor-pointer [&_a]:text-primary [&_a]:underline-offset-4 [&_a:hover]:underline [&_a:focus-visible]:outline-2 [&_a:focus-visible]:outline-ring">
{children}
</dd>
</div>
);
}

View File

@ -0,0 +1,13 @@
import type { ReactNode } from "react";
/** Agrupación plegable accesible para fichas; comienza abierta sin ocultar información. */
export function DetailSection({ title, children }: { title: string; children: ReactNode }) {
return (
<details className="@container border-t border-border/60 first:border-t-0" open>
<summary className="cursor-pointer py-2 text-sm font-semibold focus-visible:outline-2 focus-visible:outline-ring">
{title}
</summary>
<div className="space-y-3 pb-2 pt-1">{children}</div>
</details>
);
}

View File

@ -29,9 +29,7 @@ export function FieldSections<T extends { sectionId?: string | undefined }>({
}, [hasErrors]); }, [hasErrors]);
const content = (fields: readonly T[]) => const content = (fields: readonly T[]) =>
values ? ( values ? (
<dl className="grid gap-4 sm:grid-cols-2 @min-[900px]:grid-cols-3"> <dl className="@container">{fields.map(renderItem)}</dl>
{fields.map(renderItem)}
</dl>
) : ( ) : (
<div className="grid gap-4 sm:grid-cols-2 @min-[900px]:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2 @min-[900px]:grid-cols-3">
{fields.map(renderItem)} {fields.map(renderItem)}
@ -62,11 +60,17 @@ export function FieldSections<T extends { sectionId?: string | undefined }>({
return null; return null;
} }
return ( return (
<details className="@container rounded-md border" key={section.id} open> <details
<summary className="cursor-pointer rounded-md px-4 py-3 text-sm font-medium focus-visible:outline-2 focus-visible:outline-ring"> className={values ? "@container" : "@container rounded-md border"}
key={section.id}
open
>
<summary
className={`cursor-pointer text-sm font-medium focus-visible:outline-2 focus-visible:outline-ring ${values ? "py-2" : "rounded-md px-4 py-3"}`}
>
{section.name} {section.name}
</summary> </summary>
<div className="space-y-3 border-t p-4"> <div className={values ? "space-y-3 pt-1" : "space-y-3 border-t p-4"}>
{section.description && ( {section.description && (
<p className="text-sm text-muted-foreground">{section.description}</p> <p className="text-sm text-muted-foreground">{section.description}</p>
)} )}

View File

@ -23,4 +23,5 @@ export {
UserRoundXIcon, UserRoundXIcon,
UsersIcon, UsersIcon,
WrenchIcon, WrenchIcon,
XIcon,
} from "lucide-react"; } from "lucide-react";