feat: mejorar diseño y accesibilidad de los diálogos de alta y edición de puestos

This commit is contained in:
David Arranz 2026-10-16 11:19:17 +02:00
parent 77cb8b0b97
commit 2fc49470d6
15 changed files with 346 additions and 168 deletions

View File

@ -473,6 +473,17 @@ No conoce entidades, formularios, consultas ni endpoints.
`FormProgress` de `@repo/rdx-ui/components/form-progress` presenta pasos numerados, marca el actual con `aria-current="step"`, identifica los completados y lleva el foco al anuncio traducido de progreso. Recibe etiquetas, descripción y textos accesibles del consumidor; no conoce entidades ni consultas. Lo usan los asistentes de recursos, activos y puestos. `FormProgress` de `@repo/rdx-ui/components/form-progress` presenta pasos numerados, marca el actual con `aria-current="step"`, identifica los completados y lleva el foco al anuncio traducido de progreso. Recibe etiquetas, descripción y textos accesibles del consumidor; no conoce entidades ni consultas. Lo usan los asistentes de recursos, activos y puestos.
El diálogo de alta de puesto usa tamaño medio y cabecera con icono decorativo; la edición
mantiene tamaño grande. Asociaciones, activos, identificación y resumen utilizan
`FormSection variant="plain"`, títulos de grupo uniformes y separación de 20 px. Las
asociaciones y el resumen pasan a dos columnas según el ancho del contenedor; el nombre
del alta ocupa todo el ancho. Los activos seleccionados usan filas compactas con separadores,
nombres que pueden partirse y botones de retirada con etiqueta accesible. El resumen
muestra `—` para valores ausentes. La ayuda del ordenador se vincula al selector mediante
`aria-describedby`; el nombre identifica su error junto al campo. La ayuda general queda
al final, antes del pie fijo, con navegación por flechas y cancelación deshabilitada durante
el guardado. Se conservan el cuerpo desplazable y la altura según contenido.
El nuevo puesto se organiza en sede/responsable, activos, ordenador principal y resumen. Los controles de asociaciones, composición, ordenador y resumen tienen responsabilidades propias y se reutilizan en la edición cuando corresponde. Las acciones Anterior/Siguiente conservan valores; Guardar aparece en el resumen. La sede única y el activo único tienen selección automática. Véase [Puestos de trabajo](puestos-de-trabajo.md#alta-en-cuatro-pasos). El nuevo puesto se organiza en sede/responsable, activos, ordenador principal y resumen. Los controles de asociaciones, composición, ordenador y resumen tienen responsabilidades propias y se reutilizan en la edición cuando corresponde. Las acciones Anterior/Siguiente conservan valores; Guardar aparece en el resumen. La sede única y el activo único tienen selección automática. Véase [Puestos de trabajo](puestos-de-trabajo.md#alta-en-cuatro-pasos).
## Selección de activos en diálogo ## Selección de activos en diálogo

View File

@ -72,6 +72,13 @@ El estado mostrado del activo se calcula desde su estado técnico y los puestos
3. Ordenador principal. Se elige en un diálogo de selección única entre los activos seleccionados. Si solo queda uno se asigna automáticamente; al retirar el ordenador se limpia la selección o se asigna el único restante. 3. Ordenador principal. Se elige en un diálogo de selección única entre los activos seleccionados. Si solo queda uno se asigna automáticamente; al retirar el ordenador se limpia la selección o se asigna el único restante.
4. Resumen. Muestra sede, responsable, composición y ordenador principal. El nombre propuesto por sede/responsable se puede editar antes de Guardar. 4. Resumen. Muestra sede, responsable, composición y ordenador principal. El nombre propuesto por sede/responsable se puede editar antes de Guardar.
El diálogo de alta utiliza tamaño medio y cabecera con icono decorativo. Los grupos
reutilizan `FormSection variant="plain"`; asociaciones y resumen responden al ancho
del contenedor, con dos columnas cuando caben y una en espacios estrechos. Los activos
seleccionados se muestran como filas compactas, sin tarjetas anidadas. El nombre del
resumen ocupa el ancho completo y los valores ausentes muestran `—`. La edición conserva
el diálogo grande y los mismos grupos, conforme al [patrón de diálogos](patron-de-dialogos.md).
Anterior conserva valores y Siguiente valida los requisitos del paso. Guardar solo aparece en el resumen y no se envía el alta desde pasos intermedios. La edición mantiene un formulario único y permite conservar/completar puestos anteriores o desactivados. Cancelar permanece en el pie del modal y la ayuda inmediatamente encima. Anterior conserva valores y Siguiente valida los requisitos del paso. Guardar solo aparece en el resumen y no se envía el alta desde pasos intermedios. La edición mantiene un formulario único y permite conservar/completar puestos anteriores o desactivados. Cancelar permanece en el pie del modal y la ayuda inmediatamente encima.
Se elimina `sharedAssetIds` de contratos, dominio, reservas y vistas. La migración explícita `removeSharedWorkstationAssets`, ejecutada durante la preparación del esquema, elimina las asociaciones de tipo compartido tanto actuales como históricas y limpia sus referencias técnicas cuando no existe ninguna asociación exclusiva histórica. No convierte activos compartidos en exclusivos, no borra activos ni revisiones y no genera reservas conflictivas. Es transaccional e idempotente. Las composiciones exclusivas e identidades se conservan. Se elimina `sharedAssetIds` de contratos, dominio, reservas y vistas. La migración explícita `removeSharedWorkstationAssets`, ejecutada durante la preparación del esquema, elimina las asociaciones de tipo compartido tanto actuales como históricas y limpia sus referencias técnicas cuando no existe ninguna asociación exclusiva histórica. No convierte activos compartidos en exclusivos, no borra activos ni revisiones y no genera reservas conflictivas. Es transaccional e idempotente. Las composiciones exclusivas e identidades se conservan.

View File

@ -326,7 +326,10 @@
}, },
"formLayout": { "formLayout": {
"identity": "Identification and assignment", "identity": "Identification and assignment",
"template": "Template details" "template": "Template details",
"workstationAssociations": "Location and owner",
"workstationIdentity": "Workstation identification",
"workstationSummary": "Selected details"
}, },
"detailSections": { "detailSections": {
"identification": "Identification", "identification": "Identification",

View File

@ -326,7 +326,10 @@
}, },
"formLayout": { "formLayout": {
"identity": "Identificación y asignación", "identity": "Identificación y asignación",
"template": "Datos de la plantilla" "template": "Datos de la plantilla",
"workstationAssociations": "Ubicación y responsable",
"workstationIdentity": "Identificación del puesto",
"workstationSummary": "Datos seleccionados"
}, },
"detailSections": { "detailSections": {
"identification": "Identificación", "identification": "Identificación",

View File

@ -20,7 +20,7 @@ import type {
WorkstationDTO, WorkstationDTO,
} from "../../../common"; } from "../../../common";
import { AssetSchema } from "../../../common/inventory"; import { AssetSchema } from "../../../common/inventory";
import { WorkstationSchema } from "../../../common/workstations"; import { WorkstationInputSchema, WorkstationSchema } from "../../../common/workstations";
import { AssetRelationships } from "../../asset-relationships"; import { AssetRelationships } from "../../asset-relationships";
import { catalogKeys } from "../../catalog/shared/api"; import { catalogKeys } from "../../catalog/shared/api";
import { CatalogRelationForm } from "../../catalog-relations"; import { CatalogRelationForm } from "../../catalog-relations";
@ -28,6 +28,10 @@ import { CatalogTemplateForm } from "../../catalog-templates";
import { assetsEnglish, assetsSpanish } from "../../shared"; import { assetsEnglish, assetsSpanish } from "../../shared";
import { inventoryKeys } from "../../shared/queries"; import { inventoryKeys } from "../../shared/queries";
import { WorkstationForm, WorkstationsPage } from "../../workstations"; import { WorkstationForm, WorkstationsPage } from "../../workstations";
import { WorkstationAssetFields } from "../../workstations/shared/ui/workstation-asset-fields";
import { WorkstationComputerField } from "../../workstations/shared/ui/workstation-computer-field";
import { WorkstationFormActions } from "../../workstations/shared/ui/workstation-form-actions";
import { WorkstationSummary } from "../../workstations/shared/ui/workstation-summary";
import { AssetCreateProgress } from "../create/ui/asset-create-progress"; import { AssetCreateProgress } from "../create/ui/asset-create-progress";
import { AssetSelectionPanel } from "../detail/ui/asset-selection-panel"; import { AssetSelectionPanel } from "../detail/ui/asset-selection-panel";
import { AssetInventoryPage } from "../list"; import { AssetInventoryPage } from "../list";
@ -786,3 +790,91 @@ test("nombre propuesto: usa texto de la plantilla, omite marca y vacíos y respe
); );
assert.equal(suggestedAssetName(undefined, fields, {}, undefined), ""); assert.equal(suggestedAssetName(undefined, fields, {}, undefined), "");
}); });
test("diseño del puesto: grupos compactos, columnas por contenedor y resumen sin textos de ausencia", async () => {
const values = WorkstationInputSchema.parse({
name: "Puesto",
active: true,
siteId: null,
personId: null,
assetIds: [],
computerAssetId: null,
});
for (const language of ["es", "en"]) {
const initial = await render(
language,
<WorkstationForm companyId={companyId} lookups={lookups} onSaved={() => undefined} />
);
assert.ok(
initial.includes(language === "es" ? "Ubicación y responsable" : "Location and owner")
);
assert.ok(initial.includes("uppercase tracking-wide"));
assert.ok(initial.includes("@min-[560px]:grid-cols-2"));
assert.ok(initial.indexOf("<aside") < initial.indexOf('type="submit"'));
const summary = await render(
language,
<WorkstationSummary assets={[]} lookups={lookups} values={values} />
);
assert.ok(summary.includes(language === "es" ? "Datos seleccionados" : "Selected details"));
assert.ok(summary.includes("@min-[560px]:grid-cols-2"));
assert.equal((summary.match(/—/g) ?? []).length, 4);
assert.equal(summary.includes(language === "es" ? "Sin asignación" : "No assignment"), false);
const computer = await render(
language,
<WorkstationComputerField
assets={[]}
companyId={companyId}
onChange={() => undefined}
prefix="design"
required
singleComputer={false}
sites={[]}
values={values}
/>
);
assert.ok(
computer.includes('aria-describedby="design-computer-help design-computer-required"')
);
assert.ok(computer.includes('id="design-computer-help"'));
const selection = await render(
language,
<WorkstationAssetFields
assets={[]}
canCreate={false}
companyId={companyId}
disabled
onAdd={() => undefined}
onCreate={() => undefined}
onRemove={() => undefined}
prefix="design"
selectedIds={[companyId]}
sites={[]}
unavailableReason={() => "ASSET_NOT_FOUND"}
/>
);
assert.ok(selection.includes("divide-y divide-border/60"));
assert.ok(selection.includes("—"));
assert.match(selection, /<button(?=[^>]*aria-label="[^"]*—[^"]*")(?=[^>]*disabled="")[^>]*>/);
}
});
test("diseño del puesto: navegación traducida y acciones bloqueadas durante el guardado", async () => {
for (const language of ["es", "en"]) {
for (const step of [2, 4] as const) {
const html = await render(
language,
<WorkstationFormActions canSubmit onPrevious={() => undefined} pending step={step} />
);
assert.ok(html.includes(language === "es" ? "Anterior" : "Previous"));
assert.ok(html.includes(language === "es" ? "Guardando" : "Saving"));
assert.equal((html.match(/<button[^>]*\sdisabled=""/g) ?? []).length, 2);
assert.ok(html.includes('aria-hidden="true"'));
}
}
});

