diff --git a/docs/componentes-de-interfaz.md b/docs/componentes-de-interfaz.md
index bf0652a..48c08cb 100644
--- a/docs/componentes-de-interfaz.md
+++ b/docs/componentes-de-interfaz.md
@@ -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.
diff --git a/docs/patron-de-dialogos.md b/docs/patron-de-dialogos.md
index bbe875c..c956340 100644
--- a/docs/patron-de-dialogos.md
+++ b/docs/patron-de-dialogos.md
@@ -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
diff --git a/docs/perfiles-de-trabajo.md b/docs/perfiles-de-trabajo.md
index 514697f..b074c39 100644
--- a/docs/perfiles-de-trabajo.md
+++ b/docs/perfiles-de-trabajo.md
@@ -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
diff --git a/modules/assets/src/web/assets/detail/ui/asset-panel-summary.tsx b/modules/assets/src/web/assets/detail/ui/asset-panel-summary.tsx
index 5cc9c94..b0c16ba 100644
--- a/modules/assets/src/web/assets/detail/ui/asset-panel-summary.tsx
+++ b/modules/assets/src/web/assets/detail/ui/asset-panel-summary.tsx
@@ -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) {
-
+
{t("workstationInAsset")}
diff --git a/modules/assets/src/web/assets/shared/inventory.test.tsx b/modules/assets/src/web/assets/shared/inventory.test.tsx
index f83d427..2ae2799 100644
--- a/modules/assets/src/web/assets/shared/inventory.test.tsx
+++ b/modules/assets/src/web/assets/shared/inventory.test.tsx
@@ -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(/]*\sdisabled=""/g) ?? []).length, 2);
assert.ok(html.includes('aria-hidden="true"'));
}
diff --git a/modules/assets/src/web/index.ts b/modules/assets/src/web/index.ts
index c7c4e82..0e520e3 100644
--- a/modules/assets/src/web/index.ts
+++ b/modules/assets/src/web/index.ts
@@ -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";
diff --git a/modules/assets/src/web/workstations/shared/ui/workstation-form-actions.tsx b/modules/assets/src/web/workstations/shared/ui/workstation-form-actions.tsx
index 8992dba..a89f470 100644
--- a/modules/assets/src/web/workstations/shared/ui/workstation-form-actions.tsx
+++ b/modules/assets/src/web/workstations/shared/ui/workstation-form-actions.tsx
@@ -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 (
-
- {step && step > 1 && (
-
-
- {t("workstationSteps.previous")}
-
- )}
-
- {t(pending ? "saving" : action)}
- {step && step < 4 && }
-
-
+
);
}
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 adbb2b8..511952c 100644
--- a/modules/assets/src/web/workstations/shared/ui/workstation-form.tsx
+++ b/modules/assets/src/web/workstations/shared/ui/workstation-form.tsx
@@ -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) {
)}
-
-
-
+
(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) => {
+ 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;
diff --git a/modules/companies/src/web/work-profiles/shared/helpers/profile-creation.ts b/modules/companies/src/web/work-profiles/shared/helpers/profile-creation.ts
new file mode 100644
index 0000000..35a88bd
--- /dev/null
+++ b/modules/companies/src/web/work-profiles/shared/helpers/profile-creation.ts
@@ -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()
+ : "";
+}
diff --git a/modules/companies/src/web/work-profiles/shared/profile.test.tsx b/modules/companies/src/web/work-profiles/shared/profile.test.tsx
index 5189738..4550578 100644
--- a/modules/companies/src/web/work-profiles/shared/profile.test.tsx
+++ b/modules/companies/src/web/work-profiles/shared/profile.test.tsx
@@ -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", 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,
undefined} />
);
const text = language === "es" ? companiesSpanish : companiesEnglish;
- const labels = html.match(/]*>[\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(/${text.save}<`), false);
+ assert.equal(html.includes('type="password"'), false);
+ assert.ok(html.indexOf(" 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,
+ undefined }}
+ disabled={false}
+ kind="person"
+ loading={false}
+ onChange={() => undefined}
+ options={[]}
+ value=""
+ />
+ );
+ const account = await render(
+ language,
+ 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,
+ 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,
+ undefined}
+ options={[]}
+ value=""
+ />
+ );
+
+ assert.equal(noQuick.includes(text.mail.newPerson), false);
+ assert.match(noQuick, /]*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, /]*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,
+
+ );
+
+ 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(/ {
+ 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",
+ 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, /]*\sdisabled=)[^>]*type="submit"[^>]*>/);
+});
diff --git a/modules/companies/src/web/work-profiles/shared/ui/work-profile-account-field.tsx b/modules/companies/src/web/work-profiles/shared/ui/work-profile-account-field.tsx
new file mode 100644
index 0000000..5fc310b
--- /dev/null
+++ b/modules/companies/src/web/work-profiles/shared/ui/work-profile-account-field.tsx
@@ -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 (
+ <>
+
+ {t("profiles.accountHint")}
+ {personId && value && !authorized && (
+
+ )}
+ >
+ );
+}
diff --git a/modules/companies/src/web/work-profiles/shared/ui/work-profile-authorization-notice.tsx b/modules/companies/src/web/work-profiles/shared/ui/work-profile-authorization-notice.tsx
new file mode 100644
index 0000000..bc4d8da
--- /dev/null
+++ b/modules/companies/src/web/work-profiles/shared/ui/work-profile-authorization-notice.tsx
@@ -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 (
+
+
{t("mail.noAuthorization")}
+ {onAuthorize && (
+
+ {t("mail.authorize")}
+
+ )}
+
+ );
+}
diff --git a/modules/companies/src/web/work-profiles/shared/ui/work-profile-computer-summary.tsx b/modules/companies/src/web/work-profiles/shared/ui/work-profile-computer-summary.tsx
new file mode 100644
index 0000000..5a48a49
--- /dev/null
+++ b/modules/companies/src/web/work-profiles/shared/ui/work-profile-computer-summary.tsx
@@ -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 (
+
+
+
+ );
+}
diff --git a/modules/companies/src/web/work-profiles/shared/ui/work-profile-editor-dialog.tsx b/modules/companies/src/web/work-profiles/shared/ui/work-profile-editor-dialog.tsx
index ba03fe3..957df22 100644
--- a/modules/companies/src/web/work-profiles/shared/ui/work-profile-editor-dialog.tsx
+++ b/modules/companies/src/web/work-profiles/shared/ui/work-profile-editor-dialog.tsx
@@ -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({
{
if (!open) {
onClose();
}
}}
open
- title={t(profile ? "profiles.edit" : "profiles.new")}
- wide
+ size={profile ? "large" : "medium"}
+ title={
+
+
+ {t(profile ? "profiles.edit" : "profiles.new")}
+
+ }
>
& {
+ 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 && (
+
+ )}
+ {layout.person && (
+ 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 && (
+ 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 && }
+ {layout.summary && (
+ option.id === c.values.accountId)}
+ computer={computer}
+ person={c.personOptions.find((option) => option.id === c.values.personId)}
+ workstationName={c.workstation?.values.name}
+ />
+ )}
+ {layout.summary && (
+ c.setQuick("authorization") : undefined}
+ />
+ )}
+ {!c.isWizard && (
+ <>
+
+ {t("profiles.fixed")}
+ >
+ )}
+ >
+ );
+}
+
+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;
diff --git a/modules/companies/src/web/work-profiles/shared/ui/work-profile-form.tsx b/modules/companies/src/web/work-profiles/shared/ui/work-profile-form.tsx
index dba83e1..68fa70a 100644
--- a/modules/companies/src/web/work-profiles/shared/ui/work-profile-form.tsx
+++ b/modules/companies/src/web/work-profiles/shared/ui/work-profile-form.tsx
@@ -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 (
<>
-
- {quick && (
+ {c.quick && (
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 ?? "" }}
/>
)}
>
diff --git a/modules/companies/src/web/work-profiles/shared/ui/work-profile-identity-fields.tsx b/modules/companies/src/web/work-profiles/shared/ui/work-profile-identity-fields.tsx
new file mode 100644
index 0000000..e75b7ff
--- /dev/null
+++ b/modules/companies/src/web/work-profiles/shared/ui/work-profile-identity-fields.tsx
@@ -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;
+ prefix: string;
+}) {
+ const { t } = useTranslation("companies");
+ const nameError = !!form.formState.errors.name;
+ const notesError = !!form.formState.errors.notes;
+
+ return (
+
+
+
+
+ {t("profiles.name")}
+
+
+ {nameError && (
+
+ {t("profiles.errors.INVALID_REQUEST")}
+
+ )}
+
+
form.setValue("active", active, { shouldDirty: true })}
+ />
+
+
{t("profiles.notes")}
+
(value === "" ? null : value),
+ })}
+ />
+ {notesError && (
+
+ {t("profiles.errors.INVALID_REQUEST")}
+
+ )}
+
+
+
+ );
+}
diff --git a/modules/companies/src/web/work-profiles/shared/ui/work-profile-selection-field.tsx b/modules/companies/src/web/work-profiles/shared/ui/work-profile-selection-field.tsx
new file mode 100644
index 0000000..3dc6aaa
--- /dev/null
+++ b/modules/companies/src/web/work-profiles/shared/ui/work-profile-selection-field.tsx
@@ -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: , person: , account: };
+
+ return (
+
+
+ {t(`profiles.${kind}`)}
+ {kind !== "account" && }
+
+
+ {t(`profiles.selection.${kind}.description`)}
+
+
+
+ );
+}
diff --git a/modules/companies/src/web/work-profiles/shared/ui/work-profile-summary.tsx b/modules/companies/src/web/work-profiles/shared/ui/work-profile-summary.tsx
new file mode 100644
index 0000000..49358df
--- /dev/null
+++ b/modules/companies/src/web/work-profiles/shared/ui/work-profile-summary.tsx
@@ -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 (
+
+
+
+
{t("profiles.workstation")}
+ {workstationName ?? "—"}
+
+
+
{t("profiles.person")}
+
+ {person ? (
+
+
+ }
+ title={person.label}
+ />
+
+
+ ) : (
+ "—"
+ )}
+
+
+
+
{t("profiles.account")}
+
+ {account ? (
+
+
+ }
+ title={account.label}
+ />
+
+
+ ) : (
+ "—"
+ )}
+
+
+
+
+
+ );
+}
diff --git a/modules/companies/src/web/work-profiles/shared/ui/work-profile-workstation-field.tsx b/modules/companies/src/web/work-profiles/shared/ui/work-profile-workstation-field.tsx
new file mode 100644
index 0000000..0ec0db5
--- /dev/null
+++ b/modules/companies/src/web/work-profiles/shared/ui/work-profile-workstation-field.tsx
@@ -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 (
+ <>
+
+ {c.selectedWorkstationId &&
+ (c.computer ? (
+
+ ) : (
+
+ {t("profiles.workstationNeedsComputer")}
+
+ ))}
+ >
+ );
+}
diff --git a/modules/companies/src/web/workstations/detail/ui/workstation-detail-page.tsx b/modules/companies/src/web/workstations/detail/ui/workstation-detail-page.tsx
index 1ef9c72..39efcb1 100644
--- a/modules/companies/src/web/workstations/detail/ui/workstation-detail-page.tsx
+++ b/modules/companies/src/web/workstations/detail/ui/workstation-detail-page.tsx
@@ -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({
-
+
{workstation.values.name}
diff --git a/packages/rdx-ui/package.json b/packages/rdx-ui/package.json
index 4bf6ded..a0a2645 100644
--- a/packages/rdx-ui/package.json
+++ b/packages/rdx-ui/package.json
@@ -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",
diff --git a/packages/rdx-ui/src/components/form-help.tsx b/packages/rdx-ui/src/components/form-help.tsx
new file mode 100644
index 0000000..20ac820
--- /dev/null
+++ b/packages/rdx-ui/src/components/form-help.tsx
@@ -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
, "variant">) {
+ return (
+
+
+
+ );
+}
diff --git a/packages/rdx-ui/src/components/form-step-actions.tsx b/packages/rdx-ui/src/components/form-step-actions.tsx
index 7e64714..73ca7a6 100644
--- a/packages/rdx-ui/src/components/form-step-actions.tsx
+++ b/packages/rdx-ui/src/components/form-step-actions.tsx
@@ -27,14 +27,14 @@ export function FormStepActions({
const hasNext = step !== undefined && step < total;
return (
-
+
{step !== undefined && step > 1 && (
{previousLabel}
)}
-
+
{hasNext ? nextLabel : saveLabel}
{hasNext && }
diff --git a/packages/rdx-ui/src/components/option-picker-dialog.tsx b/packages/rdx-ui/src/components/option-picker-dialog.tsx
new file mode 100644
index 0000000..21da620
--- /dev/null
+++ b/packages/rdx-ui/src/components/option-picker-dialog.tsx
@@ -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 (
+ {
+ if (!open) {
+ onClose();
+ }
+ }}
+ open
+ size="medium"
+ title={title}
+ >
+
+
+
+
+ {
+ if (available) {
+ onSelect(selected);
+ onClose();
+ }
+ }}
+ type="button"
+ >
+ {labels.confirm}
+
+
+
+ );
+}
diff --git a/packages/rdx-ui/src/components/option-picker-list.tsx b/packages/rdx-ui/src/components/option-picker-list.tsx
new file mode 100644
index 0000000..eabb833
--- /dev/null
+++ b/packages/rdx-ui/src/components/option-picker-list.tsx
@@ -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 (
+ {
+ if (typeof id === "string") {
+ choose(id);
+ }
+ }}
+ value={value}
+ >
+ (
+
+ ),
+ },
+ {
+ id: "name",
+ label: labels.name,
+ value: (option) => option.label,
+ render: (option) => {option.label} ,
+ },
+ {
+ 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}
+ />
+
+ );
+}
diff --git a/packages/rdx-ui/src/components/option-selection-field.tsx b/packages/rdx-ui/src/components/option-selection-field.tsx
new file mode 100644
index 0000000..4fb66d3
--- /dev/null
+++ b/packages/rdx-ui/src/components/option-selection-field.tsx
@@ -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 = (
+ <>
+ setOpen(true)}
+ type="button"
+ >
+ {selected ? labels.change : labels.select}
+
+ {createAction && !locked && (
+
+ {createAction.label}
+
+ )}
+ {selected && clearLabel && (
+ onChange("")}
+ type="button"
+ variant="ghost"
+ >
+ {clearLabel}
+
+ )}
+ >
+ );
+
+ return (
+ <>
+ {selected ? (
+
+ ) : (
+
+ )}
+ {open && (
+ setOpen(false)}
+ onSelect={onChange}
+ options={options}
+ title={labels.select}
+ value={value}
+ />
+ )}
+ >
+ );
+}
diff --git a/packages/rdx-ui/src/components/selection-summary-card.tsx b/packages/rdx-ui/src/components/selection-summary-card.tsx
new file mode 100644
index 0000000..b099600
--- /dev/null
+++ b/packages/rdx-ui/src/components/selection-summary-card.tsx
@@ -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 (
+
+
+
+ {icon}
+
+
+
{title}
+
{description}
+
+ {actions && {actions}
}
+
+
+ );
+}
diff --git a/packages/rdx-ui/src/selection-components.test.tsx b/packages/rdx-ui/src/selection-components.test.tsx
new file mode 100644
index 0000000..1c53d2b
--- /dev/null
+++ b/packages/rdx-ui/src/selection-components.test.tsx
@@ -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: Icono ,
+ 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(/]*>[\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( );
+
+ 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(
+
+ );
+
+ assert.ok(selected.includes("Ana"));
+ assert.ok(selected.includes("Administración"));
+ assert.ok(selected.includes(" {
+ for (const state of [{ disabled: true }, { loading: true }]) {
+ const html = renderToStaticMarkup(
+
+ );
+
+ assert.ok(button(html, labels.change).includes('disabled=""'));
+ assert.ok(button(html, "Nueva persona").includes('disabled=""'));
+ }
+ const locked = renderToStaticMarkup(
+
+ );
+
+ 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(
+
+ );
+
+ assert.equal(noCreate.includes("Nueva persona"), false);
+});
+
+test("PickerList: búsqueda etiquetada, selección única y opciones indisponibles visibles", () => {
+ const html = renderToStaticMarkup(
+ 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( );
+ const loading = renderToStaticMarkup( );
+
+ 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(
+ 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(
+ 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=""'));
+ }
+ }
+});