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.
|
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
|
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.
|
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
|
pasos: plantilla, características y datos finales. Reutilizar `FormProgress` y
|
||||||
`FormStepActions`, validar antes de avanzar y conservar el borrador al retroceder.
|
`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.
|
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.
|
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
|
- Los formularios con contenido variable mantienen altura exterior estable según la
|
||||||
regla de estabilidad; sus grupos interiores conservan altura según contenido.
|
regla de estabilidad; sus grupos interiores conservan altura según contenido.
|
||||||
No introducir pestañas ni pasos para resolver solo espacio.
|
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:
|
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
|
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
|
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.
|
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
|
## 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.
|
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
|
## Alta desde el puesto
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { BrowserLink } from "@metis/core/client";
|
import { BrowserLink } from "@metis/core/client";
|
||||||
import { EntityStatus } from "@repo/rdx-ui/components/entity-list";
|
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 { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { useAssetDetailController } from "../controllers";
|
import { useAssetDetailController } from "../controllers";
|
||||||
@ -37,7 +37,7 @@ export function AssetPanelSummary(props: AssetDetailProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<dt className="shrink-0 pt-0.5 text-muted-foreground">
|
<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>
|
<span className="sr-only">{t("workstationInAsset")}</span>
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="min-w-0">
|
<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" ? "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.equal((html.match(/<button[^>]*\sdisabled=""/g) ?? []).length, 2);
|
||||||
assert.ok(html.includes('aria-hidden="true"'));
|
assert.ok(html.includes('aria-hidden="true"'));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
export { AssetForm, AssetInventoryPage } from "./assets";
|
export { AssetForm, AssetInventoryPage } from "./assets";
|
||||||
export { AssetSelect } from "./assets/select/ui/asset-select";
|
export { AssetSelect } from "./assets/select/ui/asset-select";
|
||||||
|
export { AssetSummaryCard } from "./assets/shared/ui/asset-summary-card";
|
||||||
export { BrandCatalogPage } from "./brands";
|
export { BrandCatalogPage } from "./brands";
|
||||||
export { AssetCatalogPage } from "./catalog";
|
export { AssetCatalogPage } from "./catalog";
|
||||||
export { catalogApi, catalogKeys } from "./catalog/shared/api";
|
export { catalogApi, catalogKeys } from "./catalog/shared/api";
|
||||||
|
|||||||
@ -1,6 +1,4 @@
|
|||||||
import { FormActions } from "@repo/rdx-ui/components/form-dialog";
|
import { FormStepActions } from "@repo/rdx-ui/components/form-step-actions";
|
||||||
import { ChevronLeftIcon, ChevronRightIcon } from "@repo/rdx-ui/lib/icons";
|
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export function WorkstationFormActions({
|
export function WorkstationFormActions({
|
||||||
@ -15,20 +13,17 @@ export function WorkstationFormActions({
|
|||||||
onPrevious: () => void;
|
onPrevious: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("assets");
|
const { t } = useTranslation("assets");
|
||||||
const action = step && step < 4 ? "workstationSteps.next" : "save";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormActions cancelDisabled={pending}>
|
<FormStepActions
|
||||||
{step && step > 1 && (
|
canContinue={canSubmit}
|
||||||
<Button disabled={pending} onClick={onPrevious} type="button" variant="outline">
|
nextLabel={t("workstationSteps.next")}
|
||||||
<ChevronLeftIcon aria-hidden="true" className="size-4" />
|
onPrevious={onPrevious}
|
||||||
{t("workstationSteps.previous")}
|
pending={pending}
|
||||||
</Button>
|
previousLabel={t("workstationSteps.previous")}
|
||||||
)}
|
saveLabel={t(pending ? "saving" : "save")}
|
||||||
<Button disabled={pending || !canSubmit} type="submit">
|
step={step}
|
||||||
{t(pending ? "saving" : action)}
|
total={4}
|
||||||
{step && step < 4 && <ChevronRightIcon aria-hidden="true" className="size-4" />}
|
/>
|
||||||
</Button>
|
|
||||||
</FormActions>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { ContextHelp } from "@repo/rdx-ui/components/context-help";
|
|
||||||
import { FormBody } from "@repo/rdx-ui/components/form-dialog";
|
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 { FormProgress } from "@repo/rdx-ui/components/form-progress";
|
||||||
import { useId } from "react";
|
import { useId } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -130,17 +130,14 @@ export function WorkstationForm(props: WorkstationFormProps) {
|
|||||||
)}
|
)}
|
||||||
<InventoryFeedback error={mutation.error ?? inventory.error ?? catalog.error} />
|
<InventoryFeedback error={mutation.error ?? inventory.error ?? catalog.error} />
|
||||||
</FormBody>
|
</FormBody>
|
||||||
<div className="min-h-0 max-h-[40%] shrink-0 overflow-y-auto px-1 pb-3 [scrollbar-gutter:stable] [&>aside]:mt-0">
|
<FormHelp
|
||||||
<ContextHelp
|
description={t("workstationDescription")}
|
||||||
description={t("workstationDescription")}
|
examples={t("workstationExamples")}
|
||||||
examples={t("workstationExamples")}
|
examplesLabel={t("examplesLabel")}
|
||||||
examplesLabel={t("examplesLabel")}
|
guidance={t("workstationGuidance")}
|
||||||
guidance={t("workstationGuidance")}
|
scopeNote={t("workstationScope")}
|
||||||
scopeNote={t("workstationScope")}
|
title={t("helpTitle")}
|
||||||
title={t("helpTitle")}
|
/>
|
||||||
variant="plain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<WorkstationFormActions
|
<WorkstationFormActions
|
||||||
canSubmit={isWizard ? canContinue : assetsReady}
|
canSubmit={isWizard ? canContinue : assetsReady}
|
||||||
onPrevious={previousStep}
|
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.",
|
"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",
|
"workstation": "Workstation",
|
||||||
"chooseWorkstation": "Select a 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",
|
"companySpaces": "Company spaces",
|
||||||
"globalCatalogs": "Global catalogs",
|
"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.",
|
"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",
|
"workstation": "Puesto de trabajo",
|
||||||
"chooseWorkstation": "Selecciona un 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",
|
"companySpaces": "Espacios de compañías",
|
||||||
"globalCatalogs": "Catálogos globales",
|
"globalCatalogs": "Catálogos globales",
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
LayoutTemplateIcon,
|
LayoutTemplateIcon,
|
||||||
MailIcon,
|
MailIcon,
|
||||||
MapPinIcon,
|
MapPinIcon,
|
||||||
|
MonitorIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
} from "@repo/rdx-ui/lib/icons";
|
} from "@repo/rdx-ui/lib/icons";
|
||||||
import { Avatar, AvatarBadge } from "@repo/shadcn-ui/components/avatar";
|
import { Avatar, AvatarBadge } from "@repo/shadcn-ui/components/avatar";
|
||||||
@ -106,7 +107,7 @@ export function CompanyDetail(props: CompanyDetailProps) {
|
|||||||
[
|
[
|
||||||
["overview", t("companyOverview.navigation"), HomeIcon],
|
["overview", t("companyOverview.navigation"), HomeIcon],
|
||||||
["people", `${t("people")} (${company.people.length})`, UsersIcon],
|
["people", `${t("people")} (${company.people.length})`, UsersIcon],
|
||||||
["workstations", assetTranslation("workstations"), BriefcaseIcon],
|
["workstations", assetTranslation("workstations"), MonitorIcon],
|
||||||
["profiles", t("profiles.title"), UsersIcon],
|
["profiles", t("profiles.title"), UsersIcon],
|
||||||
["inventory", assetTranslation("inventory"), BoxIcon],
|
["inventory", assetTranslation("inventory"), BoxIcon],
|
||||||
["resources", resourceTranslation("inventory"), LayersIcon],
|
["resources", resourceTranslation("inventory"), LayersIcon],
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
|
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { FormEvent } from "react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { WorkProfileInput } from "../../../../common/work-profiles";
|
import type { WorkProfileInput } from "../../../../common/work-profiles";
|
||||||
import { WorkProfileInputSchema } 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 { accessKeys } from "../../../access/shared/queries";
|
||||||
import type { ConfigurationQuickKind } from "../../../shared/types";
|
import type { ConfigurationQuickKind } from "../../../shared/types";
|
||||||
import { profileApi, profileKeys } from "../api";
|
import { profileApi, profileKeys } from "../api";
|
||||||
|
import type { ProfileCreationStep } from "../helpers/profile-creation";
|
||||||
|
import { profileCreationReady, suggestedProfileName } from "../helpers/profile-creation";
|
||||||
import type { WorkProfileFormProps } from "../types";
|
import type { WorkProfileFormProps } from "../types";
|
||||||
|
|
||||||
export function useWorkProfileFormController({
|
export function useWorkProfileFormController({
|
||||||
@ -20,6 +24,9 @@ export function useWorkProfileFormController({
|
|||||||
onSaved,
|
onSaved,
|
||||||
}: WorkProfileFormProps) {
|
}: WorkProfileFormProps) {
|
||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
|
const { t } = useTranslation("companies");
|
||||||
|
const isWizard = !profile;
|
||||||
|
const [step, setStep] = useState<ProfileCreationStep>(1);
|
||||||
const mounted = useRef(true);
|
const mounted = useRef(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -42,7 +49,11 @@ export function useWorkProfileFormController({
|
|||||||
const workstations = (inventory.data?.workstations ?? []).filter(
|
const workstations = (inventory.data?.workstations ?? []).filter(
|
||||||
(station) => station.companyId === company.id && station.values.active
|
(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(
|
const computer = (inventory.data?.assets ?? []).find(
|
||||||
(asset) =>
|
(asset) =>
|
||||||
asset.id === workstation?.values.computerAssetId &&
|
asset.id === workstation?.values.computerAssetId &&
|
||||||
@ -98,37 +109,102 @@ export function useWorkProfileFormController({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const disabled = mutation.isPending;
|
const disabled = mutation.isPending;
|
||||||
const selectors = [
|
const person = company.people.find((item) => item.id === values.personId);
|
||||||
{
|
const personOptions = company.people
|
||||||
field: "personId",
|
.filter((item) => item.active || item.id === values.personId)
|
||||||
label: "person",
|
.map((item) => ({
|
||||||
create: "newPerson",
|
id: item.id,
|
||||||
kind: "person",
|
label: item.name,
|
||||||
options: company.people
|
description: item.position || item.email || "—",
|
||||||
.filter((item) => item.active || item.id === values.personId)
|
disabled: !item.active,
|
||||||
.map((item) => ({ value: item.id, label: item.name })),
|
}));
|
||||||
},
|
const accountOptions = items.flatMap((item) => {
|
||||||
{
|
if (
|
||||||
field: "accountId",
|
item.values.kind !== "account" ||
|
||||||
label: "account",
|
!((item.values.active && item.values.usage !== "technical") || item.id === values.accountId)
|
||||||
create: "newAccount",
|
) {
|
||||||
kind: "account",
|
return [];
|
||||||
options: items
|
}
|
||||||
.filter(
|
|
||||||
(item) =>
|
return [
|
||||||
item.values.kind === "account" &&
|
{
|
||||||
((item.values.active && item.values.usage !== "technical") ||
|
id: item.id,
|
||||||
item.id === values.accountId)
|
label: accessLabel(item, items, company.people),
|
||||||
)
|
description: t(item.values.usage),
|
||||||
.map((item) => ({ value: item.id, label: accessLabel(item, items, company.people) })),
|
disabled: !item.values.active || item.values.usage === "technical",
|
||||||
},
|
},
|
||||||
] as const;
|
];
|
||||||
const submit = form.handleSubmit((input) => {
|
});
|
||||||
if (targetReady) {
|
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);
|
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) => {
|
const quickCreated = (field: "personId" | "accountId" | "assetId", id: string | undefined) => {
|
||||||
if (mounted.current && id) {
|
if (mounted.current && id) {
|
||||||
form.setValue(field, id);
|
form.setValue(field, id);
|
||||||
@ -151,8 +227,21 @@ export function useWorkProfileFormController({
|
|||||||
authorized,
|
authorized,
|
||||||
mutation,
|
mutation,
|
||||||
disabled,
|
disabled,
|
||||||
selectors,
|
isWizard,
|
||||||
|
step,
|
||||||
|
previousStep,
|
||||||
|
canContinue,
|
||||||
|
canSave,
|
||||||
|
workstation,
|
||||||
|
personOptions,
|
||||||
|
accountOptions,
|
||||||
|
workstationOptions,
|
||||||
submit,
|
submit,
|
||||||
quickCreated,
|
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 assert from "node:assert/strict";
|
||||||
import { test } from "node:test";
|
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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { createInstance } from "i18next";
|
import { createInstance } from "i18next";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { I18nextProvider } from "react-i18next";
|
import { I18nextProvider } from "react-i18next";
|
||||||
|
|
||||||
|
import { WorkProfileSchema } from "../../../common/work-profiles";
|
||||||
import { accessKeys } from "../../access/shared/queries";
|
import { accessKeys } from "../../access/shared/queries";
|
||||||
import { companiesEnglish, companiesSpanish } from "../../shared";
|
import { companiesEnglish, companiesSpanish } from "../../shared";
|
||||||
import { WorkProfilesPage } from "../list";
|
import { WorkProfilesPage } from "../list";
|
||||||
|
|
||||||
import { profileKeys } from "./api";
|
import { profileKeys } from "./api";
|
||||||
|
import { profileCreationReady, suggestedProfileName } from "./helpers/profile-creation";
|
||||||
import { WorkProfileForm } from "./ui";
|
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 = {
|
const company = {
|
||||||
id: "0199b200-0000-7000-8000-000000000001",
|
id: "0199b200-0000-7000-8000-000000000001",
|
||||||
@ -31,7 +37,10 @@ async function render(language: string, node: ReactNode, seed?: (client: QueryCl
|
|||||||
|
|
||||||
await i18n.init({
|
await i18n.init({
|
||||||
lng: language,
|
lng: language,
|
||||||
resources: { es: { companies: companiesSpanish }, en: { companies: companiesEnglish } },
|
resources: {
|
||||||
|
es: { companies: companiesSpanish, assets: assetsSpanish },
|
||||||
|
en: { companies: companiesEnglish, assets: assetsEnglish },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const client = new QueryClient({
|
const client = new QueryClient({
|
||||||
defaultOptions: { queries: { retry: false, staleTime: Number.POSITIVE_INFINITY } },
|
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"));
|
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 () => {
|
test("perfiles: alta en cuatro pasos, sin guardado intermedio y ayuda anclada 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 () => {
|
|
||||||
for (const language of ["es", "en"]) {
|
for (const language of ["es", "en"]) {
|
||||||
const html = await render(
|
const html = await render(
|
||||||
language,
|
language,
|
||||||
<WorkProfileForm company={company} onSaved={() => undefined} />
|
<WorkProfileForm company={company} onSaved={() => undefined} />
|
||||||
);
|
);
|
||||||
const text = language === "es" ? companiesSpanish : companiesEnglish;
|
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]) {
|
assert.equal((html.match(/<li\b/g) ?? []).length, 4);
|
||||||
const label = labels.find((label) => label.includes(`>${name}<`));
|
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);
|
test("perfiles: selección de persona explícita y cuenta opcional con altas independientes", async () => {
|
||||||
}
|
for (const language of ["es", "en"]) {
|
||||||
for (const name of [text.profiles.account, text.profiles.notes]) {
|
const text = language === "es" ? companiesSpanish : companiesEnglish;
|
||||||
const label = labels.find((label) => label.includes(`>${name}<`));
|
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.ok(person.includes(text.mail.newPerson));
|
||||||
assert.equal(label.includes('data-slot="required-indicator"'), false, name);
|
assert.ok(person.includes('data-slot="required-indicator"'));
|
||||||
}
|
assert.ok(person.includes(text.profiles.selection.person.description));
|
||||||
assert.equal(html.includes(language === "es" ? "(opcional)" : "(optional)"), false);
|
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: [],
|
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 = {
|
const assignedCompany = {
|
||||||
...company,
|
...company,
|
||||||
people: [
|
people: [
|
||||||
@ -145,13 +256,7 @@ test("perfiles: el puesto fijado aporta su ordenador y no selecciona al responsa
|
|||||||
/>,
|
/>,
|
||||||
(client) => {
|
(client) => {
|
||||||
client.setQueryData(inventoryKeys.company(company.id), {
|
client.setQueryData(inventoryKeys.company(company.id), {
|
||||||
assets: [
|
assets: [computer],
|
||||||
{
|
|
||||||
id: computerId,
|
|
||||||
companyId: company.id,
|
|
||||||
values: { name: "PC-ADMIN", status: "operational" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
relationships: [],
|
relationships: [],
|
||||||
workstations: [station],
|
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("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("Responsable test"), false);
|
||||||
assert.equal(html.includes('aria-label="Nuevo dispositivo"'), 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 { FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
||||||
|
import { UsersIcon } from "@repo/rdx-ui/lib/icons";
|
||||||
import type { ComponentProps } from "react";
|
import type { ComponentProps } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@ -23,14 +24,20 @@ export function WorkProfileEditorDialog({
|
|||||||
<FormDialog
|
<FormDialog
|
||||||
cancelLabel={t("cancel")}
|
cancelLabel={t("cancel")}
|
||||||
formActions
|
formActions
|
||||||
|
layout="stable"
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
open
|
open
|
||||||
title={t(profile ? "profiles.edit" : "profiles.new")}
|
size={profile ? "large" : "medium"}
|
||||||
wide
|
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
|
<WorkProfileForm
|
||||||
company={company}
|
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 { FormBody } from "@repo/rdx-ui/components/form-dialog";
|
||||||
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
|
import { FormHelp } from "@repo/rdx-ui/components/form-help";
|
||||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
import { FormProgress } from "@repo/rdx-ui/components/form-progress";
|
||||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
import { FormStepActions } from "@repo/rdx-ui/components/form-step-actions";
|
||||||
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 { useId } from "react";
|
import { useId } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@ -14,178 +10,78 @@ import { useWorkProfileFormController } from "../controllers";
|
|||||||
import type { WorkProfileFormProps } from "../types";
|
import type { WorkProfileFormProps } from "../types";
|
||||||
|
|
||||||
import { ProfileFeedback } from "./profile-feedback";
|
import { ProfileFeedback } from "./profile-feedback";
|
||||||
|
import { WorkProfileFormFields } from "./work-profile-form-fields";
|
||||||
|
|
||||||
export function WorkProfileForm(props: WorkProfileFormProps) {
|
export function WorkProfileForm(props: WorkProfileFormProps) {
|
||||||
const { company, profile, allowQuickCreate = true } = props;
|
const { company, profile, allowQuickCreate = true } = props;
|
||||||
const {
|
const c = useWorkProfileFormController(props);
|
||||||
workstations,
|
|
||||||
selectedWorkstationId,
|
|
||||||
chooseWorkstation,
|
|
||||||
computer,
|
|
||||||
targetReady,
|
|
||||||
quick,
|
|
||||||
setQuick,
|
|
||||||
access,
|
|
||||||
inventory,
|
|
||||||
form,
|
|
||||||
values,
|
|
||||||
authorized,
|
|
||||||
mutation,
|
|
||||||
disabled,
|
|
||||||
selectors,
|
|
||||||
submit,
|
|
||||||
quickCreated,
|
|
||||||
} = useWorkProfileFormController(props);
|
|
||||||
const { t } = useTranslation("companies");
|
const { t } = useTranslation("companies");
|
||||||
const prefix = useId();
|
const prefix = useId();
|
||||||
|
const keys = ["workstation", "person", "account", "summary"] as const;
|
||||||
|
const loading = c.inventory.isPending || c.access.isPending;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<form className="flex min-h-0 flex-1 flex-col overflow-hidden" onSubmit={submit}>
|
<form className="flex min-h-0 flex-1 flex-col overflow-hidden" onSubmit={c.submit}>
|
||||||
<FormBody>
|
<FormBody contentLayout="fill">
|
||||||
<fieldset className="space-y-4" disabled={disabled}>
|
{c.isWizard && (
|
||||||
<div className="space-y-2">
|
<FormProgress
|
||||||
<FormLabel htmlFor={prefix} required>
|
completedLabel={t("profiles.steps.completed")}
|
||||||
{t("profiles.name")}
|
description={t(`profiles.steps.${keys[c.step - 1]}Description`)}
|
||||||
</FormLabel>
|
label={t("profiles.steps.label")}
|
||||||
<Input id={prefix} maxLength={100} required {...form.register("name")} />
|
labels={keys.map((key) => t(`profiles.steps.${key}`))}
|
||||||
</div>
|
progress={t("profiles.steps.progress", { step: c.step, total: 4 })}
|
||||||
{!profile && (
|
step={c.step}
|
||||||
<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)}
|
|
||||||
/>
|
/>
|
||||||
<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">
|
<p className="text-destructive" role="alert">
|
||||||
{t("profiles.errors.INVALID_REQUEST")}
|
{t("profiles.errors.INVALID_REQUEST")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<ProfileFeedback error={mutation.error ?? access.error ?? inventory.error} />
|
{loading && (
|
||||||
<ContextHelp
|
<p className="text-sm text-muted-foreground" role="status">
|
||||||
description={t("profiles.description")}
|
{t("loading")}
|
||||||
examples={t("profiles.examples")}
|
</p>
|
||||||
examplesLabel={t("profiles.examplesLabel")}
|
)}
|
||||||
guidance={t("profiles.guidance")}
|
<ProfileFeedback error={c.mutation.error ?? c.access.error ?? c.inventory.error} />
|
||||||
scopeNote={t("profiles.scope")}
|
|
||||||
title={t("profiles.help")}
|
|
||||||
/>
|
|
||||||
</FormBody>
|
</FormBody>
|
||||||
<FormActions>
|
<FormHelp
|
||||||
<Button
|
description={t("profiles.description")}
|
||||||
disabled={disabled || !inventory.data || !access.data || !targetReady}
|
examples={t("profiles.examples")}
|
||||||
type="submit"
|
examplesLabel={t("profiles.examplesLabel")}
|
||||||
>
|
guidance={t("profiles.guidance")}
|
||||||
{t(disabled ? "saving" : "save")}
|
scopeNote={t("profiles.scope")}
|
||||||
</Button>
|
title={t("profiles.help")}
|
||||||
</FormActions>
|
/>
|
||||||
|
<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>
|
</form>
|
||||||
{quick && (
|
{c.quick && (
|
||||||
<ConfigurationQuickEditor
|
<ConfigurationQuickEditor
|
||||||
company={company}
|
company={company}
|
||||||
data={access.data}
|
data={c.access.data}
|
||||||
kind={quick}
|
kind={c.quick}
|
||||||
onClose={() => setQuick(undefined)}
|
onClose={() => c.setQuick(undefined)}
|
||||||
onCreated={quickCreated}
|
onCreated={c.quickCreated}
|
||||||
values={{ personId: values.personId, accountId: values.accountId ?? "" }}
|
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 { WorkstationActions, WorkstationEditorDialog } from "@metis/assets/client";
|
||||||
import { BrowserLink, navigateTo } from "@metis/core/client";
|
import { BrowserLink, navigateTo } from "@metis/core/client";
|
||||||
import { EntityStatus } from "@repo/rdx-ui/components/entity-list";
|
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 { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import type { ComponentProps } from "react";
|
import type { ComponentProps } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -72,7 +72,7 @@ export function WorkstationDetailPage({
|
|||||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h1 className="flex items-center gap-3 text-2xl font-semibold">
|
<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}
|
{workstation.values.name}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc -p tsconfig.json",
|
"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": {
|
"dependencies": {
|
||||||
"@base-ui/react": "1.8.0",
|
"@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;
|
const hasNext = step !== undefined && step < total;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormActions>
|
<FormActions cancelDisabled={pending}>
|
||||||
{step !== undefined && step > 1 && (
|
{step !== undefined && step > 1 && (
|
||||||
<Button disabled={pending} onClick={onPrevious} type="button" variant="outline">
|
<Button disabled={pending} onClick={onPrevious} type="button" variant="outline">
|
||||||
<ChevronLeftIcon aria-hidden="true" className="size-4" />
|
<ChevronLeftIcon aria-hidden="true" className="size-4" />
|
||||||
{previousLabel}
|
{previousLabel}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button disabled={pending || (step === 1 && !canContinue)} type="submit">
|
<Button disabled={pending || !canContinue} type="submit">
|
||||||
{hasNext ? nextLabel : saveLabel}
|
{hasNext ? nextLabel : saveLabel}
|
||||||
{hasNext && <ChevronRightIcon aria-hidden="true" className="size-4" />}
|
{hasNext && <ChevronRightIcon aria-hidden="true" className="size-4" />}
|
||||||
</Button>
|
</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