feat: mejorar diseño y accesibilidad de los diálogos de alta y edición de puestos
This commit is contained in:
parent
77cb8b0b97
commit
2fc49470d6
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
>
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user