feat: add governed assisted form intake

This commit is contained in:
2026-08-19 02:45:53 +02:00
parent eea3db3d2e
commit d83bb92ec8
18 changed files with 1895 additions and 39 deletions
@@ -0,0 +1,216 @@
import { Play } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import {
Button,
Dialog,
DialogForm,
DialogSection,
DismissibleAlert,
FormField,
FormGrid,
LoadingIndicator,
ToggleSwitch,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
listAssistedIntakeProfiles,
startAssistedFormIntake,
type AssistedIntakeContext,
type FormIntakeProfile
} from "../../api/formsRuntime";
type AssistedIntakeDialogProps = {
open: boolean;
settings: PlatformRouteContext["settings"];
language: string;
onStarted: (instanceId: string) => void;
onClose: () => void;
};
export default function AssistedIntakeDialog({
open,
settings,
language,
onStarted,
onClose
}: AssistedIntakeDialogProps) {
const [profiles, setProfiles] = useState<FormIntakeProfile[]>([]);
const [profileId, setProfileId] = useState("");
const [channel, setChannel] = useState<AssistedIntakeContext["channel"]>("counter");
const [affectedPartyRef, setAffectedPartyRef] = useState("");
const [representedPartyRef, setRepresentedPartyRef] = useState("");
const [authorityBasis, setAuthorityBasis] = useState("self");
const [purpose, setPurpose] = useState("");
const [legalBasisRef, setLegalBasisRef] = useState("");
const [consentBasis, setConsentBasis] = useState("");
const [noticeGiven, setNoticeGiven] = useState(false);
const [responsibleFunctionRef, setResponsibleFunctionRef] = useState("");
const [accessibilityNeeds, setAccessibilityNeeds] = useState("");
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const load = useCallback(async (signal?: AbortSignal) => {
setLoading(true);
setError("");
try {
const result = await listAssistedIntakeProfiles(settings, signal);
setProfiles(result.profiles);
setProfileId((current) => current || result.profiles[0]?.profile_id || "");
} finally {
setLoading(false);
}
}, [settings]);
useEffect(() => {
if (!open) return undefined;
const controller = new AbortController();
void load(controller.signal).catch((reason) => {
if ((reason as Error).name !== "AbortError") {
setError(reason instanceof Error ? reason.message : "Assisted intake profiles could not be loaded.");
}
});
return () => controller.abort();
}, [load, open]);
async function start() {
if (!valid()) return;
setBusy(true);
setError("");
try {
const result = await startAssistedFormIntake(settings, {
profileId,
channel,
affectedPartyRef: affectedPartyRef.trim(),
representedPartyRef: representedPartyRef.trim() || undefined,
authorityBasis,
purpose: purpose.trim(),
legalBasisRef: legalBasisRef.trim() || undefined,
consentBasis: consentBasis.trim() || undefined,
noticeGiven,
responsibleFunctionRef: responsibleFunctionRef.trim(),
language: language || "de",
accessibilityNeeds: accessibilityNeeds.split(/[,\n]/).map((item) => item.trim()).filter(Boolean)
});
if (!result.instance) throw new Error("The assisted session was created without a Form instance.");
onStarted(result.instance.instance_id);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "The assisted intake could not be started.");
} finally {
setBusy(false);
}
}
function valid() {
return Boolean(
profileId
&& affectedPartyRef.trim()
&& authorityBasis.trim()
&& purpose.trim()
&& responsibleFunctionRef.trim()
);
}
return (
<Dialog
open={open}
title="Start assisted intake"
description="Capture who is acting, for whom, through which channel, and for what purpose before entering Form values."
size="large"
closeDisabled={busy}
onClose={onClose}
helpContextId="forms_runtime.assisted-intake"
helpTopicId="forms_runtime.submissions"
footer={
<>
<Button onClick={onClose} disabled={busy}>Cancel</Button>
<Button variant="primary" onClick={() => void start()} disabled={busy || loading || !valid()}>
<Play size={16} aria-hidden="true" />
Start session
</Button>
</>
}>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{loading && <LoadingIndicator label="Loading assisted intake profiles" />}
{!loading && profiles.length === 0 &&
<DismissibleAlert tone="info">
No assisted intake profile is enabled. Ask a Forms Runtime administrator to add one for the published Form.
</DismissibleAlert>
}
{!loading && profiles.length > 0 &&
<DialogForm onSubmit={(event) => { event.preventDefault(); void start(); }}>
<DialogSection>
<FormGrid columns={2} gap="small" collapseAt="workspace">
<FormField label="Published Form">
<select value={profileId} onChange={(event) => setProfileId(event.target.value)} disabled={busy}>
{profiles.map((profile) =>
<option key={profile.profile_id} value={profile.profile_id}>
{profileTitle(profile)} · revision {profile.definition_ref.version}
</option>
)}
</select>
</FormField>
<FormField label="Intake channel">
<select value={channel} onChange={(event) => setChannel(event.target.value as AssistedIntakeContext["channel"])} disabled={busy}>
<option value="counter">Service counter</option>
<option value="telephone">Telephone</option>
<option value="paper">Paper</option>
<option value="email">Email</option>
<option value="mobile">Mobile service</option>
<option value="representative">Representative</option>
<option value="offline_import">Offline import</option>
</select>
</FormField>
<FormField label="Affected party reference" help="Use the governed person or organization reference; do not enter a display name only.">
<input value={affectedPartyRef} onChange={(event) => setAffectedPartyRef(event.target.value)} disabled={busy} required />
</FormField>
<FormField label="Represented party reference" help="Optional when the affected party is acting directly.">
<input value={representedPartyRef} onChange={(event) => setRepresentedPartyRef(event.target.value)} disabled={busy} />
</FormField>
<FormField label="Authority basis">
<select value={authorityBasis} onChange={(event) => setAuthorityBasis(event.target.value)} disabled={busy}>
<option value="self">Acting for self</option>
<option value="documented_representation">Documented representation</option>
<option value="legal_guardianship">Legal guardianship</option>
<option value="statutory_authority">Statutory authority</option>
</select>
</FormField>
<FormField label="Responsible function reference">
<input value={responsibleFunctionRef} onChange={(event) => setResponsibleFunctionRef(event.target.value)} disabled={busy} required />
</FormField>
<FormField label="Purpose">
<input value={purpose} onChange={(event) => setPurpose(event.target.value)} disabled={busy} required />
</FormField>
<FormField label="Legal basis reference">
<input value={legalBasisRef} onChange={(event) => setLegalBasisRef(event.target.value)} disabled={busy} />
</FormField>
<FormField label="Consent basis">
<input value={consentBasis} onChange={(event) => setConsentBasis(event.target.value)} disabled={busy} />
</FormField>
<FormField label="Accessibility or communication support" help="Separate multiple needs with commas.">
<input value={accessibilityNeeds} onChange={(event) => setAccessibilityNeeds(event.target.value)} disabled={busy} />
</FormField>
</FormGrid>
</DialogSection>
<DialogSection variant="inset">
<ToggleSwitch
label="Privacy and procedural notice was provided"
checked={noticeGiven}
onChange={setNoticeGiven}
disabled={busy}
help="Record the fact of notice here; retain any separately required evidence through its owning module."
/>
</DialogSection>
</DialogForm>
}
</Dialog>
);
}
function profileTitle(profile: FormIntakeProfile): string {
const title = profile.metadata.definition_title;
return typeof title === "string" && title.trim()
? title
: profile.definition_ref.label ?? profile.definition_ref.object_id;
}