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
+137 -3
View File
@@ -40,7 +40,7 @@ export type FormIntakeProfile = {
profile_id: string;
public_id: string;
definition_ref: InstitutionalReference;
mode: "anonymous" | "invitation";
mode: "anonymous" | "invitation" | "assisted";
enabled: boolean;
revision: number;
draft_ttl_seconds: number;
@@ -51,7 +51,7 @@ export type FormIntakeProfile = {
export type PublicIntakeResult = {
session_id: string;
mode: "anonymous" | "invitation";
mode: "anonymous" | "invitation" | "assisted";
status: string;
expires_at: string;
instance?: FormInstance | null;
@@ -59,6 +59,43 @@ export type PublicIntakeResult = {
replayed: boolean;
};
export type AssistedIntakeContext = {
session_id: string;
profile_id: string;
mode: "assisted";
channel: "counter" | "telephone" | "paper" | "email" | "mobile" | "representative" | "offline_import";
affected_party_ref: string;
represented_party_ref?: string | null;
authority_basis: string;
purpose: string;
legal_basis_ref?: string | null;
consent_basis?: string | null;
notice_given: boolean;
responsible_function_ref: string;
language: string;
accessibility_needs: string[];
field_sources: Record<string, {
source: "person_statement" | "representative_statement" | "document" | "system" | "derived";
confidence: "stated" | "verified" | "uncertain";
declared_by_ref?: string | null;
}>;
operator: Record<string, unknown>;
};
export type AssistedConfirmation = {
confirmation_id: string;
instance_id: string;
instance_revision: number;
outcome: "confirmed" | "corrected" | "confirmation_unavailable";
method: "spoken_readback" | "written_preview" | "accessible_copy" | "unavailable";
confirmed_by_ref: string;
operator_actor_id: string;
confirmed_at: string;
payload_sha256: string;
correction_note?: string | null;
metadata: Record<string, unknown>;
};
export type FormCondition =
| { kind: "predicate"; field_key: string; operator: string; value?: unknown }
| { kind: "all" | "any" | "not"; conditions: FormCondition[] };
@@ -139,6 +176,7 @@ export type FormInstance = {
change_reason: string;
created_by: string;
changed_by: string;
metadata: Record<string, unknown>;
replayed: boolean;
};
@@ -349,7 +387,7 @@ export function listFormIntakeDefinitions(
export function createFormIntakeProfile(
settings: ApiSettings,
definitionRef: InstitutionalReference,
mode: "anonymous" | "invitation",
mode: "anonymous" | "invitation" | "assisted",
options: {
draftTtlSeconds?: number;
invitationTtlSeconds?: number;
@@ -369,6 +407,102 @@ export function createFormIntakeProfile(
});
}
export function listAssistedIntakeProfiles(
settings: ApiSettings,
signal?: AbortSignal
): Promise<{ profiles: FormIntakeProfile[] }> {
return apiFetch(settings, "/api/v1/forms-runtime/assisted-intake/profiles", { signal });
}
export function startAssistedFormIntake(
settings: ApiSettings,
options: {
profileId: string;
channel: AssistedIntakeContext["channel"];
affectedPartyRef: string;
representedPartyRef?: string;
authorityBasis: string;
purpose: string;
legalBasisRef?: string;
consentBasis?: string;
noticeGiven: boolean;
responsibleFunctionRef: string;
language: string;
accessibilityNeeds: string[];
}
): Promise<PublicIntakeResult> {
return apiFetch(settings, "/api/v1/forms-runtime/assisted-intake/start", {
method: "POST",
body: JSON.stringify({
profile_id: options.profileId,
values: {},
channel: options.channel,
affected_party_ref: options.affectedPartyRef,
represented_party_ref: options.representedPartyRef?.trim() || null,
authority_basis: options.authorityBasis,
purpose: options.purpose,
legal_basis_ref: options.legalBasisRef?.trim() || null,
consent_basis: options.consentBasis?.trim() || null,
notice_given: options.noticeGiven,
responsible_function_ref: options.responsibleFunctionRef,
language: options.language,
accessibility_needs: options.accessibilityNeeds,
field_sources: {},
idempotency_key: crypto.randomUUID(),
recorded_at: new Date().toISOString()
})
});
}
export function listAssistedConfirmations(
settings: ApiSettings,
instanceId: string,
signal?: AbortSignal
): Promise<{ confirmations: AssistedConfirmation[] }> {
return apiFetch(
settings,
`/api/v1/forms-runtime/instances/${encodeURIComponent(instanceId)}/assisted-confirmations`,
{ signal }
);
}
export function recordAssistedConfirmation(
settings: ApiSettings,
instance: FormInstance,
values: Record<string, unknown>,
attachmentRefs: EvidenceReference[],
signatureRefs: EvidenceReference[],
options: {
outcome: AssistedConfirmation["outcome"];
method: AssistedConfirmation["method"];
confirmedByRef: string;
fieldSources: AssistedIntakeContext["field_sources"];
correctionNote?: string;
}
): Promise<AssistedConfirmation> {
return apiFetch(
settings,
`/api/v1/forms-runtime/instances/${encodeURIComponent(instance.instance_id)}/assisted-confirmations`,
{
method: "POST",
body: JSON.stringify({
expected_revision: instance.revision,
values,
attachment_refs: attachmentRefs,
signature_refs: signatureRefs,
outcome: options.outcome,
method: options.method,
confirmed_by_ref: options.confirmedByRef,
confirmed_at: new Date().toISOString(),
idempotency_key: crypto.randomUUID(),
field_sources: options.fieldSources,
correction_note: options.correctionNote?.trim() || null,
metadata: {}
})
}
);
}
export function setFormIntakeProfileEnabled(
settings: ApiSettings,
profile: FormIntakeProfile,
@@ -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;
}
+215 -6
View File
@@ -1,13 +1,18 @@
import { Archive, ArrowLeft, BadgeCheck, ExternalLink, RefreshCw, Save, Send, Trash2 } from "lucide-react";
import { Archive, ArrowLeft, BadgeCheck, ClipboardCheck, ExternalLink, RefreshCw, Save, Send, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useParams } from "react-router";
import { ActionToolbar,
ActionBlockerHint,
Button,
ConfirmDialog,
DescriptionItem,
DescriptionList,
Dialog,
DocumentationHelpLink,
DismissibleAlert,
FileDropZone,
FormField as CoreFormField,
FormGrid,
LoadingIndicator,
PageScrollViewport,
StatusBadge,
@@ -27,6 +32,8 @@ import {
getFormInstanceEvents,
getFormInstanceHistory,
listFormHandoffs,
listAssistedConfirmations,
recordAssistedConfirmation,
startFormHandoff,
actOnFormHandoff,
acknowledgeFormInstance,
@@ -36,6 +43,8 @@ import {
submitFormInstance,
uploadFormEvidence,
type EvidenceReference,
type AssistedConfirmation,
type AssistedIntakeContext,
type FormDefinition,
type FormFieldDefinition,
type FormInstance,
@@ -75,23 +84,37 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
const [confirmingSubmit, setConfirmingSubmit] = useState(false);
const [confirmingHandoff, setConfirmingHandoff] = useState(false);
const [confirmingAcknowledgement, setConfirmingAcknowledgement] = useState(false);
const [confirmingAssisted, setConfirmingAssisted] = useState(false);
const [assistedConfirmations, setAssistedConfirmations] = useState<AssistedConfirmation[]>([]);
const [assistedOutcome, setAssistedOutcome] = useState<AssistedConfirmation["outcome"]>("confirmed");
const [assistedMethod, setAssistedMethod] = useState<AssistedConfirmation["method"]>("spoken_readback");
const [assistedConfirmedBy, setAssistedConfirmedBy] = useState("");
const [assistedSource, setAssistedSource] = useState<AssistedIntakeContext["field_sources"][string]["source"]>("person_statement");
const [assistedConfidence, setAssistedConfidence] = useState<AssistedIntakeContext["field_sources"][string]["confidence"]>("stated");
const [assistedNote, setAssistedNote] = useState("");
const load = useCallback(async (signal?: AbortSignal) => {
setLoading(true);
setError("");
try {
const nextInstance = await getFormInstance(settings, instanceId, signal);
const [nextDefinition, nextHistory, nextEvents, nextHandoffs] = await Promise.all([
const assisted = intakeContext(nextInstance);
const [nextDefinition, nextHistory, nextEvents, nextHandoffs, nextConfirmations] = await Promise.all([
getFormDefinition(settings, instanceId, signal),
getFormInstanceHistory(settings, instanceId, signal),
getFormInstanceEvents(settings, instanceId, signal),
listFormHandoffs(settings, instanceId, signal)
listFormHandoffs(settings, instanceId, signal),
assisted
? listAssistedConfirmations(settings, instanceId, signal)
: Promise.resolve({ confirmations: [] })
]);
setInstance(nextInstance);
setDefinition(nextDefinition);
setHistory(nextHistory.revisions);
setEvents(nextEvents.events);
setHandoffs(nextHandoffs.handoffs);
setAssistedConfirmations(nextConfirmations.confirmations);
setAssistedConfirmedBy((current) => current || assisted?.affected_party_ref || "");
setValues(nextInstance.values);
setAttachmentRefs(nextInstance.attachment_refs);
setSignatureRefs(nextInstance.signature_refs);
@@ -156,6 +179,13 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
const attachmentLimitReached = Boolean(
definition && attachmentRefs.length >= definition.max_attachments
);
const assisted = intakeContext(instance);
const assistedConfirmationCurrent = Boolean(
assisted
&& !changed
&& assistedConfirmations.some((item) => item.instance_revision === instance?.revision)
);
const assistedSaveBlocked = Boolean(assisted && changed);
async function save(): Promise<boolean> {
if (!instance || !canSave || !changed || !changeReason.trim()) return false;
@@ -200,6 +230,43 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
}
}
async function confirmAssistedReadback() {
if (!instance || !assisted || !assistedConfirmedBy.trim()) return;
setSaving(true);
setError("");
try {
const confirmation = await recordAssistedConfirmation(
settings,
instance,
values,
attachmentRefs,
signatureRefs,
{
outcome: assistedOutcome,
method: assistedMethod,
confirmedByRef: assistedConfirmedBy.trim(),
fieldSources: Object.fromEntries(
Object.entries(values)
.filter(([, value]) => value !== null && value !== undefined && value !== "")
.map(([key]) => [key, {
source: assistedSource,
confidence: assistedConfidence,
declared_by_ref: assistedConfirmedBy.trim()
}])
),
correctionNote: assistedNote
}
);
setAssistedConfirmations((current) => [confirmation, ...current]);
setConfirmingAssisted(false);
setConfirmingSubmit(true);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "The assisted read-back confirmation could not be recorded.");
} finally {
setSaving(false);
}
}
function changeValue(fieldKey: string, value: unknown) {
setValues((current) => {
const next = { ...current };
@@ -358,6 +425,33 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
<h1>{localized.title}</h1>
{localized.description && <p>{localized.description}</p>}
</header>
{assisted &&
<section className="form-assisted-context">
<div className="form-evidence-heading">
<h2>Assisted intake context</h2>
<StatusBadge
status={assistedConfirmationCurrent ? "active" : "inactive"}
label={assistedConfirmationCurrent ? "Read-back current" : "Read-back required"}
/>
</div>
<DescriptionList columns={3} collapseAt="workspace" density="compact">
<DescriptionItem term="Channel">{humanize(assisted.channel)}</DescriptionItem>
<DescriptionItem term="Affected party">{assisted.affected_party_ref}</DescriptionItem>
{assisted.represented_party_ref && <DescriptionItem term="Represented party">{assisted.represented_party_ref}</DescriptionItem>}
<DescriptionItem term="Authority">{humanize(assisted.authority_basis)}</DescriptionItem>
<DescriptionItem term="Responsible function">{assisted.responsible_function_ref}</DescriptionItem>
<DescriptionItem term="Purpose">{assisted.purpose}</DescriptionItem>
<DescriptionItem term="Notice">{assisted.notice_given ? "Provided" : "Not recorded"}</DescriptionItem>
<DescriptionItem term="Language">{assisted.language}</DescriptionItem>
{assisted.accessibility_needs.length > 0 &&
<DescriptionItem term="Communication support">{assisted.accessibility_needs.join(", ")}</DescriptionItem>
}
</DescriptionList>
<p className="form-assisted-context-note">
This context records provenance and does not bypass the published Form rules. Any saved correction invalidates the earlier read-back for submission.
</p>
</section>
}
{groups.map((group) =>
<section className="form-runtime-section" key={`${group.pageKey}:${group.sectionKey}`}>
{(groups.length > 1 || definition.pages?.length) && <header><h2>{localized.sectionTitles[group.sectionKey] ?? group.sectionTitle}</h2>{group.description && <p>{group.description}</p>}</header>}
@@ -453,9 +547,19 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
Save draft
</Button>
}
<Button variant="primary" onClick={() => setConfirmingSubmit(true)} disabled={saving || signatureBlocked} disabledReason={saving ? FORMS_RUNTIME_I18N.saving : signatureBlocked ? "Record the required acknowledgement before submitting." : undefined}>
<Send size={16} aria-hidden="true" />
Submit
<Button
variant="primary"
onClick={() => assisted && !assistedConfirmationCurrent ? setConfirmingAssisted(true) : setConfirmingSubmit(true)}
disabled={saving || signatureBlocked || assistedSaveBlocked}
disabledReason={saving
? FORMS_RUNTIME_I18N.saving
: signatureBlocked
? "Record and save the required acknowledgement before submitting."
: assistedSaveBlocked
? "Save assisted corrections and evidence before read-back."
: undefined}>
{assisted && !assistedConfirmationCurrent ? <ClipboardCheck size={16} aria-hidden="true" /> : <Send size={16} aria-hidden="true" />}
{assisted && !assistedConfirmationCurrent ? "Read back and submit" : "Submit"}
</Button>
</div>
}
@@ -529,6 +633,74 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
}
</PageScrollViewport>
</WorkspaceFrame>
<Dialog
open={confirmingAssisted}
title="Record assisted read-back"
description="Make the exact values and managed evidence available to the confirming party. Record corrections before continuing; the server binds this evidence to the current revision and submission payload."
closeDisabled={saving}
onClose={() => setConfirmingAssisted(false)}
helpContextId="forms_runtime.assisted-confirmation"
helpTopicId="forms_runtime.submissions"
footer={
<>
<Button onClick={() => setConfirmingAssisted(false)} disabled={saving}>Cancel</Button>
<Button
variant="primary"
disabled={saving || !assistedConfirmedBy.trim() || (assistedOutcome !== "confirmed" && !assistedNote.trim())}
onClick={() => void confirmAssistedReadback()}>
<ClipboardCheck size={16} aria-hidden="true" />
Record and continue
</Button>
</>
}>
<FormGrid columns={2} gap="small" collapseAt="workspace">
<CoreFormField label="Outcome">
<select
value={assistedOutcome}
disabled={saving}
onChange={(event) => {
const outcome = event.target.value as AssistedConfirmation["outcome"];
setAssistedOutcome(outcome);
if (outcome === "confirmation_unavailable") setAssistedMethod("unavailable");
else if (assistedMethod === "unavailable") setAssistedMethod("spoken_readback");
}}>
<option value="confirmed">Confirmed without correction</option>
<option value="corrected">Corrected and confirmed</option>
<option value="confirmation_unavailable">Confirmation unavailable</option>
</select>
</CoreFormField>
<CoreFormField label="Confirmation method">
<select value={assistedMethod} onChange={(event) => setAssistedMethod(event.target.value as AssistedConfirmation["method"])} disabled={saving || assistedOutcome === "confirmation_unavailable"}>
<option value="spoken_readback">Spoken read-back</option>
<option value="written_preview">Written preview</option>
<option value="accessible_copy">Accessible copy</option>
{assistedOutcome === "confirmation_unavailable" && <option value="unavailable">Unavailable</option>}
</select>
</CoreFormField>
<CoreFormField label="Confirming party reference">
<input value={assistedConfirmedBy} onChange={(event) => setAssistedConfirmedBy(event.target.value)} disabled={saving} required />
</CoreFormField>
<CoreFormField label="Value source" help="Applied to each populated field in this first assisted-intake slice.">
<select value={assistedSource} onChange={(event) => setAssistedSource(event.target.value as typeof assistedSource)} disabled={saving}>
<option value="person_statement">Person statement</option>
<option value="representative_statement">Representative statement</option>
<option value="document">Document</option>
<option value="system">Existing system</option>
<option value="derived">Derived by operator</option>
</select>
</CoreFormField>
<CoreFormField label="Source confidence">
<select value={assistedConfidence} onChange={(event) => setAssistedConfidence(event.target.value as typeof assistedConfidence)} disabled={saving}>
<option value="stated">Stated</option>
<option value="verified">Verified</option>
<option value="uncertain">Uncertain</option>
</select>
</CoreFormField>
<CoreFormField label={assistedOutcome === "confirmed" ? "Note (optional)" : "Correction or exception note"}>
<textarea rows={3} value={assistedNote} onChange={(event) => setAssistedNote(event.target.value)} disabled={saving} required={assistedOutcome !== "confirmed"} />
</CoreFormField>
</FormGrid>
</Dialog>
<ConfirmDialog
open={confirmingAcknowledgement}
title="Record acknowledgement"
@@ -729,6 +901,43 @@ function numericConstraint(value: unknown): number | undefined {
return typeof value === "number" ? value : undefined;
}
function intakeContext(instance: FormInstance | null): AssistedIntakeContext | null {
const value = instance?.metadata?.intake;
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const context = value as Record<string, unknown>;
if (context.mode !== "assisted"
|| typeof context.session_id !== "string"
|| typeof context.profile_id !== "string"
|| typeof context.channel !== "string"
|| typeof context.affected_party_ref !== "string"
|| typeof context.authority_basis !== "string"
|| typeof context.purpose !== "string"
|| typeof context.responsible_function_ref !== "string"
|| typeof context.language !== "string") return null;
return {
session_id: context.session_id,
profile_id: context.profile_id,
mode: "assisted",
channel: context.channel as AssistedIntakeContext["channel"],
affected_party_ref: context.affected_party_ref,
represented_party_ref: typeof context.represented_party_ref === "string" ? context.represented_party_ref : null,
authority_basis: context.authority_basis,
purpose: context.purpose,
legal_basis_ref: typeof context.legal_basis_ref === "string" ? context.legal_basis_ref : null,
consent_basis: typeof context.consent_basis === "string" ? context.consent_basis : null,
notice_given: context.notice_given === true,
responsible_function_ref: context.responsible_function_ref,
language: context.language,
accessibility_needs: Array.isArray(context.accessibility_needs) ? context.accessibility_needs.map(String) : [],
field_sources: typeof context.field_sources === "object" && context.field_sources && !Array.isArray(context.field_sources)
? context.field_sources as AssistedIntakeContext["field_sources"]
: {},
operator: typeof context.operator === "object" && context.operator && !Array.isArray(context.operator)
? context.operator as Record<string, unknown>
: {}
};
}
export function visibleGroups(definition: FormDefinition, values: Record<string, unknown>) {
const fields = new Map(definition.fields.map((field) => [field.key, field]));
const fieldVisible = (field: FormFieldDefinition) => !field.visibility_condition || evaluateCondition(field.visibility_condition, values);
+21 -1
View File
@@ -1,4 +1,4 @@
import { Link2, RefreshCw } from "lucide-react";
import { Link2, RefreshCw, UserRoundPlus } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { ActionToolbar,
Button,
@@ -21,6 +21,7 @@ import { ActionToolbar,
import { listFormInstances, type FormInstance } from "../../api/formsRuntime";
import { FORMS_RUNTIME_DOCUMENTATION, FORMS_RUNTIME_I18N } from "./interfacePatterns";
import IntakeProfilesDialog from "./IntakeProfilesDialog";
import AssistedIntakeDialog from "./AssistedIntakeDialog";
const OPEN_STATUSES = ["started", "draft", "submitted", "validated", "needs_review"];
@@ -34,7 +35,10 @@ export default function FormsRuntimePage({ settings, auth }: PlatformRouteContex
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [intakeOpen, setIntakeOpen] = useState(false);
const [assistedOpen, setAssistedOpen] = useState(false);
const canAdmin = hasScope(auth, "forms_runtime:workspace:admin");
const canAssist = hasScope(auth, "forms_runtime:submission:assist")
|| hasScope(auth, "forms_runtime:workspace:write");
const load = useCallback((signal?: AbortSignal) => {
setLoading(true);
@@ -74,6 +78,12 @@ export default function FormsRuntimePage({ settings, auth }: PlatformRouteContex
Public intake
</Button>
}
{canAssist &&
<Button onClick={() => setAssistedOpen(true)}>
<UserRoundPlus size={16} aria-hidden="true" />
Assisted intake
</Button>
}
<label>
<span>Status</span>
<select value={status} onChange={(event) => setStatus(event.target.value)}>
@@ -117,6 +127,16 @@ export default function FormsRuntimePage({ settings, auth }: PlatformRouteContex
</PageScrollViewport>
</WorkspaceFrame>
<IntakeProfilesDialog open={intakeOpen} settings={settings} onClose={() => setIntakeOpen(false)} />
<AssistedIntakeDialog
open={assistedOpen}
settings={settings}
language={language}
onClose={() => setAssistedOpen(false)}
onStarted={(instanceId) => {
setAssistedOpen(false);
navigate(`/forms-runtime/${encodeURIComponent(instanceId)}`);
}}
/>
</main>
);
}
@@ -30,7 +30,7 @@ export default function IntakeProfilesDialog({ open, settings, onClose }: Intake
const [profiles, setProfiles] = useState<FormIntakeProfile[]>([]);
const [definitions, setDefinitions] = useState<FormDefinition[]>([]);
const [definitionId, setDefinitionId] = useState("");
const [mode, setMode] = useState<"anonymous" | "invitation">("invitation");
const [mode, setMode] = useState<FormIntakeProfile["mode"]>("invitation");
const [draftDays, setDraftDays] = useState(30);
const [invitationDays, setInvitationDays] = useState(14);
const [rateLimit, setRateLimit] = useState(60);
@@ -85,7 +85,7 @@ export default function IntakeProfilesDialog({ open, settings, onClose }: Intake
rateLimitPerMinute: rateLimit
});
await load();
setNotice("The public intake profile was created.");
setNotice("The Form intake profile was created.");
} catch (reason) {
setError(reason instanceof Error ? reason.message : "The public intake profile could not be created.");
} finally {
@@ -138,7 +138,7 @@ export default function IntakeProfilesDialog({ open, settings, onClose }: Intake
return (
<Dialog
open={open}
title="Public Form intake"
title="Form intake profiles"
className="form-intake-dialog"
closeDisabled={Boolean(busyKey)}
onClose={onClose}
@@ -165,9 +165,10 @@ export default function IntakeProfilesDialog({ open, settings, onClose }: Intake
</label>
<label>
<span>Access mode</span>
<select value={mode} onChange={(event) => setMode(event.target.value as "anonymous" | "invitation")} disabled={Boolean(busyKey)}>
<select value={mode} onChange={(event) => setMode(event.target.value as FormIntakeProfile["mode"])} disabled={Boolean(busyKey)}>
<option value="invitation">Invitation link</option>
<option value="anonymous">Open anonymous link</option>
<option value="assisted">Authenticated assisted session</option>
</select>
</label>
<label>
@@ -176,11 +177,11 @@ export default function IntakeProfilesDialog({ open, settings, onClose }: Intake
</label>
<label>
<span>Invitation validity (days)</span>
<input type="number" min={1} max={90} value={invitationDays} onChange={(event) => setInvitationDays(Number(event.target.value))} disabled={Boolean(busyKey) || mode === "anonymous"} />
<input type="number" min={1} max={90} value={invitationDays} onChange={(event) => setInvitationDays(Number(event.target.value))} disabled={Boolean(busyKey) || mode !== "invitation"} />
</label>
<label>
<span>Starts per minute</span>
<input type="number" min={1} max={10_000} value={rateLimit} onChange={(event) => setRateLimit(Number(event.target.value))} disabled={Boolean(busyKey)} />
<input type="number" min={1} max={10_000} value={rateLimit} onChange={(event) => setRateLimit(Number(event.target.value))} disabled={Boolean(busyKey) || mode === "assisted"} />
</label>
<Button
variant="primary"
@@ -194,7 +195,7 @@ export default function IntakeProfilesDialog({ open, settings, onClose }: Intake
</section>
<section className="form-intake-profiles">
<h3>Configured profiles</h3>
{profiles.length === 0 && <p className="form-intake-empty">No public intake profile has been configured.</p>}
{profiles.length === 0 && <p className="form-intake-empty">No Form intake profile has been configured.</p>}
{profiles.map((profile) => {
const definition = definitionsByKey.get(referenceKey(profile));
const link = profile.mode === "anonymous"
@@ -205,7 +206,7 @@ export default function IntakeProfilesDialog({ open, settings, onClose }: Intake
<div className="form-intake-profile-row" key={profile.profile_id}>
<span className="form-intake-profile-main">
<strong>{definition?.title ?? profile.definition_ref.label ?? profile.definition_ref.object_id}</strong>
<small>Revision {profile.definition_ref.version} · {profile.mode === "anonymous" ? "Anonymous link" : "Invitation links"}</small>
<small>Revision {profile.definition_ref.version} · {modeLabel(profile.mode)}</small>
</span>
<StatusBadge status={profile.enabled ? "active" : "inactive"} label={profile.enabled ? "Active" : "Inactive"} />
<ToggleSwitch label="Profile active" checked={profile.enabled} disabled={Boolean(busyKey)} onChange={(enabled) => void setEnabled(profile, enabled)} />
@@ -243,6 +244,12 @@ function referenceKey(profile: FormIntakeProfile): string {
return `${profile.definition_ref.object_id}:${profile.definition_ref.version ?? ""}`;
}
function modeLabel(mode: FormIntakeProfile["mode"]): string {
if (mode === "anonymous") return "Anonymous link";
if (mode === "invitation") return "Invitation links";
return "Authenticated assisted sessions";
}
function absolutePath(path: string): string {
return typeof window === "undefined" ? path : new URL(path, window.location.origin).toString();
}
+17
View File
@@ -261,6 +261,23 @@
width: min(980px, calc(100vw - 40px));
}
.form-assisted-context {
display: flex;
flex-direction: column;
gap: 12px;
margin: 16px 0 4px;
padding: 14px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface-subtle);
}
.form-assisted-context-note {
margin: 0;
color: var(--text-soft);
font-size: 0.78rem;
}
.form-intake-create,
.form-intake-profiles {
display: flex;