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 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
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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]);
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
|
||||
@ -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");
|
||||
});
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
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. */
|
||||
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}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user