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 retirada conserva el nombre accesible del activo. Los errores se presentan junto a
la ficha y se distinguen también mediante texto. 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 ## Elección visual del ordenador principal
`WorkstationComputerField` reutiliza `AssetSummaryCard` para los activos ya incorporados `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 - 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. 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 ## Grupos, títulos y separación
- Las etiquetas de campos utilizan `FormLabel` de `@repo/rdx-ui`. Añadir `required` - 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. 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. 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. 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. 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. 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 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 `FormDialog layout="stable"`. Su altura exterior depende de la ventana y se conserva

View File

@ -10,7 +10,7 @@
}, },
"scripts": { "scripts": {
"typecheck": "tsc -p tsconfig.json", "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": { "dependencies": {
"@hookform/resolvers": "5.2.2", "@hookform/resolvers": "5.2.2",

View File

@ -149,11 +149,12 @@
"newWorkstation": "New workstation", "newWorkstation": "New workstation",
"editWorkstation": "Edit workstation", "editWorkstation": "Edit workstation",
"workstationPerson": "Workstation custodian", "workstationPerson": "Workstation custodian",
"workstationSuggestedName": "{{person}}'s workstation at {{site}}", "workstationSuggestedName": "Workstation for {{computer}} at {{site}}",
"workstationHistory": "Workstation history", "workstationHistory": "Workstation history",
"workstationAssets": "Workstation assets", "workstationAssets": "Workstation assets",
"noWorkstations": "This company has no workstations yet.", "noWorkstations": "This company has no workstations yet.",
"noWorkstationAssets": "This workstation has no assets 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", "addWorkstationAsset": "Add assets",
"workstationOccupied": "{{name}} · used in another workstation", "workstationOccupied": "{{name}} · used in another workstation",
"removeWorkstationAsset": "Remove {{name}} from workstation", "removeWorkstationAsset": "Remove {{name}} from workstation",

View File

@ -149,11 +149,12 @@
"newWorkstation": "Nuevo puesto", "newWorkstation": "Nuevo puesto",
"editWorkstation": "Editar puesto", "editWorkstation": "Editar puesto",
"workstationPerson": "Responsable del puesto", "workstationPerson": "Responsable del puesto",
"workstationSuggestedName": "Puesto de {{person}} en {{site}}", "workstationSuggestedName": "Puesto {{computer}} en {{site}}",
"workstationHistory": "Historial del puesto", "workstationHistory": "Historial del puesto",
"workstationAssets": "Activos del puesto", "workstationAssets": "Activos del puesto",
"noWorkstations": "Esta compañía todavía no tiene puestos de trabajo.", "noWorkstations": "Esta compañía todavía no tiene puestos de trabajo.",
"noWorkstationAssets": "Este puesto todavía no tiene activos.", "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", "addWorkstationAsset": "Añadir activos",
"workstationOccupied": "{{name}} · ocupado en otro puesto", "workstationOccupied": "{{name}} · ocupado en otro puesto",
"removeWorkstationAsset": "Retirar {{name}} del puesto", "removeWorkstationAsset": "Retirar {{name}} del puesto",

View File

@ -5,6 +5,7 @@ type AssetSelectBaseProps = {
id: string; id: string;
label: string; label: string;
placeholder: string; placeholder: string;
presentation?: "field" | "action";
options: readonly SelectOption[]; options: readonly SelectOption[];
sites?: readonly { id: string; name: string }[] | undefined; sites?: readonly { id: string; name: string }[] | undefined;
disabled?: boolean | 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 } = const { id, label, placeholder, options, disabled, action, invalid, describedBy, required } =
props; props;
const { t } = useTranslation("assets"); const { t } = useTranslation("assets");
const { presentation = "field" } = props;
const isAction = presentation === "action";
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const requiredDescriptionId = required ? `${id}-required` : undefined; const requiredDescriptionId = required ? `${id}-required` : undefined;
const descriptionIds = [describedBy, requiredDescriptionId].filter(Boolean).join(" "); const descriptionIds = [describedBy, requiredDescriptionId].filter(Boolean).join(" ");
@ -25,19 +27,25 @@ export function AssetSelect(props: AssetSelectProps) {
return ( 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 <Button
aria-describedby={descriptionIds || undefined} aria-describedby={descriptionIds || undefined}
aria-haspopup="dialog" aria-haspopup="dialog"
aria-invalid={invalid || undefined} aria-invalid={invalid || undefined}
aria-label={label} 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} disabled={disabled}
id={id} id={id}
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
type="button" type="button"
variant="outline" variant={isAction ? "default" : "outline"}
> >
{isAction && <span aria-hidden="true">+</span>}
{displayValue} {displayValue}
{required && ( {required && (
<span className="sr-only" id={requiredDescriptionId}> <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 () => { test("diseño del puesto: navegación traducida y acciones bloqueadas durante el guardado", async () => {
for (const language of ["es", "en"]) { for (const language of ["es", "en"]) {
for (const step of [2, 4] as const) { 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]; const computerSummary = summary.split(`${computerLabel}</dt>`)[1]?.split("</dd>")[0];
assert.ok(computerSummary); 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('data-slot="card"'));
assert.ok(computerSummary.includes(values.name)); assert.ok(computerSummary.includes(values.name));
assert.ok(computerSummary.includes(template.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]; const assetList = composition.split(`${assetsLabel}</dt>`)[1]?.split("</dd>")[0];
assert.ok(assetList); assert.ok(assetList);
assert.match(assetList, /<ul[^>]*><li[^>]*>/);
assert.ok(assetList.includes(otherAsset.values.name)); assert.ok(assetList.includes(otherAsset.values.name));
assert.equal(assetList.includes(asset.values.name), computerAssetId === null); assert.equal(assetList.includes(asset.values.name), computerAssetId === null);
assert.equal((composition.match(/data-slot="card"/g) ?? []).length, 2); assert.equal((composition.match(/data-slot="card"/g) ?? []).length, 2);

View File

@ -114,16 +114,17 @@ export function useWorkstationFormController({
const selectedId = id || null; const selectedId = id || null;
form.setValue(field, selectedId); form.setValue(field, selectedId);
if (workstation || form.formState.dirtyFields.name) { };
const updateSuggestedName = () => {
if (workstation || form.getFieldState("name").isDirty) {
return; return;
} }
const siteId = field === "siteId" ? selectedId : form.getValues("siteId"); const siteName = lookups.sites.find((item) => item.id === form.getValues("siteId"))?.name;
const personId = field === "personId" ? selectedId : form.getValues("personId"); const computerName = assets.find((item) => item.id === form.getValues("computerAssetId"))
const siteName = lookups.sites.find((item) => item.id === siteId)?.name; ?.values.name;
const name = suggestedWorkstationName( const name = suggestedWorkstationName(computerName, siteName, (computer, site) =>
lookups.people.find((item) => item.id === personId)?.name, t("workstationSuggestedName", { computer, site })
siteName,
(person, site) => t("workstationSuggestedName", { person, site })
); );
form.setValue("name", name ?? (siteName ? t("workstationNameBySite", { site: siteName }) : "")); form.setValue("name", name ?? (siteName ? t("workstationNameBySite", { site: siteName }) : ""));
@ -155,6 +156,7 @@ export function useWorkstationFormController({
if (isWizard && step !== 4) { if (isWizard && step !== 4) {
event.preventDefault(); event.preventDefault();
if (canContinue) { if (canContinue) {
updateSuggestedName();
setStep(NEXT_STEPS[step]); 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 { 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 { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -40,77 +40,93 @@ export function WorkstationAssetFields({
}) { }) {
const { t } = useTranslation("assets"); 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 ( return (
<FormSection title={t("workstationAssets")} variant="plain"> <FormSection title={t("workstationAssets")} variant="plain">
<div className="space-y-2"> <div className="space-y-2">
<FormLabel htmlFor={`${prefix}-assets`}>{t("addWorkstationAsset")}</FormLabel> <div className="flex w-full flex-wrap items-center justify-center gap-3">{actions}</div>
<AssetSelect <div className="space-y-3 pt-3">
action={{ label: t("newAsset"), onClick: onCreate, disabled: !canCreate }} <p aria-live="polite" className="text-xs font-medium text-muted-foreground">
companyId={companyId} {t("assetPicker.selected", { count: selectedIds.length })}
disabled={disabled || selectedIds.length >= 200} </p>
id={`${prefix}-assets`} <ul className="grid items-stretch gap-3 @min-[560px]:grid-cols-2">
label={t("addWorkstationAsset")} {selectedIds.map((id) => {
maxSelection={200 - selectedIds.length} const asset = assets.find((item) => item.id === id);
mode="multiple" const name = asset?.values.name ?? "—";
onValueChange={onAdd} const reason = unavailableReason(id);
options={assets
.filter((asset) => !selectedIds.includes(asset.id))
.map((asset) => {
const reason = unavailableReason(asset.id);
return { return (
value: asset.id, <li className="min-w-0" key={id}>
label: <AssetSummaryCard
reason === "ASSET_NOT_OPERATIONAL" actions={
? `${asset.values.name} · ${t(asset.values.status)}` <Button
: reason aria-label={t("removeWorkstationAsset", { name })}
? t("workstationOccupied", { name: asset.values.name }) disabled={disabled}
: asset.values.name, onClick={() => onRemove(id)}
disabled: !!reason, 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")} </ul>
sites={sites} </div>
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>
)}
</div> </div>
</FormSection> </FormSection>
); );

View File

@ -50,7 +50,7 @@ export function WorkstationForm(props: WorkstationFormProps) {
return ( return (
<> <>
<form className="flex min-h-0 flex-1 flex-col overflow-hidden" onSubmit={submit}> <form className="flex min-h-0 flex-1 flex-col overflow-hidden" onSubmit={submit}>
<FormBody> <FormBody contentLayout="fill">
{isWizard && ( {isWizard && (
<FormProgress <FormProgress
completedLabel={t("workstationSteps.completed")} completedLabel={t("workstationSteps.completed")}
@ -61,7 +61,11 @@ export function WorkstationForm(props: WorkstationFormProps) {
step={step} 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 && ( {layout.location && (
<WorkstationAssociationFields <WorkstationAssociationFields
lookups={lookups} lookups={lookups}

View File

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

View File

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

View File

@ -1,11 +1,11 @@
export function suggestedWorkstationName( export function suggestedWorkstationName(
personName: string | undefined, computerName: string | undefined,
siteName: 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 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. */ /** 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 layout = useContext(FormLayoutContext);
const fills = layout === "picker" || contentLayout === "fill";
return ( return (
<div <div
className={ 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]"}`}
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]" : ""}`
}
data-slot="form-body" data-slot="form-body"
> >
{children} {children}