feat: enhance work profile management with new components and functionality

- Updated WorkstationDetailPage to replace BriefcaseIcon with MonitorIcon.
- Modified package.json to include new test files.
- Refactored FormStepActions to improve button state management.
- Introduced profile-creation helper functions for better profile management.
- Added WorkProfileAccountField and WorkProfileAuthorizationNotice components for account handling.
- Created WorkProfileComputerSummary and WorkProfileSummary for displaying profile details.
- Implemented WorkProfileFormFields to consolidate form fields for work profiles.
- Developed WorkProfileIdentityFields for managing identity-related inputs.
- Added WorkProfileSelectionField for selecting accounts, workstations, and persons.
- Created OptionPickerDialog and OptionPickerList for enhanced option selection.
- Introduced OptionSelectionField for visual selection with actions.
- Added tests for selection components to ensure functionality and reliability.
This commit is contained in:
David Arranz 2026-10-16 13:11:52 +02:00
parent b7a1c61017
commit 94ddc5cf16
33 changed files with 1601 additions and 272 deletions

View File

@ -743,3 +743,48 @@ escribir desde la apertura, sin anunciar falsos vacíos. El foco no se reaplica
o recargar datos, y la primitiva conserva la restauración al cerrar diálogos anidados.
La [regla normativa](patron-de-dialogos.md#foco-inicial-en-la-búsqueda-rápida) se aplica
a todos los PickerList actuales y futuros, incluidos los de campos de ambos catálogos.
## Selección y revisión de perfiles de usuario
El alta de perfiles sigue cuatro pasos: puesto y ordenador derivado, persona elegida
expresamente, cuenta opcional y resumen con nombre, notas y estado. Reutiliza el progreso,
las acciones y el cuerpo de los puestos, con tamaño medio y altura estable; la edición
conserva el formulario completo y el tamaño grande, sin cambiar persona ni dispositivo.
El nombre propuesto es «Persona · Ordenador», limitado por el contrato del perfil; una
edición manual se conserva al retroceder y volver al resumen. Véase
[Perfiles de usuario](perfiles-de-trabajo.md).
Componentes genéricos de `@repo/rdx-ui`, independientes de DTO, consultas y traducciones:
- `FormHelp` encapsula `ContextHelp` y su área inferior con altura limitada y scroll propio.
Perfiles y puestos lo sitúan como hermano de `FormBody`, inmediatamente antes del pie.
- `FormStepActions` comparte Anterior, Siguiente y Guardar: `canContinue` bloquea el envío
en cualquier paso; durante el guardado bloquea también Cancelar y Anterior. El consumidor
aporta todos los textos y la navegación. Los puestos usan el mismo componente.
- `SelectionSummaryCard` recibe `icon`, `title`, `description` y acciones opcionales.
Presenta una ficha de consulta sin decidir cómo elegir ni consultar una entidad.
- `OptionSelectionField` recibe opciones `{ id, label, description, disabled? }`, valor,
icono, textos, idioma y callbacks. Sin valor muestra `EmptyState`; con selección muestra
una ficha en `ul`/`li`. Ofrece elegir/cambiar, alta opcional y retirada opcional como
acciones independientes. `locked` impide cambiar o retirar y oculta el alta; `disabled`
y `loading` bloquean las acciones que requieren disponibilidad.
- `OptionPickerDialog` conserva una elección provisional hasta confirmar; cancelar no
modifica el formulario. `OptionPickerList` reutiliza `EntityTable layout="fill"` y radios,
con selección al pulsar zonas no interactivas de la fila y opciones no disponibles
deshabilitadas. El diálogo usa `FormDialog layout="picker"`: búsqueda rápida con foco
inicial, altura estable y columna `selection` compacta al principio.
El módulo de perfiles aporta las opciones disponibles de esta compañía, mantiene el puesto
fijado cuando procede de su ficha y deriva el ordenador de su principal. No ofrece alta
de puesto desde este selector: un puesto incompleto debe completarse antes. Persona y
cuenta conservan sus altas rápidas, condicionadas por `allowQuickCreate`, sin un tercer
modal. `AssetSummaryCard`, exportada por `@metis/assets/client`, presenta el ordenador
en el paso del puesto y una sola vez en el resumen. Persona y cuenta se presentan en
listas sin duplicar el ordenador; los valores ausentes muestran `—`.
La autorización de cuenta sigue siendo una acción explícita y la cuenta es opcional.
Una autorización pendiente se explica tanto en su paso como en el resumen, con acción
para registrarla cuando está permitida. Guardar solo se ofrece al final del alta. La edición
conserva las excepciones de dominio para modificar nombre/notas o archivar perfiles con
referencias que requieren revisión; el servidor mantiene la validación definitiva.

View File

@ -181,11 +181,14 @@ quedar en las pestañas, filtros, controles de selección o botones del pie.
pasos: plantilla, características y datos finales. Reutilizar `FormProgress` y
`FormStepActions`, validar antes de avanzar y conservar el borrador al retroceder.
Guardar se muestra al final; ayuda, cancelación y errores permanecen dentro del diálogo.
Los puestos y perfiles siguen cuatro pasos adaptados a sus propias referencias; comparten
`FormProgress`, `FormStepActions` y `FormHelp`. La cuenta de un perfil sigue siendo opcional
y su ordenador se deriva del puesto, sin añadir una segunda elección del dispositivo.
El alta usa tamaño medio y la edición completa conserva tamaño grande.
- Los formularios con contenido variable mantienen altura exterior estable según la
regla de estabilidad; sus grupos interiores conservan altura según contenido.
No introducir pestañas ni pasos para resolver solo espacio.
- Situar la ayuda contextual general inmediatamente antes de `FormActions`. En los
- Reutilizar `FormHelp` para la ayuda contextual general inmediatamente antes de `FormActions`. En los
diálogos de altura estable debe quedar anclada abajo, fuera del scroll principal:
campos, progreso y errores se desplazan en `FormBody`, mientras la ayuda permanece
pegada al pie incluso en pasos cortos. Eliminar márgenes superiores innecesarios y

View File

@ -22,9 +22,20 @@ Los puestos agrupan un único conjunto de activos. Consultar [Puestos de trabajo
Un perfil vigente muestra un aviso si la persona se desactiva, el equipo se retira, deja de ser el ordenador principal de un puesto activo o la cuenta pierde disponibilidad o autorización. Se conservan sus valores y revisiones. Se permite editar nombre/notas y archivarlo aunque necesite revisión. Crear, reactivar o cambiar la cuenta de un perfil vigente exige referencias disponibles. Las referencias siempre deben pertenecer a la compañía, incluso al archivar.
Persona y cuenta reutilizan SelectWithAction para altas en un modal adicional. Se conserva el formulario padre y el elemento guardado queda seleccionado. La autorización de cuenta también puede registrarse mediante una acción explícita. El editor adicional es compartido con los correos; no abre un tercer nivel de alta. Cancelar mantiene los valores del padre. Una creación ya guardada permanece aunque se cancele el perfil.
Persona y cuenta permiten altas rápidas en un modal adicional cuando están habilitadas para el consumidor. Las acciones de seleccionar y crear se presentan por separado. Se conserva el formulario padre y el elemento guardado queda seleccionado. La autorización de cuenta también puede registrarse mediante una acción explícita. El editor adicional es compartido con los correos; no abre un tercer nivel de alta. Cancelar mantiene los valores del padre. Una creación ya guardada permanece aunque se cancele el perfil.
La ayuda aparece inmediatamente antes del pie con Guardar y Cancelar. Se mantienen traducciones es/en y estados de carga, error y vacío.
La ayuda permanece anclada inmediatamente antes del pie, fuera del scroll principal. El diálogo mantiene una altura estable al cambiar de paso o de selección, con desplazamiento interno y cabecera y acciones visibles. Se mantienen traducciones es/en y estados de carga, error y vacío, siguiendo el [patrón de diálogos](patron-de-dialogos.md).
## Alta en cuatro pasos
1. **Puesto.** Selecciona un puesto activo existente y muestra su ordenador principal mediante una tarjeta de activo. Desde la ficha de un puesto, este queda fijado. El ordenador se deriva de la composición del puesto y no es un campo editable; debe seguir perteneciendo a ella y no estar retirado. Si falta un ordenador disponible, se indica que hay que completar el puesto primero. Este formulario no crea puestos.
2. **Persona.** Selecciona una persona activa de la compañía de forma explícita, sin tomarla del responsable del puesto. Permite crear una persona mediante la acción de alta rápida cuando está habilitada.
3. **Cuenta.** Permite seleccionar una cuenta de inicio de sesión o continuar sin cuenta. La creación de cuentas y el registro de su autorización se ofrecen como acciones explícitas cuando están habilitadas. Seleccionar una cuenta no concede autorización ni configura el equipo; se conservan las comprobaciones de disponibilidad y vigencia.
4. **Resumen.** Reúne persona, puesto, ordenador y cuenta sin repetir el ordenador en otra lista. Propone el nombre «Persona · Ordenador principal», permite editarlo y completar notas y estado. Una edición manual del nombre se conserva al retroceder y cambiar selecciones; mientras no se haya editado, la propuesta sigue las selecciones actuales. Solo este paso ofrece Guardar.
Los pasos conservan los valores al avanzar, retroceder o cerrar un selector. Las selecciones se presentan mediante tarjetas y se cambian desde diálogos `PickerList`, que sitúan el foco inicial en la búsqueda rápida. Cuando todavía no hay selección, `EmptyState` ocupa el ancho y alto libres del contenido con icono, explicación y acciones adecuadas; las acciones de seleccionar y crear permanecen separadas. Las tarjetas de activos reutilizan `AssetSummaryCard` dentro de listas `ul`/`li` y el resumen las presenta en modo de consulta.
La edición conserva el formulario completo, con persona y dispositivo fijos. Nombre, cuenta, notas y estado mantienen las reglas de modificación y revisión existentes.
## Arquitectura y persistencia
@ -44,7 +55,7 @@ La base del perfil incorpora ahora [buzones y configuraciones de correo](correos
El perfil incorpora también carpetas compartidas e [impresoras por perfil](impresoras-del-perfil.md), con acciones para abrir cada consulta filtrada. Las [aplicaciones por perfil](aplicaciones-del-perfil.md) están implementadas sobre recursos. Los [certificados por perfil](certificados-del-perfil.md) también están implementados. La ficha [Entorno actual](entorno-actual.md) reúne estas configuraciones. El mapa visual queda aplazado.
La comprobación en navegador verifica la nueva sección, los desplegables de persona y dispositivo, la apertura de las tres altas rápidas y la conservación del formulario al cancelarlas. En puestos se comprueba la carga de una composición anterior, el selector de activos, su incorporación visual y la cancelación sin persistir cambios.
La comprobación en navegador debe verificar los cuatro pasos, los selectores de puesto, persona y cuenta, el foco inicial de su búsqueda, las altas rápidas habilitadas y la conservación del formulario al cancelarlas. También debe comprobar la altura estable, el estado vacío, la ayuda junto al pie, el resumen sin duplicados y la conservación del nombre editado al retroceder. En puestos se comprueba la carga de una composición anterior, el selector de activos, su incorporación visual y la cancelación sin persistir cambios.
## Alta desde el puesto

View File

@ -1,6 +1,6 @@
import { BrowserLink } from "@metis/core/client";
import { EntityStatus } from "@repo/rdx-ui/components/entity-list";
import { BriefcaseIcon, LayoutTemplateIcon } from "@repo/rdx-ui/lib/icons";
import { LayoutTemplateIcon, MonitorIcon } from "@repo/rdx-ui/lib/icons";
import { useTranslation } from "react-i18next";
import { useAssetDetailController } from "../controllers";
@ -37,7 +37,7 @@ export function AssetPanelSummary(props: AssetDetailProps) {
</div>
<div className="flex items-start gap-2">
<dt className="shrink-0 pt-0.5 text-muted-foreground">
<BriefcaseIcon aria-hidden="true" className="size-4" />
<MonitorIcon aria-hidden="true" className="size-4" />
<span className="sr-only">{t("workstationInAsset")}</span>
</dt>
<dd className="min-w-0">

View File

@ -928,7 +928,9 @@ test("diseño del puesto: navegación traducida y acciones bloqueadas durante el
);
assert.ok(html.includes(language === "es" ? "Anterior" : "Previous"));
assert.ok(html.includes(language === "es" ? "Guardando" : "Saving"));
const text = language === "es" ? assetsSpanish : assetsEnglish;
assert.ok(html.includes(step === 4 ? text.saving : text.workstationSteps.next));
assert.equal((html.match(/<button[^>]*\sdisabled=""/g) ?? []).length, 2);
assert.ok(html.includes('aria-hidden="true"'));
}

View File

@ -1,5 +1,6 @@
export { AssetForm, AssetInventoryPage } from "./assets";
export { AssetSelect } from "./assets/select/ui/asset-select";
export { AssetSummaryCard } from "./assets/shared/ui/asset-summary-card";
export { BrandCatalogPage } from "./brands";
export { AssetCatalogPage } from "./catalog";
export { catalogApi, catalogKeys } from "./catalog/shared/api";

View File

@ -1,6 +1,4 @@
import { FormActions } from "@repo/rdx-ui/components/form-dialog";
import { ChevronLeftIcon, ChevronRightIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { FormStepActions } from "@repo/rdx-ui/components/form-step-actions";
import { useTranslation } from "react-i18next";
export function WorkstationFormActions({
@ -15,20 +13,17 @@ export function WorkstationFormActions({
onPrevious: () => void;
}) {
const { t } = useTranslation("assets");
const action = step && step < 4 ? "workstationSteps.next" : "save";
return (
<FormActions cancelDisabled={pending}>
{step && step > 1 && (
<Button disabled={pending} onClick={onPrevious} type="button" variant="outline">
<ChevronLeftIcon aria-hidden="true" className="size-4" />
{t("workstationSteps.previous")}
</Button>
)}
<Button disabled={pending || !canSubmit} type="submit">
{t(pending ? "saving" : action)}
{step && step < 4 && <ChevronRightIcon aria-hidden="true" className="size-4" />}
</Button>
</FormActions>
<FormStepActions
canContinue={canSubmit}
nextLabel={t("workstationSteps.next")}
onPrevious={onPrevious}
pending={pending}
previousLabel={t("workstationSteps.previous")}
saveLabel={t(pending ? "saving" : "save")}
step={step}
total={4}
/>
);
}

View File

@ -1,5 +1,5 @@
import { ContextHelp } from "@repo/rdx-ui/components/context-help";
import { FormBody } from "@repo/rdx-ui/components/form-dialog";
import { FormHelp } from "@repo/rdx-ui/components/form-help";
import { FormProgress } from "@repo/rdx-ui/components/form-progress";
import { useId } from "react";
import { useTranslation } from "react-i18next";
@ -130,17 +130,14 @@ export function WorkstationForm(props: WorkstationFormProps) {
)}
<InventoryFeedback error={mutation.error ?? inventory.error ?? catalog.error} />
</FormBody>
<div className="min-h-0 max-h-[40%] shrink-0 overflow-y-auto px-1 pb-3 [scrollbar-gutter:stable] [&>aside]:mt-0">
<ContextHelp
description={t("workstationDescription")}
examples={t("workstationExamples")}
examplesLabel={t("examplesLabel")}
guidance={t("workstationGuidance")}
scopeNote={t("workstationScope")}
title={t("helpTitle")}
variant="plain"
/>
</div>
<FormHelp
description={t("workstationDescription")}
examples={t("workstationExamples")}
examplesLabel={t("examplesLabel")}
guidance={t("workstationGuidance")}
scopeNote={t("workstationScope")}
title={t("helpTitle")}
/>
<WorkstationFormActions
canSubmit={isWizard ? canContinue : assetsReady}
onPrevious={previousStep}

View File

@ -543,7 +543,50 @@
"lifecycleHelp": "To delete a profile, deactivate it first and delete its linked configuration records. Deactivating preserves history and does not revoke external access.",
"workstation": "Workstation",
"chooseWorkstation": "Select a workstation",
"workstationNeedsComputer": "Edit this workstation and identify an available main computer before creating the profile."
"workstationNeedsComputer": "Edit this workstation and identify an available main computer before creating the profile.",
"suggestedName": "{{person}} · {{computer}}",
"steps": {
"label": "New profile steps",
"completed": "Completed",
"progress": "Step {{step}} of {{total}}",
"workstation": "Workstation",
"person": "Person",
"account": "Account",
"summary": "Summary",
"details": "Profile details",
"selected": "Selected details",
"previous": "Previous",
"next": "Next",
"workstationDescription": "Select the workstation where the profile will be used. Its main computer becomes the profile device.",
"personDescription": "Choose the person who will use this profile. This can be someone other than the workstation owner.",
"accountDescription": "Link a sign-in account or continue without one. If you choose an account, review its authorization.",
"summaryDescription": "Review your selections and adjust the name, notes and status before saving."
},
"selection": {
"change": "Change selection",
"clear": "Remove account",
"column": "Selection",
"details": "Details",
"confirm": "Select",
"workstation": {
"empty": "Choose the profile workstation",
"description": "Select an active workstation in this company with an available main computer. If none exists yet, complete the workstation first.",
"select": "Select workstation",
"search": "Search by workstation or computer"
},
"person": {
"empty": "Who will use this profile?",
"description": "Select an active person in the company or create a new one. The workstation owner is not assigned automatically.",
"select": "Select person",
"search": "Search by person, position or email"
},
"account": {
"empty": "Add a sign-in account",
"description": "The account is optional. Select an existing one, create a new one, or continue without linking an account.",
"select": "Select account",
"search": "Search by account, system or usage"
}
}
},
"companySpaces": "Company spaces",
"globalCatalogs": "Global catalogs",

View File

@ -543,7 +543,50 @@
"lifecycleHelp": "Para eliminar un perfil, primero desactívalo y elimina sus configuraciones vinculadas. Desactivar conserva el historial y no retira accesos externos.",
"workstation": "Puesto de trabajo",
"chooseWorkstation": "Selecciona un puesto de trabajo",
"workstationNeedsComputer": "Edita el puesto e identifica un ordenador principal disponible antes de crear el perfil."
"workstationNeedsComputer": "Edita el puesto e identifica un ordenador principal disponible antes de crear el perfil.",
"suggestedName": "{{person}} · {{computer}}",
"steps": {
"label": "Pasos del nuevo perfil",
"completed": "Completado",
"progress": "Paso {{step}} de {{total}}",
"workstation": "Puesto",
"person": "Persona",
"account": "Cuenta",
"summary": "Resumen",
"details": "Datos del perfil",
"selected": "Datos seleccionados",
"previous": "Anterior",
"next": "Siguiente",
"workstationDescription": "Selecciona el puesto donde se utilizará el perfil. Su ordenador principal será el dispositivo del perfil.",
"personDescription": "Elige expresamente la persona que utilizará este perfil. Puede ser distinta del responsable del puesto.",
"accountDescription": "Puedes vincular una cuenta de inicio de sesión o continuar sin ella. Si la eliges, revisa su autorización.",
"summaryDescription": "Revisa las selecciones y ajusta el nombre, las notas y el estado antes de guardar."
},
"selection": {
"change": "Cambiar selección",
"clear": "Quitar cuenta",
"column": "Selección",
"details": "Detalles",
"confirm": "Seleccionar",
"workstation": {
"empty": "Elige el puesto de trabajo del perfil",
"description": "Selecciona un puesto activo de esta compañía con un ordenador principal disponible. Si todavía no existe, completa primero el puesto de trabajo.",
"select": "Seleccionar puesto",
"search": "Buscar por puesto u ordenador"
},
"person": {
"empty": "¿Quién utilizará este perfil?",
"description": "Selecciona una persona activa de la compañía o crea una nueva. El responsable del puesto no se asigna automáticamente.",
"select": "Seleccionar persona",
"search": "Buscar por persona, puesto o email"
},
"account": {
"empty": "Añade una cuenta de inicio de sesión",
"description": "La cuenta es opcional. Selecciona una existente, crea una nueva o continúa sin vincular ninguna.",
"select": "Seleccionar cuenta",
"search": "Buscar por cuenta, sistema o uso"
}
}
},
"companySpaces": "Espacios de compañías",
"globalCatalogs": "Catálogos globales",

View File

@ -10,6 +10,7 @@ import {
LayoutTemplateIcon,
MailIcon,
MapPinIcon,
MonitorIcon,
UsersIcon,
} from "@repo/rdx-ui/lib/icons";
import { Avatar, AvatarBadge } from "@repo/shadcn-ui/components/avatar";
@ -106,7 +107,7 @@ export function CompanyDetail(props: CompanyDetailProps) {
[
["overview", t("companyOverview.navigation"), HomeIcon],
["people", `${t("people")} (${company.people.length})`, UsersIcon],
["workstations", assetTranslation("workstations"), BriefcaseIcon],
["workstations", assetTranslation("workstations"), MonitorIcon],
["profiles", t("profiles.title"), UsersIcon],
["inventory", assetTranslation("inventory"), BoxIcon],
["resources", resourceTranslation("inventory"), LayersIcon],

View File

@ -1,8 +1,10 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { FormEvent } from "react";
import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { WorkProfileInput } from "../../../../common/work-profiles";
import { WorkProfileInputSchema } from "../../../../common/work-profiles";
@ -11,6 +13,8 @@ import { accessLabel } from "../../../access/shared/helpers";
import { accessKeys } from "../../../access/shared/queries";
import type { ConfigurationQuickKind } from "../../../shared/types";
import { profileApi, profileKeys } from "../api";
import type { ProfileCreationStep } from "../helpers/profile-creation";
import { profileCreationReady, suggestedProfileName } from "../helpers/profile-creation";
import type { WorkProfileFormProps } from "../types";
export function useWorkProfileFormController({
@ -20,6 +24,9 @@ export function useWorkProfileFormController({
onSaved,
}: WorkProfileFormProps) {
const client = useQueryClient();
const { t } = useTranslation("companies");
const isWizard = !profile;
const [step, setStep] = useState<ProfileCreationStep>(1);
const mounted = useRef(true);
useEffect(() => {
@ -42,7 +49,11 @@ export function useWorkProfileFormController({
const workstations = (inventory.data?.workstations ?? []).filter(
(station) => station.companyId === company.id && station.values.active
);
const workstation = workstations.find((station) => station.id === selectedWorkstationId);
const workstation = workstations.find((station) =>
profile
? station.values.computerAssetId === profile.values.assetId
: station.id === selectedWorkstationId
);
const computer = (inventory.data?.assets ?? []).find(
(asset) =>
asset.id === workstation?.values.computerAssetId &&
@ -98,37 +109,102 @@ export function useWorkProfileFormController({
},
});
const disabled = mutation.isPending;
const selectors = [
{
field: "personId",
label: "person",
create: "newPerson",
kind: "person",
options: company.people
.filter((item) => item.active || item.id === values.personId)
.map((item) => ({ value: item.id, label: item.name })),
},
{
field: "accountId",
label: "account",
create: "newAccount",
kind: "account",
options: items
.filter(
(item) =>
item.values.kind === "account" &&
((item.values.active && item.values.usage !== "technical") ||
item.id === values.accountId)
)
.map((item) => ({ value: item.id, label: accessLabel(item, items, company.people) })),
},
] as const;
const submit = form.handleSubmit((input) => {
if (targetReady) {
const person = company.people.find((item) => item.id === values.personId);
const personOptions = company.people
.filter((item) => item.active || item.id === values.personId)
.map((item) => ({
id: item.id,
label: item.name,
description: item.position || item.email || "—",
disabled: !item.active,
}));
const accountOptions = items.flatMap((item) => {
if (
item.values.kind !== "account" ||
!((item.values.active && item.values.usage !== "technical") || item.id === values.accountId)
) {
return [];
}
return [
{
id: item.id,
label: accessLabel(item, items, company.people),
description: t(item.values.usage),
disabled: !item.values.active || item.values.usage === "technical",
},
];
});
const workstationOptions = workstations.map((station) => {
const asset = inventory.data?.assets.find(
(item) =>
item.id === station.values.computerAssetId &&
station.values.assetIds.includes(item.id) &&
item.values.status !== "retired"
);
return {
id: station.id,
label: station.values.name,
description: asset?.values.name ?? t("profiles.workstationNeedsComputer"),
disabled: !asset,
};
});
const dataReady = !!inventory.data && !!access.data;
const canContinue = profileCreationReady(step, {
data: dataReady,
target: targetReady,
person: Boolean(person?.active),
});
const requiresAvailability =
values.active &&
(!(profile && profile.values.active) || profile.values.accountId !== values.accountId);
const canSave =
dataReady &&
targetReady &&
(!requiresAvailability ||
(Boolean(computer) &&
Boolean(person?.active) &&
authorized &&
(!values.accountId ||
accountOptions.some((option) => option.id === values.accountId && !option.disabled))));
const save = form.handleSubmit((input) => {
if (canSave) {
mutation.mutate(input);
}
});
const updateSuggestedName = () => {
if (!(profile || form.getFieldState("name").isDirty)) {
form.setValue(
"name",
suggestedProfileName(person?.name, computer?.values.name, (person, computer) =>
t("profiles.suggestedName", { person, computer })
)
);
}
};
const submit = async (event: FormEvent<HTMLFormElement>) => {
if (disabled) {
event.preventDefault();
return;
}
if (isWizard && step !== 4) {
event.preventDefault();
if (canContinue) {
updateSuggestedName();
setStep(NEXT_STEPS[step]);
}
return;
}
await save(event);
};
const previousStep = () => setStep(PREVIOUS_STEPS[step]);
const quickCreated = (field: "personId" | "accountId" | "assetId", id: string | undefined) => {
if (mounted.current && id) {
form.setValue(field, id);
@ -151,8 +227,21 @@ export function useWorkProfileFormController({
authorized,
mutation,
disabled,
selectors,
isWizard,
step,
previousStep,
canContinue,
canSave,
workstation,
personOptions,
accountOptions,
workstationOptions,
submit,
quickCreated,
};
}
const NEXT_STEPS = { 1: 2, 2: 3, 3: 4, 4: 4 } as const;
const PREVIOUS_STEPS = { 1: 1, 2: 1, 3: 2, 4: 3 } as const;
export type WorkProfileFormController = ReturnType<typeof useWorkProfileFormController>;

View File

@ -0,0 +1,28 @@
import { WorkProfileInputSchema } from "../../../../common/work-profiles";
export type ProfileCreationStep = 1 | 2 | 3 | 4;
/** La cuenta es opcional; su autorización se comprueba al guardar un perfil vigente. */
export function profileCreationReady(
step: ProfileCreationStep,
ready: {
data: boolean;
target: boolean;
person: boolean;
}
) {
return ready.data && ready.target && (step === 1 || ready.person);
}
/** No deduce al usuario del responsable del puesto. */
export function suggestedProfileName(
person: string | undefined,
computer: string | undefined,
format: (person: string, computer: string) => string
) {
return person && computer
? format(person, computer)
.slice(0, WorkProfileInputSchema.shape.name.maxLength ?? undefined)
.trim()
: "";
}

View File

@ -1,19 +1,25 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { inventoryKeys } from "@metis/assets/client";
import { assetsEnglish, assetsSpanish, inventoryKeys } from "@metis/assets/client";
import { AssetSchema } from "@metis/assets/common";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createInstance } from "i18next";
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nextProvider } from "react-i18next";
import { WorkProfileSchema } from "../../../common/work-profiles";
import { accessKeys } from "../../access/shared/queries";
import { companiesEnglish, companiesSpanish } from "../../shared";
import { WorkProfilesPage } from "../list";
import { profileKeys } from "./api";
import { profileCreationReady, suggestedProfileName } from "./helpers/profile-creation";
import { WorkProfileForm } from "./ui";
import { WorkProfileAccountField } from "./ui/work-profile-account-field";
import { WorkProfileSelectionField } from "./ui/work-profile-selection-field";
import { WorkProfileSummary } from "./ui/work-profile-summary";
const company = {
id: "0199b200-0000-7000-8000-000000000001",
@ -31,7 +37,10 @@ async function render(language: string, node: ReactNode, seed?: (client: QueryCl
await i18n.init({
lng: language,
resources: { es: { companies: companiesSpanish }, en: { companies: companiesEnglish } },
resources: {
es: { companies: companiesSpanish, assets: assetsSpanish },
en: { companies: companiesEnglish, assets: assetsEnglish },
},
});
const client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Number.POSITIVE_INFINITY } },
@ -66,41 +75,99 @@ test("perfiles: listado es/en, alta en cabecera y ausencia de formulario junto a
assert.ok(html.includes(language === "es" ? "primero desactívalo" : "deactivate it first"));
}
});
test("perfiles: cuenta opcional, altas rápidas y ayuda anterior al pie", async () => {
const html = await render("es", <WorkProfileForm company={company} onSaved={() => undefined} />);
for (const label of ["Nueva persona", "Nueva cuenta"]) {
assert.ok(html.includes(`aria-label="${label}"`));
}
assert.equal(html.includes('aria-label="Nuevo dispositivo"'), false);
assert.ok(html.includes("Puesto de trabajo"));
assert.ok(html.includes("Cuenta de inicio de sesión"));
assert.ok(html.includes("su responsable no se convierte automáticamente en usuario"));
assert.ok(html.indexOf("¿Para qué sirve un perfil de usuario?") < html.indexOf("Guardar"));
assert.equal(html.includes('type="password"'), false);
});
test("perfiles: las etiquetas marcan los requisitos de cada selector en es/en", async () => {
test("perfiles: alta en cuatro pasos, sin guardado intermedio y ayuda anclada al pie", async () => {
for (const language of ["es", "en"]) {
const html = await render(
language,
<WorkProfileForm company={company} onSaved={() => undefined} />
);
const text = language === "es" ? companiesSpanish : companiesEnglish;
const labels = html.match(/<label\b[^>]*>[\s\S]*?<\/label>/g) ?? [];
for (const name of [text.profiles.name, text.profiles.workstation, text.profiles.person]) {
const label = labels.find((label) => label.includes(`>${name}<`));
assert.equal((html.match(/<li\b/g) ?? []).length, 4);
assert.ok(
html.includes(text.profiles.steps.progress.replace("{{step}}", "1").replace("{{total}}", "4"))
);
assert.ok(html.includes(text.profiles.selection.workstation.empty));
assert.ok(html.includes('data-slot="empty"'));
assert.ok(html.includes(text.profiles.steps.next));
assert.equal(html.includes(`>${text.save}<`), false);
assert.equal(html.includes('type="password"'), false);
assert.ok(html.indexOf("<aside") > html.indexOf('data-slot="form-body"'));
assert.ok(html.indexOf(text.profiles.help) < html.indexOf(text.profiles.steps.next));
assert.ok(html.includes("max-h-[40%]"));
}
});
assert.ok(label?.includes('data-slot="required-indicator"'), name);
}
for (const name of [text.profiles.account, text.profiles.notes]) {
const label = labels.find((label) => label.includes(`>${name}<`));
test("perfiles: selección de persona explícita y cuenta opcional con altas independientes", async () => {
for (const language of ["es", "en"]) {
const text = language === "es" ? companiesSpanish : companiesEnglish;
const person = await render(
language,
<WorkProfileSelectionField
createAction={{ label: text.mail.newPerson, onClick: () => undefined }}
disabled={false}
kind="person"
loading={false}
onChange={() => undefined}
options={[]}
value=""
/>
);
const account = await render(
language,
<WorkProfileAccountField
authorized
disabled={false}
loading={false}
onAuthorize={() => undefined}
onChange={() => undefined}
onCreate={() => undefined}
options={[]}
personId=""
value=""
/>
);
assert.ok(label, name);
assert.equal(label.includes('data-slot="required-indicator"'), false, name);
}
assert.equal(html.includes(language === "es" ? "(opcional)" : "(optional)"), false);
assert.ok(person.includes(text.mail.newPerson));
assert.ok(person.includes('data-slot="required-indicator"'));
assert.ok(person.includes(text.profiles.selection.person.description));
assert.ok(account.includes(text.mail.newAccount));
assert.ok(account.includes(text.profiles.selection.account.description));
assert.equal(account.includes('data-slot="required-indicator"'), false);
assert.equal(account.includes('type="password"'), false);
const pending = await render(
language,
<WorkProfileAccountField
authorized={false}
disabled={false}
loading={false}
onAuthorize={() => undefined}
onChange={() => undefined}
onCreate={undefined}
options={[{ id: "account", label: "ana@example.test", description: "—" }]}
personId="person"
value="account"
/>
);
assert.ok(pending.includes('role="alert"'));
assert.ok(pending.includes(text.mail.noAuthorization));
assert.ok(pending.includes(text.mail.authorize));
assert.ok(pending.includes(text.profiles.selection.clear));
const noQuick = await render(
language,
<WorkProfileSelectionField
disabled
kind="person"
loading={false}
onChange={() => undefined}
options={[]}
value=""
/>
);
assert.equal(noQuick.includes(text.mail.newPerson), false);
assert.match(noQuick, /<button(?=[^>]*aria-haspopup="dialog")(?=[^>]*disabled="")[^>]*>/);
}
});
@ -121,6 +188,50 @@ test("perfiles: el puesto fijado aporta su ordenador y no selecciona al responsa
},
history: [],
};
const computer = AssetSchema.parse({
id: computerId,
companyId: company.id,
values: {
name: "PC-ADMIN",
templateId: company.id,
siteId: null,
status: "operational",
notes: "",
characteristics: {},
},
displayStatus: "in_use",
assigned: true,
specification: {
templateId: company.id,
templateName: "Ordenador",
fields: [
{
fieldId: company.id,
name: "Modelo",
type: "text",
unit: "",
choices: [],
required: false,
},
],
},
history: [
{
id: company.id,
actorId: company.id,
actorName: "Técnico",
recordedAt: "2026-10-16T12:00:00.000Z",
values: {
name: "PC-ADMIN",
templateId: company.id,
siteId: null,
status: "operational",
notes: "",
characteristics: {},
},
},
],
});
const assignedCompany = {
...company,
people: [
@ -145,13 +256,7 @@ test("perfiles: el puesto fijado aporta su ordenador y no selecciona al responsa
/>,
(client) => {
client.setQueryData(inventoryKeys.company(company.id), {
assets: [
{
id: computerId,
companyId: company.id,
values: { name: "PC-ADMIN", status: "operational" },
},
],
assets: [computer],
relationships: [],
workstations: [station],
});
@ -159,7 +264,101 @@ test("perfiles: el puesto fijado aporta su ordenador y no selecciona al responsa
);
assert.ok(html.includes("Administración"));
assert.ok(html.includes("Dispositivo: PC-ADMIN"));
assert.ok(html.includes("PC-ADMIN"));
assert.ok(html.includes('data-slot="card"'));
assert.match(html, /<button(?=[^>]*aria-haspopup="dialog")(?=[^>]*disabled="")[^>]*>/);
assert.equal(html.includes("Responsable test"), false);
assert.equal(html.includes('aria-label="Nuevo dispositivo"'), false);
});
test("perfiles: requisitos por paso, cuenta opcional y propuesta basada en persona y ordenador", () => {
assert.equal(profileCreationReady(1, { data: false, target: true, person: true }), false);
assert.equal(profileCreationReady(1, { data: true, target: false, person: true }), false);
assert.equal(profileCreationReady(1, { data: true, target: true, person: false }), true);
for (const step of [2, 3, 4] as const) {
assert.equal(profileCreationReady(step, { data: true, target: true, person: false }), false);
assert.equal(profileCreationReady(step, { data: true, target: true, person: true }), true);
}
assert.equal(
suggestedProfileName(undefined, "PC-01", (person, computer) => `${person} · ${computer}`),
""
);
assert.equal(
suggestedProfileName("Ana", undefined, (person, computer) => `${person} · ${computer}`),
""
);
assert.equal(
suggestedProfileName("Ana", "PC-01", (person, computer) => `${person} · ${computer}`),
"Ana · PC-01"
);
});
test("perfiles: el nombre sugerido respeta el límite del contrato", () => {
const name = suggestedProfileName(
"A".repeat(100),
"PC-01",
(person, computer) => `${person} · ${computer}`
);
assert.equal(name.length, 100);
assert.equal(WorkProfileSchema.shape.values.shape.name.safeParse(name).success, true);
});
test("perfiles: resumen con listas, ordenador único y ausencias representadas por guion", async () => {
for (const language of ["es", "en"]) {
const html = await render(
language,
<WorkProfileSummary
account={undefined}
computer={undefined}
person={{ id: "person", label: "Ana", description: "Administración" }}
workstationName="Oficina"
/>
);
assert.ok(html.includes("Ana"));
assert.ok(html.includes("Administración"));
assert.ok(html.includes("Oficina"));
assert.equal((html.match(/data-slot="card"/g) ?? []).length, 2);
assert.equal((html.match(/<ul\b/g) ?? []).length, 2);
assert.equal(html.includes("<button"), false);
assert.ok(html.includes("—"));
}
});
test("perfiles: edición completa con identidad fija aunque el perfil requiera revisión", async () => {
const values = {
name: "Perfil histórico",
personId: company.id,
assetId: company.id,
accountId: null,
active: true,
notes: null,
};
const profile = WorkProfileSchema.parse({
id: company.id,
companyId: company.id,
values,
needsReview: true,
history: [
{
id: company.id,
actorId: company.id,
actorName: "Técnico",
recordedAt: "2026-10-16T12:00:00.000Z",
values,
},
],
});
const html = await render(
"es",
<WorkProfileForm company={company} onSaved={() => undefined} profile={profile} />
);
assert.ok(html.includes("Guardar"));
assert.equal(html.includes("Siguiente"), false);
assert.ok(html.includes("La persona y el dispositivo identifican este perfil."));
assert.ok(html.includes("Nueva cuenta"));
assert.equal(html.includes("Nueva persona"), false);
assert.match(html, /<button(?![^>]*\sdisabled=)[^>]*type="submit"[^>]*>/);
});

View File

@ -0,0 +1,51 @@
import type { PickerOption } from "@repo/rdx-ui/components/option-picker-dialog";
import { useTranslation } from "react-i18next";
import { WorkProfileAuthorizationNotice } from "./work-profile-authorization-notice";
import { WorkProfileSelectionField } from "./work-profile-selection-field";
export function WorkProfileAccountField({
options,
value,
onChange,
disabled,
loading,
authorized,
personId,
onCreate,
onAuthorize,
}: {
options: readonly PickerOption[];
value: string;
onChange: (id: string) => void;
disabled: boolean;
loading: boolean;
authorized: boolean;
personId: string;
onCreate: (() => void) | undefined;
onAuthorize: (() => void) | undefined;
}) {
const { t } = useTranslation("companies");
return (
<>
<WorkProfileSelectionField
createAction={onCreate ? { label: t("mail.newAccount"), onClick: onCreate } : undefined}
disabled={disabled}
kind="account"
loading={loading}
onChange={onChange}
options={options}
value={value}
/>
<p className="text-sm text-muted-foreground">{t("profiles.accountHint")}</p>
{personId && value && !authorized && (
<WorkProfileAuthorizationNotice
authorized={authorized}
disabled={disabled}
onAuthorize={onAuthorize}
/>
)}
</>
);
}

View File

@ -0,0 +1,33 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next";
/** Explica la falta de autorización junto a la cuenta y antes del guardado final. */
export function WorkProfileAuthorizationNotice({
authorized,
disabled,
onAuthorize,
}: {
authorized: boolean;
disabled: boolean;
onAuthorize: (() => void) | undefined;
}) {
const { t } = useTranslation("companies");
if (authorized) {
return null;
}
return (
<div
className="space-y-2 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm"
role="alert"
>
<p>{t("mail.noAuthorization")}</p>
{onAuthorize && (
<Button disabled={disabled} onClick={onAuthorize} type="button" variant="outline">
{t("mail.authorize")}
</Button>
)}
</div>
);
}

View File

@ -0,0 +1,19 @@
import { AssetSummaryCard } from "@metis/assets/client";
import type { AssetDTO } from "@metis/assets/common";
import { FormSection } from "@repo/rdx-ui/components/form-section";
import { useTranslation } from "react-i18next";
/** El ordenador es una consecuencia del puesto, nunca una segunda elección del perfil. */
export function WorkProfileComputerSummary({ computer }: { computer: AssetDTO | undefined }) {
const { t } = useTranslation("companies");
return (
<FormSection title={t("profiles.asset")} variant="plain">
<ul className="grid items-stretch gap-3">
<li className="min-w-0">
<AssetSummaryCard asset={computer} />
</li>
</ul>
</FormSection>
);
}

View File

@ -1,4 +1,5 @@
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
import { UsersIcon } from "@repo/rdx-ui/lib/icons";
import type { ComponentProps } from "react";
import { useTranslation } from "react-i18next";
@ -23,14 +24,20 @@ export function WorkProfileEditorDialog({
<FormDialog
cancelLabel={t("cancel")}
formActions
layout="stable"
onOpenChange={(open) => {
if (!open) {
onClose();
}
}}
open
title={t(profile ? "profiles.edit" : "profiles.new")}
wide
size={profile ? "large" : "medium"}
title={
<span className="flex items-center gap-3">
<UsersIcon aria-hidden="true" className="size-5 shrink-0 text-muted-foreground" />
{t(profile ? "profiles.edit" : "profiles.new")}
</span>
}
>
<WorkProfileForm
company={company}

View File

@ -0,0 +1,104 @@
import { useTranslation } from "react-i18next";
import type { WorkProfileFormController } from "../controllers/use-work-profile-form-controller";
import type { WorkProfileFormProps } from "../types";
import { WorkProfileAccountField } from "./work-profile-account-field";
import { WorkProfileAuthorizationNotice } from "./work-profile-authorization-notice";
import { WorkProfileComputerSummary } from "./work-profile-computer-summary";
import { WorkProfileIdentityFields } from "./work-profile-identity-fields";
import { WorkProfileSelectionField } from "./work-profile-selection-field";
import { WorkProfileSummary } from "./work-profile-summary";
import { WorkProfileWorkstationField } from "./work-profile-workstation-field";
export function WorkProfileFormFields({
controller: c,
profile,
allowQuickCreate,
workstationId,
prefix,
}: Pick<WorkProfileFormProps, "profile" | "workstationId"> & {
controller: WorkProfileFormController;
allowQuickCreate: boolean;
prefix: string;
}) {
const { t } = useTranslation("companies");
const loading = c.inventory.isPending || c.access.isPending;
const layout = c.isWizard ? STEP_LAYOUTS[c.step] : FULL_LAYOUT;
const computer = profile
? c.inventory.data?.assets.find((asset) => asset.id === profile.values.assetId)
: c.computer;
return (
<>
{layout.workstation && (
<WorkProfileWorkstationField controller={c} locked={Boolean(workstationId)} />
)}
{layout.person && (
<WorkProfileSelectionField
createAction={
allowQuickCreate
? { label: t("mail.newPerson"), onClick: () => c.setQuick("person") }
: undefined
}
disabled={c.disabled}
kind="person"
loading={loading}
locked={!c.isWizard}
onChange={(id) => c.form.setValue("personId", id, { shouldDirty: true })}
options={c.personOptions}
value={c.values.personId}
/>
)}
{layout.account && (
<WorkProfileAccountField
authorized={c.authorized}
disabled={c.disabled}
loading={loading}
onAuthorize={allowQuickCreate ? () => c.setQuick("authorization") : undefined}
onChange={(id) => c.form.setValue("accountId", id || null, { shouldDirty: true })}
onCreate={allowQuickCreate ? () => c.setQuick("account") : undefined}
options={c.accountOptions}
personId={c.values.personId}
value={c.values.accountId ?? ""}
/>
)}
{layout.identity && <WorkProfileIdentityFields form={c.form} prefix={prefix} />}
{layout.summary && (
<WorkProfileSummary
account={c.accountOptions.find((option) => option.id === c.values.accountId)}
computer={computer}
person={c.personOptions.find((option) => option.id === c.values.personId)}
workstationName={c.workstation?.values.name}
/>
)}
{layout.summary && (
<WorkProfileAuthorizationNotice
authorized={c.authorized}
disabled={c.disabled}
onAuthorize={allowQuickCreate ? () => c.setQuick("authorization") : undefined}
/>
)}
{!c.isWizard && (
<>
<WorkProfileComputerSummary computer={computer} />
<p className="text-sm text-muted-foreground">{t("profiles.fixed")}</p>
</>
)}
</>
);
}
const FULL_LAYOUT = {
workstation: false,
person: true,
account: true,
identity: true,
summary: false,
};
const STEP_LAYOUTS = {
1: { workstation: true, person: false, account: false, identity: false, summary: false },
2: { workstation: false, person: true, account: false, identity: false, summary: false },
3: { workstation: false, person: false, account: true, identity: false, summary: false },
4: { workstation: false, person: false, account: false, identity: true, summary: true },
} as const;

View File

@ -1,11 +1,7 @@
import { ContextHelp } from "@repo/rdx-ui/components/context-help";
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
import { FormLabel } from "@repo/rdx-ui/components/form-label";
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
import { SwitchField } from "@repo/rdx-ui/components/switch-field";
import { Button } from "@repo/shadcn-ui/components/button";
import { Input } from "@repo/shadcn-ui/components/input";
import { Textarea } from "@repo/shadcn-ui/components/textarea";
import { FormBody } from "@repo/rdx-ui/components/form-dialog";
import { FormHelp } from "@repo/rdx-ui/components/form-help";
import { FormProgress } from "@repo/rdx-ui/components/form-progress";
import { FormStepActions } from "@repo/rdx-ui/components/form-step-actions";
import { useId } from "react";
import { useTranslation } from "react-i18next";
@ -14,178 +10,78 @@ import { useWorkProfileFormController } from "../controllers";
import type { WorkProfileFormProps } from "../types";
import { ProfileFeedback } from "./profile-feedback";
import { WorkProfileFormFields } from "./work-profile-form-fields";
export function WorkProfileForm(props: WorkProfileFormProps) {
const { company, profile, allowQuickCreate = true } = props;
const {
workstations,
selectedWorkstationId,
chooseWorkstation,
computer,
targetReady,
quick,
setQuick,
access,
inventory,
form,
values,
authorized,
mutation,
disabled,
selectors,
submit,
quickCreated,
} = useWorkProfileFormController(props);
const c = useWorkProfileFormController(props);
const { t } = useTranslation("companies");
const prefix = useId();
const keys = ["workstation", "person", "account", "summary"] as const;
const loading = c.inventory.isPending || c.access.isPending;
return (
<>
<form className="flex min-h-0 flex-1 flex-col overflow-hidden" onSubmit={submit}>
<FormBody>
<fieldset className="space-y-4" disabled={disabled}>
<div className="space-y-2">
<FormLabel htmlFor={prefix} required>
{t("profiles.name")}
</FormLabel>
<Input id={prefix} maxLength={100} required {...form.register("name")} />
</div>
{!profile && (
<div className="space-y-2">
<FormLabel htmlFor={`${prefix}-workstation`} required>
{t("profiles.workstation")}
</FormLabel>
<SelectWithAction
disabled={disabled || !inventory.data || Boolean(props.workstationId)}
id={`${prefix}-workstation`}
label={t("profiles.workstation")}
onValueChange={chooseWorkstation}
options={workstations.map((station) => ({
value: station.id,
label: station.values.name,
}))}
placeholder={t("profiles.chooseWorkstation")}
required
value={selectedWorkstationId}
/>
{selectedWorkstationId && (
<p className="text-sm text-muted-foreground" role="status">
{computer
? `${t("profiles.asset")}: ${computer.values.name}`
: t("profiles.workstationNeedsComputer")}
</p>
)}
</div>
)}
<div className="grid gap-4 @min-[560px]:grid-cols-2">
{selectors.map((selector) => {
const fixed = !!profile && selector.field !== "accountId";
return (
<div className="space-y-2" key={selector.field}>
<FormLabel
htmlFor={`${prefix}-${selector.field}`}
required={selector.field !== "accountId"}
>
{t(`profiles.${selector.label}`)}
</FormLabel>
<SelectWithAction
action={
fixed || !allowQuickCreate
? undefined
: {
label: t(`mail.${selector.create}`),
onClick: () => setQuick(selector.kind),
}
}
disabled={disabled || fixed || !inventory.data || !access.data}
id={`${prefix}-${selector.field}`}
label={t(`profiles.${selector.label}`)}
onValueChange={(id) =>
form.setValue(
selector.field,
selector.field === "accountId" ? id || null : id
)
}
options={selector.options}
placeholder={t(
selector.field === "accountId" ? "profiles.noAccount" : "profiles.choose"
)}
required={selector.field !== "accountId"}
value={values[selector.field] ?? ""}
/>
</div>
);
})}
</div>
<p className="text-sm text-muted-foreground">{t("profiles.accountHint")}</p>
{values.personId && values.accountId && !authorized && allowQuickCreate && (
<div className="space-y-2 rounded-md border p-3 text-sm">
<p>{t("mail.noAuthorization")}</p>
<Button onClick={() => setQuick("authorization")} type="button" variant="outline">
{t("mail.authorize")}
</Button>
</div>
)}
<SwitchField
checked={values.active}
id={`${prefix}-active`}
label={t("profiles.active")}
onCheckedChange={(active) => form.setValue("active", active)}
<form className="flex min-h-0 flex-1 flex-col overflow-hidden" onSubmit={c.submit}>
<FormBody contentLayout="fill">
{c.isWizard && (
<FormProgress
completedLabel={t("profiles.steps.completed")}
description={t(`profiles.steps.${keys[c.step - 1]}Description`)}
label={t("profiles.steps.label")}
labels={keys.map((key) => t(`profiles.steps.${key}`))}
progress={t("profiles.steps.progress", { step: c.step, total: 4 })}
step={c.step}
/>
<div className="space-y-2">
<FormLabel htmlFor={`${prefix}-notes`}>{t("profiles.notes")}</FormLabel>
<Textarea
id={`${prefix}-notes`}
maxLength={4000}
onChange={(event) => form.setValue("notes", event.target.value || null)}
rows={3}
value={values.notes ?? ""}
/>
</div>
</fieldset>
{profile && (
<div className="space-y-1 text-sm text-muted-foreground">
<p>
{t("profiles.asset")}:{" "}
{inventory.data?.assets.find((asset) => asset.id === profile.values.assetId)?.values
.name ?? profile.values.assetId}
</p>
<p>{t("profiles.fixed")}</p>
</div>
)}
{Object.keys(form.formState.errors).length > 0 && (
<fieldset className="flex min-w-0 flex-1 flex-col gap-5" disabled={c.disabled}>
<WorkProfileFormFields
allowQuickCreate={allowQuickCreate}
controller={c}
prefix={prefix}
profile={profile}
workstationId={props.workstationId}
/>
</fieldset>
{Object.keys(c.form.formState.errors).length > 0 && (
<p className="text-destructive" role="alert">
{t("profiles.errors.INVALID_REQUEST")}
</p>
)}
<ProfileFeedback error={mutation.error ?? access.error ?? inventory.error} />
<ContextHelp
description={t("profiles.description")}
examples={t("profiles.examples")}
examplesLabel={t("profiles.examplesLabel")}
guidance={t("profiles.guidance")}
scopeNote={t("profiles.scope")}
title={t("profiles.help")}
/>
{loading && (
<p className="text-sm text-muted-foreground" role="status">
{t("loading")}
</p>
)}
<ProfileFeedback error={c.mutation.error ?? c.access.error ?? c.inventory.error} />
</FormBody>
<FormActions>
<Button
disabled={disabled || !inventory.data || !access.data || !targetReady}
type="submit"
>
{t(disabled ? "saving" : "save")}
</Button>
</FormActions>
<FormHelp
description={t("profiles.description")}
examples={t("profiles.examples")}
examplesLabel={t("profiles.examplesLabel")}
guidance={t("profiles.guidance")}
scopeNote={t("profiles.scope")}
title={t("profiles.help")}
/>
<FormStepActions
canContinue={c.isWizard && c.step < 4 ? c.canContinue : c.canSave}
nextLabel={t("profiles.steps.next")}
onPrevious={c.previousStep}
pending={c.disabled}
previousLabel={t("profiles.steps.previous")}
saveLabel={t(c.disabled ? "saving" : "save")}
step={c.isWizard ? c.step : undefined}
total={4}
/>
</form>
{quick && (
{c.quick && (
<ConfigurationQuickEditor
company={company}
data={access.data}
kind={quick}
onClose={() => setQuick(undefined)}
onCreated={quickCreated}
values={{ personId: values.personId, accountId: values.accountId ?? "" }}
data={c.access.data}
kind={c.quick}
onClose={() => c.setQuick(undefined)}
onCreated={c.quickCreated}
values={{ personId: c.values.personId, accountId: c.values.accountId ?? "" }}
/>
)}
</>

View File

@ -0,0 +1,70 @@
import { FormLabel } from "@repo/rdx-ui/components/form-label";
import { FormSection } from "@repo/rdx-ui/components/form-section";
import { SwitchField } from "@repo/rdx-ui/components/switch-field";
import { Input } from "@repo/shadcn-ui/components/input";
import { Textarea } from "@repo/shadcn-ui/components/textarea";
import type { UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { WorkProfileInput } from "../../../../common/work-profiles";
export function WorkProfileIdentityFields({
form,
prefix,
}: {
form: UseFormReturn<WorkProfileInput>;
prefix: string;
}) {
const { t } = useTranslation("companies");
const nameError = !!form.formState.errors.name;
const notesError = !!form.formState.errors.notes;
return (
<FormSection title={t("profiles.steps.details")} variant="plain">
<div className="grid items-end gap-4 @min-[560px]:grid-cols-2">
<div className="space-y-2">
<FormLabel htmlFor={`${prefix}-name`} required>
{t("profiles.name")}
</FormLabel>
<Input
aria-describedby={nameError ? `${prefix}-name-error` : undefined}
aria-invalid={nameError}
id={`${prefix}-name`}
maxLength={100}
required
{...form.register("name")}
/>
{nameError && (
<p className="text-sm text-destructive" id={`${prefix}-name-error`} role="alert">
{t("profiles.errors.INVALID_REQUEST")}
</p>
)}
</div>
<SwitchField
checked={form.watch("active")}
id={`${prefix}-active`}
label={t("profiles.active")}
onCheckedChange={(active) => form.setValue("active", active, { shouldDirty: true })}
/>
<div className="col-span-full space-y-2">
<FormLabel htmlFor={`${prefix}-notes`}>{t("profiles.notes")}</FormLabel>
<Textarea
aria-describedby={notesError ? `${prefix}-notes-error` : undefined}
aria-invalid={notesError}
id={`${prefix}-notes`}
maxLength={4000}
rows={3}
{...form.register("notes", {
setValueAs: (value: unknown) => (value === "" ? null : value),
})}
/>
{notesError && (
<p className="text-sm text-destructive" id={`${prefix}-notes-error`} role="alert">
{t("profiles.errors.INVALID_REQUEST")}
</p>
)}
</div>
</div>
</FormSection>
);
}

View File

@ -0,0 +1,66 @@
import type { PickerOption } from "@repo/rdx-ui/components/option-picker-dialog";
import { OptionSelectionField } from "@repo/rdx-ui/components/option-selection-field";
import { RequiredIndicator } from "@repo/rdx-ui/components/required-indicator";
import { KeyRoundIcon, MonitorIcon, UsersIcon } from "@repo/rdx-ui/lib/icons";
import { useId } from "react";
import { useTranslation } from "react-i18next";
export function WorkProfileSelectionField({
kind,
options,
value,
onChange,
disabled,
loading,
locked = false,
createAction,
}: {
kind: "workstation" | "person" | "account";
options: readonly PickerOption[];
value: string;
onChange: (id: string) => void;
disabled: boolean;
loading: boolean;
locked?: boolean;
createAction?: { label: string; onClick: () => void } | undefined;
}) {
const { t, i18n } = useTranslation("companies");
const id = useId();
const icons = { workstation: <MonitorIcon />, person: <UsersIcon />, account: <KeyRoundIcon /> };
return (
<fieldset aria-describedby={`${id}-help`} className="flex min-w-0 flex-1 flex-col gap-3">
<legend className="mb-3 flex items-center gap-1 text-sm font-medium">
{t(`profiles.${kind}`)}
{kind !== "account" && <RequiredIndicator />}
</legend>
<p className="text-sm text-muted-foreground" id={`${id}-help`}>
{t(`profiles.selection.${kind}.description`)}
</p>
<OptionSelectionField
clearLabel={kind === "account" ? t("profiles.selection.clear") : undefined}
createAction={createAction}
description={t(`profiles.selection.${kind}.description`)}
disabled={disabled}
icon={icons[kind]}
labels={{
select: t(`profiles.selection.${kind}.select`),
change: t("profiles.selection.change"),
selection: t("profiles.selection.column"),
name: t(`profiles.${kind}`),
description: t("profiles.selection.details"),
confirm: t("profiles.selection.confirm"),
cancel: t("cancel"),
search: t(`profiles.selection.${kind}.search`),
}}
loading={loading}
locale={i18n.language}
locked={locked}
onChange={onChange}
options={options}
title={t(`profiles.selection.${kind}.empty`)}
value={value}
/>
</fieldset>
);
}

View File

@ -0,0 +1,70 @@
import type { AssetDTO } from "@metis/assets/common";
import { FormSection } from "@repo/rdx-ui/components/form-section";
import type { PickerOption } from "@repo/rdx-ui/components/option-picker-dialog";
import { SelectionSummaryCard } from "@repo/rdx-ui/components/selection-summary-card";
import { KeyRoundIcon, UsersIcon } from "@repo/rdx-ui/lib/icons";
import { useTranslation } from "react-i18next";
import { WorkProfileComputerSummary } from "./work-profile-computer-summary";
export function WorkProfileSummary({
workstationName,
computer,
person,
account,
}: {
workstationName: string | undefined;
computer: AssetDTO | undefined;
person: PickerOption | undefined;
account: PickerOption | undefined;
}) {
const { t } = useTranslation("companies");
return (
<FormSection title={t("profiles.steps.selected")} variant="plain">
<dl className="grid items-stretch gap-4 @min-[560px]:grid-cols-2">
<div className="col-span-full space-y-1">
<dt className="text-sm text-muted-foreground">{t("profiles.workstation")}</dt>
<dd className="break-words text-sm font-medium">{workstationName ?? "—"}</dd>
</div>
<div className="min-w-0 space-y-3">
<dt className="text-sm text-muted-foreground">{t("profiles.person")}</dt>
<dd>
{person ? (
<ul className="grid gap-3">
<li className="min-w-0">
<SelectionSummaryCard
description={person.description}
icon={<UsersIcon />}
title={person.label}
/>
</li>
</ul>
) : (
"—"
)}
</dd>
</div>
<div className="min-w-0 space-y-3">
<dt className="text-sm text-muted-foreground">{t("profiles.account")}</dt>
<dd>
{account ? (
<ul className="grid gap-3">
<li className="min-w-0">
<SelectionSummaryCard
description={account.description}
icon={<KeyRoundIcon />}
title={account.label}
/>
</li>
</ul>
) : (
"—"
)}
</dd>
</div>
</dl>
<WorkProfileComputerSummary computer={computer} />
</FormSection>
);
}

View File

@ -0,0 +1,38 @@
import { useTranslation } from "react-i18next";
import type { WorkProfileFormController } from "../controllers/use-work-profile-form-controller";
import { WorkProfileComputerSummary } from "./work-profile-computer-summary";
import { WorkProfileSelectionField } from "./work-profile-selection-field";
export function WorkProfileWorkstationField({
controller: c,
locked,
}: {
controller: WorkProfileFormController;
locked: boolean;
}) {
const { t } = useTranslation("companies");
return (
<>
<WorkProfileSelectionField
disabled={c.disabled}
kind="workstation"
loading={c.inventory.isPending}
locked={locked}
onChange={c.chooseWorkstation}
options={c.workstationOptions}
value={c.selectedWorkstationId}
/>
{c.selectedWorkstationId &&
(c.computer ? (
<WorkProfileComputerSummary computer={c.computer} />
) : (
<p className="text-sm text-destructive" role="alert">
{t("profiles.workstationNeedsComputer")}
</p>
))}
</>
);
}

View File

@ -1,7 +1,7 @@
import { WorkstationActions, WorkstationEditorDialog } from "@metis/assets/client";
import { BrowserLink, navigateTo } from "@metis/core/client";
import { EntityStatus } from "@repo/rdx-ui/components/entity-list";
import { BriefcaseIcon, ChevronLeftIcon, UsersIcon } from "@repo/rdx-ui/lib/icons";
import { ChevronLeftIcon, MonitorIcon, UsersIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import type { ComponentProps } from "react";
import { useTranslation } from "react-i18next";
@ -72,7 +72,7 @@ export function WorkstationDetailPage({
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<h1 className="flex items-center gap-3 text-2xl font-semibold">
<BriefcaseIcon aria-hidden="true" className="size-6 text-muted-foreground" />
<MonitorIcon aria-hidden="true" className="size-6 text-muted-foreground" />
{workstation.values.name}
</h1>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">

View File

@ -11,7 +11,7 @@
},
"scripts": {
"typecheck": "tsc -p tsconfig.json",
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/select-with-action.test.tsx src/entity-list.test.tsx src/scrollable-tabs.test.tsx src/field-sections.test.tsx src/phone-link.test.tsx src/lib/reorder-items.test.ts"
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/select-with-action.test.tsx src/selection-components.test.tsx src/entity-list.test.tsx src/scrollable-tabs.test.tsx src/field-sections.test.tsx src/phone-link.test.tsx src/lib/reorder-items.test.ts"
},
"dependencies": {
"@base-ui/react": "1.8.0",

View File

@ -0,0 +1,12 @@
import type { ComponentProps } from "react";
import { ContextHelp } from "./context-help";
/** Mantiene la ayuda general junto al pie, fuera del scroll principal del formulario. */
export function FormHelp(props: Omit<ComponentProps<typeof ContextHelp>, "variant">) {
return (
<div className="min-h-0 max-h-[40%] shrink-0 overflow-y-auto px-1 pb-3 [scrollbar-gutter:stable] [&>aside]:mt-0">
<ContextHelp {...props} variant="plain" />
</div>
);
}

View File

@ -27,14 +27,14 @@ export function FormStepActions({
const hasNext = step !== undefined && step < total;
return (
<FormActions>
<FormActions cancelDisabled={pending}>
{step !== undefined && step > 1 && (
<Button disabled={pending} onClick={onPrevious} type="button" variant="outline">
<ChevronLeftIcon aria-hidden="true" className="size-4" />
{previousLabel}
</Button>
)}
<Button disabled={pending || (step === 1 && !canContinue)} type="submit">
<Button disabled={pending || !canContinue} type="submit">
{hasNext ? nextLabel : saveLabel}
{hasNext && <ChevronRightIcon aria-hidden="true" className="size-4" />}
</Button>

View File

@ -0,0 +1,86 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { useState } from "react";
import { FormActions, FormBody, FormDialog } from "./form-dialog";
import { OptionPickerList } from "./option-picker-list";
export type PickerOption = {
id: string;
label: string;
description: string;
disabled?: boolean;
};
/** Selección única provisional: buscar y cancelar nunca modifican el formulario padre. */
export function OptionPickerDialog({
title,
options,
value,
onSelect,
onClose,
labels,
locale,
loading = false,
}: {
title: string;
options: readonly PickerOption[];
value: string;
onSelect: (id: string) => void;
onClose: () => void;
labels: {
selection: string;
name: string;
description: string;
confirm: string;
cancel: string;
search: string;
};
locale: string;
loading?: boolean;
}) {
const [selected, setSelected] = useState(value);
const available =
!loading && options.some((option) => option.id === selected && !option.disabled);
return (
<FormDialog
cancelLabel={labels.cancel}
formActions
layout="picker"
onOpenChange={(open) => {
if (!open) {
onClose();
}
}}
open
size="medium"
title={title}
>
<FormBody>
<OptionPickerList
labels={labels}
loading={loading}
locale={locale}
onChange={setSelected}
options={options}
title={title}
value={selected}
/>
</FormBody>
<FormActions>
<Button
disabled={!available}
onClick={() => {
if (available) {
onSelect(selected);
onClose();
}
}}
type="button"
>
{labels.confirm}
</Button>
</FormActions>
</FormDialog>
);
}

View File

@ -0,0 +1,79 @@
import { RadioGroup, RadioGroupItem } from "@repo/shadcn-ui/components/radio-group";
import { EntityTable } from "./entity-list";
import type { PickerOption } from "./option-picker-dialog";
/** Listado de selección única con búsqueda; el diálogo conserva la confirmación provisional. */
export function OptionPickerList({
title,
options,
value,
onChange,
labels,
locale,
loading = false,
}: {
title: string;
options: readonly PickerOption[];
value: string;
onChange: (id: string) => void;
labels: { selection: string; name: string; description: string; search: string };
locale: string;
loading?: boolean;
}) {
const choose = (id: string) => {
if (!loading && options.some((option) => option.id === id && !option.disabled)) {
onChange(id);
}
};
return (
<RadioGroup
aria-label={title}
className="flex min-h-96 flex-1 flex-col"
onValueChange={(id) => {
if (typeof id === "string") {
choose(id);
}
}}
value={value}
>
<EntityTable
columns={[
{
id: "selection",
label: labels.selection,
hideable: false,
render: (option) => (
<RadioGroupItem
aria-label={option.label}
disabled={loading || option.disabled}
value={option.id}
/>
),
},
{
id: "name",
label: labels.name,
value: (option) => option.label,
render: (option) => <span className="font-medium">{option.label}</span>,
},
{
id: "description",
label: labels.description,
value: (option) => option.description,
render: (option) => option.description || "—",
},
]}
items={options}
layout="fill"
loading={loading}
locale={locale}
onRowClick={(option) => choose(option.id)}
rowClassName={(option) => (option.disabled ? "cursor-not-allowed!" : "")}
searchPlaceholder={labels.search}
title={title}
/>
</RadioGroup>
);
}

View File

@ -0,0 +1,117 @@
import { Button } from "@repo/shadcn-ui/components/button";
import type { ReactNode } from "react";
import { useState } from "react";
import { EmptyState } from "./empty-state";
import type { PickerOption } from "./option-picker-dialog";
import { OptionPickerDialog } from "./option-picker-dialog";
import { SelectionSummaryCard } from "./selection-summary-card";
/** Selección visual con estado vacío, ficha y listado; no conoce consultas ni entidades. */
export function OptionSelectionField({
title,
description,
icon,
options,
value,
onChange,
labels,
locale,
disabled = false,
locked = false,
loading = false,
createAction,
clearLabel,
}: {
title: string;
description: string;
icon: ReactNode;
options: readonly PickerOption[];
value: string;
onChange: (id: string) => void;
labels: {
select: string;
change: string;
selection: string;
name: string;
description: string;
confirm: string;
cancel: string;
search: string;
};
locale: string;
disabled?: boolean;
locked?: boolean;
loading?: boolean;
createAction?: { label: string; onClick: () => void; disabled?: boolean } | undefined;
clearLabel?: string | undefined;
}) {
const [open, setOpen] = useState(false);
const selected = options.find((option) => option.id === value);
const actions = (
<>
<Button
aria-haspopup="dialog"
disabled={disabled || locked || loading}
onClick={() => setOpen(true)}
type="button"
>
{selected ? labels.change : labels.select}
</Button>
{createAction && !locked && (
<Button
aria-haspopup="dialog"
disabled={disabled || loading || createAction.disabled}
onClick={createAction.onClick}
type="button"
variant="outline"
>
{createAction.label}
</Button>
)}
{selected && clearLabel && (
<Button
disabled={disabled || locked}
onClick={() => onChange("")}
type="button"
variant="ghost"
>
{clearLabel}
</Button>
)}
</>
);
return (
<>
{selected ? (
<div className="space-y-3">
<ul className="grid items-stretch gap-3">
<li className="min-w-0">
<SelectionSummaryCard
description={selected.description || "—"}
icon={icon}
title={selected.label}
/>
</li>
</ul>
<div className="flex flex-wrap gap-3">{actions}</div>
</div>
) : (
<EmptyState actions={actions} description={description} icon={icon} title={title} />
)}
{open && (
<OptionPickerDialog
labels={labels}
loading={loading}
locale={locale}
onClose={() => setOpen(false)}
onSelect={onChange}
options={options}
title={labels.select}
value={value}
/>
)}
</>
);
}

View File

@ -0,0 +1,33 @@
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
import type { ReactNode } from "react";
/** Ficha de consulta genérica; textos y acciones pertenecen al consumidor. */
export function SelectionSummaryCard({
icon,
title,
description,
actions,
}: {
icon: ReactNode;
title: string;
description: string;
actions?: ReactNode;
}) {
return (
<Card className="h-full min-w-0 rounded-lg bg-muted/15 shadow-none" size="sm">
<CardContent className="flex items-start gap-3">
<span
aria-hidden="true"
className="flex size-10 shrink-0 items-center justify-center rounded-lg border border-primary/10 bg-primary/8 text-primary [&_svg]:size-5"
>
{icon}
</span>
<div className="min-w-0 flex-1 space-y-1 break-words">
<p className="font-semibold leading-snug">{title}</p>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
{actions && <div className="shrink-0">{actions}</div>}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,191 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { FormStepActions } from "./components/form-step-actions";
import { OptionPickerList } from "./components/option-picker-list";
import { OptionSelectionField } from "./components/option-selection-field";
const options = [
{ id: "available", label: "Ana", description: "Administración" },
{ id: "inactive", label: "Luis", description: "", disabled: true },
];
const labels = {
select: "Seleccionar persona",
change: "Cambiar persona",
selection: "Selección",
name: "Persona",
description: "Detalles",
confirm: "Confirmar",
cancel: "Cancelar",
search: "Buscar personas",
};
const selectionProps = {
title: "Todavía no has elegido una persona",
description: "Selecciona quién utiliza el equipo.",
icon: <span aria-hidden="true">Icono</span>,
options,
value: "",
onChange: () => undefined,
labels,
locale: "es",
createAction: { label: "Nueva persona", onClick: () => undefined },
clearLabel: "Quitar selección",
};
function button(markup: string, label: string) {
const match = (markup.match(/<button\b[^>]*>[\s\S]*?<\/button>/g) ?? []).find((item) =>
item.includes(label)
);
assert.ok(match, `No se encuentra el botón ${label}`);
return match;
}
test("selección visual: vacío con acciones separadas y ficha elegida sin controles de envío", () => {
const empty = renderToStaticMarkup(<OptionSelectionField {...selectionProps} />);
assert.ok(empty.includes(selectionProps.title));
assert.ok(empty.includes(selectionProps.description));
for (const label of [labels.select, "Nueva persona"]) {
assert.ok(button(empty, label).includes('type="button"'));
assert.equal(button(empty, label).includes('disabled=""'), false);
}
assert.equal(empty.includes("Quitar selección"), false);
assert.equal(empty.includes("Administración"), false);
const selected = renderToStaticMarkup(
<OptionSelectionField {...selectionProps} value="available" />
);
assert.ok(selected.includes("Ana"));
assert.ok(selected.includes("Administración"));
assert.ok(selected.includes("<ul"));
assert.ok(selected.includes("<li"));
assert.equal(selected.includes(selectionProps.title), false);
assert.ok(button(selected, labels.change).includes('aria-haspopup="dialog"'));
assert.ok(button(selected, "Quitar selección").includes('type="button"'));
});
test("selección visual: pendiente, deshabilitado y bloqueo protegen las acciones correspondientes", () => {
for (const state of [{ disabled: true }, { loading: true }]) {
const html = renderToStaticMarkup(
<OptionSelectionField {...selectionProps} {...state} value="available" />
);
assert.ok(button(html, labels.change).includes('disabled=""'));
assert.ok(button(html, "Nueva persona").includes('disabled=""'));
}
const locked = renderToStaticMarkup(
<OptionSelectionField {...selectionProps} locked value="inactive" />
);
assert.ok(locked.includes("Luis"));
assert.ok(button(locked, labels.change).includes('disabled=""'));
assert.ok(button(locked, "Quitar selección").includes('disabled=""'));
const noCreate = renderToStaticMarkup(
<OptionSelectionField {...selectionProps} createAction={undefined} />
);
assert.equal(noCreate.includes("Nueva persona"), false);
});
test("PickerList: búsqueda etiquetada, selección única y opciones indisponibles visibles", () => {
const html = renderToStaticMarkup(
<OptionPickerList
labels={labels}
locale="es"
onChange={() => undefined}
options={options}
title="Personas"
value="available"
/>
);
assert.ok(html.includes('role="radiogroup"'));
assert.ok(html.includes('aria-label="Personas"'));
assert.ok(html.includes('placeholder="Buscar personas"'));
assert.ok(html.includes('data-quick-search=""'));
const radios = html.match(/<[^>]*role="radio"[^>]*>/g) ?? [];
assert.equal(radios.length, 2);
const selected = radios.find((item) => item.includes('aria-label="Ana"'));
const inactive = radios.find((item) => item.includes('aria-label="Luis"'));
assert.ok(selected?.includes('aria-checked="true"'));
assert.equal(selected?.includes('aria-disabled="true"'), false);
assert.ok(inactive?.includes('aria-checked="false"'));
assert.ok(inactive?.includes('aria-disabled="true"'));
assert.ok(html.includes("—"));
});
test("PickerList: estados de carga y vacío no presentan opciones falsas", () => {
const props = {
labels,
locale: "es",
onChange: () => undefined,
title: "Personas",
value: "",
};
const empty = renderToStaticMarkup(<OptionPickerList {...props} options={[]} />);
const loading = renderToStaticMarkup(<OptionPickerList {...props} loading options={options} />);
assert.equal(empty.includes('role="radio"'), false);
assert.ok(empty.includes('placeholder="Buscar personas"'));
assert.ok(loading.includes('aria-busy="true"'));
assert.equal(loading.includes('role="radio"'), false);
});
test("acciones multipaso: requisitos bloquean avance y guardado en cada paso", () => {
for (const step of [1, 2, 3, 4, undefined]) {
for (const canContinue of [false, true]) {
const html = renderToStaticMarkup(
<FormStepActions
canContinue={canContinue}
nextLabel="Siguiente"
onPrevious={() => undefined}
pending={false}
previousLabel="Anterior"
saveLabel="Guardar"
step={step}
total={4}
/>
);
const final = step === undefined || step === 4;
const primary = button(html, final ? "Guardar" : "Siguiente");
assert.ok(primary.includes('type="submit"'));
assert.equal(primary.includes('disabled=""'), !canContinue);
assert.equal(html.includes("Anterior"), step !== undefined && step > 1);
if (step !== undefined && step > 1) {
assert.ok(button(html, "Anterior").includes('type="button"'));
assert.equal(button(html, "Anterior").includes('disabled=""'), false);
}
}
}
});
test("acciones multipaso: guardar pendiente impide avanzar y retroceder", () => {
for (const step of [1, 2, 3, 4, undefined]) {
const html = renderToStaticMarkup(
<FormStepActions
canContinue
nextLabel="Siguiente"
onPrevious={() => undefined}
pending
previousLabel="Anterior"
saveLabel="Guardando"
step={step}
total={4}
/>
);
const action = step === undefined || step === 4 ? "Guardando" : "Siguiente";
assert.ok(button(html, action).includes('disabled=""'));
if (step !== undefined && step > 1) {
assert.ok(button(html, "Anterior").includes('disabled=""'));
}
}
});