feat: implementar estado vacío y acciones en la gestión de activos de puestos

This commit is contained in:
David Arranz 2026-10-16 12:28:39 +02:00
parent 7777192d20
commit b7a1c61017
17 changed files with 301 additions and 111 deletions

View File

@ -690,6 +690,35 @@ por fila y mantienen el orden del DOM. En selección, un contador anuncia los ca
la retirada conserva el nombre accesible del activo. Los errores se presentan junto a
la ficha y se distinguen también mediante texto.
El paso de activos del puesto sin selección reutiliza `EmptyState` de `@repo/rdx-ui`:
icono de ordenador, título, explicación traducida y dos botones independientes centrados:
«Seleccionar activos» y «Nuevo activo». `AssetSelect` ofrece
`presentation="action"` para un disparador compacto y primario; `field` conserva la
presentación habitual. Ambas variantes abren el mismo selector y respetan permisos,
estados deshabilitados. El alta se ofrece mediante su propio botón con texto y permisos.
Al añadir activos aparecen las fichas conservando ambas acciones; al retirar el último
vuelve el estado vacío. El formulario utiliza `FormBody contentLayout="fill"`: el propio
cuerpo desplazable distribuye mediante flex el espacio disponible, sin un contenedor
intermedio con altura porcentual. En el paso 2, el fieldset crece y distribuye ese alto
al estado vacío, que ocupa también todo el ancho. El contenido
permanece centrado, con acciones que se adaptan a espacios estrechos y scroll cuando
se necesita; no altera la altura exterior del modal ni la ayuda anclada al pie.
## Estado vacío con acciones
`EmptyState` se importa desde `@repo/rdx-ui/components/empty-state`. Encapsula las
primitivas `Empty` de shadcn/ui con borde discontinuo, icono decorativo, título,
descripción y acciones independientes, centradas y adaptables al ancho disponible.
Recibe `icon` y `actions` como `ReactNode`, y `title` y `description` como textos
ya traducidos por el consumidor. Vincula el título y la descripción a una región
accesible con identificadores únicos; no mueve el foco ni anuncia cambios por sí mismo.
Ocupa todo el ancho y crece en un padre flex para llenar el alto restante, sin fijar
alturas. El consumidor mantiene el scroll, la cabecera y el pie del diálogo.
No conoce activos, DTO, consultas, permisos ni callbacks de negocio: el consumidor
compone sus botones o selectores con sus propios estados deshabilitados y acciones.
El paso 2 de puestos aporta un ordenador como icono y los botones de selección y alta.
## Elección visual del ordenador principal
`WorkstationComputerField` reutiliza `AssetSummaryCard` para los activos ya incorporados

View File

@ -16,6 +16,18 @@ el [inventario de modales](inventario-de-modales.md) recoge las implementaciones
- Aplicar el mismo patrón a alta, edición y copia y a entidades equivalentes. Conservar los
campos y restricciones propios de cada entidad, como el uso de las plantillas de recursos.
## Estados vacíos con incorporación
Cuando un paso todavía no contiene elementos y permite incorporarlos, reutilizar
`EmptyState` de `@repo/rdx-ui/components/empty-state`: icono, título, descripción y
acciones aportados por el consumidor. Mantener los textos traducidos y las acciones
independientes, con sus permisos y estados deshabilitados. El componente llena el ancho
y puede crecer en un contenedor flex para ocupar el alto restante. Para distribuir ese
espacio desde el cuerpo del diálogo, utilizar `FormBody contentLayout="fill"` y mantener
la cadena de crecimiento hasta el estado vacío. No depender de `min-height: 100%` en
contenedores intermedios con altura automática. Conservar el scroll, la altura exterior
estable y la ayuda inmediatamente encima del pie del modal.
## Grupos, títulos y separación
- Las etiquetas de campos utilizan `FormLabel` de `@repo/rdx-ui`. Añadir `required`

View File

