feat: enhance work profile management with new components and functionality
- Updated WorkstationDetailPage to replace BriefcaseIcon with MonitorIcon. - Modified package.json to include new test files. - Refactored FormStepActions to improve button state management. - Introduced profile-creation helper functions for better profile management. - Added WorkProfileAccountField and WorkProfileAuthorizationNotice components for account handling. - Created WorkProfileComputerSummary and WorkProfileSummary for displaying profile details. - Implemented WorkProfileFormFields to consolidate form fields for work profiles. - Developed WorkProfileIdentityFields for managing identity-related inputs. - Added WorkProfileSelectionField for selecting accounts, workstations, and persons. - Created OptionPickerDialog and OptionPickerList for enhanced option selection. - Introduced OptionSelectionField for visual selection with actions. - Added tests for selection components to ensure functionality and reliability.
This commit is contained in:
parent
b7a1c61017
commit
94ddc5cf16
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { BrowserLink } from "@metis/core/client";
|
||||
import { EntityStatus } from "@repo/rdx-ui/components/entity-list";
|
||||
import { BriefcaseIcon, LayoutTemplateIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { LayoutTemplateIcon, MonitorIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useAssetDetailController } from "../controllers";
|
||||
@ -37,7 +37,7 @@ export function AssetPanelSummary(props: AssetDetailProps) {
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<dt className="shrink-0 pt-0.5 text-muted-foreground">
|
||||
<BriefcaseIcon aria-hidden="true" className="size-4" />
|
||||
<MonitorIcon aria-hidden="true" className="size-4" />
|
||||
<span className="sr-only">{t("workstationInAsset")}</span>
|
||||
</dt>
|
||||
<dd className="min-w-0">
|
||||
|
||||
@ -928,7 +928,9 @@ test("diseño del puesto: navegación traducida y acciones bloqueadas durante el
|
||||
);
|
||||
|
||||
assert.ok(html.includes(language === "es" ? "Anterior" : "Previous"));
|
||||
assert.ok(html.includes(language === "es" ? "Guardando" : "Saving"));
|
||||
const text = language === "es" ? assetsSpanish : assetsEnglish;
|
||||
|
||||
assert.ok(html.includes(step === 4 ? text.saving : text.workstationSteps.next));
|
||||
assert.equal((html.match(/<button[^>]*\sdisabled=""/g) ?? []).length, 2);
|
||||
assert.ok(html.includes('aria-hidden="true"'));
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
import { FormActions } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { FormStepActions } from "@repo/rdx-ui/components/form-step-actions";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function WorkstationFormActions({
|
||||
@ -15,20 +13,17 @@ export function WorkstationFormActions({
|
||||
onPrevious: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("assets");
|
||||
const action = step && step < 4 ? "workstationSteps.next" : "save";
|
||||
|
||||
return (
|
||||
<FormActions cancelDisabled={pending}>
|
||||
{step && step > 1 && (
|
||||
<Button disabled={pending} onClick={onPrevious} type="button" variant="outline">
|
||||
<ChevronLeftIcon aria-hidden="true" className="size-4" />
|
||||
{t("workstationSteps.previous")}
|
||||
</Button>
|
||||
)}
|
||||
<Button disabled={pending || !canSubmit} type="submit">
|
||||
{t(pending ? "saving" : action)}
|
||||
{step && step < 4 && <ChevronRightIcon aria-hidden="true" className="size-4" />}
|
||||
</Button>
|
||||
</FormActions>
|
||||
<FormStepActions
|
||||
canContinue={canSubmit}
|
||||
nextLabel={t("workstationSteps.next")}
|
||||
onPrevious={onPrevious}
|
||||
pending={pending}
|
||||
previousLabel={t("workstationSteps.previous")}
|
||||
saveLabel={t(pending ? "saving" : "save")}
|
||||
step={step}
|
||||
total={4}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { ContextHelp } from "@repo/rdx-ui/components/context-help";
|
||||
import { FormBody } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormHelp } from "@repo/rdx-ui/components/form-help";
|
||||
import { FormProgress } from "@repo/rdx-ui/components/form-progress";
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -130,17 +130,14 @@ export function WorkstationForm(props: WorkstationFormProps) {
|
||||
)}
|
||||
<InventoryFeedback error={mutation.error ?? inventory.error ?? catalog.error} />
|
||||
</FormBody>
|
||||
<div className="min-h-0 max-h-[40%] shrink-0 overflow-y-auto px-1 pb-3 [scrollbar-gutter:stable] [&>aside]:mt-0">
|
||||
<ContextHelp
|
||||
description={t("workstationDescription")}
|
||||
examples={t("workstationExamples")}
|
||||
examplesLabel={t("examplesLabel")}
|
||||
guidance={t("workstationGuidance")}
|
||||
scopeNote={t("workstationScope")}
|
||||
title={t("helpTitle")}
|
||||
variant="plain"
|
||||
/>
|
||||
</div>
|
||||
<FormHelp
|
||||
description={t("workstationDescription")}
|
||||
examples={t("workstationExamples")}
|
||||
examplesLabel={t("examplesLabel")}
|
||||
guidance={t("workstationGuidance")}
|
||||
scopeNote={t("workstationScope")}
|
||||
title={t("helpTitle")}
|
||||
/>
|
||||
<WorkstationFormActions
|
||||
canSubmit={isWizard ? canContinue : assetsReady}
|
||||
onPrevious={previousStep}
|
||||
|
||||
@ -543,7 +543,50 @@
|
||||
"lifecycleHelp": "To delete a profile, deactivate it first and delete its linked configuration records. Deactivating preserves history and does not revoke external access.",
|
||||
"workstation": "Workstation",
|
||||
"chooseWorkstation": "Select a workstation",
|
||||
"workstationNeedsComputer": "Edit this workstation and identify an available main computer before creating the profile."
|
||||
"workstationNeedsComputer": "Edit this workstation and identify an available main computer before creating the profile.",
|
||||
"suggestedName": "{{person}} · {{computer}}",
|
||||
"steps": {
|
||||
"label": "New profile steps",
|
||||
"completed": "Completed",
|
||||
"progress": "Step {{step}} of {{total}}",
|
||||
"workstation": "Workstation",
|
||||
"person": "Person",
|
||||
"account": "Account",
|
||||
"summary": "Summary",
|
||||
"details": "Profile details",
|
||||
"selected": "Selected details",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"workstationDescription": "Select the workstation where the profile will be used. Its main computer becomes the profile device.",
|
||||
"personDescription": "Choose the person who will use this profile. This can be someone other than the workstation owner.",
|
||||
"accountDescription": "Link a sign-in account or continue without one. If you choose an account, review its authorization.",
|
||||
"summaryDescription": "Review your selections and adjust the name, notes and status before saving."
|
||||
},
|
||||
"selection": {
|
||||
"change": "Change selection",
|
||||
"clear": "Remove account",
|
||||
"column": "Selection",
|
||||
"details": "Details",
|
||||
"confirm": "Select",
|
||||
"workstation": {
|
||||
"empty": "Choose the profile workstation",
|
||||
"description": "Select an active workstation in this company with an available main computer. If none exists yet, complete the workstation first.",
|
||||
"select": "Select workstation",
|
||||
"search": "Search by workstation or computer"
|
||||
},
|
||||
"person": {
|
||||
"empty": "Who will use this profile?",
|
||||
"description": "Select an active person in the company or create a new one. The workstation owner is not assigned automatically.",
|
||||
"select": "Select person",
|
||||
"search": "Search by person, position or email"
|
||||
},
|
||||
"account": {
|
||||
"empty": "Add a sign-in account",
|
||||
"description": "The account is optional. Select an existing one, create a new one, or continue without linking an account.",
|
||||
"select": "Select account",
|
||||
"search": "Search by account, system or usage"
|
||||
}
|
||||
}
|
||||
},
|
||||
"companySpaces": "Company spaces",
|
||||
"globalCatalogs": "Global catalogs",
|
||||
|
||||
@ -543,7 +543,50 @@
|
||||
"lifecycleHelp": "Para eliminar un perfil, primero desactívalo y elimina sus configuraciones vinculadas. Desactivar conserva el historial y no retira accesos externos.",
|
||||
"workstation": "Puesto de trabajo",
|
||||
"chooseWorkstation": "Selecciona un puesto de trabajo",
|
||||
"workstationNeedsComputer": "Edita el puesto e identifica un ordenador principal disponible antes de crear el perfil."
|
||||
"workstationNeedsComputer": "Edita el puesto e identifica un ordenador principal disponible antes de crear el perfil.",
|
||||
"suggestedName": "{{person}} · {{computer}}",
|
||||
"steps": {
|
||||
"label": "Pasos del nuevo perfil",
|
||||
"completed": "Completado",
|
||||
"progress": "Paso {{step}} de {{total}}",
|
||||
"workstation": "Puesto",
|
||||
"person": "Persona",
|
||||
"account": "Cuenta",
|
||||
"summary": "Resumen",
|
||||
"details": "Datos del perfil",
|
||||
"selected": "Datos seleccionados",
|
||||
"previous": "Anterior",
|
||||
"next": "Siguiente",
|
||||
"workstationDescription": "Selecciona el puesto donde se utilizará el perfil. Su ordenador principal será el dispositivo del perfil.",
|
||||
"personDescription": "Elige expresamente la persona que utilizará este perfil. Puede ser distinta del responsable del puesto.",
|
||||
"accountDescription": "Puedes vincular una cuenta de inicio de sesión o continuar sin ella. Si la eliges, revisa su autorización.",
|
||||
"summaryDescription": "Revisa las selecciones y ajusta el nombre, las notas y el estado antes de guardar."
|
||||
},
|
||||
"selection": {
|
||||
"change": "Cambiar selección",
|
||||
"clear": "Quitar cuenta",
|
||||
"column": "Selección",
|
||||
"details": "Detalles",
|
||||
"confirm": "Seleccionar",
|
||||
"workstation": {
|
||||
"empty": "Elige el puesto de trabajo del perfil",
|
||||
"description": "Selecciona un puesto activo de esta compañía con un ordenador principal disponible. Si todavía no existe, completa primero el puesto de trabajo.",
|
||||
"select": "Seleccionar puesto",
|
||||
"search": "Buscar por puesto u ordenador"
|
||||
},
|
||||
"person": {
|
||||
"empty": "¿Quién utilizará este perfil?",
|
||||
"description": "Selecciona una persona activa de la compañía o crea una nueva. El responsable del puesto no se asigna automáticamente.",
|
||||
"select": "Seleccionar persona",
|
||||
"search": "Buscar por persona, puesto o email"
|
||||
},
|
||||
"account": {
|
||||
"empty": "Añade una cuenta de inicio de sesión",
|
||||
"description": "La cuenta es opcional. Selecciona una existente, crea una nueva o continúa sin vincular ninguna.",
|
||||
"select": "Seleccionar cuenta",
|
||||
"search": "Buscar por cuenta, sistema o uso"
|
||||
}
|
||||
}
|
||||
},
|
||||
"companySpaces": "Espacios de compañías",
|
||||
"globalCatalogs": "Catálogos globales",
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
LayoutTemplateIcon,
|
||||
MailIcon,
|
||||
MapPinIcon,
|
||||
MonitorIcon,
|
||||
UsersIcon,
|
||||
} from "@repo/rdx-ui/lib/icons";
|
||||
import { Avatar, AvatarBadge } from "@repo/shadcn-ui/components/avatar";
|
||||
@ -106,7 +107,7 @@ export function CompanyDetail(props: CompanyDetailProps) {
|
||||
[
|
||||
["overview", t("companyOverview.navigation"), HomeIcon],
|
||||
["people", `${t("people")} (${company.people.length})`, UsersIcon],
|
||||
["workstations", assetTranslation("workstations"), BriefcaseIcon],
|
||||
["workstations", assetTranslation("workstations"), MonitorIcon],
|
||||
["profiles", t("profiles.title"), UsersIcon],
|
||||
["inventory", assetTranslation("inventory"), BoxIcon],
|
||||
["resources", resourceTranslation("inventory"), LayersIcon],
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { FormEvent } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { WorkProfileInput } from "../../../../common/work-profiles";
|
||||
import { WorkProfileInputSchema } from "../../../../common/work-profiles";
|
||||
@ -11,6 +13,8 @@ import { accessLabel } from "../../../access/shared/helpers";
|
||||
import { accessKeys } from "../../../access/shared/queries";
|
||||
import type { ConfigurationQuickKind } from "../../../shared/types";
|
||||
import { profileApi, profileKeys } from "../api";
|
||||
import type { ProfileCreationStep } from "../helpers/profile-creation";
|
||||
import { profileCreationReady, suggestedProfileName } from "../helpers/profile-creation";
|
||||
import type { WorkProfileFormProps } from "../types";
|
||||
|
||||
export function useWorkProfileFormController({
|
||||
@ -20,6 +24,9 @@ export function useWorkProfileFormController({
|
||||
onSaved,
|
||||
}: WorkProfileFormProps) {
|
||||
const client = useQueryClient();
|
||||
const { t } = useTranslation("companies");
|
||||
const isWizard = !profile;
|
||||
const [step, setStep] = useState<ProfileCreationStep>(1);
|
||||
const mounted = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
@ -42,7 +49,11 @@ export function useWorkProfileFormController({
|
||||
const workstations = (inventory.data?.workstations ?? []).filter(
|
||||
(station) => station.companyId === company.id && station.values.active
|
||||
);
|
||||
const workstation = workstations.find((station) => station.id === selectedWorkstationId);
|
||||
const workstation = workstations.find((station) =>
|
||||
profile
|
||||
? station.values.computerAssetId === profile.values.assetId
|
||||
: station.id === selectedWorkstationId
|
||||
);
|
||||
const computer = (inventory.data?.assets ?? []).find(
|
||||
(asset) =>
|
||||
asset.id === workstation?.values.computerAssetId &&
|
||||
@ -98,37 +109,102 @@ export function useWorkProfileFormController({
|
||||
},
|
||||
});
|
||||
const disabled = mutation.isPending;
|
||||
const selectors = [
|
||||
{
|
||||
field: "personId",
|
||||
label: "person",
|
||||
create: "newPerson",
|
||||
kind: "person",
|
||||
options: company.people
|
||||
.filter((item) => item.active || item.id === values.personId)
|
||||
.map((item) => ({ value: item.id, label: item.name })),
|
||||
},
|
||||
{
|
||||
field: "accountId",
|
||||
label: "account",
|
||||
create: "newAccount",
|
||||
kind: "account",
|
||||
options: items
|
||||
.filter(
|
||||
(item) =>
|
||||
item.values.kind === "account" &&
|
||||
((item.values.active && item.values.usage !== "technical") ||
|
||||
item.id === values.accountId)
|
||||
)
|
||||
.map((item) => ({ value: item.id, label: accessLabel(item, items, company.people) })),
|
||||
},
|
||||
] as const;
|
||||
const submit = form.handleSubmit((input) => {
|
||||
if (targetReady) {
|
||||
const person = company.people.find((item) => item.id === values.personId);
|
||||
const personOptions = company.people
|
||||
.filter((item) => item.active || item.id === values.personId)
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
label: item.name,
|
||||
description: item.position || item.email || "—",
|
||||
disabled: !item.active,
|
||||
}));
|
||||
const accountOptions = items.flatMap((item) => {
|
||||
if (
|
||||
item.values.kind !== "account" ||
|
||||
!((item.values.active && item.values.usage !== "technical") || item.id === values.accountId)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: item.id,
|
||||
label: accessLabel(item, items, company.people),
|
||||
description: t(item.values.usage),
|
||||
disabled: !item.values.active || item.values.usage === "technical",
|
||||
},
|
||||
];
|
||||
});
|
||||
const workstationOptions = workstations.map((station) => {
|
||||
const asset = inventory.data?.assets.find(
|
||||
(item) =>
|
||||
item.id === station.values.computerAssetId &&
|
||||
station.values.assetIds.includes(item.id) &&
|
||||
item.values.status !== "retired"
|
||||
);
|
||||
|
||||
return {
|
||||
id: station.id,
|
||||
label: station.values.name,
|
||||
description: asset?.values.name ?? t("profiles.workstationNeedsComputer"),
|
||||
disabled: !asset,
|
||||
};
|
||||
});
|
||||
const dataReady = !!inventory.data && !!access.data;
|
||||
const canContinue = profileCreationReady(step, {
|
||||
data: dataReady,
|
||||
target: targetReady,
|
||||
person: Boolean(person?.active),
|
||||
});
|
||||
const requiresAvailability =
|
||||
values.active &&
|
||||
(!(profile && profile.values.active) || profile.values.accountId !== values.accountId);
|
||||
const canSave =
|
||||
dataReady &&
|
||||
targetReady &&
|
||||
(!requiresAvailability ||
|
||||
(Boolean(computer) &&
|
||||
Boolean(person?.active) &&
|
||||
authorized &&
|
||||
(!values.accountId ||
|
||||
accountOptions.some((option) => option.id === values.accountId && !option.disabled))));
|
||||
const save = form.handleSubmit((input) => {
|
||||
if (canSave) {
|
||||
mutation.mutate(input);
|
||||
}
|
||||
});
|
||||
|
||||
const updateSuggestedName = () => {
|
||||
if (!(profile || form.getFieldState("name").isDirty)) {
|
||||
form.setValue(
|
||||
"name",
|
||||
suggestedProfileName(person?.name, computer?.values.name, (person, computer) =>
|
||||
t("profiles.suggestedName", { person, computer })
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
if (disabled) {
|
||||
event.preventDefault();
|
||||
|
||||
return;
|
||||
}
|
||||
if (isWizard && step !== 4) {
|
||||
event.preventDefault();
|
||||
if (canContinue) {
|
||||
updateSuggestedName();
|
||||
setStep(NEXT_STEPS[step]);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
await save(event);
|
||||
};
|
||||
|
||||
const previousStep = () => setStep(PREVIOUS_STEPS[step]);
|
||||
|
||||
const quickCreated = (field: "personId" | "accountId" | "assetId", id: string | undefined) => {
|
||||
if (mounted.current && id) {
|
||||
form.setValue(field, id);
|
||||
@ -151,8 +227,21 @@ export function useWorkProfileFormController({
|
||||
authorized,
|
||||
mutation,
|
||||
disabled,
|
||||
selectors,
|
||||
isWizard,
|
||||
step,
|
||||
previousStep,
|
||||
canContinue,
|
||||
canSave,
|
||||
workstation,
|
||||
personOptions,
|
||||
accountOptions,
|
||||
workstationOptions,
|
||||
submit,
|
||||
quickCreated,
|
||||
};
|
||||
}
|
||||
|
||||
const NEXT_STEPS = { 1: 2, 2: 3, 3: 4, 4: 4 } as const;
|
||||
const PREVIOUS_STEPS = { 1: 1, 2: 1, 3: 2, 4: 3 } as const;
|
||||
|
||||
export type WorkProfileFormController = ReturnType<typeof useWorkProfileFormController>;
|
||||
|
||||
@ -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()
|
||||
: "";
|
||||
}
|
||||
@ -1,19 +1,25 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { inventoryKeys } from "@metis/assets/client";
|
||||
import { assetsEnglish, assetsSpanish, inventoryKeys } from "@metis/assets/client";
|
||||
import { AssetSchema } from "@metis/assets/common";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createInstance } from "i18next";
|
||||
import type { ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
|
||||
import { WorkProfileSchema } from "../../../common/work-profiles";
|
||||
import { accessKeys } from "../../access/shared/queries";
|
||||
import { companiesEnglish, companiesSpanish } from "../../shared";
|
||||
import { WorkProfilesPage } from "../list";
|
||||
|
||||
import { profileKeys } from "./api";
|
||||
import { profileCreationReady, suggestedProfileName } from "./helpers/profile-creation";
|
||||
import { WorkProfileForm } from "./ui";
|
||||
import { WorkProfileAccountField } from "./ui/work-profile-account-field";
|
||||
import { WorkProfileSelectionField } from "./ui/work-profile-selection-field";
|
||||
import { WorkProfileSummary } from "./ui/work-profile-summary";
|
||||
|
||||
const company = {
|
||||
id: "0199b200-0000-7000-8000-000000000001",
|
||||
@ -31,7 +37,10 @@ async function render(language: string, node: ReactNode, seed?: (client: QueryCl
|
||||
|
||||
await i18n.init({
|
||||
lng: language,
|
||||
resources: { es: { companies: companiesSpanish }, en: { companies: companiesEnglish } },
|
||||
resources: {
|
||||
es: { companies: companiesSpanish, assets: assetsSpanish },
|
||||
en: { companies: companiesEnglish, assets: assetsEnglish },
|
||||
},
|
||||
});
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, staleTime: Number.POSITIVE_INFINITY } },
|
||||
@ -66,41 +75,99 @@ test("perfiles: listado es/en, alta en cabecera y ausencia de formulario junto a
|
||||
assert.ok(html.includes(language === "es" ? "primero desactívalo" : "deactivate it first"));
|
||||
}
|
||||
});
|
||||
test("perfiles: cuenta opcional, altas rápidas y ayuda anterior al pie", async () => {
|
||||
const html = await render("es", <WorkProfileForm company={company} onSaved={() => undefined} />);
|
||||
|
||||
for (const label of ["Nueva persona", "Nueva cuenta"]) {
|
||||
assert.ok(html.includes(`aria-label="${label}"`));
|
||||
}
|
||||
assert.equal(html.includes('aria-label="Nuevo dispositivo"'), false);
|
||||
assert.ok(html.includes("Puesto de trabajo"));
|
||||
assert.ok(html.includes("Cuenta de inicio de sesión"));
|
||||
assert.ok(html.includes("su responsable no se convierte automáticamente en usuario"));
|
||||
assert.ok(html.indexOf("¿Para qué sirve un perfil de usuario?") < html.indexOf("Guardar"));
|
||||
assert.equal(html.includes('type="password"'), false);
|
||||
});
|
||||
|
||||
test("perfiles: las etiquetas marcan los requisitos de cada selector en es/en", async () => {
|
||||
test("perfiles: alta en cuatro pasos, sin guardado intermedio y ayuda anclada al pie", async () => {
|
||||
for (const language of ["es", "en"]) {
|
||||
const html = await render(
|
||||
language,
|
||||
<WorkProfileForm company={company} onSaved={() => undefined} />
|
||||
);
|
||||
const text = language === "es" ? companiesSpanish : companiesEnglish;
|
||||
const labels = html.match(/<label\b[^>]*>[\s\S]*?<\/label>/g) ?? [];
|
||||
|
||||
for (const name of [text.profiles.name, text.profiles.workstation, text.profiles.person]) {
|
||||
const label = labels.find((label) => label.includes(`>${name}<`));
|
||||
assert.equal((html.match(/<li\b/g) ?? []).length, 4);
|
||||
assert.ok(
|
||||
html.includes(text.profiles.steps.progress.replace("{{step}}", "1").replace("{{total}}", "4"))
|
||||
);
|
||||
assert.ok(html.includes(text.profiles.selection.workstation.empty));
|
||||
assert.ok(html.includes('data-slot="empty"'));
|
||||
assert.ok(html.includes(text.profiles.steps.next));
|
||||
assert.equal(html.includes(`>${text.save}<`), false);
|
||||
assert.equal(html.includes('type="password"'), false);
|
||||
assert.ok(html.indexOf("<aside") > html.indexOf('data-slot="form-body"'));
|
||||
assert.ok(html.indexOf(text.profiles.help) < html.indexOf(text.profiles.steps.next));
|
||||
assert.ok(html.includes("max-h-[40%]"));
|
||||
}
|
||||
});
|
||||
|
||||
assert.ok(label?.includes('data-slot="required-indicator"'), name);
|
||||
}
|
||||
for (const name of [text.profiles.account, text.profiles.notes]) {
|
||||
const label = labels.find((label) => label.includes(`>${name}<`));
|
||||
test("perfiles: selección de persona explícita y cuenta opcional con altas independientes", async () => {
|
||||
for (const language of ["es", "en"]) {
|
||||
const text = language === "es" ? companiesSpanish : companiesEnglish;
|
||||
const person = await render(
|
||||
language,
|
||||
<WorkProfileSelectionField
|
||||
createAction={{ label: text.mail.newPerson, onClick: () => undefined }}
|
||||
disabled={false}
|
||||
kind="person"
|
||||
loading={false}
|
||||
onChange={() => undefined}
|
||||
options={[]}
|
||||
value=""
|
||||
/>
|
||||
);
|
||||
const account = await render(
|
||||
language,
|
||||
<WorkProfileAccountField
|
||||
authorized
|
||||
disabled={false}
|
||||
loading={false}
|
||||
onAuthorize={() => undefined}
|
||||
onChange={() => undefined}
|
||||
onCreate={() => undefined}
|
||||
options={[]}
|
||||
personId=""
|
||||
value=""
|
||||
/>
|
||||
);
|
||||
|
||||
assert.ok(label, name);
|
||||
assert.equal(label.includes('data-slot="required-indicator"'), false, name);
|
||||
}
|
||||
assert.equal(html.includes(language === "es" ? "(opcional)" : "(optional)"), false);
|
||||
assert.ok(person.includes(text.mail.newPerson));
|
||||
assert.ok(person.includes('data-slot="required-indicator"'));
|
||||
assert.ok(person.includes(text.profiles.selection.person.description));
|
||||
assert.ok(account.includes(text.mail.newAccount));
|
||||
assert.ok(account.includes(text.profiles.selection.account.description));
|
||||
assert.equal(account.includes('data-slot="required-indicator"'), false);
|
||||
assert.equal(account.includes('type="password"'), false);
|
||||
const pending = await render(
|
||||
language,
|
||||
<WorkProfileAccountField
|
||||
authorized={false}
|
||||
disabled={false}
|
||||
loading={false}
|
||||
onAuthorize={() => undefined}
|
||||
onChange={() => undefined}
|
||||
onCreate={undefined}
|
||||
options={[{ id: "account", label: "ana@example.test", description: "—" }]}
|
||||
personId="person"
|
||||
value="account"
|
||||
/>
|
||||
);
|
||||
|
||||
assert.ok(pending.includes('role="alert"'));
|
||||
assert.ok(pending.includes(text.mail.noAuthorization));
|
||||
assert.ok(pending.includes(text.mail.authorize));
|
||||
assert.ok(pending.includes(text.profiles.selection.clear));
|
||||
const noQuick = await render(
|
||||
language,
|
||||
<WorkProfileSelectionField
|
||||
disabled
|
||||
kind="person"
|
||||
loading={false}
|
||||
onChange={() => undefined}
|
||||
options={[]}
|
||||
value=""
|
||||
/>
|
||||
);
|
||||
|
||||
assert.equal(noQuick.includes(text.mail.newPerson), false);
|
||||
assert.match(noQuick, /<button(?=[^>]*aria-haspopup="dialog")(?=[^>]*disabled="")[^>]*>/);
|
||||
}
|
||||
});
|
||||
|
||||
@ -121,6 +188,50 @@ test("perfiles: el puesto fijado aporta su ordenador y no selecciona al responsa
|
||||
},
|
||||
history: [],
|
||||
};
|
||||
const computer = AssetSchema.parse({
|
||||
id: computerId,
|
||||
companyId: company.id,
|
||||
values: {
|
||||
name: "PC-ADMIN",
|
||||
templateId: company.id,
|
||||
siteId: null,
|
||||
status: "operational",
|
||||
notes: "",
|
||||
characteristics: {},
|
||||
},
|
||||
displayStatus: "in_use",
|
||||
assigned: true,
|
||||
specification: {
|
||||
templateId: company.id,
|
||||
templateName: "Ordenador",
|
||||
fields: [
|
||||
{
|
||||
fieldId: company.id,
|
||||
name: "Modelo",
|
||||
type: "text",
|
||||
unit: "",
|
||||
choices: [],
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
history: [
|
||||
{
|
||||
id: company.id,
|
||||
actorId: company.id,
|
||||
actorName: "Técnico",
|
||||
recordedAt: "2026-10-16T12:00:00.000Z",
|
||||
values: {
|
||||
name: "PC-ADMIN",
|
||||
templateId: company.id,
|
||||
siteId: null,
|
||||
status: "operational",
|
||||
notes: "",
|
||||
characteristics: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const assignedCompany = {
|
||||
...company,
|
||||
people: [
|
||||
@ -145,13 +256,7 @@ test("perfiles: el puesto fijado aporta su ordenador y no selecciona al responsa
|
||||
/>,
|
||||
(client) => {
|
||||
client.setQueryData(inventoryKeys.company(company.id), {
|
||||
assets: [
|
||||
{
|
||||
id: computerId,
|
||||
companyId: company.id,
|
||||
values: { name: "PC-ADMIN", status: "operational" },
|
||||
},
|
||||
],
|
||||
assets: [computer],
|
||||
relationships: [],
|
||||
workstations: [station],
|
||||
});
|
||||
@ -159,7 +264,101 @@ test("perfiles: el puesto fijado aporta su ordenador y no selecciona al responsa
|
||||
);
|
||||
|
||||
assert.ok(html.includes("Administración"));
|
||||
assert.ok(html.includes("Dispositivo: PC-ADMIN"));
|
||||
assert.ok(html.includes("PC-ADMIN"));
|
||||
assert.ok(html.includes('data-slot="card"'));
|
||||
assert.match(html, /<button(?=[^>]*aria-haspopup="dialog")(?=[^>]*disabled="")[^>]*>/);
|
||||
assert.equal(html.includes("Responsable test"), false);
|
||||
assert.equal(html.includes('aria-label="Nuevo dispositivo"'), false);
|
||||
});
|
||||
|
||||
test("perfiles: requisitos por paso, cuenta opcional y propuesta basada en persona y ordenador", () => {
|
||||
assert.equal(profileCreationReady(1, { data: false, target: true, person: true }), false);
|
||||
assert.equal(profileCreationReady(1, { data: true, target: false, person: true }), false);
|
||||
assert.equal(profileCreationReady(1, { data: true, target: true, person: false }), true);
|
||||
for (const step of [2, 3, 4] as const) {
|
||||
assert.equal(profileCreationReady(step, { data: true, target: true, person: false }), false);
|
||||
assert.equal(profileCreationReady(step, { data: true, target: true, person: true }), true);
|
||||
}
|
||||
assert.equal(
|
||||
suggestedProfileName(undefined, "PC-01", (person, computer) => `${person} · ${computer}`),
|
||||
""
|
||||
);
|
||||
assert.equal(
|
||||
suggestedProfileName("Ana", undefined, (person, computer) => `${person} · ${computer}`),
|
||||
""
|
||||
);
|
||||
assert.equal(
|
||||
suggestedProfileName("Ana", "PC-01", (person, computer) => `${person} · ${computer}`),
|
||||
"Ana · PC-01"
|
||||
);
|
||||
});
|
||||
|
||||
test("perfiles: el nombre sugerido respeta el límite del contrato", () => {
|
||||
const name = suggestedProfileName(
|
||||
"A".repeat(100),
|
||||
"PC-01",
|
||||
(person, computer) => `${person} · ${computer}`
|
||||
);
|
||||
|
||||
assert.equal(name.length, 100);
|
||||
assert.equal(WorkProfileSchema.shape.values.shape.name.safeParse(name).success, true);
|
||||
});
|
||||
|
||||
test("perfiles: resumen con listas, ordenador único y ausencias representadas por guion", async () => {
|
||||
for (const language of ["es", "en"]) {
|
||||
const html = await render(
|
||||
language,
|
||||
<WorkProfileSummary
|
||||
account={undefined}
|
||||
computer={undefined}
|
||||
person={{ id: "person", label: "Ana", description: "Administración" }}
|
||||
workstationName="Oficina"
|
||||
/>
|
||||
);
|
||||
|
||||
assert.ok(html.includes("Ana"));
|
||||
assert.ok(html.includes("Administración"));
|
||||
assert.ok(html.includes("Oficina"));
|
||||
assert.equal((html.match(/data-slot="card"/g) ?? []).length, 2);
|
||||
assert.equal((html.match(/<ul\b/g) ?? []).length, 2);
|
||||
assert.equal(html.includes("<button"), false);
|
||||
assert.ok(html.includes("—"));
|
||||
}
|
||||
});
|
||||
|
||||
test("perfiles: edición completa con identidad fija aunque el perfil requiera revisión", async () => {
|
||||
const values = {
|
||||
name: "Perfil histórico",
|
||||
personId: company.id,
|
||||
assetId: company.id,
|
||||
accountId: null,
|
||||
active: true,
|
||||
notes: null,
|
||||
};
|
||||
const profile = WorkProfileSchema.parse({
|
||||
id: company.id,
|
||||
companyId: company.id,
|
||||
values,
|
||||
needsReview: true,
|
||||
history: [
|
||||
{
|
||||
id: company.id,
|
||||
actorId: company.id,
|
||||
actorName: "Técnico",
|
||||
recordedAt: "2026-10-16T12:00:00.000Z",
|
||||
values,
|
||||
},
|
||||
],
|
||||
});
|
||||
const html = await render(
|
||||
"es",
|
||||
<WorkProfileForm company={company} onSaved={() => undefined} profile={profile} />
|
||||
);
|
||||
|
||||
assert.ok(html.includes("Guardar"));
|
||||
assert.equal(html.includes("Siguiente"), false);
|
||||
assert.ok(html.includes("La persona y el dispositivo identifican este perfil."));
|
||||
assert.ok(html.includes("Nueva cuenta"));
|
||||
assert.equal(html.includes("Nueva persona"), false);
|
||||
assert.match(html, /<button(?![^>]*\sdisabled=)[^>]*type="submit"[^>]*>/);
|
||||
});
|
||||
|
||||
@ -0,0 +1,51 @@
|
||||
import type { PickerOption } from "@repo/rdx-ui/components/option-picker-dialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { WorkProfileAuthorizationNotice } from "./work-profile-authorization-notice";
|
||||
import { WorkProfileSelectionField } from "./work-profile-selection-field";
|
||||
|
||||
export function WorkProfileAccountField({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
loading,
|
||||
authorized,
|
||||
personId,
|
||||
onCreate,
|
||||
onAuthorize,
|
||||
}: {
|
||||
options: readonly PickerOption[];
|
||||
value: string;
|
||||
onChange: (id: string) => void;
|
||||
disabled: boolean;
|
||||
loading: boolean;
|
||||
authorized: boolean;
|
||||
personId: string;
|
||||
onCreate: (() => void) | undefined;
|
||||
onAuthorize: (() => void) | undefined;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
|
||||
return (
|
||||
<>
|
||||
<WorkProfileSelectionField
|
||||
createAction={onCreate ? { label: t("mail.newAccount"), onClick: onCreate } : undefined}
|
||||
disabled={disabled}
|
||||
kind="account"
|
||||
loading={loading}
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
value={value}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">{t("profiles.accountHint")}</p>
|
||||
{personId && value && !authorized && (
|
||||
<WorkProfileAuthorizationNotice
|
||||
authorized={authorized}
|
||||
disabled={disabled}
|
||||
onAuthorize={onAuthorize}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/** Explica la falta de autorización junto a la cuenta y antes del guardado final. */
|
||||
export function WorkProfileAuthorizationNotice({
|
||||
authorized,
|
||||
disabled,
|
||||
onAuthorize,
|
||||
}: {
|
||||
authorized: boolean;
|
||||
disabled: boolean;
|
||||
onAuthorize: (() => void) | undefined;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
|
||||
if (authorized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="space-y-2 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm"
|
||||
role="alert"
|
||||
>
|
||||
<p>{t("mail.noAuthorization")}</p>
|
||||
{onAuthorize && (
|
||||
<Button disabled={disabled} onClick={onAuthorize} type="button" variant="outline">
|
||||
{t("mail.authorize")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
import { AssetSummaryCard } from "@metis/assets/client";
|
||||
import type { AssetDTO } from "@metis/assets/common";
|
||||
import { FormSection } from "@repo/rdx-ui/components/form-section";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/** El ordenador es una consecuencia del puesto, nunca una segunda elección del perfil. */
|
||||
export function WorkProfileComputerSummary({ computer }: { computer: AssetDTO | undefined }) {
|
||||
const { t } = useTranslation("companies");
|
||||
|
||||
return (
|
||||
<FormSection title={t("profiles.asset")} variant="plain">
|
||||
<ul className="grid items-stretch gap-3">
|
||||
<li className="min-w-0">
|
||||
<AssetSummaryCard asset={computer} />
|
||||
</li>
|
||||
</ul>
|
||||
</FormSection>
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { UsersIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -23,14 +24,20 @@ export function WorkProfileEditorDialog({
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
layout="stable"
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
open
|
||||
title={t(profile ? "profiles.edit" : "profiles.new")}
|
||||
wide
|
||||
size={profile ? "large" : "medium"}
|
||||
title={
|
||||
<span className="flex items-center gap-3">
|
||||
<UsersIcon aria-hidden="true" className="size-5 shrink-0 text-muted-foreground" />
|
||||
{t(profile ? "profiles.edit" : "profiles.new")}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<WorkProfileForm
|
||||
company={company}
|
||||
|
||||
@ -0,0 +1,104 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { WorkProfileFormController } from "../controllers/use-work-profile-form-controller";
|
||||
import type { WorkProfileFormProps } from "../types";
|
||||
|
||||
import { WorkProfileAccountField } from "./work-profile-account-field";
|
||||
import { WorkProfileAuthorizationNotice } from "./work-profile-authorization-notice";
|
||||
import { WorkProfileComputerSummary } from "./work-profile-computer-summary";
|
||||
import { WorkProfileIdentityFields } from "./work-profile-identity-fields";
|
||||
import { WorkProfileSelectionField } from "./work-profile-selection-field";
|
||||
import { WorkProfileSummary } from "./work-profile-summary";
|
||||
import { WorkProfileWorkstationField } from "./work-profile-workstation-field";
|
||||
|
||||
export function WorkProfileFormFields({
|
||||
controller: c,
|
||||
profile,
|
||||
allowQuickCreate,
|
||||
workstationId,
|
||||
prefix,
|
||||
}: Pick<WorkProfileFormProps, "profile" | "workstationId"> & {
|
||||
controller: WorkProfileFormController;
|
||||
allowQuickCreate: boolean;
|
||||
prefix: string;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
const loading = c.inventory.isPending || c.access.isPending;
|
||||
const layout = c.isWizard ? STEP_LAYOUTS[c.step] : FULL_LAYOUT;
|
||||
const computer = profile
|
||||
? c.inventory.data?.assets.find((asset) => asset.id === profile.values.assetId)
|
||||
: c.computer;
|
||||
|
||||
return (
|
||||
<>
|
||||
{layout.workstation && (
|
||||
<WorkProfileWorkstationField controller={c} locked={Boolean(workstationId)} />
|
||||
)}
|
||||
{layout.person && (
|
||||
<WorkProfileSelectionField
|
||||
createAction={
|
||||
allowQuickCreate
|
||||
? { label: t("mail.newPerson"), onClick: () => c.setQuick("person") }
|
||||
: undefined
|
||||
}
|
||||
disabled={c.disabled}
|
||||
kind="person"
|
||||
loading={loading}
|
||||
locked={!c.isWizard}
|
||||
onChange={(id) => c.form.setValue("personId", id, { shouldDirty: true })}
|
||||
options={c.personOptions}
|
||||
value={c.values.personId}
|
||||
/>
|
||||
)}
|
||||
{layout.account && (
|
||||
<WorkProfileAccountField
|
||||
authorized={c.authorized}
|
||||
disabled={c.disabled}
|
||||
loading={loading}
|
||||
onAuthorize={allowQuickCreate ? () => c.setQuick("authorization") : undefined}
|
||||
onChange={(id) => c.form.setValue("accountId", id || null, { shouldDirty: true })}
|
||||
onCreate={allowQuickCreate ? () => c.setQuick("account") : undefined}
|
||||
options={c.accountOptions}
|
||||
personId={c.values.personId}
|
||||
value={c.values.accountId ?? ""}
|
||||
/>
|
||||
)}
|
||||
{layout.identity && <WorkProfileIdentityFields form={c.form} prefix={prefix} />}
|
||||
{layout.summary && (
|
||||
<WorkProfileSummary
|
||||
account={c.accountOptions.find((option) => option.id === c.values.accountId)}
|
||||
computer={computer}
|
||||
person={c.personOptions.find((option) => option.id === c.values.personId)}
|
||||
workstationName={c.workstation?.values.name}
|
||||
/>
|
||||
)}
|
||||
{layout.summary && (
|
||||
<WorkProfileAuthorizationNotice
|
||||
authorized={c.authorized}
|
||||
disabled={c.disabled}
|
||||
onAuthorize={allowQuickCreate ? () => c.setQuick("authorization") : undefined}
|
||||
/>
|
||||
)}
|
||||
{!c.isWizard && (
|
||||
<>
|
||||
<WorkProfileComputerSummary computer={computer} />
|
||||
<p className="text-sm text-muted-foreground">{t("profiles.fixed")}</p>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const FULL_LAYOUT = {
|
||||
workstation: false,
|
||||
person: true,
|
||||
account: true,
|
||||
identity: true,
|
||||
summary: false,
|
||||
};
|
||||
const STEP_LAYOUTS = {
|
||||
1: { workstation: true, person: false, account: false, identity: false, summary: false },
|
||||
2: { workstation: false, person: true, account: false, identity: false, summary: false },
|
||||
3: { workstation: false, person: false, account: true, identity: false, summary: false },
|
||||
4: { workstation: false, person: false, account: false, identity: true, summary: true },
|
||||
} as const;
|
||||
@ -1,11 +1,7 @@
|
||||
import { ContextHelp } from "@repo/rdx-ui/components/context-help";
|
||||
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
import { SwitchField } from "@repo/rdx-ui/components/switch-field";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||
import { FormBody } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormHelp } from "@repo/rdx-ui/components/form-help";
|
||||
import { FormProgress } from "@repo/rdx-ui/components/form-progress";
|
||||
import { FormStepActions } from "@repo/rdx-ui/components/form-step-actions";
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -14,178 +10,78 @@ import { useWorkProfileFormController } from "../controllers";
|
||||
import type { WorkProfileFormProps } from "../types";
|
||||
|
||||
import { ProfileFeedback } from "./profile-feedback";
|
||||
import { WorkProfileFormFields } from "./work-profile-form-fields";
|
||||
|
||||
export function WorkProfileForm(props: WorkProfileFormProps) {
|
||||
const { company, profile, allowQuickCreate = true } = props;
|
||||
const {
|
||||
workstations,
|
||||
selectedWorkstationId,
|
||||
chooseWorkstation,
|
||||
computer,
|
||||
targetReady,
|
||||
quick,
|
||||
setQuick,
|
||||
access,
|
||||
inventory,
|
||||
form,
|
||||
values,
|
||||
authorized,
|
||||
mutation,
|
||||
disabled,
|
||||
selectors,
|
||||
submit,
|
||||
quickCreated,
|
||||
} = useWorkProfileFormController(props);
|
||||
const c = useWorkProfileFormController(props);
|
||||
const { t } = useTranslation("companies");
|
||||
const prefix = useId();
|
||||
const keys = ["workstation", "person", "account", "summary"] as const;
|
||||
const loading = c.inventory.isPending || c.access.isPending;
|
||||
|
||||
return (
|
||||
<>
|
||||
<form className="flex min-h-0 flex-1 flex-col overflow-hidden" onSubmit={submit}>
|
||||
<FormBody>
|
||||
<fieldset className="space-y-4" disabled={disabled}>
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor={prefix} required>
|
||||
{t("profiles.name")}
|
||||
</FormLabel>
|
||||
<Input id={prefix} maxLength={100} required {...form.register("name")} />
|
||||
</div>
|
||||
{!profile && (
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor={`${prefix}-workstation`} required>
|
||||
{t("profiles.workstation")}
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
disabled={disabled || !inventory.data || Boolean(props.workstationId)}
|
||||
id={`${prefix}-workstation`}
|
||||
label={t("profiles.workstation")}
|
||||
onValueChange={chooseWorkstation}
|
||||
options={workstations.map((station) => ({
|
||||
value: station.id,
|
||||
label: station.values.name,
|
||||
}))}
|
||||
placeholder={t("profiles.chooseWorkstation")}
|
||||
required
|
||||
value={selectedWorkstationId}
|
||||
/>
|
||||
{selectedWorkstationId && (
|
||||
<p className="text-sm text-muted-foreground" role="status">
|
||||
{computer
|
||||
? `${t("profiles.asset")}: ${computer.values.name}`
|
||||
: t("profiles.workstationNeedsComputer")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-4 @min-[560px]:grid-cols-2">
|
||||
{selectors.map((selector) => {
|
||||
const fixed = !!profile && selector.field !== "accountId";
|
||||
|
||||
return (
|
||||
<div className="space-y-2" key={selector.field}>
|
||||
<FormLabel
|
||||
htmlFor={`${prefix}-${selector.field}`}
|
||||
required={selector.field !== "accountId"}
|
||||
>
|
||||
{t(`profiles.${selector.label}`)}
|
||||
</FormLabel>
|
||||
<SelectWithAction
|
||||
action={
|
||||
fixed || !allowQuickCreate
|
||||
? undefined
|
||||
: {
|
||||
label: t(`mail.${selector.create}`),
|
||||
onClick: () => setQuick(selector.kind),
|
||||
}
|
||||
}
|
||||
disabled={disabled || fixed || !inventory.data || !access.data}
|
||||
id={`${prefix}-${selector.field}`}
|
||||
label={t(`profiles.${selector.label}`)}
|
||||
onValueChange={(id) =>
|
||||
form.setValue(
|
||||
selector.field,
|
||||
selector.field === "accountId" ? id || null : id
|
||||
)
|
||||
}
|
||||
options={selector.options}
|
||||
placeholder={t(
|
||||
selector.field === "accountId" ? "profiles.noAccount" : "profiles.choose"
|
||||
)}
|
||||
required={selector.field !== "accountId"}
|
||||
value={values[selector.field] ?? ""}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{t("profiles.accountHint")}</p>
|
||||
{values.personId && values.accountId && !authorized && allowQuickCreate && (
|
||||
<div className="space-y-2 rounded-md border p-3 text-sm">
|
||||
<p>{t("mail.noAuthorization")}</p>
|
||||
<Button onClick={() => setQuick("authorization")} type="button" variant="outline">
|
||||
{t("mail.authorize")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<SwitchField
|
||||
checked={values.active}
|
||||
id={`${prefix}-active`}
|
||||
label={t("profiles.active")}
|
||||
onCheckedChange={(active) => form.setValue("active", active)}
|
||||
<form className="flex min-h-0 flex-1 flex-col overflow-hidden" onSubmit={c.submit}>
|
||||
<FormBody contentLayout="fill">
|
||||
{c.isWizard && (
|
||||
<FormProgress
|
||||
completedLabel={t("profiles.steps.completed")}
|
||||
description={t(`profiles.steps.${keys[c.step - 1]}Description`)}
|
||||
label={t("profiles.steps.label")}
|
||||
labels={keys.map((key) => t(`profiles.steps.${key}`))}
|
||||
progress={t("profiles.steps.progress", { step: c.step, total: 4 })}
|
||||
step={c.step}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor={`${prefix}-notes`}>{t("profiles.notes")}</FormLabel>
|
||||
<Textarea
|
||||
id={`${prefix}-notes`}
|
||||
maxLength={4000}
|
||||
onChange={(event) => form.setValue("notes", event.target.value || null)}
|
||||
rows={3}
|
||||
value={values.notes ?? ""}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
{profile && (
|
||||
<div className="space-y-1 text-sm text-muted-foreground">
|
||||
<p>
|
||||
{t("profiles.asset")}:{" "}
|
||||
{inventory.data?.assets.find((asset) => asset.id === profile.values.assetId)?.values
|
||||
.name ?? profile.values.assetId}
|
||||
</p>
|
||||
<p>{t("profiles.fixed")}</p>
|
||||
</div>
|
||||
)}
|
||||
{Object.keys(form.formState.errors).length > 0 && (
|
||||
<fieldset className="flex min-w-0 flex-1 flex-col gap-5" disabled={c.disabled}>
|
||||
<WorkProfileFormFields
|
||||
allowQuickCreate={allowQuickCreate}
|
||||
controller={c}
|
||||
prefix={prefix}
|
||||
profile={profile}
|
||||
workstationId={props.workstationId}
|
||||
/>
|
||||
</fieldset>
|
||||
{Object.keys(c.form.formState.errors).length > 0 && (
|
||||
<p className="text-destructive" role="alert">
|
||||
{t("profiles.errors.INVALID_REQUEST")}
|
||||
</p>
|
||||
)}
|
||||
<ProfileFeedback error={mutation.error ?? access.error ?? inventory.error} />
|
||||
<ContextHelp
|
||||
description={t("profiles.description")}
|
||||
examples={t("profiles.examples")}
|
||||
examplesLabel={t("profiles.examplesLabel")}
|
||||
guidance={t("profiles.guidance")}
|
||||
scopeNote={t("profiles.scope")}
|
||||
title={t("profiles.help")}
|
||||
/>
|
||||
{loading && (
|
||||
<p className="text-sm text-muted-foreground" role="status">
|
||||
{t("loading")}
|
||||
</p>
|
||||
)}
|
||||
<ProfileFeedback error={c.mutation.error ?? c.access.error ?? c.inventory.error} />
|
||||
</FormBody>
|
||||
<FormActions>
|
||||
<Button
|
||||
disabled={disabled || !inventory.data || !access.data || !targetReady}
|
||||
type="submit"
|
||||
>
|
||||
{t(disabled ? "saving" : "save")}
|
||||
</Button>
|
||||
</FormActions>
|
||||
<FormHelp
|
||||
description={t("profiles.description")}
|
||||
examples={t("profiles.examples")}
|
||||
examplesLabel={t("profiles.examplesLabel")}
|
||||
guidance={t("profiles.guidance")}
|
||||
scopeNote={t("profiles.scope")}
|
||||
title={t("profiles.help")}
|
||||
/>
|
||||
<FormStepActions
|
||||
canContinue={c.isWizard && c.step < 4 ? c.canContinue : c.canSave}
|
||||
nextLabel={t("profiles.steps.next")}
|
||||
onPrevious={c.previousStep}
|
||||
pending={c.disabled}
|
||||
previousLabel={t("profiles.steps.previous")}
|
||||
saveLabel={t(c.disabled ? "saving" : "save")}
|
||||
step={c.isWizard ? c.step : undefined}
|
||||
total={4}
|
||||
/>
|
||||
</form>
|
||||
{quick && (
|
||||
{c.quick && (
|
||||
<ConfigurationQuickEditor
|
||||
company={company}
|
||||
data={access.data}
|
||||
kind={quick}
|
||||
onClose={() => setQuick(undefined)}
|
||||
onCreated={quickCreated}
|
||||
values={{ personId: values.personId, accountId: values.accountId ?? "" }}
|
||||
data={c.access.data}
|
||||
kind={c.quick}
|
||||
onClose={() => c.setQuick(undefined)}
|
||||
onCreated={c.quickCreated}
|
||||
values={{ personId: c.values.personId, accountId: c.values.accountId ?? "" }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -0,0 +1,70 @@
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { FormSection } from "@repo/rdx-ui/components/form-section";
|
||||
import { SwitchField } from "@repo/rdx-ui/components/switch-field";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { WorkProfileInput } from "../../../../common/work-profiles";
|
||||
|
||||
export function WorkProfileIdentityFields({
|
||||
form,
|
||||
prefix,
|
||||
}: {
|
||||
form: UseFormReturn<WorkProfileInput>;
|
||||
prefix: string;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
const nameError = !!form.formState.errors.name;
|
||||
const notesError = !!form.formState.errors.notes;
|
||||
|
||||
return (
|
||||
<FormSection title={t("profiles.steps.details")} variant="plain">
|
||||
<div className="grid items-end gap-4 @min-[560px]:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor={`${prefix}-name`} required>
|
||||
{t("profiles.name")}
|
||||
</FormLabel>
|
||||
<Input
|
||||
aria-describedby={nameError ? `${prefix}-name-error` : undefined}
|
||||
aria-invalid={nameError}
|
||||
id={`${prefix}-name`}
|
||||
maxLength={100}
|
||||
required
|
||||
{...form.register("name")}
|
||||
/>
|
||||
{nameError && (
|
||||
<p className="text-sm text-destructive" id={`${prefix}-name-error`} role="alert">
|
||||
{t("profiles.errors.INVALID_REQUEST")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<SwitchField
|
||||
checked={form.watch("active")}
|
||||
id={`${prefix}-active`}
|
||||
label={t("profiles.active")}
|
||||
onCheckedChange={(active) => form.setValue("active", active, { shouldDirty: true })}
|
||||
/>
|
||||
<div className="col-span-full space-y-2">
|
||||
<FormLabel htmlFor={`${prefix}-notes`}>{t("profiles.notes")}</FormLabel>
|
||||
<Textarea
|
||||
aria-describedby={notesError ? `${prefix}-notes-error` : undefined}
|
||||
aria-invalid={notesError}
|
||||
id={`${prefix}-notes`}
|
||||
maxLength={4000}
|
||||
rows={3}
|
||||
{...form.register("notes", {
|
||||
setValueAs: (value: unknown) => (value === "" ? null : value),
|
||||
})}
|
||||
/>
|
||||
{notesError && (
|
||||
<p className="text-sm text-destructive" id={`${prefix}-notes-error`} role="alert">
|
||||
{t("profiles.errors.INVALID_REQUEST")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,66 @@
|
||||
import type { PickerOption } from "@repo/rdx-ui/components/option-picker-dialog";
|
||||
import { OptionSelectionField } from "@repo/rdx-ui/components/option-selection-field";
|
||||
import { RequiredIndicator } from "@repo/rdx-ui/components/required-indicator";
|
||||
import { KeyRoundIcon, MonitorIcon, UsersIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function WorkProfileSelectionField({
|
||||
kind,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
loading,
|
||||
locked = false,
|
||||
createAction,
|
||||
}: {
|
||||
kind: "workstation" | "person" | "account";
|
||||
options: readonly PickerOption[];
|
||||
value: string;
|
||||
onChange: (id: string) => void;
|
||||
disabled: boolean;
|
||||
loading: boolean;
|
||||
locked?: boolean;
|
||||
createAction?: { label: string; onClick: () => void } | undefined;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation("companies");
|
||||
const id = useId();
|
||||
const icons = { workstation: <MonitorIcon />, person: <UsersIcon />, account: <KeyRoundIcon /> };
|
||||
|
||||
return (
|
||||
<fieldset aria-describedby={`${id}-help`} className="flex min-w-0 flex-1 flex-col gap-3">
|
||||
<legend className="mb-3 flex items-center gap-1 text-sm font-medium">
|
||||
{t(`profiles.${kind}`)}
|
||||
{kind !== "account" && <RequiredIndicator />}
|
||||
</legend>
|
||||
<p className="text-sm text-muted-foreground" id={`${id}-help`}>
|
||||
{t(`profiles.selection.${kind}.description`)}
|
||||
</p>
|
||||
<OptionSelectionField
|
||||
clearLabel={kind === "account" ? t("profiles.selection.clear") : undefined}
|
||||
createAction={createAction}
|
||||
description={t(`profiles.selection.${kind}.description`)}
|
||||
disabled={disabled}
|
||||
icon={icons[kind]}
|
||||
labels={{
|
||||
select: t(`profiles.selection.${kind}.select`),
|
||||
change: t("profiles.selection.change"),
|
||||
selection: t("profiles.selection.column"),
|
||||
name: t(`profiles.${kind}`),
|
||||
description: t("profiles.selection.details"),
|
||||
confirm: t("profiles.selection.confirm"),
|
||||
cancel: t("cancel"),
|
||||
search: t(`profiles.selection.${kind}.search`),
|
||||
}}
|
||||
loading={loading}
|
||||
locale={i18n.language}
|
||||
locked={locked}
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
title={t(`profiles.selection.${kind}.empty`)}
|
||||
value={value}
|
||||
/>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,70 @@
|
||||
import type { AssetDTO } from "@metis/assets/common";
|
||||
import { FormSection } from "@repo/rdx-ui/components/form-section";
|
||||
import type { PickerOption } from "@repo/rdx-ui/components/option-picker-dialog";
|
||||
import { SelectionSummaryCard } from "@repo/rdx-ui/components/selection-summary-card";
|
||||
import { KeyRoundIcon, UsersIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { WorkProfileComputerSummary } from "./work-profile-computer-summary";
|
||||
|
||||
export function WorkProfileSummary({
|
||||
workstationName,
|
||||
computer,
|
||||
person,
|
||||
account,
|
||||
}: {
|
||||
workstationName: string | undefined;
|
||||
computer: AssetDTO | undefined;
|
||||
person: PickerOption | undefined;
|
||||
account: PickerOption | undefined;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
|
||||
return (
|
||||
<FormSection title={t("profiles.steps.selected")} variant="plain">
|
||||
<dl className="grid items-stretch gap-4 @min-[560px]:grid-cols-2">
|
||||
<div className="col-span-full space-y-1">
|
||||
<dt className="text-sm text-muted-foreground">{t("profiles.workstation")}</dt>
|
||||
<dd className="break-words text-sm font-medium">{workstationName ?? "—"}</dd>
|
||||
</div>
|
||||
<div className="min-w-0 space-y-3">
|
||||
<dt className="text-sm text-muted-foreground">{t("profiles.person")}</dt>
|
||||
<dd>
|
||||
{person ? (
|
||||
<ul className="grid gap-3">
|
||||
<li className="min-w-0">
|
||||
<SelectionSummaryCard
|
||||
description={person.description}
|
||||
icon={<UsersIcon />}
|
||||
title={person.label}
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="min-w-0 space-y-3">
|
||||
<dt className="text-sm text-muted-foreground">{t("profiles.account")}</dt>
|
||||
<dd>
|
||||
{account ? (
|
||||
<ul className="grid gap-3">
|
||||
<li className="min-w-0">
|
||||
<SelectionSummaryCard
|
||||
description={account.description}
|
||||
icon={<KeyRoundIcon />}
|
||||
title={account.label}
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<WorkProfileComputerSummary computer={computer} />
|
||||
</FormSection>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { WorkProfileFormController } from "../controllers/use-work-profile-form-controller";
|
||||
|
||||
import { WorkProfileComputerSummary } from "./work-profile-computer-summary";
|
||||
import { WorkProfileSelectionField } from "./work-profile-selection-field";
|
||||
|
||||
export function WorkProfileWorkstationField({
|
||||
controller: c,
|
||||
locked,
|
||||
}: {
|
||||
controller: WorkProfileFormController;
|
||||
locked: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("companies");
|
||||
|
||||
return (
|
||||
<>
|
||||
<WorkProfileSelectionField
|
||||
disabled={c.disabled}
|
||||
kind="workstation"
|
||||
loading={c.inventory.isPending}
|
||||
locked={locked}
|
||||
onChange={c.chooseWorkstation}
|
||||
options={c.workstationOptions}
|
||||
value={c.selectedWorkstationId}
|
||||
/>
|
||||
{c.selectedWorkstationId &&
|
||||
(c.computer ? (
|
||||
<WorkProfileComputerSummary computer={c.computer} />
|
||||
) : (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{t("profiles.workstationNeedsComputer")}
|
||||
</p>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import { WorkstationActions, WorkstationEditorDialog } from "@metis/assets/client";
|
||||
import { BrowserLink, navigateTo } from "@metis/core/client";
|
||||
import { EntityStatus } from "@repo/rdx-ui/components/entity-list";
|
||||
import { BriefcaseIcon, ChevronLeftIcon, UsersIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { ChevronLeftIcon, MonitorIcon, UsersIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -72,7 +72,7 @@ export function WorkstationDetailPage({
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<h1 className="flex items-center gap-3 text-2xl font-semibold">
|
||||
<BriefcaseIcon aria-hidden="true" className="size-6 text-muted-foreground" />
|
||||
<MonitorIcon aria-hidden="true" className="size-6 text-muted-foreground" />
|
||||
{workstation.values.name}
|
||||
</h1>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
|
||||
|
||||
@ -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",
|
||||
|
||||
12
packages/rdx-ui/src/components/form-help.tsx
Normal file
12
packages/rdx-ui/src/components/form-help.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { ContextHelp } from "./context-help";
|
||||
|
||||
/** Mantiene la ayuda general junto al pie, fuera del scroll principal del formulario. */
|
||||
export function FormHelp(props: Omit<ComponentProps<typeof ContextHelp>, "variant">) {
|
||||
return (
|
||||
<div className="min-h-0 max-h-[40%] shrink-0 overflow-y-auto px-1 pb-3 [scrollbar-gutter:stable] [&>aside]:mt-0">
|
||||
<ContextHelp {...props} variant="plain" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -27,14 +27,14 @@ export function FormStepActions({
|
||||
const hasNext = step !== undefined && step < total;
|
||||
|
||||
return (
|
||||
<FormActions>
|
||||
<FormActions cancelDisabled={pending}>
|
||||
{step !== undefined && step > 1 && (
|
||||
<Button disabled={pending} onClick={onPrevious} type="button" variant="outline">
|
||||
<ChevronLeftIcon aria-hidden="true" className="size-4" />
|
||||
{previousLabel}
|
||||
</Button>
|
||||
)}
|
||||
<Button disabled={pending || (step === 1 && !canContinue)} type="submit">
|
||||
<Button disabled={pending || !canContinue} type="submit">
|
||||
{hasNext ? nextLabel : saveLabel}
|
||||
{hasNext && <ChevronRightIcon aria-hidden="true" className="size-4" />}
|
||||
</Button>
|
||||
|
||||
86
packages/rdx-ui/src/components/option-picker-dialog.tsx
Normal file
86
packages/rdx-ui/src/components/option-picker-dialog.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { useState } from "react";
|
||||
|
||||
import { FormActions, FormBody, FormDialog } from "./form-dialog";
|
||||
import { OptionPickerList } from "./option-picker-list";
|
||||
|
||||
export type PickerOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
/** Selección única provisional: buscar y cancelar nunca modifican el formulario padre. */
|
||||
export function OptionPickerDialog({
|
||||
title,
|
||||
options,
|
||||
value,
|
||||
onSelect,
|
||||
onClose,
|
||||
labels,
|
||||
locale,
|
||||
loading = false,
|
||||
}: {
|
||||
title: string;
|
||||
options: readonly PickerOption[];
|
||||
value: string;
|
||||
onSelect: (id: string) => void;
|
||||
onClose: () => void;
|
||||
labels: {
|
||||
selection: string;
|
||||
name: string;
|
||||
description: string;
|
||||
confirm: string;
|
||||
cancel: string;
|
||||
search: string;
|
||||
};
|
||||
locale: string;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
const [selected, setSelected] = useState(value);
|
||||
const available =
|
||||
!loading && options.some((option) => option.id === selected && !option.disabled);
|
||||
|
||||
return (
|
||||
<FormDialog
|
||||
cancelLabel={labels.cancel}
|
||||
formActions
|
||||
layout="picker"
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
open
|
||||
size="medium"
|
||||
title={title}
|
||||
>
|
||||
<FormBody>
|
||||
<OptionPickerList
|
||||
labels={labels}
|
||||
loading={loading}
|
||||
locale={locale}
|
||||
onChange={setSelected}
|
||||
options={options}
|
||||
title={title}
|
||||
value={selected}
|
||||
/>
|
||||
</FormBody>
|
||||
<FormActions>
|
||||
<Button
|
||||
disabled={!available}
|
||||
onClick={() => {
|
||||
if (available) {
|
||||
onSelect(selected);
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{labels.confirm}
|
||||
</Button>
|
||||
</FormActions>
|
||||
</FormDialog>
|
||||
);
|
||||
}
|
||||
79
packages/rdx-ui/src/components/option-picker-list.tsx
Normal file
79
packages/rdx-ui/src/components/option-picker-list.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import { RadioGroup, RadioGroupItem } from "@repo/shadcn-ui/components/radio-group";
|
||||
|
||||
import { EntityTable } from "./entity-list";
|
||||
import type { PickerOption } from "./option-picker-dialog";
|
||||
|
||||
/** Listado de selección única con búsqueda; el diálogo conserva la confirmación provisional. */
|
||||
export function OptionPickerList({
|
||||
title,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
labels,
|
||||
locale,
|
||||
loading = false,
|
||||
}: {
|
||||
title: string;
|
||||
options: readonly PickerOption[];
|
||||
value: string;
|
||||
onChange: (id: string) => void;
|
||||
labels: { selection: string; name: string; description: string; search: string };
|
||||
locale: string;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
const choose = (id: string) => {
|
||||
if (!loading && options.some((option) => option.id === id && !option.disabled)) {
|
||||
onChange(id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<RadioGroup
|
||||
aria-label={title}
|
||||
className="flex min-h-96 flex-1 flex-col"
|
||||
onValueChange={(id) => {
|
||||
if (typeof id === "string") {
|
||||
choose(id);
|
||||
}
|
||||
}}
|
||||
value={value}
|
||||
>
|
||||
<EntityTable
|
||||
columns={[
|
||||
{
|
||||
id: "selection",
|
||||
label: labels.selection,
|
||||
hideable: false,
|
||||
render: (option) => (
|
||||
<RadioGroupItem
|
||||
aria-label={option.label}
|
||||
disabled={loading || option.disabled}
|
||||
value={option.id}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "name",
|
||||
label: labels.name,
|
||||
value: (option) => option.label,
|
||||
render: (option) => <span className="font-medium">{option.label}</span>,
|
||||
},
|
||||
{
|
||||
id: "description",
|
||||
label: labels.description,
|
||||
value: (option) => option.description,
|
||||
render: (option) => option.description || "—",
|
||||
},
|
||||
]}
|
||||
items={options}
|
||||
layout="fill"
|
||||
loading={loading}
|
||||
locale={locale}
|
||||
onRowClick={(option) => choose(option.id)}
|
||||
rowClassName={(option) => (option.disabled ? "cursor-not-allowed!" : "")}
|
||||
searchPlaceholder={labels.search}
|
||||
title={title}
|
||||
/>
|
||||
</RadioGroup>
|
||||
);
|
||||
}
|
||||
117
packages/rdx-ui/src/components/option-selection-field.tsx
Normal file
117
packages/rdx-ui/src/components/option-selection-field.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { EmptyState } from "./empty-state";
|
||||
import type { PickerOption } from "./option-picker-dialog";
|
||||
import { OptionPickerDialog } from "./option-picker-dialog";
|
||||
import { SelectionSummaryCard } from "./selection-summary-card";
|
||||
|
||||
/** Selección visual con estado vacío, ficha y listado; no conoce consultas ni entidades. */
|
||||
export function OptionSelectionField({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
labels,
|
||||
locale,
|
||||
disabled = false,
|
||||
locked = false,
|
||||
loading = false,
|
||||
createAction,
|
||||
clearLabel,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: ReactNode;
|
||||
options: readonly PickerOption[];
|
||||
value: string;
|
||||
onChange: (id: string) => void;
|
||||
labels: {
|
||||
select: string;
|
||||
change: string;
|
||||
selection: string;
|
||||
name: string;
|
||||
description: string;
|
||||
confirm: string;
|
||||
cancel: string;
|
||||
search: string;
|
||||
};
|
||||
locale: string;
|
||||
disabled?: boolean;
|
||||
locked?: boolean;
|
||||
loading?: boolean;
|
||||
createAction?: { label: string; onClick: () => void; disabled?: boolean } | undefined;
|
||||
clearLabel?: string | undefined;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const selected = options.find((option) => option.id === value);
|
||||
const actions = (
|
||||
<>
|
||||
<Button
|
||||
aria-haspopup="dialog"
|
||||
disabled={disabled || locked || loading}
|
||||
onClick={() => setOpen(true)}
|
||||
type="button"
|
||||
>
|
||||
{selected ? labels.change : labels.select}
|
||||
</Button>
|
||||
{createAction && !locked && (
|
||||
<Button
|
||||
aria-haspopup="dialog"
|
||||
disabled={disabled || loading || createAction.disabled}
|
||||
onClick={createAction.onClick}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{createAction.label}
|
||||
</Button>
|
||||
)}
|
||||
{selected && clearLabel && (
|
||||
<Button
|
||||
disabled={disabled || locked}
|
||||
onClick={() => onChange("")}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{clearLabel}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{selected ? (
|
||||
<div className="space-y-3">
|
||||
<ul className="grid items-stretch gap-3">
|
||||
<li className="min-w-0">
|
||||
<SelectionSummaryCard
|
||||
description={selected.description || "—"}
|
||||
icon={icon}
|
||||
title={selected.label}
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="flex flex-wrap gap-3">{actions}</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState actions={actions} description={description} icon={icon} title={title} />
|
||||
)}
|
||||
{open && (
|
||||
<OptionPickerDialog
|
||||
labels={labels}
|
||||
loading={loading}
|
||||
locale={locale}
|
||||
onClose={() => setOpen(false)}
|
||||
onSelect={onChange}
|
||||
options={options}
|
||||
title={labels.select}
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
33
packages/rdx-ui/src/components/selection-summary-card.tsx
Normal file
33
packages/rdx-ui/src/components/selection-summary-card.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/** Ficha de consulta genérica; textos y acciones pertenecen al consumidor. */
|
||||
export function SelectionSummaryCard({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card className="h-full min-w-0 rounded-lg bg-muted/15 shadow-none" size="sm">
|
||||
<CardContent className="flex items-start gap-3">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-lg border border-primary/10 bg-primary/8 text-primary [&_svg]:size-5"
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 space-y-1 break-words">
|
||||
<p className="font-semibold leading-snug">{title}</p>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
{actions && <div className="shrink-0">{actions}</div>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
191
packages/rdx-ui/src/selection-components.test.tsx
Normal file
191
packages/rdx-ui/src/selection-components.test.tsx
Normal file
@ -0,0 +1,191 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { FormStepActions } from "./components/form-step-actions";
|
||||
import { OptionPickerList } from "./components/option-picker-list";
|
||||
import { OptionSelectionField } from "./components/option-selection-field";
|
||||
|
||||
const options = [
|
||||
{ id: "available", label: "Ana", description: "Administración" },
|
||||
{ id: "inactive", label: "Luis", description: "", disabled: true },
|
||||
];
|
||||
const labels = {
|
||||
select: "Seleccionar persona",
|
||||
change: "Cambiar persona",
|
||||
selection: "Selección",
|
||||
name: "Persona",
|
||||
description: "Detalles",
|
||||
confirm: "Confirmar",
|
||||
cancel: "Cancelar",
|
||||
search: "Buscar personas",
|
||||
};
|
||||
const selectionProps = {
|
||||
title: "Todavía no has elegido una persona",
|
||||
description: "Selecciona quién utiliza el equipo.",
|
||||
icon: <span aria-hidden="true">Icono</span>,
|
||||
options,
|
||||
value: "",
|
||||
onChange: () => undefined,
|
||||
labels,
|
||||
locale: "es",
|
||||
createAction: { label: "Nueva persona", onClick: () => undefined },
|
||||
clearLabel: "Quitar selección",
|
||||
};
|
||||
|
||||
function button(markup: string, label: string) {
|
||||
const match = (markup.match(/<button\b[^>]*>[\s\S]*?<\/button>/g) ?? []).find((item) =>
|
||||
item.includes(label)
|
||||
);
|
||||
|
||||
assert.ok(match, `No se encuentra el botón ${label}`);
|
||||
|
||||
return match;
|
||||
}
|
||||
|
||||
test("selección visual: vacío con acciones separadas y ficha elegida sin controles de envío", () => {
|
||||
const empty = renderToStaticMarkup(<OptionSelectionField {...selectionProps} />);
|
||||
|
||||
assert.ok(empty.includes(selectionProps.title));
|
||||
assert.ok(empty.includes(selectionProps.description));
|
||||
for (const label of [labels.select, "Nueva persona"]) {
|
||||
assert.ok(button(empty, label).includes('type="button"'));
|
||||
assert.equal(button(empty, label).includes('disabled=""'), false);
|
||||
}
|
||||
assert.equal(empty.includes("Quitar selección"), false);
|
||||
assert.equal(empty.includes("Administración"), false);
|
||||
const selected = renderToStaticMarkup(
|
||||
<OptionSelectionField {...selectionProps} value="available" />
|
||||
);
|
||||
|
||||
assert.ok(selected.includes("Ana"));
|
||||
assert.ok(selected.includes("Administración"));
|
||||
assert.ok(selected.includes("<ul"));
|
||||
assert.ok(selected.includes("<li"));
|
||||
assert.equal(selected.includes(selectionProps.title), false);
|
||||
assert.ok(button(selected, labels.change).includes('aria-haspopup="dialog"'));
|
||||
assert.ok(button(selected, "Quitar selección").includes('type="button"'));
|
||||
});
|
||||
|
||||
test("selección visual: pendiente, deshabilitado y bloqueo protegen las acciones correspondientes", () => {
|
||||
for (const state of [{ disabled: true }, { loading: true }]) {
|
||||
const html = renderToStaticMarkup(
|
||||
<OptionSelectionField {...selectionProps} {...state} value="available" />
|
||||
);
|
||||
|
||||
assert.ok(button(html, labels.change).includes('disabled=""'));
|
||||
assert.ok(button(html, "Nueva persona").includes('disabled=""'));
|
||||
}
|
||||
const locked = renderToStaticMarkup(
|
||||
<OptionSelectionField {...selectionProps} locked value="inactive" />
|
||||
);
|
||||
|
||||
assert.ok(locked.includes("Luis"));
|
||||
assert.ok(button(locked, labels.change).includes('disabled=""'));
|
||||
assert.ok(button(locked, "Quitar selección").includes('disabled=""'));
|
||||
const noCreate = renderToStaticMarkup(
|
||||
<OptionSelectionField {...selectionProps} createAction={undefined} />
|
||||
);
|
||||
|
||||
assert.equal(noCreate.includes("Nueva persona"), false);
|
||||
});
|
||||
|
||||
test("PickerList: búsqueda etiquetada, selección única y opciones indisponibles visibles", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<OptionPickerList
|
||||
labels={labels}
|
||||
locale="es"
|
||||
onChange={() => undefined}
|
||||
options={options}
|
||||
title="Personas"
|
||||
value="available"
|
||||
/>
|
||||
);
|
||||
|
||||
assert.ok(html.includes('role="radiogroup"'));
|
||||
assert.ok(html.includes('aria-label="Personas"'));
|
||||
assert.ok(html.includes('placeholder="Buscar personas"'));
|
||||
assert.ok(html.includes('data-quick-search=""'));
|
||||
const radios = html.match(/<[^>]*role="radio"[^>]*>/g) ?? [];
|
||||
|
||||
assert.equal(radios.length, 2);
|
||||
const selected = radios.find((item) => item.includes('aria-label="Ana"'));
|
||||
const inactive = radios.find((item) => item.includes('aria-label="Luis"'));
|
||||
|
||||
assert.ok(selected?.includes('aria-checked="true"'));
|
||||
assert.equal(selected?.includes('aria-disabled="true"'), false);
|
||||
assert.ok(inactive?.includes('aria-checked="false"'));
|
||||
assert.ok(inactive?.includes('aria-disabled="true"'));
|
||||
assert.ok(html.includes("—"));
|
||||
});
|
||||
|
||||
test("PickerList: estados de carga y vacío no presentan opciones falsas", () => {
|
||||
const props = {
|
||||
labels,
|
||||
locale: "es",
|
||||
onChange: () => undefined,
|
||||
title: "Personas",
|
||||
value: "",
|
||||
};
|
||||
const empty = renderToStaticMarkup(<OptionPickerList {...props} options={[]} />);
|
||||
const loading = renderToStaticMarkup(<OptionPickerList {...props} loading options={options} />);
|
||||
|
||||
assert.equal(empty.includes('role="radio"'), false);
|
||||
assert.ok(empty.includes('placeholder="Buscar personas"'));
|
||||
assert.ok(loading.includes('aria-busy="true"'));
|
||||
assert.equal(loading.includes('role="radio"'), false);
|
||||
});
|
||||
|
||||
test("acciones multipaso: requisitos bloquean avance y guardado en cada paso", () => {
|
||||
for (const step of [1, 2, 3, 4, undefined]) {
|
||||
for (const canContinue of [false, true]) {
|
||||
const html = renderToStaticMarkup(
|
||||
<FormStepActions
|
||||
canContinue={canContinue}
|
||||
nextLabel="Siguiente"
|
||||
onPrevious={() => undefined}
|
||||
pending={false}
|
||||
previousLabel="Anterior"
|
||||
saveLabel="Guardar"
|
||||
step={step}
|
||||
total={4}
|
||||
/>
|
||||
);
|
||||
const final = step === undefined || step === 4;
|
||||
const primary = button(html, final ? "Guardar" : "Siguiente");
|
||||
|
||||
assert.ok(primary.includes('type="submit"'));
|
||||
assert.equal(primary.includes('disabled=""'), !canContinue);
|
||||
assert.equal(html.includes("Anterior"), step !== undefined && step > 1);
|
||||
if (step !== undefined && step > 1) {
|
||||
assert.ok(button(html, "Anterior").includes('type="button"'));
|
||||
assert.equal(button(html, "Anterior").includes('disabled=""'), false);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("acciones multipaso: guardar pendiente impide avanzar y retroceder", () => {
|
||||
for (const step of [1, 2, 3, 4, undefined]) {
|
||||
const html = renderToStaticMarkup(
|
||||
<FormStepActions
|
||||
canContinue
|
||||
nextLabel="Siguiente"
|
||||
onPrevious={() => undefined}
|
||||
pending
|
||||
previousLabel="Anterior"
|
||||
saveLabel="Guardando"
|
||||
step={step}
|
||||
total={4}
|
||||
/>
|
||||
);
|
||||
|
||||
const action = step === undefined || step === 4 ? "Guardando" : "Siguiente";
|
||||
|
||||
assert.ok(button(html, action).includes('disabled=""'));
|
||||
if (step !== undefined && step > 1) {
|
||||
assert.ok(button(html, "Anterior").includes('disabled=""'));
|
||||
}
|
||||
}
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user