285 lines
11 KiB
TypeScript
285 lines
11 KiB
TypeScript
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>
|
||
)}
|
||
</>
|
||
);
|
||
}
|