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

276 lines
10 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";
2026-10-08 14:43:34 +00:00
import { FormActions } from "@repo/shadcn-ui/components/form-dialog";
2026-10-04 19:04:14 +00:00
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";
2026-10-08 14:43:34 +00:00
import type { WorkstationDTO, WorkstationInput } from "../common";
import { WorkstationInputSchema } from "../common";
2026-10-04 19:04:14 +00:00
2026-10-08 12:14:01 +00:00
import type { InventoryLookups } from "./asset-form";
2026-10-04 19:04:14 +00:00
import { catalogApi, catalogKeys } from "./catalog-api";
import { inventoryApi, inventoryKeys } from "./inventory-api";
2026-10-08 12:14:01 +00:00
import { InventoryFeedback } from "./inventory-feedback";
2026-10-08 14:43:34 +00:00
import { WorkstationAssetCreateDialog } from "./workstation-asset-create-dialog";
2026-10-04 19:04:14 +00:00
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: [],
2026-10-05 07:53:29 +00:00
sharedAssetIds: [],
2026-10-04 19:04:14 +00:00
},
});
const values = form.watch();
const [quick, setQuick] = useState(false);
2026-10-05 07:53:29 +00:00
const [quickField, setQuickField] = useState<"assetIds" | "sharedAssetIds">("assetIds");
2026-10-04 19:04:14 +00:00
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)
);
2026-10-05 07:53:29 +00:00
const sharedUsed = new Set(
stations
.filter((item) => item.values.active && item.id !== workstation?.id)
.flatMap((item) => item.values.sharedAssetIds)
);
const unavailable = (field: "assetIds" | "sharedAssetIds", id: string) =>
values.active && (occupied.has(id) || (field === "assetIds" && sharedUsed.has(id)));
const add = (field: "assetIds" | "sharedAssetIds", id: string) => {
if (
id &&
mounted.current &&
![...form.getValues("assetIds"), ...form.getValues("sharedAssetIds")].includes(id)
) {
form.setValue(field, [...form.getValues(field), id]);
2026-10-04 19:04:14 +00:00
}
};
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>
2026-10-05 07:53:29 +00:00
{(["assetIds", "sharedAssetIds"] as const).map((field) => (
<div className="space-y-2" key={field}>
<Label htmlFor={`${prefix}-${field}`}>
{t(field === "assetIds" ? "addWorkstationAsset" : "addSharedWorkstationAsset")}
</Label>
<SelectWithAction
action={{
label: t("newAsset"),
onClick: () => {
setQuickField(field);
setQuick(true);
},
disabled: !catalog.data,
}}
disabled={inventory.isPending || values[field].length >= 200}
id={`${prefix}-${field}`}
label={t(
field === "assetIds" ? "addWorkstationAsset" : "addSharedWorkstationAsset"
)}
onValueChange={(id) => add(field, id)}
options={assets
.filter(
(item) => ![...values.assetIds, ...values.sharedAssetIds].includes(item.id)
)
.map((item) => ({
value: item.id,
label: unavailable(field, item.id)
? t("workstationOccupied", { name: item.values.name })
: item.values.name,
disabled: unavailable(field, item.id),
}))}
placeholder={t("chooseAsset")}
value=""
/>
{values[field].length ? (
<ul className="space-y-2">
{values[field].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>
{unavailable(field, 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(
field,
values[field].filter((item) => item !== id)
)
}
size="sm"
type="button"
variant="outline"
>
×
</Button>
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">
{t(field === "assetIds" ? "noWorkstationAssets" : "noSharedWorkstationAssets")}
</p>
)}
</div>
))}
2026-10-04 19:04:14 +00:00
</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 ||
2026-10-05 07:53:29 +00:00
values.assetIds.some((id) => unavailable("assetIds", id)) ||
values.sharedAssetIds.some((id) => unavailable("sharedAssetIds", id))
2026-10-04 19:04:14 +00:00
}
type="submit"
>
{t(mutation.isPending ? "saving" : "save")}
</Button>
</FormActions>
</form>
{quick && catalog.data && (
2026-10-08 14:43:34 +00:00
<WorkstationAssetCreateDialog
companyId={companyId}
csrfToken={csrfToken}
items={catalog.data.items}
lookups={lookups}
2026-10-04 19:04:14 +00:00
onOpenChange={setQuick}
2026-10-08 14:43:34 +00:00
onSaved={(id) => {
add(quickField, id);
setQuick(false);
}}
/>
2026-10-04 19:04:14 +00:00
)}
</>
);
}