diff --git a/docs/componentes-de-interfaz.md b/docs/componentes-de-interfaz.md index 5a1cd92..bf0652a 100644 --- a/docs/componentes-de-interfaz.md +++ b/docs/componentes-de-interfaz.md @@ -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 diff --git a/docs/patron-de-dialogos.md b/docs/patron-de-dialogos.md index 5261fac..bbe875c 100644 --- a/docs/patron-de-dialogos.md +++ b/docs/patron-de-dialogos.md @@ -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` diff --git a/docs/puestos-de-trabajo.md b/docs/puestos-de-trabajo.md index f5c3061..edb988d 100644 --- a/docs/puestos-de-trabajo.md +++ b/docs/puestos-de-trabajo.md @@ -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 diff --git a/modules/assets/package.json b/modules/assets/package.json index 038d89a..f25e552 100644 --- a/modules/assets/package.json +++ b/modules/assets/package.json @@ -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", diff --git a/modules/assets/src/common/locales/en.json b/modules/assets/src/common/locales/en.json index ead2079..5dbbd92 100644 --- a/modules/assets/src/common/locales/en.json +++ b/modules/assets/src/common/locales/en.json @@ -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", @@ -451,4 +452,4 @@ "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." } -} +} \ No newline at end of file diff --git a/modules/assets/src/common/locales/es.json b/modules/assets/src/common/locales/es.json index 619b81b..61b53ab 100644 --- a/modules/assets/src/common/locales/es.json +++ b/modules/assets/src/common/locales/es.json @@ -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", @@ -451,4 +452,4 @@ "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." } -} +} \ No newline at end of file diff --git a/modules/assets/src/web/assets/select/types/asset-select-props.ts b/modules/assets/src/web/assets/select/types/asset-select-props.ts index 7c792ea..d98a2da 100644 --- a/modules/assets/src/web/assets/select/types/asset-select-props.ts +++ b/modules/assets/src/web/assets/select/types/asset-select-props.ts @@ -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; diff --git a/modules/assets/src/web/assets/select/ui/asset-select.tsx b/modules/assets/src/web/assets/select/ui/asset-select.tsx index 443f197..4636a56 100644 --- a/modules/assets/src/web/assets/select/ui/asset-select.tsx +++ b/modules/assets/src/web/assets/select/ui/asset-select.tsx @@ -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 ( <> - + + + ); + + if (selectedIds.length === 0) { + return ( + } + title={t("noWorkstationAssets")} + /> + ); + } + return (
- {t("addWorkstationAsset")} - = 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); +
{actions}
+
+

+ {t("assetPicker.selected", { count: selectedIds.length })} +

+
    + {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 ( +
  • + onRemove(id)} + size="icon-sm" + type="button" + variant="ghost" + > +
  • + ); })} - placeholder={t("assetPicker.multiple")} - sites={sites} - value={[]} - /> - {selectedIds.length ? ( -
    -

    - {t("assetPicker.selected", { count: selectedIds.length })} -

    -
      - {selectedIds.map((id) => { - const asset = assets.find((item) => item.id === id); - const name = asset?.values.name ?? "—"; - const reason = unavailableReason(id); - - return ( -
    • - onRemove(id)} - size="icon-sm" - type="button" - variant="ghost" - > -
    • - ); - })} -
    -
    - ) : ( -

    {t("noWorkstationAssets")}

    - )} +
+
); diff --git a/modules/assets/src/web/workstations/shared/ui/workstation-form.tsx b/modules/assets/src/web/workstations/shared/ui/workstation-form.tsx index fe9fb6a..adbb2b8 100644 --- a/modules/assets/src/web/workstations/shared/ui/workstation-form.tsx +++ b/modules/assets/src/web/workstations/shared/ui/workstation-form.tsx @@ -50,7 +50,7 @@ export function WorkstationForm(props: WorkstationFormProps) { return ( <>
- + {isWizard && ( )} -
+
{layout.location && ( {t("workstationComputer")}
{computer ? ( -
- item.id === computer.values.templateId)} - /> -
+
    +
  • + item.id === computer.values.templateId)} + /> +
  • +
) : ( "—" )} diff --git a/modules/assets/src/web/workstations/shared/workstation-name.test.ts b/modules/assets/src/web/workstations/shared/workstation-name.test.ts index aeca901..2afdbc2 100644 --- a/modules/assets/src/web/workstations/shared/workstation-name.test.ts +++ b/modules/assets/src/web/workstations/shared/workstation-name.test.ts @@ -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"); }); diff --git a/modules/assets/src/web/workstations/shared/workstation-name.ts b/modules/assets/src/web/workstations/shared/workstation-name.ts index ca493a0..b1298fe 100644 --- a/modules/assets/src/web/workstations/shared/workstation-name.ts +++ b/modules/assets/src/web/workstations/shared/workstation-name.ts @@ -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); } diff --git a/packages/rdx-ui/src/components/empty-state.tsx b/packages/rdx-ui/src/components/empty-state.tsx new file mode 100644 index 0000000..d06aeb1 --- /dev/null +++ b/packages/rdx-ui/src/components/empty-state.tsx @@ -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 ( + + + + + {title} + + {description} + + +
{actions}
+
+
+ ); +} diff --git a/packages/rdx-ui/src/components/form-dialog.tsx b/packages/rdx-ui/src/components/form-dialog.tsx index b9774a5..d363db2 100644 --- a/packages/rdx-ui/src/components/form-dialog.tsx +++ b/packages/rdx-ui/src/components/form-dialog.tsx @@ -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 (
*]: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}