metis-desk/modules/assets/src/web/workstation-form.tsx
2026-10-08 14:14:01 +02:00

285 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 type { InventoryLookups } from "./asset-form";
import { AssetForm } from "./asset-form";
import { catalogApi, catalogKeys } from "./catalog-api";
import { inventoryApi, inventoryKeys } from "./inventory-api";
import { InventoryFeedback } from "./inventory-feedback";
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: [],
sharedAssetIds: [],
},
});
const values = form.watch();
const [quick, setQuick] = useState(false);
const [quickField, setQuickField] = useState<"assetIds" | "sharedAssetIds">("assetIds");
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 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]);
}
};
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>
{(["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>
))}
</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.assetIds.some((id) => unavailable("assetIds", id)) ||
values.sharedAssetIds.some((id) => unavailable("sharedAssetIds", 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(quickField, id);
setQuick(false);
}}
/>
</FormDialog>
)}
</>
);
}