View File

@ -1,4 +1,6 @@
import { FormLabel } from "@repo/rdx-ui/components/form-label"; import { FormLabel } from "@repo/rdx-ui/components/form-label";
import { FormSection } from "@repo/rdx-ui/components/form-section";
import { XIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button"; import { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -35,6 +37,7 @@ export function WorkstationAssetFields({
const { t } = useTranslation("assets"); const { t } = useTranslation("assets");
return ( return (
<FormSection title={t("workstationAssets")} variant="plain">
<div className="space-y-2"> <div className="space-y-2">
<FormLabel htmlFor={`${prefix}-assets`}>{t("addWorkstationAsset")}</FormLabel> <FormLabel htmlFor={`${prefix}-assets`}>{t("addWorkstationAsset")}</FormLabel>
<AssetSelect <AssetSelect
@ -67,28 +70,29 @@ export function WorkstationAssetFields({
value={[]} value={[]}
/> />
{selectedIds.length ? ( {selectedIds.length ? (
<ul className="space-y-2"> <ul className="divide-y divide-border/60">
{selectedIds.map((id) => { {selectedIds.map((id) => {
const name = assets.find((asset) => asset.id === id)?.values.name ?? id; const name = assets.find((asset) => asset.id === id)?.values.name ?? "—";
const reason = unavailableReason(id); const reason = unavailableReason(id);
return ( return (
<li <li
className="flex items-center justify-between gap-3 rounded-md border p-3 text-sm" className="flex min-w-0 items-center justify-between gap-3 py-3 text-sm first:pt-0 last:pb-0"
key={id} key={id}
> >
<div> <div className="min-w-0 space-y-1 break-words">
<p>{name}</p> <p>{name}</p>
{reason && <p className="text-destructive">{t(`inventoryErrors.${reason}`)}</p>} {reason && <p className="text-destructive">{t(`inventoryErrors.${reason}`)}</p>}
</div> </div>
<Button <Button
aria-label={t("removeWorkstationAsset", { name })} aria-label={t("removeWorkstationAsset", { name })}
disabled={disabled}
onClick={() => onRemove(id)} onClick={() => onRemove(id)}
size="sm" size="icon-sm"
type="button" type="button"
variant="outline" variant="ghost"
> >
× <XIcon aria-hidden="true" className="size-4" />
</Button> </Button>
</li> </li>
); );
@ -98,5 +102,6 @@ export function WorkstationAssetFields({
<p className="text-sm text-muted-foreground">{t("noWorkstationAssets")}</p> <p className="text-sm text-muted-foreground">{t("noWorkstationAssets")}</p>
)} )}
</div> </div>
</FormSection>
); );
} }

View File

@ -1,4 +1,5 @@
import { FormLabel } from "@repo/rdx-ui/components/form-label"; import { FormLabel } from "@repo/rdx-ui/components/form-label";
import { FormSection } from "@repo/rdx-ui/components/form-section";
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action"; import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -27,7 +28,8 @@ export function WorkstationAssociationFields({
const { t } = useTranslation("assets"); const { t } = useTranslation("assets");
return ( return (
<div className="grid gap-4 @min-[560px]:grid-cols-2"> <FormSection title={t("formLayout.workstationAssociations")} variant="plain">
<div className="grid gap-x-5 gap-y-4 @min-[560px]:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<FormLabel htmlFor={`${prefix}-site`} required={required}> <FormLabel htmlFor={`${prefix}-site`} required={required}>
{t("site")} {t("site")}
@ -66,5 +68,6 @@ export function WorkstationAssociationFields({
/> />
</div> </div>
</div> </div>
</FormSection>
); );
} }

View File

@ -33,6 +33,7 @@ export function WorkstationComputerField({
</FormLabel> </FormLabel>
<AssetSelect <AssetSelect
companyId={companyId} companyId={companyId}
describedBy={`${prefix}-computer-help`}
disabled={singleComputer} disabled={singleComputer}
id={`${prefix}-computer`} id={`${prefix}-computer`}
label={t("workstationComputer")} label={t("workstationComputer")}
@ -45,7 +46,7 @@ export function WorkstationComputerField({
sites={sites} sites={sites}
value={values.computerAssetId ?? ""} value={values.computerAssetId ?? ""}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground" id={`${prefix}-computer-help`}>
{t(singleComputer ? "workstationSteps.singleComputer" : "workstationComputerHelp")} {t(singleComputer ? "workstationSteps.singleComputer" : "workstationComputerHelp")}
</p> </p>
</div> </div>

View File

@ -1,4 +1,5 @@
import { FormDialog } from "@repo/rdx-ui/components/form-dialog"; import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
import { MonitorIcon } 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";
@ -27,8 +28,13 @@ export function WorkstationEditorDialog({
} }
}} }}
open open
title={t(workstation ? "editWorkstation" : "newWorkstation")} size={workstation ? "large" : "medium"}
wide title={
<span className="flex items-center gap-3">
<MonitorIcon aria-hidden="true" className="size-5 shrink-0 text-muted-foreground" />
{t(workstation ? "editWorkstation" : "newWorkstation")}
</span>
}
> >
<WorkstationForm <WorkstationForm
companyId={companyId} companyId={companyId}

View File

@ -1,4 +1,5 @@
import { FormActions } from "@repo/rdx-ui/components/form-dialog"; import { FormActions } from "@repo/rdx-ui/components/form-dialog";
import { ChevronLeftIcon, ChevronRightIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button"; import { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -17,14 +18,16 @@ export function WorkstationFormActions({
const action = step && step < 4 ? "workstationSteps.next" : "save"; const action = step && step < 4 ? "workstationSteps.next" : "save";
return ( return (
<FormActions> <FormActions cancelDisabled={pending}>
{step && step > 1 && ( {step && 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" />
{t("workstationSteps.previous")} {t("workstationSteps.previous")}
</Button> </Button>
)} )}
<Button disabled={pending || !canSubmit} type="submit"> <Button disabled={pending || !canSubmit} type="submit">
{t(pending ? "saving" : action)} {t(pending ? "saving" : action)}
{step && step < 4 && <ChevronRightIcon aria-hidden="true" className="size-4" />}
</Button> </Button>
</FormActions> </FormActions>
); );

View File

@ -1,9 +1,6 @@
import { ContextHelp } from "@repo/rdx-ui/components/context-help"; 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 { FormLabel } from "@repo/rdx-ui/components/form-label";
import { FormProgress } from "@repo/rdx-ui/components/form-progress"; import { FormProgress } from "@repo/rdx-ui/components/form-progress";
import { SwitchField } from "@repo/rdx-ui/components/switch-field";
import { Input } from "@repo/shadcn-ui/components/input";
import { useId } from "react"; import { useId } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -16,6 +13,7 @@ import { WorkstationAssetFields } from "./workstation-asset-fields";
import { WorkstationAssociationFields } from "./workstation-association-fields"; import { WorkstationAssociationFields } from "./workstation-association-fields";
import { WorkstationComputerField } from "./workstation-computer-field"; import { WorkstationComputerField } from "./workstation-computer-field";
import { WorkstationFormActions } from "./workstation-form-actions"; import { WorkstationFormActions } from "./workstation-form-actions";
import { WorkstationIdentityFields } from "./workstation-identity-fields";
import { WorkstationSummary } from "./workstation-summary"; import { WorkstationSummary } from "./workstation-summary";
export function WorkstationForm(props: WorkstationFormProps) { export function WorkstationForm(props: WorkstationFormProps) {
@ -63,7 +61,7 @@ export function WorkstationForm(props: WorkstationFormProps) {
step={step} step={step}
/> />
)} )}
<fieldset className="space-y-4" disabled={mutation.isPending}> <fieldset className="space-y-5" disabled={mutation.isPending}>
{layout.location && ( {layout.location && (
<WorkstationAssociationFields <WorkstationAssociationFields
lookups={lookups} lookups={lookups}
@ -103,25 +101,11 @@ export function WorkstationForm(props: WorkstationFormProps) {
values={values} values={values}
/> />
)} )}
{layout.summary && ( {layout.identity && (
<> <WorkstationIdentityFields form={form} prefix={prefix} showStatus={!isWizard} />
<div className="space-y-2">
<FormLabel htmlFor={prefix} required>
{t("name")}
</FormLabel>
<Input id={prefix} maxLength={100} required {...form.register("name")} />
</div>
{isWizard ? (
<WorkstationSummary assets={assets} lookups={lookups} values={values} />
) : (
<SwitchField
checked={values.active}
id={`${prefix}-active`}
label={t("active")}
onCheckedChange={(active) => form.setValue("active", active)}
/>
)} )}
</> {layout.review && (
<WorkstationSummary assets={assets} lookups={lookups} values={values} />
)} )}
</fieldset> </fieldset>
{Object.keys(form.formState.errors).length > 0 && ( {Object.keys(form.formState.errors).length > 0 && (
@ -129,7 +113,12 @@ export function WorkstationForm(props: WorkstationFormProps) {
{t("inventoryErrors.INVALID_REQUEST")} {t("inventoryErrors.INVALID_REQUEST")}
</p> </p>
)} )}
<InventoryFeedback error={mutation.error ?? inventory.error} /> {(inventory.isPending || catalog.isPending) && (
<p className="text-sm text-muted-foreground" role="status">
{t("loading")}
</p>
)}
<InventoryFeedback error={mutation.error ?? inventory.error ?? catalog.error} />
<ContextHelp <ContextHelp
description={t("workstationDescription")} description={t("workstationDescription")}
examples={t("workstationExamples")} examples={t("workstationExamples")}
@ -163,10 +152,10 @@ export function WorkstationForm(props: WorkstationFormProps) {
); );
} }
const FULL_LAYOUT = { location: true, assets: true, computer: true, summary: true }; const FULL_LAYOUT = { location: true, assets: true, computer: true, identity: true, review: false };
const STEP_LAYOUTS = { const STEP_LAYOUTS = {
1: { location: true, assets: false, computer: false, summary: false }, 1: { location: true, assets: false, computer: false, identity: false, review: false },
2: { location: false, assets: true, computer: false, summary: false }, 2: { location: false, assets: true, computer: false, identity: false, review: false },
3: { location: false, assets: false, computer: true, summary: false }, 3: { location: false, assets: false, computer: true, identity: false, review: false },
4: { location: false, assets: false, computer: false, summary: true }, 4: { location: false, assets: false, computer: false, identity: true, review: true },
} as const; } as const;

View File

@ -0,0 +1,56 @@
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 type { UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { WorkstationInput } from "../../../../common";
export function WorkstationIdentityFields({
form,
prefix,
showStatus,
}: {
form: UseFormReturn<WorkstationInput>;
prefix: string;
showStatus: boolean;
}) {
const { t } = useTranslation("assets");
const invalidName = !!form.formState.errors.name;
return (
<FormSection title={t("formLayout.workstationIdentity")} variant="plain">
<div
className={`grid items-end gap-x-5 gap-y-4 ${showStatus ? "@min-[560px]:grid-cols-2" : ""}`}
>
<div className="space-y-2">
<FormLabel htmlFor={`${prefix}-name`} required>
{t("name")}
</FormLabel>
<Input
aria-describedby={invalidName ? `${prefix}-name-error` : undefined}
aria-invalid={invalidName}
id={`${prefix}-name`}
maxLength={100}
required
{...form.register("name")}
/>
{invalidName && (
<p className="text-sm text-destructive" id={`${prefix}-name-error`} role="alert">
{t("inventoryErrors.INVALID_REQUEST")}
</p>
)}
</div>
{showStatus && (
<SwitchField
checked={form.watch("active")}
id={`${prefix}-active`}
label={t("active")}
onCheckedChange={(active) => form.setValue("active", active)}
/>
)}
</div>
</FormSection>
);
}

View File

@ -1,3 +1,4 @@
import { FormSection } from "@repo/rdx-ui/components/form-section";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { WorkstationInput } from "../../../../common"; import type { WorkstationInput } from "../../../../common";
@ -16,37 +17,35 @@ export function WorkstationSummary({
const { t } = useTranslation("assets"); const { t } = useTranslation("assets");
return ( return (
<dl className="space-y-4 text-sm"> <FormSection title={t("formLayout.workstationSummary")} variant="plain">
<div> <dl className="grid items-stretch gap-x-5 gap-y-4 text-sm @min-[560px]:grid-cols-2">
<div className="min-w-0 space-y-1 break-words">
<dt className="text-muted-foreground">{t("site")}</dt> <dt className="text-muted-foreground">{t("site")}</dt>
<dd> <dd>{lookups.sites.find((site) => site.id === values.siteId)?.name ?? "—"}</dd>
{lookups.sites.find((site) => site.id === values.siteId)?.name ?? t("noAssignment")}
</dd>
</div> </div>
<div> <div className="min-w-0 space-y-1 break-words">
<dt className="text-muted-foreground">{t("workstationPerson")}</dt> <dt className="text-muted-foreground">{t("workstationPerson")}</dt>
<dd> <dd>{lookups.people.find((person) => person.id === values.personId)?.name ?? "—"}</dd>
{lookups.people.find((person) => person.id === values.personId)?.name ??
t("noAssignment")}
</dd>
</div> </div>
<div> <div className="min-w-0 space-y-1 break-words">
<dt className="text-muted-foreground">{t("workstationAssets")}</dt> <dt className="text-muted-foreground">{t("workstationAssets")}</dt>
<dd> <dd>
{values.assetIds.length > 0 ? (
<ul className="list-inside list-disc"> <ul className="list-inside list-disc">
{values.assetIds.map((id) => ( {values.assetIds.map((id) => (
<li key={id}>{assets.find((asset) => asset.id === id)?.values.name ?? id}</li> <li key={id}>{assets.find((asset) => asset.id === id)?.values.name ?? "—"}</li>
))} ))}
</ul> </ul>
) : (
"—"
)}
</dd> </dd>
</div> </div>
<div> <div className="min-w-0 space-y-1 break-words">
<dt className="text-muted-foreground">{t("workstationComputer")}</dt> <dt className="text-muted-foreground">{t("workstationComputer")}</dt>
<dd> <dd>{assets.find((asset) => asset.id === values.computerAssetId)?.values.name ?? "—"}</dd>
{assets.find((asset) => asset.id === values.computerAssetId)?.values.name ??
t("noAssignment")}
</dd>
</div> </div>
</dl> </dl>
</FormSection>
); );
} }

View File

@ -25,7 +25,7 @@ export function ContextHelp({
aria-label={title} aria-label={title}
className={ className={
variant === "plain" variant === "plain"
? "space-y-1 border-t border-border/60 pt-3 text-sm" ? "space-y-1 border-t border-border/60 pt-3 text-sm mt-12"
: "space-y-4 rounded-xl border bg-muted/30 p-4 text-sm sm:p-5" : "space-y-4 rounded-xl border bg-muted/30 p-4 text-sm sm:p-5"
} }
> >

View File

@ -44,7 +44,7 @@ export function FormProgress({
})} })}
</ol> </ol>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1"> <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 sr-only">
<h2 className="text-base font-semibold">{labels[step - 1]}</h2> <h2 className="text-base font-semibold">{labels[step - 1]}</h2>
<p <p
aria-live="polite" aria-live="polite"