metis-desk/modules/companies/src/web/certificates/certificate-configuration-form.tsx

262 lines
9.3 KiB
TypeScript
Raw Normal View History

import { zodResolver } from "@hookform/resolvers/zod";
2026-10-08 12:14:01 +00:00
import { catalogApi, catalogKeys, inventoryApi, inventoryKeys } from "@metis/resources/client";
import type { CatalogItemDTO, ResourceDTO } from "@metis/resources/common";
import { Button } from "@repo/shadcn-ui/components/button";
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
2026-10-08 12:14:01 +00:00
import { FormActions } 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 { Textarea } from "@repo/shadcn-ui/components/textarea";
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 { CompanyDTO, WorkProfileDTO } from "../../common";
import {
CERTIFICATE_STATE_TRANSITIONS,
type CertificateConfigurationDTO,
type CertificateConfigurationInput,
CertificateConfigurationInputSchema,
} from "../../common/certificate-configurations";
import { profileApi, profileKeys } from "../profiles/work-profile-api";
import { certificateApi, certificateKeys } from "./certificate-api";
import { CertificateFeedback } from "./certificate-feedback";
2026-10-08 12:14:01 +00:00
import { CertificateQuickEditor } from "./certificate-quick-editor";
export function CertificateConfigurationForm({
company,
csrfToken,
configuration,
initialProfileId = "",
onSaved,
}: {
company: CompanyDTO;
csrfToken: string;
configuration?: CertificateConfigurationDTO | undefined;
initialProfileId?: string;
onSaved: () => void;
}) {
const { t } = useTranslation("companies");
const prefix = useId();
const client = useQueryClient();
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const [quick, setQuick] = useState<"profile" | "certificate">();
const profiles = useQuery({
queryKey: profileKeys.company(company.id),
queryFn: () => profileApi.get(company.id),
});
const resources = useQuery({
queryKey: inventoryKeys.company(company.id),
queryFn: () => inventoryApi.get(company.id),
});
const catalog = useQuery({
queryKey: catalogKeys.scope(company.id),
queryFn: () => catalogApi.get(company.id),
});
const form = useForm<CertificateConfigurationInput>({
resolver: zodResolver(CertificateConfigurationInputSchema),
defaultValues: certificateDefaults(configuration, initialProfileId),
});
const values = form.watch();
const ready = [profiles.data, resources.data, catalog.data].every(Boolean);
const fixed = Boolean(configuration || initialProfileId);
const states = certificateStates(configuration);
const mutation = useMutation({
mutationFn: (input: CertificateConfigurationInput) =>
certificateApi.save(company.id, input, csrfToken, configuration?.id),
onSuccess: async () => {
await client.invalidateQueries({ queryKey: certificateKeys.company(company.id) });
if (mounted.current) {
onSaved();
}
},
});
return (
<>
<form className="space-y-5" onSubmit={form.handleSubmit((input) => mutation.mutate(input))}>
<fieldset className="space-y-4" disabled={mutation.isPending}>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`${prefix}-profile`}>{t("mail.profile")}</Label>
<SelectWithAction
action={
fixed
? undefined
: { label: t("profiles.new"), onClick: () => setQuick("profile") }
}
disabled={fixed || !ready}
id={`${prefix}-profile`}
label={t("mail.profile")}
onValueChange={(id) => form.setValue("profileId", id)}
options={certificateProfiles(values.profileId, profiles.data?.profiles)}
placeholder={t("mail.choose")}
required
value={values.profileId}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-resource`}>{t("certificates.resource")}</Label>
<SelectWithAction
action={
configuration
? undefined
: {
label: t("certificates.newResource"),
onClick: () => setQuick("certificate"),
}
}
disabled={!!configuration || !ready}
id={`${prefix}-resource`}
label={t("certificates.resource")}
onValueChange={(id) => form.setValue("resourceId", id)}
options={certificateOptions(
values.resourceId,
resources.data?.resources,
catalog.data?.items
)}
placeholder={t("mail.choose")}
required
value={values.resourceId}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-state`}>{t("mail.state")}</Label>
<SelectWithAction
id={`${prefix}-state`}
label={t("mail.state")}
onValueChange={(value) => {
const parsed = CertificateConfigurationInputSchema.shape.state.safeParse(value);
if (parsed.success) {
form.setValue("state", parsed.data);
}
}}
options={states.map((value) => ({ value, label: t(`mail.${value}`) }))}
placeholder={t("mail.choose")}
required
value={values.state}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-location`}>{t("certificates.location")}</Label>
<Input
id={`${prefix}-location`}
maxLength={255}
onChange={(event) => form.setValue("location", event.target.value || null)}
value={values.location ?? ""}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${prefix}-notes`}>{t("mail.notes")}</Label>
<Textarea
id={`${prefix}-notes`}
maxLength={4000}
onChange={(event) => form.setValue("notes", event.target.value || null)}
rows={3}
value={values.notes ?? ""}
/>
</div>
</fieldset>
{configuration && (
<p className="text-sm text-muted-foreground">{t("certificates.fixed")}</p>
)}
{Object.keys(form.formState.errors).length > 0 && (
<p className="text-sm text-destructive" role="alert">
{t("certificates.errors.INVALID_REQUEST")}
</p>
)}
<CertificateFeedback
error={mutation.error ?? profiles.error ?? resources.error ?? catalog.error}
/>
<ContextHelp
description={t("certificates.description")}
examples={t("certificates.examples")}
examplesLabel={t("mail.examplesLabel")}
guidance={t("certificates.guidance")}
scopeNote={t("certificates.scope")}
title={t("certificates.help")}
/>
<FormActions>
<Button disabled={!ready || mutation.isPending} type="submit">
{t(mutation.isPending ? "saving" : "save")}
</Button>
</FormActions>
</form>
{quick && (
<CertificateQuickEditor
company={company}
csrfToken={csrfToken}
kind={quick}
onClose={() => setQuick(undefined)}
onSaved={(field, id) => {
if (mounted.current) {
form.setValue(field, id);
setQuick(undefined);
}
}}
/>
)}
</>
);
}
function certificateOptions(
selected: string,
resources: readonly ResourceDTO[] = [],
items: readonly CatalogItemDTO[] = []
) {
const templates = new Set(
items
.filter(
(item) =>
item.values.kind === "template" &&
(item.builtinKey === "template:certificate" || item.values.usage === "certificate")
)
.map((item) => item.id)
);
return resources
.filter(
(item) =>
templates.has(item.values.templateId) && (item.values.active || item.id === selected)
)
.map((item) => ({ value: item.id, label: item.values.name }));
}
function certificateDefaults(
configuration: CertificateConfigurationDTO | undefined,
profileId: string
): CertificateConfigurationInput {
return (
configuration?.values ?? {
profileId,
resourceId: "",
location: null,
state: "pending",
notes: null,
}
);
}
2026-10-08 12:14:01 +00:00
function certificateStates(
configuration: CertificateConfigurationDTO | undefined
): readonly CertificateConfigurationInput["state"][] {
return configuration
? CERTIFICATE_STATE_TRANSITIONS[configuration.values.state]
: ["pending", "configured"];
}
2026-10-08 12:14:01 +00:00
function certificateProfiles(selected: string, profiles: readonly WorkProfileDTO[] = []) {
return profiles
.filter((item) => item.values.active || item.id === selected)
.map((item) => ({ value: item.id, label: item.values.name }));
}