feat: implementar estado vacío y acciones en la gestión de activos de puestos
This commit is contained in:
parent
7777192d20
commit
b7a1c61017
@ -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
|
||||||
|
|||||||
@ -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`
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
@ -451,4 +452,4 @@
|
|||||||
"fieldsDescription": "Complete the template details. Fields marked with * are required.",
|
"fieldsDescription": "Complete the template details. Fields marked with * are required.",
|
||||||
"nameDescription": "Review the suggested name, technical status, site and registration date. Add notes if you need to provide more context."
|
"nameDescription": "Review the suggested name, technical status, site and registration date. Add notes if you need to provide more context."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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",
|
||||||
@ -451,4 +452,4 @@
|
|||||||
"fieldsDescription": "Completa los datos de la plantilla. Los campos marcados con * son obligatorios.",
|
"fieldsDescription": "Completa los datos de la plantilla. Los campos marcados con * son obligatorios.",
|
||||||
"nameDescription": "Revisa el nombre propuesto, el estado técnico, la sede y la fecha de alta. Añade notas si necesitas aportar más contexto."
|
"nameDescription": "Revisa el nombre propuesto, el estado técnico, la sede y la fecha de alta. Añade notas si necesitas aportar más contexto."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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}>
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
) : (
|
) : (
|
||||||
"—"
|
"—"
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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"
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
49
packages/rdx-ui/src/components/empty-state.tsx
Normal file
49
packages/rdx-ui/src/components/empty-state.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user