@ -4,7 +4,7 @@
La sección Puestos de trabajo de cada compañía agrupa los activos utilizados habitualmente como un conjunto. Un puesto tiene UUID v7, nombre, estado activo/inactivo, sede, responsable opcional y hasta 200 activos sin duplicados. El alta exige al menos un activo y un ordenador principal. Los puestos antiguos o desactivados pueden conservar una composición vacía hasta completarlos. Una persona puede tener varios puestos. Los puestos no contienen otros puestos.
Nuevo puesto abre un modal desde la cabecera derecha; Editar abre otro modal con los datos existentes. Al crear, elegir responsable y sede propone el nombre «Puesto de {persona} en {sede}»; el técnico puede modificarlo o borrarlo antes de guardar y esa edición manual no se sobrescribe. El formulario ofrece selectores con «+» para dar de alta sedes, personas y activos sin perder sus datos. Guardar un elemento relacionado lo selecciona o incorpora al puesto; cancelarlo mantiene el formulario principal. Los elementos guardados sobreviven si posteriormente se cancela el puesto. El alta de activo desde un puesto usa plantillas y sedes existentes para evitar un tercer modal.
Nuevo puesto abre un modal desde la cabecera derecha; Editar abre otro modal con los datos existentes. Al crear, el resumen propone el nombre «{sede} · {ordenador principal}»; el técnico puede modificarlo o borrarlo antes de guardar y esa edición manual no se sobrescribe al retroceder, cambiar la sede o el ordenador y volver al resumen. Mientras el nombre no se haya editado, la propuesta se recalcula al avanzar con las selecciones actuales; el responsable no interviene en ella. El formulario ofrece selectores con «+» para dar de alta sedes, personas y activos sin perder sus datos. Guardar un elemento relacionado lo selecciona o incorpora al puesto; cancelarlo mantiene el formulario principal. Los elementos guardados sobreviven si posteriormente se cancela el puesto. El alta de activo desde un puesto usa plantillas y sedes existentes para evitar un tercer modal.
La ayuda aparece inmediatamente antes de Guardar y Cancelar. El listado permite buscar por nombre, persona o sede. Historial del puesto muestra las versiones completas, con estado, asignación y composición, fecha y responsable. La ficha de cada activo muestra los puestos vigentes en los que se utiliza.
@ -70,7 +70,7 @@ El estado mostrado del activo se calcula desde su estado técnico y los puestos
1. Sede y responsable. Si existe una única sede se selecciona automáticamente. El responsable es opcional; las altas relacionadas conservan el formulario.
2. Activos. Un diálogo con listado, búsqueda rápida y pestañas por plantilla permite añadir varios a la vez. Es obligatorio añadir al menos uno; se impiden duplicados, activos ocupados y nuevas asociaciones no operativas.
3. Ordenador principal. Se elige pulsando una de las fichas `AssetSummaryCard` de los activos seleccionados, sin abrir otro diálogo. Solo una tarjeta puede estar marcada; el borde y el radio identifican la elegida. El grupo conserva navegación por teclado mediante flechas y Espacio. Si solo queda uno se asigna automáticamente; al retirar el ordenador se limpia la selección o se asigna el único restante.
4. Resumen. Muestra sede, responsable, composición y ordenador principal. El ordenador principal reutiliza `AssetSummaryCard` del paso 3, a ancho completo y con el mismo borde destacado: nombre, plantilla, icono, características y estado, sin controles de selección. La lista de activos excluye el ordenador principal para evitar duplicarlo; si no quedan otros activos muestra `—`. El nombre propuesto por sede/responsable se puede editar antes de Guardar.
4. Resumen. Muestra sede, responsable, composición y ordenador principal. El ordenador principal reutiliza `AssetSummaryCard` del paso 3, a ancho completo y con el mismo borde destacado: nombre, plantilla, icono, características y estado, sin controles de selección. La lista de activos excluye el ordenador principal para evitar duplicarlo; si no quedan otros activos muestra `—`. El nombre propuesto por sede y ordenador principal se puede editar antes de Guardar.
El diálogo de alta utiliza tamaño medio, cabecera con icono decorativo y
`FormDialog layout="stable"`. Su altura exterior depende de la ventana y se conserva

