feat: add governed assisted form intake
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user