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
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|