View File

@ -10,7 +10,7 @@
},
"scripts": {
"typecheck": "tsc -p tsconfig.json",
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/api/domain/workstation.test.ts src/api/domain/asset-relationship.test.ts src/web/catalog/shared/catalog.test.tsx src/web/assets/shared/inventory.test.tsx src/api/domain/support.test.ts src/web/support-connections/shared/support.test.tsx src/api/domain/brand.test.ts src/web/brands/shared/brands.test.tsx src/api/domain/template-sections.test.ts src/web/workstations/shared/helpers/workstation-creation.test.ts src/web/assets/select/asset-selection.test.tsx src/web/catalog-fields/select/catalog-field-selection.test.tsx"
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/api/domain/workstation.test.ts src/api/domain/asset-relationship.test.ts src/web/catalog/shared/catalog.test.tsx src/web/assets/shared/inventory.test.tsx src/api/domain/support.test.ts src/web/support-connections/shared/support.test.tsx src/api/domain/brand.test.ts src/web/brands/shared/brands.test.tsx src/api/domain/template-sections.test.ts src/web/workstations/shared/helpers/workstation-creation.test.ts src/web/workstations/shared/workstation-name.test.ts src/web/assets/select/asset-selection.test.tsx src/web/catalog-fields/select/catalog-field-selection.test.tsx"
},
"dependencies": {
"@hookform/resolvers": "5.2.2",

View File

@ -149,11 +149,12 @@
"newWorkstation": "New workstation",
"editWorkstation": "Edit workstation",
"workstationPerson": "Workstation custodian",
"workstationSuggestedName": "{{person}}'s workstation at {{site}}",
"workstationSuggestedName": "Workstation for {{computer}} at {{site}}",
"workstationHistory": "Workstation history",
"workstationAssets": "Workstation assets",
"noWorkstations": "This company has no workstations yet.",
"noWorkstationAssets": "This workstation has no assets yet.",
"workstationAssetsEmptyDescription": "Select the computer and peripherals for this workstation. You can add several assets at once.",
"addWorkstationAsset": "Add assets",
"workstationOccupied": "{{name}} · used in another workstation",
"removeWorkstationAsset": "Remove {{name}} from workstation",

View File

@ -149,11 +149,12 @@
"newWorkstation": "Nuevo puesto",
"editWorkstation": "Editar puesto",
"workstationPerson": "Responsable del puesto",
"workstationSuggestedName": "Puesto de {{person}} en {{site}}",
"workstationSuggestedName": "Puesto {{computer}} en {{site}}",
"workstationHistory": "Historial del puesto",
"workstationAssets": "Activos del puesto",
"noWorkstations": "Esta compañía todavía no tiene puestos de trabajo.",
"noWorkstationAssets": "Este puesto todavía no tiene activos.",
"workstationAssetsEmptyDescription": "Selecciona el ordenador y los periféricos que formarán este puesto. Puedes añadir varios activos a la vez.",
"addWorkstationAsset": "Añadir activos",
"workstationOccupied": "{{name}} · ocupado en otro puesto",
"removeWorkstationAsset": "Retirar {{name}} del puesto",

View File

@ -5,6 +5,7 @@ type AssetSelectBaseProps = {
id: string;
label: string;
placeholder: string;
presentation?: "field" | "action";
options: readonly SelectOption[];
sites?: readonly { id: string; name: string }[] | undefined;
disabled?: boolean | undefined;

View File

@ -12,6 +12,8 @@ export function AssetSelect(props: AssetSelectProps) {
const { id, label, placeholder, options, disabled, action, invalid, describedBy, required } =
props;
const { t } = useTranslation("assets");
const { presentation = "field" } = props;
const isAction = presentation === "action";
const [open, setOpen] = useState(false);
const requiredDescriptionId = required ? `${id}-required` : undefined;
const descriptionIds = [describedBy, requiredDescriptionId].filter(Boolean).join(" ");
@ -25,19 +27,25 @@ export function AssetSelect(props: AssetSelectProps) {
return (
<>
<ButtonGroup aria-label={label} className="w-full min-w-0">
<ButtonGroup
aria-label={label}
className="w-full min-w-0 data-[presentation=action]:w-fit data-[presentation=action]:max-w-full"
data-presentation={presentation}
>
<Button
aria-describedby={descriptionIds || undefined}
aria-haspopup="dialog"
aria-invalid={invalid || undefined}
aria-label={label}
className="min-w-0 flex-1 justify-start whitespace-normal text-left"
className="min-w-0 flex-1 justify-start whitespace-normal text-left data-[presentation=action]:flex-none data-[presentation=action]:justify-center data-[presentation=action]:text-center"
data-presentation={presentation}
disabled={disabled}
id={id}
onClick={() => setOpen(true)}
type="button"
variant="outline"
variant={isAction ? "default" : "outline"}
>
{isAction && <span aria-hidden="true">+</span>}
{displayValue}
{required && (
<span className="sr-only" id={requiredDescriptionId}>

View File

@ -860,6 +860,65 @@ test("diseño del puesto: grupos compactos, columnas por contenedor y resumen si
}
});
test("activos del puesto: estado vacío visual traducido y acciones según disponibilidad", async () => {
for (const { language, title, description, action, createAction } of [
{
language: "es",
title: "Este puesto todavía no tiene activos.",
description: "periféricos",
action: "Seleccionar activos",
createAction: "Nuevo activo",
},
{
language: "en",
title: "This workstation has no assets yet.",
description: "peripherals",
action: "Select assets",
createAction: "New asset",
},
]) {
for (const { disabled, canCreate, disabledButtons } of [
{ disabled: false, canCreate: false, disabledButtons: 1 },
{ disabled: true, canCreate: true, disabledButtons: 2 },
{ disabled: false, canCreate: true, disabledButtons: 0 },
]) {
const empty = await render(
language,
<WorkstationAssetFields
assets={[]}
canCreate={canCreate}
companyId={companyId}
disabled={disabled}
onAdd={() => undefined}
onCreate={() => undefined}
onRemove={() => undefined}
prefix="empty-design"
selectedIds={[]}
sites={[]}
unavailableReason={() => undefined}
/>
);
assert.ok(empty.includes('data-slot="empty"'));
assert.ok(empty.includes('data-slot="empty-icon"'));
assert.ok(empty.includes('data-slot="empty-content"'));
assert.ok(empty.includes(title));
assert.ok(empty.includes(description));
assert.ok(empty.includes(action));
assert.ok(empty.includes(createAction));
assert.equal((empty.match(/<button\b/g) ?? []).length, 2);
assert.equal((empty.match(/data-slot="button-group"/g) ?? []).length, 1);
assert.ok(empty.includes(`${createAction}</button>`));
assert.equal(empty.includes('data-slot="card"'), false);
assert.match(
empty,
/<button(?=[^>]*aria-haspopup="dialog")(?=[^>]*data-presentation="action")[^>]*>/
);
assert.equal((empty.match(/<button[^>]* disabled=""/g) ?? []).length, disabledButtons);
}
}
});
test("diseño del puesto: navegación traducida y acciones bloqueadas durante el guardado", async () => {
for (const language of ["es", "en"]) {
for (const step of [2, 4] as const) {
@ -975,6 +1034,8 @@ test("ficha compacta de activo: comparte información, traducciones y acciones s
const computerSummary = summary.split(`${computerLabel}</dt>`)[1]?.split("</dd>")[0];
assert.ok(computerSummary);
assert.match(computerSummary, /<ul[^>]*><li[^>]*>/);
assert.equal((computerSummary.match(/<li\b/g) ?? []).length, 1);
assert.ok(computerSummary.includes('data-slot="card"'));
assert.ok(computerSummary.includes(values.name));
assert.ok(computerSummary.includes(template.values.name));
@ -1016,6 +1077,7 @@ test("ficha compacta de activo: comparte información, traducciones y acciones s
const assetList = composition.split(`${assetsLabel}</dt>`)[1]?.split("</dd>")[0];
assert.ok(assetList);
assert.match(assetList, /<ul[^>]*><li[^>]*>/);
assert.ok(assetList.includes(otherAsset.values.name));
assert.equal(assetList.includes(asset.values.name), computerAssetId === null);
assert.equal((composition.match(/data-slot="card"/g) ?? []).length, 2);

View File

@ -114,16 +114,17 @@ export function useWorkstationFormController({
const selectedId = id || null;
form.setValue(field, selectedId);
if (workstation || form.formState.dirtyFields.name) {
};
const updateSuggestedName = () => {
if (workstation || form.getFieldState("name").isDirty) {
return;
}
const siteId = field === "siteId" ? selectedId : form.getValues("siteId");
const personId = field === "personId" ? selectedId : form.getValues("personId");
const siteName = lookups.sites.find((item) => item.id === siteId)?.name;
const name = suggestedWorkstationName(
lookups.people.find((item) => item.id === personId)?.name,
siteName,
(person, site) => t("workstationSuggestedName", { person, site })
const siteName = lookups.sites.find((item) => item.id === form.getValues("siteId"))?.name;
const computerName = assets.find((item) => item.id === form.getValues("computerAssetId"))
?.values.name;
const name = suggestedWorkstationName(computerName, siteName, (computer, site) =>
t("workstationSuggestedName", { computer, site })
);
form.setValue("name", name ?? (siteName ? t("workstationNameBySite", { site: siteName }) : ""));
@ -155,6 +156,7 @@ export function useWorkstationFormController({
if (isWizard && step !== 4) {
event.preventDefault();
if (canContinue) {
updateSuggestedName();
setStep(NEXT_STEPS[step]);
}

View File

@ -1,6 +1,6 @@
import { FormLabel } from "@repo/rdx-ui/components/form-label";
import { EmptyState } from "@repo/rdx-ui/components/empty-state";
import { FormSection } from "@repo/rdx-ui/components/form-section";
import { XIcon } from "@repo/rdx-ui/lib/icons";
import { MonitorIcon, XIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next";
@ -40,77 +40,93 @@ export function WorkstationAssetFields({
}) {
const { t } = useTranslation("assets");
const actions = (
<>
<AssetSelect
companyId={companyId}
disabled={disabled || selectedIds.length >= 200}
id={`${prefix}-assets`}
label={t("addWorkstationAsset")}
maxSelection={200 - selectedIds.length}
mode="multiple"
onValueChange={onAdd}
options={assets
.filter((asset) => !selectedIds.includes(asset.id))
.map((asset) => {
const reason = unavailableReason(asset.id);
return {
value: asset.id,
label:
reason === "ASSET_NOT_OPERATIONAL"
? `${asset.values.name} · ${t(asset.values.status)}`
: reason
? t("workstationOccupied", { name: asset.values.name })
: asset.values.name,
disabled: !!reason,
};
})}
placeholder={t("assetPicker.multiple")}
presentation="action"
sites={sites}
value={[]}
/>
<Button disabled={disabled || !canCreate} onClick={onCreate} type="button" variant="outline">
<span aria-hidden="true">+</span>
{t("newAsset")}
</Button>
</>
);
if (selectedIds.length === 0) {
return (
<EmptyState
actions={actions}
description={t("workstationAssetsEmptyDescription")}
icon={<MonitorIcon />}
title={t("noWorkstationAssets")}
/>
);
}
return (
<FormSection title={t("workstationAssets")} variant="plain">
<div className="space-y-2">
<FormLabel htmlFor={`${prefix}-assets`}>{t("addWorkstationAsset")}</FormLabel>
<AssetSelect
action={{ label: t("newAsset"), onClick: onCreate, disabled: !canCreate }}
companyId={companyId}
disabled={disabled || selectedIds.length >= 200}
id={`${prefix}-assets`}
label={t("addWorkstationAsset")}
maxSelection={200 - selectedIds.length}
mode="multiple"
onValueChange={onAdd}
options={assets
.filter((asset) => !selectedIds.includes(asset.id))
.map((asset) => {
const reason = unavailableReason(asset.id);
<div className="flex w-full flex-wrap items-center justify-center gap-3">{actions}</div>
<div className="space-y-3 pt-3">
<p aria-live="polite" className="text-xs font-medium text-muted-foreground">
{t("assetPicker.selected", { count: selectedIds.length })}
</p>
<ul className="grid items-stretch gap-3 @min-[560px]:grid-cols-2">
{selectedIds.map((id) => {
const asset = assets.find((item) => item.id === id);
const name = asset?.values.name ?? "—";
const reason = unavailableReason(id);
return {
value: asset.id,
label:
reason === "ASSET_NOT_OPERATIONAL"
? `${asset.values.name} · ${t(asset.values.status)}`
: reason
? t("workstationOccupied", { name: asset.values.name })
: asset.values.name,
disabled: !!reason,
};
return (
<li className="min-w-0" key={id}>
<AssetSummaryCard
actions={
<Button
aria-label={t("removeWorkstationAsset", { name })}
disabled={disabled}
onClick={() => onRemove(id)}
size="icon-sm"
type="button"
variant="ghost"
>
<XIcon aria-hidden="true" className="size-4" />
</Button>
}
asset={asset}
error={reason ? t(`inventoryErrors.${reason}`) : undefined}
template={catalog.find((item) => item.id === asset?.values.templateId)}
/>
</li>
);
})}
placeholder={t("assetPicker.multiple")}
sites={sites}
value={[]}
/>
{selectedIds.length ? (
<div className="space-y-3 pt-3">
<p aria-live="polite" className="text-xs font-medium text-muted-foreground">
{t("assetPicker.selected", { count: selectedIds.length })}
</p>
<ul className="grid items-stretch gap-3 @min-[560px]:grid-cols-2">
{selectedIds.map((id) => {
const asset = assets.find((item) => item.id === id);
const name = asset?.values.name ?? "—";
const reason = unavailableReason(id);
return (
<li className="min-w-0" key={id}>
<AssetSummaryCard
actions={
<Button
aria-label={t("removeWorkstationAsset", { name })}
disabled={disabled}
onClick={() => onRemove(id)}
size="icon-sm"
type="button"
variant="ghost"
>
<XIcon aria-hidden="true" className="size-4" />
</Button>
}
asset={asset}
error={reason ? t(`inventoryErrors.${reason}`) : undefined}
template={catalog.find((item) => item.id === asset?.values.templateId)}
/>
</li>
);
})}
</ul>
</div>
) : (
<p className="text-sm text-muted-foreground">{t("noWorkstationAssets")}</p>
)}
</ul>
</div>
</div>
</FormSection>
);

View File

@ -50,7 +50,7 @@ export function WorkstationForm(props: WorkstationFormProps) {
return (
<>
<form className="flex min-h-0 flex-1 flex-col overflow-hidden" onSubmit={submit}>
<FormBody>
<FormBody contentLayout="fill">
{isWizard && (
<FormProgress
completedLabel={t("workstationSteps.completed")}
@ -61,7 +61,11 @@ export function WorkstationForm(props: WorkstationFormProps) {
step={step}
/>
)}
<fieldset className="space-y-5" disabled={mutation.isPending}>
<fieldset
className="space-y-5 data-[step=2]:flex data-[step=2]:flex-1 data-[step=2]:flex-col"
data-step={step}
disabled={mutation.isPending}
>
{layout.location && (
<WorkstationAssociationFields
lookups={lookups}

View File

@ -36,12 +36,14 @@ export function WorkstationSummary({
<dt className="text-muted-foreground">{t("workstationComputer")}</dt>
<dd>
{computer ? (
<div className="rounded-lg ring-2 ring-primary ring-offset-2 ring-offset-background">
<AssetSummaryCard
asset={computer}
template={catalog.find((item) => item.id === computer.values.templateId)}
/>
</div>
<ul className="grid items-stretch gap-3 @min-[560px]:grid-cols-2">
<li className="min-w-0 rounded-lg ring-2 ring-primary ring-offset-2 ring-offset-background">
<AssetSummaryCard
asset={computer}
template={catalog.find((item) => item.id === computer.values.templateId)}
/>
</li>
</ul>
) : (
"—"
)}

View File

@ -3,13 +3,13 @@ import { test } from "node:test";
import { suggestedWorkstationName } from "./workstation-name";
test("propone un nombre de puesto solo al conocer persona y sede", () => {
const format = (person: string, site: string) => `${person} @ ${site}`;
test("propone un nombre de puesto solo al conocer ordenador principal y sede", () => {
const format = (computer: string, site: string) => `${site} · ${computer}`;
assert.equal(suggestedWorkstationName("", "Fábrica", format), undefined);
assert.equal(suggestedWorkstationName("PC-01", "", format), undefined);
assert.equal(suggestedWorkstationName(undefined, "Fábrica", format), undefined);
assert.equal(suggestedWorkstationName("Ana", undefined, format), undefined);
assert.equal(
suggestedWorkstationName("Ana García", "Uecko Fábrica", format),
"Ana García @ Uecko Fábrica"
);
assert.equal(suggestedWorkstationName("PC-01", undefined, format), undefined);
assert.equal(suggestedWorkstationName("PC-01", "Uecko Fábrica", format), "Uecko Fábrica · PC-01");
assert.equal(suggestedWorkstationName("Portátil", "Oficina", format), "Oficina · Portátil");
});

View File

@ -1,11 +1,11 @@
export function suggestedWorkstationName(
personName: string | undefined,
computerName: string | undefined,
siteName: string | undefined,
format: (person: string, site: string) => string
format: (computer: string, site: string) => string
) {
if (!(personName && siteName)) {
if (!(computerName && siteName)) {
return undefined;
}
return format(personName, siteName);
return format(computerName, siteName);
}

View File

@ -0,0 +1,49 @@
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@repo/shadcn-ui/components/empty";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { type ReactNode, useId } from "react";
/** Estado vacío de incorporación; el consumidor aporta los textos traducidos y las acciones. */
export function EmptyState({
icon,
title,
description,
actions,
className,
}: {
icon: ReactNode;
title: string;
description: string;
actions: ReactNode;
className?: string;
}) {
const id = useId();
return (
<Empty
aria-describedby={`${id}-description`}
aria-labelledby={`${id}-title`}
className={cn("border bg-red-200/10 px-4 py-8 @min-[560px]:py-10 flex", className)}
role="region"
>
<EmptyHeader>
<EmptyMedia aria-hidden="true" variant="icon">
{icon}
</EmptyMedia>
<EmptyTitle className="text-base" id={`${id}-title`}>
{title}
</EmptyTitle>
<EmptyDescription id={`${id}-description`}>{description}</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<div className="flex w-full flex-wrap items-center justify-center gap-3">{actions}</div>
</EmptyContent>
</Empty>
);
}

View File

@ -109,16 +109,19 @@ export function FormActions({
}
/** El cuerpo reserva espacio simétrico para que el scroll no recorte el anillo de foco. */
export function FormBody({ children }: { children: ReactNode }) {
export function FormBody({
children,
contentLayout = "stack",
}: {
children: ReactNode;
contentLayout?: "stack" | "fill";
}) {
const layout = useContext(FormLayoutContext);
const fills = layout === "picker" || contentLayout === "fill";
return (
<div
className={
layout === "picker"
? "@container flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-1 pt-1 pb-5 [scrollbar-gutter:stable] [&>*]:shrink-0"
: `@container min-h-0 flex-1 space-y-5 overflow-y-auto px-1 pt-1 pb-5 ${layout === "stable" ? "[scrollbar-gutter:stable]" : ""}`
}
className={`@container min-h-0 flex-1 overflow-y-auto px-1 pt-1 pb-5 ${fills ? "flex flex-col [&>*]:shrink-0" : "space-y-5"} ${layout === "picker" ? "gap-3" : fills ? "gap-5" : ""} ${layout === "content" ? "" : "[scrollbar-gutter:stable]"}`}
data-slot="form-body"
>
{children}