metis-desk/modules/assets/src/web/workstation-form.tsx

255 lines
9.2 KiB
TypeScript
Raw Normal View History

2026-10-04 19:04:14 +00:00
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@repo/shadcn-ui/components/button";
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
import { FormActions, FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { Input } from "@repo/shadcn-ui/components/input";
import { Label } from "@repo/shadcn-ui/components/label";
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useId, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { type WorkstationDTO, type WorkstationInput, WorkstationInputSchema } from "../common";
import { AssetForm, InventoryFeedback, type InventoryLookups } from "./asset-form";
import { catalogApi, catalogKeys } from "./catalog-api";
import { inventoryApi, inventoryKeys } from "./inventory-api";
export function WorkstationForm({
companyId,
csrfToken,
lookups,
workstation,
onSaved,
}: {
companyId: string;
csrfToken: string;
lookups: InventoryLookups;
workstation?: WorkstationDTO | undefined;
onSaved: (id: string) => void;
}) {
const { t } = useTranslation("assets");
const prefix = useId();
const client = useQueryClient();
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const form = useForm<WorkstationInput>({
resolver: zodResolver(WorkstationInputSchema),
defaultValues: workstation?.values ?? {
name: "",
active: true,
siteId: null,
personId: null,
assetIds: [],
},
});
const values = form.watch();
const [quick, setQuick] = useState(false);
const inventory = useQuery({
queryKey: inventoryKeys.company(companyId),
queryFn: () => inventoryApi.get(companyId),
});
const catalog = useQuery({
queryKey: catalogKeys.scope(companyId),
queryFn: () => catalogApi.get(companyId),
});
const assets = inventory.data?.assets ?? [];
const stations = inventory.data?.workstations ?? [];
const occupied = new Set(
stations
.filter((item) => item.values.active && item.id !== workstation?.id)
.flatMap((item) => item.values.assetIds)
);
const add = (id: string) => {
if (id && mounted.current && !form.getValues("assetIds").includes(id)) {
form.setValue("assetIds", [...form.getValues("assetIds"), id]);
}
};
const mutation = useMutation({
mutationFn: (input: WorkstationInput) =>
inventoryApi.saveWorkstation(companyId, input, csrfToken, workstation?.id),
onSuccess: async (response) => {
await client.invalidateQueries({ queryKey: inventoryKeys.company(companyId) });
if (mounted.current) {
onSaved(response.workstation.id);
}
},
});
return (
<>
<form className="space-y-5" onSubmit={form.handleSubmit((input) => mutation.mutate(input))}>
<fieldset className="space-y-4" disabled={mutation.isPending}>
<div className="space-y-2">
<Label htmlFor={prefix}>{t("name")}</Label>
<Input id={prefix} maxLength={100} required {...form.register("name")} />
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" {...form.register("active")} />
{t("active")}
</label>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`${prefix}-site`}>{t("site")}</Label>
<SelectWithAction
action={{
label: t("newSite"),
onClick: () =>
lookups.onCreateSite((id) => {
if (mounted.current) {
form.setValue("siteId", id);
}
}),
}}
id={`${prefix}-site`}
label={t("site")}
onValueChange={(id) => form.setValue("siteId", id || null)}
options={lookups.sites.map((item) => ({ value: item.id, label: item.name }))}
placeholder={t("noAssignment")}
value={values.siteId ?? ""}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-person`}>{t("workstationPerson")}</Label>
<SelectWithAction
action={{
label: t("newPerson"),
onClick: () =>
lookups.onCreatePerson((id) => {
if (mounted.current) {
form.setValue("personId", id);
}
}),
}}
id={`${prefix}-person`}
label={t("workstationPerson")}
onValueChange={(id) => form.setValue("personId", id || null)}
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">
<Label htmlFor={`${prefix}-assets`}>{t("addWorkstationAsset")}</Label>
<SelectWithAction
action={{
label: t("newAsset"),
onClick: () => setQuick(true),
disabled: !catalog.data,
}}
disabled={inventory.isPending || values.assetIds.length >= 200}
id={`${prefix}-assets`}
label={t("addWorkstationAsset")}
onValueChange={add}
options={assets
.filter((item) => !values.assetIds.includes(item.id))
.map((item) => ({
value: item.id,
label: occupied.has(item.id)
? t("workstationOccupied", { name: item.values.name })
: item.values.name,
disabled: values.active && occupied.has(item.id),
}))}
placeholder={t("chooseAsset")}
value=""
/>
</div>
{values.assetIds.length ? (
<ul className="space-y-2">
{values.assetIds.map((id) => (
<li
className="flex items-center justify-between gap-3 rounded-md border p-3 text-sm"
key={id}
>
<div>
<p>{assets.find((item) => item.id === id)?.values.name ?? id}</p>
{values.active && occupied.has(id) && (
<p className="text-destructive">
{t("inventoryErrors.ASSET_IN_WORKSTATION")}
</p>
)}
</div>
<Button
aria-label={t("removeWorkstationAsset", {
name: assets.find((item) => item.id === id)?.values.name ?? id,
})}
onClick={() =>
form.setValue(
"assetIds",
values.assetIds.filter((item) => item !== id)
)
}
size="sm"
type="button"
variant="outline"
>
×
</Button>
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">{t("noWorkstationAssets")}</p>
)}
</fieldset>
{Object.keys(form.formState.errors).length > 0 && (
<p className="text-sm text-destructive" role="alert">
{t("inventoryErrors.INVALID_REQUEST")}
</p>
)}
<InventoryFeedback error={mutation.error ?? inventory.error} />
<ContextHelp
description={t("workstationDescription")}
examples={t("workstationExamples")}
examplesLabel={t("examplesLabel")}
guidance={t("workstationGuidance")}
scopeNote={t("workstationScope")}
title={t("helpTitle")}
/>
<FormActions>
<Button
disabled={
mutation.isPending ||
!inventory.data ||
(values.active && values.assetIds.some((id) => occupied.has(id)))
}
type="submit"
>
{t(mutation.isPending ? "saving" : "save")}
</Button>
</FormActions>
</form>
{quick && catalog.data && (
<FormDialog
cancelLabel={t("cancel")}
formActions
onOpenChange={setQuick}
open
title={t("newAsset")}
wide
>
<p className="text-sm text-muted-foreground">{t("relatedNotice")}</p>
<AssetForm
allowQuickCreate={false}
companyId={companyId}
csrfToken={csrfToken}
items={catalog.data.items}
lookups={lookups}
onSaved={(id) => {
add(id);
setQuick(false);
}}
/>
</FormDialog>
)}
</>
);
}