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.
|
`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
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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"'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 { 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user