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.
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).
## 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.
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.
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": {
"identity": "Identification and assignment",
"template": "Template details"
"template": "Template details",
"workstationAssociations": "Location and owner",
"workstationIdentity": "Workstation identification",
"workstationSummary": "Selected details"
},
"detailSections": {
"identification": "Identification",

View File

@ -326,7 +326,10 @@
},
"formLayout": {
"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": {
"identification": "Identificación",

View File

@ -20,7 +20,7 @@ import type {
WorkstationDTO,
} from "../../../common";
import { AssetSchema } from "../../../common/inventory";
import { WorkstationSchema } from "../../../common/workstations";
import { WorkstationInputSchema, WorkstationSchema } from "../../../common/workstations";
import { AssetRelationships } from "../../asset-relationships";
import { catalogKeys } from "../../catalog/shared/api";
import { CatalogRelationForm } from "../../catalog-relations";
@ -28,6 +28,10 @@ import { CatalogTemplateForm } from "../../catalog-templates";
import { assetsEnglish, assetsSpanish } from "../../shared";
import { inventoryKeys } from "../../shared/queries";
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 { AssetSelectionPanel } from "../detail/ui/asset-selection-panel";
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), "");
});
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 { 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 { useTranslation } from "react-i18next";
@ -35,68 +37,71 @@ export function WorkstationAssetFields({
const { t } = useTranslation("assets");
return (
<div className="space-y-2">
<FormLabel htmlFor={`${prefix}-assets`}>{t("addWorkstationAsset")}</FormLabel>
<AssetSelect
action={{ label: t("newAsset"), onClick: onCreate, disabled: !canCreate }}
companyId={companyId}
disabled={disabled || selectedIds.length >= 200}
id={`${prefix}-assets`}
label={t("addWorkstationAsset")}
maxSelection={200 - selectedIds.length}
mode="multiple"
onValueChange={onAdd}
options={assets
.filter((asset) => !selectedIds.includes(asset.id))
.map((asset) => {
const reason = unavailableReason(asset.id);
<FormSection title={t("workstationAssets")} variant="plain">
<div className="space-y-2">
<FormLabel htmlFor={`${prefix}-assets`}>{t("addWorkstationAsset")}</FormLabel>
<AssetSelect
action={{ label: t("newAsset"), onClick: onCreate, disabled: !canCreate }}
companyId={companyId}
disabled={disabled || selectedIds.length >= 200}
id={`${prefix}-assets`}
label={t("addWorkstationAsset")}
maxSelection={200 - selectedIds.length}
mode="multiple"
onValueChange={onAdd}
options={assets
.filter((asset) => !selectedIds.includes(asset.id))
.map((asset) => {
const reason = unavailableReason(asset.id);
return {
value: asset.id,
label:
reason === "ASSET_NOT_OPERATIONAL"
? `${asset.values.name} · ${t(asset.values.status)}`
: reason
? t("workstationOccupied", { name: asset.values.name })
: asset.values.name,
disabled: !!reason,
};
})}
placeholder={t("assetPicker.multiple")}
sites={sites}
value={[]}
/>
{selectedIds.length ? (
<ul className="space-y-2">
{selectedIds.map((id) => {
const name = assets.find((asset) => asset.id === id)?.values.name ?? id;
const reason = unavailableReason(id);
return {
value: asset.id,
label:
reason === "ASSET_NOT_OPERATIONAL"
? `${asset.values.name} · ${t(asset.values.status)}`
: reason
? t("workstationOccupied", { name: asset.values.name })
: asset.values.name,
disabled: !!reason,
};
})}
placeholder={t("assetPicker.multiple")}
sites={sites}
value={[]}
/>
{selectedIds.length ? (
<ul className="divide-y divide-border/60">
{selectedIds.map((id) => {
const name = assets.find((asset) => asset.id === id)?.values.name ?? "—";
const reason = unavailableReason(id);
return (
<li
className="flex items-center justify-between gap-3 rounded-md border p-3 text-sm"
key={id}
>
<div>
<p>{name}</p>
{reason && <p className="text-destructive">{t(`inventoryErrors.${reason}`)}</p>}
</div>
<Button
aria-label={t("removeWorkstationAsset", { name })}
onClick={() => onRemove(id)}
size="sm"
type="button"
variant="outline"
return (
<li
className="flex min-w-0 items-center justify-between gap-3 py-3 text-sm first:pt-0 last:pb-0"
key={id}
>
×
</Button>
</li>
);
})}
</ul>
) : (
<p className="text-sm text-muted-foreground">{t("noWorkstationAssets")}</p>
)}
</div>
<div className="min-w-0 space-y-1 break-words">
<p>{name}</p>
{reason && <p className="text-destructive">{t(`inventoryErrors.${reason}`)}</p>}
</div>
<Button
aria-label={t("removeWorkstationAsset", { name })}
disabled={disabled}
onClick={() => onRemove(id)}
size="icon-sm"
type="button"
variant="ghost"
>
<XIcon aria-hidden="true" className="size-4" />
</Button>
</li>
);
})}
</ul>
) : (
<p className="text-sm text-muted-foreground">{t("noWorkstationAssets")}</p>
)}
</div>
</FormSection>
);
}

View File

@ -1,4 +1,5 @@
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 { useTranslation } from "react-i18next";
@ -27,44 +28,46 @@ export function WorkstationAssociationFields({
const { t } = useTranslation("assets");
return (
<div className="grid gap-4 @min-[560px]:grid-cols-2">
<div className="space-y-2">
<FormLabel htmlFor={`${prefix}-site`} required={required}>
{t("site")}
</FormLabel>
<SelectWithAction
action={{
label: t("newSite"),
onClick: () => lookups.onCreateSite(onSiteCreated),
}}
id={`${prefix}-site`}
label={t("site")}
onValueChange={(id) => onSelect("siteId", id)}
options={lookups.sites
.filter((item) => item.active || item.id === workstation?.values.siteId)
.map((item) => ({ value: item.id, label: item.name }))}
placeholder={t("noAssignment")}
required={required}
value={values.siteId ?? ""}
/>
<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">
<FormLabel htmlFor={`${prefix}-site`} required={required}>
{t("site")}
</FormLabel>
<SelectWithAction
action={{
label: t("newSite"),
onClick: () => lookups.onCreateSite(onSiteCreated),
}}
id={`${prefix}-site`}
label={t("site")}
onValueChange={(id) => onSelect("siteId", id)}
options={lookups.sites
.filter((item) => item.active || item.id === workstation?.values.siteId)
.map((item) => ({ value: item.id, label: item.name }))}
placeholder={t("noAssignment")}
required={required}
value={values.siteId ?? ""}
/>
</div>
<div className="space-y-2">
<FormLabel htmlFor={`${prefix}-person`}>{t("workstationPersonOptional")}</FormLabel>
<SelectWithAction
action={{
label: t("newPerson"),
onClick: () => lookups.onCreatePerson(onPersonCreated),
}}
id={`${prefix}-person`}
label={t("workstationPersonOptional")}
onValueChange={(id) => onSelect("personId", id)}
options={lookups.people
.filter((item) => item.active || item.id === workstation?.values.personId)
.map((item) => ({ value: item.id, label: item.name }))}
placeholder={t("noAssignment")}
value={values.personId ?? ""}
/>
</div>
</div>
<div className="space-y-2">
<FormLabel htmlFor={`${prefix}-person`}>{t("workstationPersonOptional")}</FormLabel>
<SelectWithAction
action={{
label: t("newPerson"),
onClick: () => lookups.onCreatePerson(onPersonCreated),
}}
id={`${prefix}-person`}
label={t("workstationPersonOptional")}
onValueChange={(id) => onSelect("personId", id)}
options={lookups.people
.filter((item) => item.active || item.id === workstation?.values.personId)
.map((item) => ({ value: item.id, label: item.name }))}
placeholder={t("noAssignment")}
value={values.personId ?? ""}
/>
</div>
</div>
</FormSection>
);
}

View File

@ -33,6 +33,7 @@ export function WorkstationComputerField({
</FormLabel>
<AssetSelect
companyId={companyId}
describedBy={`${prefix}-computer-help`}
disabled={singleComputer}
id={`${prefix}-computer`}
label={t("workstationComputer")}
@ -45,7 +46,7 @@ export function WorkstationComputerField({
sites={sites}
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")}
</p>
</div>

View File

@ -1,4 +1,5 @@
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
import { MonitorIcon } from "@repo/rdx-ui/lib/icons";
import type { ComponentProps } from "react";
import { useTranslation } from "react-i18next";
@ -27,8 +28,13 @@ export function WorkstationEditorDialog({
}
}}
open
title={t(workstation ? "editWorkstation" : "newWorkstation")}
wide
size={workstation ? "large" : "medium"}
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
companyId={companyId}

View File

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

View File

@ -1,9 +1,6 @@
import { ContextHelp } from "@repo/rdx-ui/components/context-help";
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 { SwitchField } from "@repo/rdx-ui/components/switch-field";
import { Input } from "@repo/shadcn-ui/components/input";
import { useId } from "react";
import { useTranslation } from "react-i18next";
@ -16,6 +13,7 @@ import { WorkstationAssetFields } from "./workstation-asset-fields";
import { WorkstationAssociationFields } from "./workstation-association-fields";
import { WorkstationComputerField } from "./workstation-computer-field";
import { WorkstationFormActions } from "./workstation-form-actions";
import { WorkstationIdentityFields } from "./workstation-identity-fields";
import { WorkstationSummary } from "./workstation-summary";
export function WorkstationForm(props: WorkstationFormProps) {
@ -63,7 +61,7 @@ export function WorkstationForm(props: WorkstationFormProps) {
step={step}
/>
)}
<fieldset className="space-y-4" disabled={mutation.isPending}>
<fieldset className="space-y-5" disabled={mutation.isPending}>
{layout.location && (
<WorkstationAssociationFields
lookups={lookups}
@ -103,25 +101,11 @@ export function WorkstationForm(props: WorkstationFormProps) {
values={values}
/>
)}
{layout.summary && (
<>
<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.identity && (
<WorkstationIdentityFields form={form} prefix={prefix} showStatus={!isWizard} />
)}
{layout.review && (
<WorkstationSummary assets={assets} lookups={lookups} values={values} />
)}
</fieldset>
{Object.keys(form.formState.errors).length > 0 && (
@ -129,7 +113,12 @@ export function WorkstationForm(props: WorkstationFormProps) {
{t("inventoryErrors.INVALID_REQUEST")}
</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
description={t("workstationDescription")}
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 = {
1: { location: true, assets: false, computer: false, summary: false },
2: { location: false, assets: true, computer: false, summary: false },
3: { location: false, assets: false, computer: true, summary: false },
4: { location: false, assets: false, computer: false, summary: true },
1: { location: true, assets: false, computer: false, identity: false, review: false },
2: { location: false, assets: true, computer: false, identity: false, review: false },
3: { location: false, assets: false, computer: true, identity: false, review: false },
4: { location: false, assets: false, computer: false, identity: true, review: true },
} 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 type { WorkstationInput } from "../../../../common";
@ -16,37 +17,35 @@ export function WorkstationSummary({
const { t } = useTranslation("assets");
return (
<dl className="space-y-4 text-sm">
<div>
<dt className="text-muted-foreground">{t("site")}</dt>
<dd>
{lookups.sites.find((site) => site.id === values.siteId)?.name ?? t("noAssignment")}
</dd>
</div>
<div>
<dt className="text-muted-foreground">{t("workstationPerson")}</dt>
<dd>
{lookups.people.find((person) => person.id === values.personId)?.name ??
t("noAssignment")}
</dd>
</div>
<div>
<dt className="text-muted-foreground">{t("workstationAssets")}</dt>
<dd>
<ul className="list-inside list-disc">
{values.assetIds.map((id) => (
<li key={id}>{assets.find((asset) => asset.id === id)?.values.name ?? id}</li>
))}
</ul>
</dd>
</div>
<div>
<dt className="text-muted-foreground">{t("workstationComputer")}</dt>
<dd>
{assets.find((asset) => asset.id === values.computerAssetId)?.values.name ??
t("noAssignment")}
</dd>
</div>
</dl>
<FormSection title={t("formLayout.workstationSummary")} variant="plain">
<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>
<dd>{lookups.sites.find((site) => site.id === values.siteId)?.name ?? "—"}</dd>
</div>
<div className="min-w-0 space-y-1 break-words">
<dt className="text-muted-foreground">{t("workstationPerson")}</dt>
<dd>{lookups.people.find((person) => person.id === values.personId)?.name ?? "—"}</dd>
</div>
<div className="min-w-0 space-y-1 break-words">
<dt className="text-muted-foreground">{t("workstationAssets")}</dt>
<dd>
{values.assetIds.length > 0 ? (
<ul className="list-inside list-disc">
{values.assetIds.map((id) => (
<li key={id}>{assets.find((asset) => asset.id === id)?.values.name ?? "—"}</li>
))}
</ul>
) : (
"—"
)}
</dd>
</div>
<div className="min-w-0 space-y-1 break-words">
<dt className="text-muted-foreground">{t("workstationComputer")}</dt>
<dd>{assets.find((asset) => asset.id === values.computerAssetId)?.values.name ?? "—"}</dd>
</div>
</dl>
</FormSection>
);
}

View File

@ -25,7 +25,7 @@ export function ContextHelp({
aria-label={title}
className={
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"
}
>

View File

@ -44,7 +44,7 @@ export function FormProgress({
})}
</ol>
<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>
<p
aria-live="polite"