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) {
-
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 && ( - - )} - - + ); } 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 && ( + + )} +
+ ); +} 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={ + + + } > & { + 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 ( <> -
- -
-
- - {t("profiles.name")} - - -
- {!profile && ( -
- - {t("profiles.workstation")} - - ({ - value: station.id, - label: station.values.name, - }))} - placeholder={t("profiles.chooseWorkstation")} - required - value={selectedWorkstationId} - /> - {selectedWorkstationId && ( -

- {computer - ? `${t("profiles.asset")}: ${computer.values.name}` - : t("profiles.workstationNeedsComputer")} -

- )} -
- )} -
- {selectors.map((selector) => { - const fixed = !!profile && selector.field !== "accountId"; - - return ( -
- - {t(`profiles.${selector.label}`)} - - 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] ?? ""} - /> -
- ); - })} -
-

{t("profiles.accountHint")}

- {values.personId && values.accountId && !authorized && allowQuickCreate && ( -
-

{t("mail.noAuthorization")}

- -
- )} - form.setValue("active", active)} + + + {c.isWizard && ( + t(`profiles.steps.${key}`))} + progress={t("profiles.steps.progress", { step: c.step, total: 4 })} + step={c.step} /> -
- {t("profiles.notes")} -