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