Add governed public form intake
This commit is contained in:
@@ -17,6 +17,46 @@ export type EvidenceReference = {
|
||||
evidence_id: string;
|
||||
tenant_id: string;
|
||||
version?: string | null;
|
||||
checksum?: string | null;
|
||||
source_ref?: string | null;
|
||||
derived_from?: string[];
|
||||
responsible_actor_ref?: string | null;
|
||||
captured_at?: string | null;
|
||||
inspection_url?: string | null;
|
||||
};
|
||||
|
||||
export type FormEvidenceGrant = {
|
||||
provider_id: string;
|
||||
grant_id: string;
|
||||
upload_token?: string | null;
|
||||
upload_url: string;
|
||||
expires_at: string;
|
||||
max_size_bytes: number;
|
||||
allowed_content_types: string[];
|
||||
replayed: boolean;
|
||||
};
|
||||
|
||||
export type FormIntakeProfile = {
|
||||
profile_id: string;
|
||||
public_id: string;
|
||||
definition_ref: InstitutionalReference;
|
||||
mode: "anonymous" | "invitation";
|
||||
enabled: boolean;
|
||||
revision: number;
|
||||
draft_ttl_seconds: number;
|
||||
invitation_ttl_seconds: number;
|
||||
rate_limit_per_minute: number;
|
||||
metadata: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type PublicIntakeResult = {
|
||||
session_id: string;
|
||||
mode: "anonymous" | "invitation";
|
||||
status: string;
|
||||
expires_at: string;
|
||||
instance?: FormInstance | null;
|
||||
token?: string | null;
|
||||
replayed: boolean;
|
||||
};
|
||||
|
||||
export type FormCondition =
|
||||
@@ -183,15 +223,17 @@ export function saveFormDraft(
|
||||
settings: ApiSettings,
|
||||
instance: FormInstance,
|
||||
values: Record<string, unknown>,
|
||||
changeReason: string
|
||||
changeReason: string,
|
||||
attachmentRefs: EvidenceReference[] = instance.attachment_refs,
|
||||
signatureRefs: EvidenceReference[] = instance.signature_refs
|
||||
): Promise<FormInstance> {
|
||||
return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instance.instance_id)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
expected_revision: instance.revision,
|
||||
values,
|
||||
attachment_refs: instance.attachment_refs,
|
||||
signature_refs: instance.signature_refs,
|
||||
attachment_refs: attachmentRefs,
|
||||
signature_refs: signatureRefs,
|
||||
idempotency_key: crypto.randomUUID(),
|
||||
recorded_at: new Date().toISOString(),
|
||||
change_reason: changeReason
|
||||
@@ -202,14 +244,243 @@ export function saveFormDraft(
|
||||
export function submitFormInstance(
|
||||
settings: ApiSettings,
|
||||
instance: FormInstance,
|
||||
values: Record<string, unknown>
|
||||
values: Record<string, unknown>,
|
||||
attachmentRefs: EvidenceReference[] = instance.attachment_refs,
|
||||
signatureRefs: EvidenceReference[] = instance.signature_refs
|
||||
): Promise<FormInstance> {
|
||||
return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instance.instance_id)}/submit`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
expected_revision: instance.revision,
|
||||
values,
|
||||
attachment_refs: instance.attachment_refs,
|
||||
attachment_refs: attachmentRefs,
|
||||
signature_refs: signatureRefs,
|
||||
idempotency_key: crypto.randomUUID(),
|
||||
recorded_at: new Date().toISOString()
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export function createFormEvidenceGrant(
|
||||
settings: ApiSettings,
|
||||
instance: FormInstance,
|
||||
options: {
|
||||
idempotencyKey: string;
|
||||
providerId?: string;
|
||||
purpose?: string;
|
||||
allowedContentTypes?: string[];
|
||||
attachmentRefs?: EvidenceReference[];
|
||||
}
|
||||
): Promise<FormEvidenceGrant> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/forms-runtime/instances/${encodeURIComponent(instance.instance_id)}/evidence-grants`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
expected_revision: instance.revision,
|
||||
provider_id: options.providerId ?? "files",
|
||||
purpose: options.purpose ?? "Form attachment",
|
||||
idempotency_key: options.idempotencyKey,
|
||||
expires_at: new Date(Date.now() + 10 * 60_000).toISOString(),
|
||||
allowed_content_types: options.allowedContentTypes ?? [],
|
||||
attachment_refs: options.attachmentRefs ?? instance.attachment_refs
|
||||
})
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function uploadFormEvidence(
|
||||
settings: ApiSettings,
|
||||
grant: FormEvidenceGrant,
|
||||
file: File
|
||||
): Promise<{ grant_id: string; evidence: EvidenceReference }> {
|
||||
if (!grant.upload_token) {
|
||||
return Promise.reject(new Error("The upload grant secret is no longer available. Request a new grant."));
|
||||
}
|
||||
const body = new FormData();
|
||||
body.append("file", file);
|
||||
return apiFetch(settings, grant.upload_url, {
|
||||
method: "POST",
|
||||
headers: { "X-Form-Evidence-Token": grant.upload_token },
|
||||
body
|
||||
});
|
||||
}
|
||||
|
||||
export function acknowledgeFormInstance(
|
||||
settings: ApiSettings,
|
||||
instance: FormInstance,
|
||||
values: Record<string, unknown>,
|
||||
attachmentRefs: EvidenceReference[],
|
||||
options: { statementId: string; statementVersion: string; idempotencyKey: string }
|
||||
): Promise<{ evidence: EvidenceReference }> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/forms-runtime/instances/${encodeURIComponent(instance.instance_id)}/acknowledgements`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
expected_revision: instance.revision,
|
||||
statement_id: options.statementId,
|
||||
statement_version: options.statementVersion,
|
||||
values,
|
||||
attachment_refs: attachmentRefs,
|
||||
accepted_at: new Date().toISOString(),
|
||||
idempotency_key: options.idempotencyKey
|
||||
})
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function listFormIntakeProfiles(
|
||||
settings: ApiSettings,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ profiles: FormIntakeProfile[] }> {
|
||||
return apiFetch(settings, "/api/v1/forms-runtime/intake-profiles", { signal });
|
||||
}
|
||||
|
||||
export function listFormIntakeDefinitions(
|
||||
settings: ApiSettings,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ definitions: FormDefinition[] }> {
|
||||
return apiFetch(settings, "/api/v1/forms-runtime/intake-profile-definitions?limit=200", { signal });
|
||||
}
|
||||
|
||||
export function createFormIntakeProfile(
|
||||
settings: ApiSettings,
|
||||
definitionRef: InstitutionalReference,
|
||||
mode: "anonymous" | "invitation",
|
||||
options: {
|
||||
draftTtlSeconds?: number;
|
||||
invitationTtlSeconds?: number;
|
||||
rateLimitPerMinute?: number;
|
||||
} = {}
|
||||
): Promise<FormIntakeProfile> {
|
||||
return apiFetch(settings, "/api/v1/forms-runtime/intake-profiles", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
definition_ref: definitionRef,
|
||||
mode,
|
||||
draft_ttl_seconds: options.draftTtlSeconds ?? 2_592_000,
|
||||
invitation_ttl_seconds: options.invitationTtlSeconds ?? 1_209_600,
|
||||
rate_limit_per_minute: options.rateLimitPerMinute ?? 60,
|
||||
recorded_at: new Date().toISOString()
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export function setFormIntakeProfileEnabled(
|
||||
settings: ApiSettings,
|
||||
profile: FormIntakeProfile,
|
||||
enabled: boolean
|
||||
): Promise<FormIntakeProfile> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/forms-runtime/intake-profiles/${encodeURIComponent(profile.profile_id)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ expected_revision: profile.revision, enabled })
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function issueFormIntakeInvitation(
|
||||
settings: ApiSettings,
|
||||
profile: FormIntakeProfile,
|
||||
idempotencyKey: string
|
||||
): Promise<PublicIntakeResult> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/forms-runtime/intake-profiles/${encodeURIComponent(profile.profile_id)}/invitations`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
idempotency_key: idempotencyKey,
|
||||
recorded_at: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function startAnonymousFormIntake(
|
||||
settings: ApiSettings,
|
||||
publicId: string,
|
||||
idempotencyKey: string,
|
||||
recordedAt: string
|
||||
): Promise<PublicIntakeResult> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/forms-runtime/public/profiles/${encodeURIComponent(publicId)}/start`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
values: {},
|
||||
idempotency_key: idempotencyKey,
|
||||
recorded_at: recordedAt
|
||||
})
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function startInvitationFormIntake(
|
||||
settings: ApiSettings,
|
||||
token: string
|
||||
): Promise<PublicIntakeResult> {
|
||||
return apiFetch(settings, "/api/v1/forms-runtime/public/intake/start", {
|
||||
method: "POST",
|
||||
headers: { "X-Form-Intake-Token": token },
|
||||
body: JSON.stringify({ values: {}, recorded_at: new Date().toISOString() })
|
||||
});
|
||||
}
|
||||
|
||||
export function getPublicFormIntake(
|
||||
settings: ApiSettings,
|
||||
token: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ instance: FormInstance; definition: FormDefinition }> {
|
||||
return apiFetch(settings, "/api/v1/forms-runtime/public/intake", {
|
||||
headers: { "X-Form-Intake-Token": token },
|
||||
signal
|
||||
});
|
||||
}
|
||||
|
||||
export function savePublicFormDraft(
|
||||
settings: ApiSettings,
|
||||
token: string,
|
||||
instance: FormInstance,
|
||||
values: Record<string, unknown>,
|
||||
attachmentRefs: EvidenceReference[],
|
||||
changeReason: string
|
||||
): Promise<FormInstance> {
|
||||
return apiFetch(settings, "/api/v1/forms-runtime/public/intake", {
|
||||
method: "PATCH",
|
||||
headers: { "X-Form-Intake-Token": token },
|
||||
body: JSON.stringify({
|
||||
expected_revision: instance.revision,
|
||||
values,
|
||||
attachment_refs: attachmentRefs,
|
||||
signature_refs: instance.signature_refs,
|
||||
idempotency_key: crypto.randomUUID(),
|
||||
recorded_at: new Date().toISOString(),
|
||||
change_reason: changeReason
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export function submitPublicFormIntake(
|
||||
settings: ApiSettings,
|
||||
token: string,
|
||||
instance: FormInstance,
|
||||
values: Record<string, unknown>,
|
||||
attachmentRefs: EvidenceReference[]
|
||||
): Promise<FormInstance> {
|
||||
return apiFetch(settings, "/api/v1/forms-runtime/public/intake/submit", {
|
||||
method: "POST",
|
||||
headers: { "X-Form-Intake-Token": token },
|
||||
body: JSON.stringify({
|
||||
expected_revision: instance.revision,
|
||||
values,
|
||||
attachment_refs: attachmentRefs,
|
||||
signature_refs: instance.signature_refs,
|
||||
idempotency_key: crypto.randomUUID(),
|
||||
recorded_at: new Date().toISOString()
|
||||
@@ -217,6 +488,27 @@ export function submitFormInstance(
|
||||
});
|
||||
}
|
||||
|
||||
export function createPublicFormEvidenceGrant(
|
||||
settings: ApiSettings,
|
||||
token: string,
|
||||
instance: FormInstance,
|
||||
idempotencyKey: string,
|
||||
attachmentRefs: EvidenceReference[] = instance.attachment_refs
|
||||
): Promise<FormEvidenceGrant> {
|
||||
return apiFetch(settings, "/api/v1/forms-runtime/public/intake/evidence-grants", {
|
||||
method: "POST",
|
||||
headers: { "X-Form-Intake-Token": token },
|
||||
body: JSON.stringify({
|
||||
expected_revision: instance.revision,
|
||||
provider_id: "files",
|
||||
purpose: "Public Form attachment",
|
||||
idempotency_key: idempotencyKey,
|
||||
expires_at: new Date(Date.now() + 10 * 60_000).toISOString(),
|
||||
attachment_refs: attachmentRefs
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export function listFormHandoffs(
|
||||
settings: ApiSettings,
|
||||
instanceId: string,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ArrowLeft, ExternalLink, RefreshCw, Save, Send } from "lucide-react";
|
||||
import { Archive, ArrowLeft, BadgeCheck, ExternalLink, RefreshCw, Save, Send, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import {
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ConfirmDialog,
|
||||
DocumentationHelpLink,
|
||||
DismissibleAlert,
|
||||
FileDropZone,
|
||||
LoadingIndicator,
|
||||
PageScrollViewport,
|
||||
StatusBadge,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
hasScope,
|
||||
i18nMessage,
|
||||
useGuardedNavigate,
|
||||
usePlatformModuleInstalled,
|
||||
usePlatformLanguage,
|
||||
useUnsavedDraftGuard,
|
||||
type PlatformRouteContext
|
||||
@@ -26,9 +28,13 @@ import {
|
||||
listFormHandoffs,
|
||||
startFormHandoff,
|
||||
actOnFormHandoff,
|
||||
acknowledgeFormInstance,
|
||||
compensateFormHandoff,
|
||||
createFormEvidenceGrant,
|
||||
saveFormDraft,
|
||||
submitFormInstance,
|
||||
uploadFormEvidence,
|
||||
type EvidenceReference,
|
||||
type FormDefinition,
|
||||
type FormFieldDefinition,
|
||||
type FormInstance,
|
||||
@@ -47,12 +53,16 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
|
||||
const { instanceId = "" } = useParams();
|
||||
const navigate = useGuardedNavigate();
|
||||
const { language } = usePlatformLanguage();
|
||||
const recordsAvailable = usePlatformModuleInstalled("records");
|
||||
const [instance, setInstance] = useState<FormInstance | null>(null);
|
||||
const [definition, setDefinition] = useState<FormDefinition | null>(null);
|
||||
const [history, setHistory] = useState<FormInstance[]>([]);
|
||||
const [events, setEvents] = useState<FormInstanceEvent[]>([]);
|
||||
const [handoffs, setHandoffs] = useState<FormHandoff[]>([]);
|
||||
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||
const [attachmentRefs, setAttachmentRefs] = useState<EvidenceReference[]>([]);
|
||||
const [attachmentNames, setAttachmentNames] = useState<Record<string, string>>({});
|
||||
const [signatureRefs, setSignatureRefs] = useState<EvidenceReference[]>([]);
|
||||
const [changeReason, setChangeReason] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -63,6 +73,7 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
|
||||
const [compensating, setCompensating] = useState<FormHandoff | null>(null);
|
||||
const [confirmingSubmit, setConfirmingSubmit] = useState(false);
|
||||
const [confirmingHandoff, setConfirmingHandoff] = useState(false);
|
||||
const [confirmingAcknowledgement, setConfirmingAcknowledgement] = useState(false);
|
||||
|
||||
const load = useCallback(async (signal?: AbortSignal) => {
|
||||
setLoading(true);
|
||||
@@ -81,6 +92,8 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
|
||||
setEvents(nextEvents.events);
|
||||
setHandoffs(nextHandoffs.handoffs);
|
||||
setValues(nextInstance.values);
|
||||
setAttachmentRefs(nextInstance.attachment_refs);
|
||||
setSignatureRefs(nextInstance.signature_refs);
|
||||
setChangeReason("");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -112,8 +125,12 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
|
||||
const editable = Boolean(editableLifecycle && canEditPermission);
|
||||
const canSave = Boolean(instance?.status === "draft" && definition?.allow_drafts && canEditPermission);
|
||||
const changed = useMemo(
|
||||
() => Boolean(instance && JSON.stringify(values) !== JSON.stringify(instance.values)),
|
||||
[instance, values]
|
||||
() => Boolean(instance && (
|
||||
JSON.stringify(values) !== JSON.stringify(instance.values)
|
||||
|| JSON.stringify(attachmentRefs) !== JSON.stringify(instance.attachment_refs)
|
||||
|| JSON.stringify(signatureRefs) !== JSON.stringify(instance.signature_refs)
|
||||
)),
|
||||
[attachmentRefs, instance, signatureRefs, values]
|
||||
);
|
||||
const diagnostics = useMemo(() => {
|
||||
const grouped = new Map<string, ValidationResult[]>();
|
||||
@@ -133,13 +150,18 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
|
||||
);
|
||||
const mayHandoff = Boolean(instance && ["submitted", "validated", "needs_review", "accepted"].includes(instance.status) && instance.service_ref);
|
||||
const canHandoff = canWrite || canAdmin;
|
||||
const signatureRequired = definition?.signature_requirement === "required";
|
||||
const signatureBlocked = Boolean(signatureRequired && signatureRefs.length === 0);
|
||||
const attachmentLimitReached = Boolean(
|
||||
definition && attachmentRefs.length >= definition.max_attachments
|
||||
);
|
||||
|
||||
async function save(): Promise<boolean> {
|
||||
if (!instance || !canSave || !changed || !changeReason.trim()) return false;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
await saveFormDraft(settings, instance, values, changeReason.trim());
|
||||
await saveFormDraft(settings, instance, values, changeReason.trim(), attachmentRefs, signatureRefs);
|
||||
await load();
|
||||
return true;
|
||||
} catch (reason) {
|
||||
@@ -155,6 +177,8 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
|
||||
onSave: save,
|
||||
onDiscard: () => {
|
||||
setValues(instance?.values ?? {});
|
||||
setAttachmentRefs(instance?.attachment_refs ?? []);
|
||||
setSignatureRefs(instance?.signature_refs ?? []);
|
||||
setChangeReason("");
|
||||
},
|
||||
title: "i18n:govoplan-forms-runtime.unsaved_title",
|
||||
@@ -166,7 +190,7 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
await submitFormInstance(settings, instance, values);
|
||||
await submitFormInstance(settings, instance, values, attachmentRefs, signatureRefs);
|
||||
await load();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "The Form could not be submitted.");
|
||||
@@ -175,6 +199,73 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
|
||||
}
|
||||
}
|
||||
|
||||
function changeValue(fieldKey: string, value: unknown) {
|
||||
setValues((current) => {
|
||||
const next = { ...current };
|
||||
if (value === undefined || value === "") delete next[fieldKey];
|
||||
else next[fieldKey] = value;
|
||||
return next;
|
||||
});
|
||||
setSignatureRefs([]);
|
||||
}
|
||||
|
||||
async function uploadAttachments(files: File[]) {
|
||||
if (!instance || !definition) return;
|
||||
const available = Math.max(0, definition.max_attachments - attachmentRefs.length);
|
||||
const selected = files.slice(0, available);
|
||||
if (selected.length === 0) return;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
const uploaded: EvidenceReference[] = [];
|
||||
const names: Record<string, string> = {};
|
||||
for (const file of selected) {
|
||||
const grant = await createFormEvidenceGrant(settings, instance, {
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
purpose: "Form attachment",
|
||||
attachmentRefs: [...attachmentRefs, ...uploaded]
|
||||
});
|
||||
const result = await uploadFormEvidence(settings, grant, file);
|
||||
uploaded.push(result.evidence);
|
||||
names[result.evidence.evidence_id] = file.name;
|
||||
}
|
||||
setAttachmentRefs((current) => [...current, ...uploaded]);
|
||||
setAttachmentNames((current) => ({ ...current, ...names }));
|
||||
setSignatureRefs([]);
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "The attachment could not be uploaded.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function acknowledge() {
|
||||
if (!instance || !editable) return;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await acknowledgeFormInstance(
|
||||
settings,
|
||||
instance,
|
||||
values,
|
||||
attachmentRefs,
|
||||
{
|
||||
statementId: "forms_runtime.submission.correct_and_complete",
|
||||
statementVersion: "1",
|
||||
idempotencyKey: crypto.randomUUID()
|
||||
}
|
||||
);
|
||||
setSignatureRefs((current) => [
|
||||
...current.filter((item) => item.owner_module !== "forms_runtime"),
|
||||
result.evidence
|
||||
]);
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "The acknowledgement could not be recorded.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function startHandoff() {
|
||||
if (!instance || !mayHandoff) return;
|
||||
setHandoffBusy(true);
|
||||
@@ -227,6 +318,12 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
|
||||
</Button>
|
||||
{definition && <strong>{localized.title}</strong>}
|
||||
{instance && <StatusBadge status={editableLifecycle ? "active" : "inactive"} label={stateLabel(instance.status)} />}
|
||||
{instance && recordsAvailable && !editableLifecycle &&
|
||||
<Button onClick={() => navigate(recordFilingPath(instance))}>
|
||||
<Archive size={16} aria-hidden="true" />
|
||||
File in eAkte
|
||||
</Button>
|
||||
}
|
||||
<DocumentationHelpLink reference={FORMS_RUNTIME_DOCUMENTATION} />
|
||||
</div>
|
||||
<PageScrollViewport className="form-instance-viewport">
|
||||
@@ -277,22 +374,61 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
|
||||
value={values[field.key]}
|
||||
disabled={!editable || saving}
|
||||
diagnostics={diagnostics.get(field.key) ?? []}
|
||||
onChange={(value) => setValues((current) => {
|
||||
const next = { ...current };
|
||||
if (value === undefined || value === "") delete next[field.key];
|
||||
else next[field.key] = value;
|
||||
return next;
|
||||
})}
|
||||
onChange={(value) => changeValue(field.key, value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{(instance.attachment_refs.length > 0 || instance.signature_refs.length > 0) &&
|
||||
<div className="form-evidence-summary">
|
||||
<span>{instance.attachment_refs.length} attachments</span>
|
||||
<span>{instance.signature_refs.length} signatures</span>
|
||||
</div>
|
||||
{(definition.max_attachments > 0 || definition.signature_requirement !== "none") &&
|
||||
<section className="form-instance-evidence">
|
||||
<div className="form-evidence-heading">
|
||||
<h2>Evidence and acknowledgement</h2>
|
||||
<span>{attachmentRefs.length} attachments · {signatureRefs.length} acknowledgements</span>
|
||||
</div>
|
||||
{editable && definition.max_attachments > 0 &&
|
||||
<FileDropZone
|
||||
multiple
|
||||
busy={saving}
|
||||
disabled={attachmentLimitReached}
|
||||
note={`${attachmentRefs.length} of ${definition.max_attachments} attachments`}
|
||||
onFiles={uploadAttachments}
|
||||
/>
|
||||
}
|
||||
{attachmentRefs.length > 0 &&
|
||||
<div className="form-public-attachment-list">
|
||||
{attachmentRefs.map((reference) =>
|
||||
<div key={`${reference.evidence_id}:${reference.version ?? ""}`}>
|
||||
<span>{attachmentNames[reference.evidence_id] ?? `Managed attachment ${reference.evidence_id.slice(0, 8)}`}</span>
|
||||
{editable &&
|
||||
<Button
|
||||
variant="danger"
|
||||
aria-label="Remove attachment"
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
setAttachmentRefs((current) => current.filter((item) => item !== reference));
|
||||
setSignatureRefs([]);
|
||||
}}>
|
||||
<Trash2 size={15} aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
{editable && definition.signature_requirement !== "none" &&
|
||||
<div className="form-acknowledgement-row">
|
||||
<span>
|
||||
<strong>Authenticated acknowledgement</strong>
|
||||
<small>Records your account, the exact values, and the exact managed attachments. It is not a qualified electronic signature.</small>
|
||||
</span>
|
||||
<Button disabled={saving} onClick={() => setConfirmingAcknowledgement(true)}>
|
||||
<BadgeCheck size={16} aria-hidden="true" />
|
||||
{signatureRefs.some((item) => item.owner_module === "forms_runtime") ? "Renew acknowledgement" : "Acknowledge"}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
{editable &&
|
||||
<div className="form-instance-actions">
|
||||
@@ -316,7 +452,7 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
|
||||
Save draft
|
||||
</Button>
|
||||
}
|
||||
<Button variant="primary" onClick={() => setConfirmingSubmit(true)} disabled={saving} disabledReason={saving ? FORMS_RUNTIME_I18N.saving : undefined}>
|
||||
<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>
|
||||
@@ -392,6 +528,18 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
|
||||
}
|
||||
</PageScrollViewport>
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
open={confirmingAcknowledgement}
|
||||
title="Record acknowledgement"
|
||||
message="Confirm that the displayed values and managed attachments are correct and complete. This records a payload-bound authenticated acknowledgement; it is not a qualified electronic signature."
|
||||
confirmLabel="Acknowledge"
|
||||
busy={saving}
|
||||
onCancel={() => setConfirmingAcknowledgement(false)}
|
||||
onConfirm={() => {
|
||||
setConfirmingAcknowledgement(false);
|
||||
void acknowledge();
|
||||
}}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={confirmingSubmit}
|
||||
title="i18n:govoplan-forms-runtime.submit_title"
|
||||
@@ -430,7 +578,7 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
|
||||
);
|
||||
}
|
||||
|
||||
function FormField({
|
||||
export function FormField({
|
||||
field,
|
||||
value,
|
||||
disabled,
|
||||
@@ -580,7 +728,7 @@ function numericConstraint(value: unknown): number | undefined {
|
||||
return typeof value === "number" ? value : undefined;
|
||||
}
|
||||
|
||||
function visibleGroups(definition: FormDefinition, values: 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);
|
||||
if (!definition.pages?.length) {
|
||||
@@ -640,7 +788,7 @@ function comparable(left: unknown, right: unknown, compare: (left: number | stri
|
||||
return false;
|
||||
}
|
||||
|
||||
function localizeDefinition(definition: FormDefinition | null, language: string) {
|
||||
export function localizeDefinition(definition: FormDefinition | null, language: string) {
|
||||
const canonical = {
|
||||
title: definition?.title ?? "",
|
||||
description: definition?.description ?? null,
|
||||
@@ -676,6 +824,17 @@ function stateLabel(value: string): string {
|
||||
return `i18n:govoplan-forms-runtime.state_${value}`;
|
||||
}
|
||||
|
||||
function recordFilingPath(instance: FormInstance): string {
|
||||
const query = new URLSearchParams({
|
||||
sourceModule: "forms_runtime",
|
||||
resourceType: "form_submission_revision",
|
||||
resourceId: instance.instance_id,
|
||||
sourceRevision: String(instance.revision),
|
||||
sourceLabel: instance.receipt_id ? `Form submission ${instance.receipt_id}` : "Form submission"
|
||||
});
|
||||
return `/records?${query.toString()}`;
|
||||
}
|
||||
|
||||
function domainLabel(value: string): string {
|
||||
return `i18n:govoplan-forms-runtime.domain_${value}`;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { Link2, RefreshCw } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
LoadingIndicator,
|
||||
PageScrollViewport,
|
||||
StatusBadge,
|
||||
hasScope,
|
||||
i18nMessage,
|
||||
useGuardedNavigate,
|
||||
usePlatformLanguage,
|
||||
@@ -14,11 +15,12 @@ import {
|
||||
} from "@govoplan/core-webui";
|
||||
import { listFormInstances, type FormInstance } from "../../api/formsRuntime";
|
||||
import { FORMS_RUNTIME_DOCUMENTATION, FORMS_RUNTIME_I18N } from "./interfacePatterns";
|
||||
import IntakeProfilesDialog from "./IntakeProfilesDialog";
|
||||
|
||||
|
||||
const OPEN_STATUSES = ["started", "draft", "submitted", "validated", "needs_review"];
|
||||
|
||||
export default function FormsRuntimePage({ settings }: PlatformRouteContext) {
|
||||
export default function FormsRuntimePage({ settings, auth }: PlatformRouteContext) {
|
||||
const navigate = useGuardedNavigate();
|
||||
const { language } = usePlatformLanguage();
|
||||
const [items, setItems] = useState<FormInstance[]>([]);
|
||||
@@ -26,6 +28,8 @@ export default function FormsRuntimePage({ settings }: PlatformRouteContext) {
|
||||
const [status, setStatus] = useState("open");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [intakeOpen, setIntakeOpen] = useState(false);
|
||||
const canAdmin = hasScope(auth, "forms_runtime:workspace:admin");
|
||||
|
||||
const load = useCallback((signal?: AbortSignal) => {
|
||||
setLoading(true);
|
||||
@@ -59,6 +63,12 @@ export default function FormsRuntimePage({ settings }: PlatformRouteContext) {
|
||||
<RefreshCw size={16} aria-hidden="true" />
|
||||
Refresh
|
||||
</Button>
|
||||
{canAdmin &&
|
||||
<Button onClick={() => setIntakeOpen(true)}>
|
||||
<Link2 size={16} aria-hidden="true" />
|
||||
Public intake
|
||||
</Button>
|
||||
}
|
||||
<label>
|
||||
<span>Status</span>
|
||||
<select value={status} onChange={(event) => setStatus(event.target.value)}>
|
||||
@@ -107,6 +117,7 @@ export default function FormsRuntimePage({ settings }: PlatformRouteContext) {
|
||||
}
|
||||
</PageScrollViewport>
|
||||
</div>
|
||||
<IntakeProfilesDialog open={intakeOpen} settings={settings} onClose={() => setIntakeOpen(false)} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import { Copy, Link, Plus, Send } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
LoadingIndicator,
|
||||
StatusBadge,
|
||||
ToggleSwitch,
|
||||
type PlatformRouteContext
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
createFormIntakeProfile,
|
||||
issueFormIntakeInvitation,
|
||||
listFormIntakeDefinitions,
|
||||
listFormIntakeProfiles,
|
||||
setFormIntakeProfileEnabled,
|
||||
type FormDefinition,
|
||||
type FormIntakeProfile
|
||||
} from "../../api/formsRuntime";
|
||||
|
||||
|
||||
type IntakeProfilesDialogProps = {
|
||||
open: boolean;
|
||||
settings: PlatformRouteContext["settings"];
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function IntakeProfilesDialog({ open, settings, onClose }: IntakeProfilesDialogProps) {
|
||||
const [profiles, setProfiles] = useState<FormIntakeProfile[]>([]);
|
||||
const [definitions, setDefinitions] = useState<FormDefinition[]>([]);
|
||||
const [definitionId, setDefinitionId] = useState("");
|
||||
const [mode, setMode] = useState<"anonymous" | "invitation">("invitation");
|
||||
const [draftDays, setDraftDays] = useState(30);
|
||||
const [invitationDays, setInvitationDays] = useState(14);
|
||||
const [rateLimit, setRateLimit] = useState(60);
|
||||
const [invitationLinks, setInvitationLinks] = useState<Record<string, string>>({});
|
||||
const [busyKey, setBusyKey] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
|
||||
const definitionsByKey = useMemo(
|
||||
() => new Map(definitions.map((item) => [definitionKey(item), item])),
|
||||
[definitions]
|
||||
);
|
||||
|
||||
const load = useCallback(async (signal?: AbortSignal) => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [profileResult, definitionResult] = await Promise.all([
|
||||
listFormIntakeProfiles(settings, signal),
|
||||
listFormIntakeDefinitions(settings, signal)
|
||||
]);
|
||||
setProfiles(profileResult.profiles);
|
||||
setDefinitions(definitionResult.definitions);
|
||||
setDefinitionId((current) => current || definitionKey(definitionResult.definitions[0]));
|
||||
} 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 : "Public intake profiles could not be loaded.");
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [load, open]);
|
||||
|
||||
async function createProfile() {
|
||||
const definition = definitionsByKey.get(definitionId);
|
||||
if (!definition) return;
|
||||
setBusyKey("create");
|
||||
setError("");
|
||||
setNotice("");
|
||||
try {
|
||||
await createFormIntakeProfile(settings, definition.reference, mode, {
|
||||
draftTtlSeconds: Math.round(draftDays * 86_400),
|
||||
invitationTtlSeconds: Math.round(invitationDays * 86_400),
|
||||
rateLimitPerMinute: rateLimit
|
||||
});
|
||||
await load();
|
||||
setNotice("The public intake profile was created.");
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "The public intake profile could not be created.");
|
||||
} finally {
|
||||
setBusyKey("");
|
||||
}
|
||||
}
|
||||
|
||||
async function setEnabled(profile: FormIntakeProfile, enabled: boolean) {
|
||||
setBusyKey(`state:${profile.profile_id}`);
|
||||
setError("");
|
||||
try {
|
||||
const updated = await setFormIntakeProfileEnabled(settings, profile, enabled);
|
||||
setProfiles((current) => current.map((item) => item.profile_id === updated.profile_id ? updated : item));
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "The public intake profile could not be updated.");
|
||||
} finally {
|
||||
setBusyKey("");
|
||||
}
|
||||
}
|
||||
|
||||
async function issueInvitation(profile: FormIntakeProfile) {
|
||||
setBusyKey(`invite:${profile.profile_id}`);
|
||||
setError("");
|
||||
setNotice("");
|
||||
try {
|
||||
const result = await issueFormIntakeInvitation(settings, profile, crypto.randomUUID());
|
||||
if (!result.token) throw new Error("The invitation was recorded, but its one-time secret is no longer available.");
|
||||
setInvitationLinks((current) => ({
|
||||
...current,
|
||||
[profile.profile_id]: absolutePath(`/forms/intake/${encodeURIComponent(result.token!)}`)
|
||||
}));
|
||||
setNotice("The invitation was issued. Copy its link now; the token is not stored in readable form.");
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "The invitation could not be issued.");
|
||||
} finally {
|
||||
setBusyKey("");
|
||||
}
|
||||
}
|
||||
|
||||
async function copy(value: string) {
|
||||
if (!navigator.clipboard?.writeText) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setNotice("The intake link was copied.");
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "The intake link could not be copied.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
title="Public Form intake"
|
||||
className="form-intake-dialog"
|
||||
closeDisabled={Boolean(busyKey)}
|
||||
onClose={onClose}
|
||||
helpContextId="forms_runtime.public-intake"
|
||||
helpTopicId="forms_runtime.submissions"
|
||||
footer={<Button onClick={onClose} disabled={Boolean(busyKey)}>Close</Button>}>
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
{notice && <DismissibleAlert tone="success" resetKey={notice}>{notice}</DismissibleAlert>}
|
||||
{loading && <LoadingIndicator label="Loading public intake profiles" />}
|
||||
{!loading &&
|
||||
<>
|
||||
<section className="form-intake-create">
|
||||
<h3>Add intake profile</h3>
|
||||
<div className="form-intake-create-grid">
|
||||
<label>
|
||||
<span>Published Form</span>
|
||||
<select value={definitionId} onChange={(event) => setDefinitionId(event.target.value)} disabled={Boolean(busyKey)}>
|
||||
{definitions.map((item) =>
|
||||
<option key={definitionKey(item)} value={definitionKey(item)}>
|
||||
{item.title} (revision {item.reference.version})
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Access mode</span>
|
||||
<select value={mode} onChange={(event) => setMode(event.target.value as "anonymous" | "invitation")} disabled={Boolean(busyKey)}>
|
||||
<option value="invitation">Invitation link</option>
|
||||
<option value="anonymous">Open anonymous link</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Draft retention (days)</span>
|
||||
<input type="number" min={1} max={365} value={draftDays} onChange={(event) => setDraftDays(Number(event.target.value))} disabled={Boolean(busyKey)} />
|
||||
</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"} />
|
||||
</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)} />
|
||||
</label>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="form-intake-create-button"
|
||||
disabled={Boolean(busyKey) || !definitionId || !validSettings(draftDays, invitationDays, rateLimit)}
|
||||
onClick={() => void createProfile()}>
|
||||
<Plus size={16} aria-hidden="true" />
|
||||
Add profile
|
||||
</Button>
|
||||
</div>
|
||||
</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.map((profile) => {
|
||||
const definition = definitionsByKey.get(referenceKey(profile));
|
||||
const link = profile.mode === "anonymous"
|
||||
? absolutePath(`/forms/public/${encodeURIComponent(profile.public_id)}`)
|
||||
: invitationLinks[profile.profile_id];
|
||||
const busy = busyKey.endsWith(profile.profile_id);
|
||||
return (
|
||||
<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>
|
||||
</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)} />
|
||||
<span className="form-intake-profile-actions">
|
||||
{profile.mode === "invitation" &&
|
||||
<Button disabled={!profile.enabled || Boolean(busyKey)} onClick={() => void issueInvitation(profile)}>
|
||||
<Send size={15} aria-hidden="true" />
|
||||
Issue invitation
|
||||
</Button>
|
||||
}
|
||||
{link &&
|
||||
<Button disabled={busy || typeof navigator === "undefined" || !navigator.clipboard?.writeText} onClick={() => void copy(link)}>
|
||||
<Copy size={15} aria-hidden="true" />
|
||||
Copy link
|
||||
</Button>
|
||||
}
|
||||
{profile.mode === "anonymous" && <Link size={16} aria-label="Reusable public link" />}
|
||||
</span>
|
||||
{link && <code className="form-intake-link">{link}</code>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
</>
|
||||
}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function definitionKey(definition?: FormDefinition): string {
|
||||
return definition ? `${definition.reference.object_id}:${definition.reference.version ?? ""}` : "";
|
||||
}
|
||||
|
||||
function referenceKey(profile: FormIntakeProfile): string {
|
||||
return `${profile.definition_ref.object_id}:${profile.definition_ref.version ?? ""}`;
|
||||
}
|
||||
|
||||
function absolutePath(path: string): string {
|
||||
return typeof window === "undefined" ? path : new URL(path, window.location.origin).toString();
|
||||
}
|
||||
|
||||
function validSettings(draftDays: number, invitationDays: number, rateLimit: number): boolean {
|
||||
return Number.isFinite(draftDays) && draftDays >= 1 && draftDays <= 365
|
||||
&& Number.isFinite(invitationDays) && invitationDays >= 1 && invitationDays <= 90
|
||||
&& Number.isInteger(rateLimit) && rateLimit >= 1 && rateLimit <= 10_000;
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
import { Save, Send, Trash2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import {
|
||||
ActionBlockerHint,
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
DismissibleAlert,
|
||||
FileDropZone,
|
||||
LoadingIndicator,
|
||||
PageScrollViewport,
|
||||
StatusBadge,
|
||||
usePlatformLanguage,
|
||||
type PlatformRouteContext
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
createPublicFormEvidenceGrant,
|
||||
getPublicFormIntake,
|
||||
savePublicFormDraft,
|
||||
startAnonymousFormIntake,
|
||||
startInvitationFormIntake,
|
||||
submitPublicFormIntake,
|
||||
uploadFormEvidence,
|
||||
type EvidenceReference,
|
||||
type FormDefinition,
|
||||
type FormInstance,
|
||||
type ValidationResult
|
||||
} from "../../api/formsRuntime";
|
||||
import {
|
||||
FormField,
|
||||
localizeDefinition,
|
||||
visibleGroups
|
||||
} from "./FormInstancePage";
|
||||
|
||||
|
||||
type AnonymousStartAttempt = {
|
||||
idempotencyKey: string;
|
||||
recordedAt: string;
|
||||
};
|
||||
|
||||
export default function PublicFormPage({ settings }: PlatformRouteContext) {
|
||||
const { publicId = "", token: invitationToken = "" } = useParams();
|
||||
const { language } = usePlatformLanguage();
|
||||
const [token, setToken] = useState(invitationToken);
|
||||
const [instance, setInstance] = useState<FormInstance | null>(null);
|
||||
const [definition, setDefinition] = useState<FormDefinition | null>(null);
|
||||
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||
const [attachmentRefs, setAttachmentRefs] = useState<EvidenceReference[]>([]);
|
||||
const [attachmentNames, setAttachmentNames] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [confirmingSubmit, setConfirmingSubmit] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
let active = true;
|
||||
async function initialize() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
let activeToken = invitationToken;
|
||||
if (activeToken) {
|
||||
await startInvitationFormIntake(settings, activeToken);
|
||||
} else if (publicId) {
|
||||
activeToken = readSessionToken(publicId);
|
||||
if (!activeToken) {
|
||||
const attempt = anonymousStartAttempt(publicId);
|
||||
const started = await startAnonymousFormIntake(
|
||||
settings,
|
||||
publicId,
|
||||
attempt.idempotencyKey,
|
||||
attempt.recordedAt
|
||||
);
|
||||
activeToken = started.token ?? "";
|
||||
if (!activeToken) {
|
||||
throw new Error("This Form was already opened in another browser session. Use the original session or request a new link.");
|
||||
}
|
||||
rememberSessionToken(publicId, activeToken);
|
||||
clearAnonymousStartAttempt(publicId);
|
||||
}
|
||||
}
|
||||
if (!activeToken) throw new Error("This Form link is incomplete.");
|
||||
const loaded = await getPublicFormIntake(settings, activeToken, controller.signal);
|
||||
if (!active) return;
|
||||
setToken(activeToken);
|
||||
applyLoaded(loaded.instance, loaded.definition);
|
||||
} catch (reason) {
|
||||
if (active && (reason as Error).name !== "AbortError") {
|
||||
setError(reason instanceof Error ? reason.message : "The public Form could not be loaded.");
|
||||
}
|
||||
} finally {
|
||||
if (active) setLoading(false);
|
||||
}
|
||||
}
|
||||
function applyLoaded(nextInstance: FormInstance, nextDefinition: FormDefinition) {
|
||||
setInstance(nextInstance);
|
||||
setDefinition(nextDefinition);
|
||||
setValues(nextInstance.values);
|
||||
setAttachmentRefs(nextInstance.attachment_refs);
|
||||
}
|
||||
void initialize();
|
||||
return () => {
|
||||
active = false;
|
||||
controller.abort();
|
||||
};
|
||||
}, [invitationToken, publicId, settings]);
|
||||
|
||||
const editable = Boolean(instance && ["started", "draft"].includes(instance.status));
|
||||
const changed = Boolean(
|
||||
instance && (
|
||||
JSON.stringify(values) !== JSON.stringify(instance.values)
|
||||
|| JSON.stringify(attachmentRefs) !== JSON.stringify(instance.attachment_refs)
|
||||
)
|
||||
);
|
||||
const localized = useMemo(
|
||||
() => localizeDefinition(definition, language),
|
||||
[definition, language]
|
||||
);
|
||||
const groups = useMemo(
|
||||
() => definition ? visibleGroups(definition, values) : [],
|
||||
[definition, values]
|
||||
);
|
||||
const diagnostics = useMemo(() => {
|
||||
const grouped = new Map<string, ValidationResult[]>();
|
||||
for (const item of instance?.validation_results ?? []) {
|
||||
const key = item.field ?? "";
|
||||
grouped.set(key, [...(grouped.get(key) ?? []), item]);
|
||||
}
|
||||
return grouped;
|
||||
}, [instance]);
|
||||
const signatureBlocked = Boolean(
|
||||
definition?.signature_requirement === "required"
|
||||
&& (instance?.signature_refs.length ?? 0) === 0
|
||||
);
|
||||
const attachmentLimitReached = Boolean(
|
||||
definition && attachmentRefs.length >= definition.max_attachments
|
||||
);
|
||||
|
||||
async function reload() {
|
||||
if (!token) return;
|
||||
const loaded = await getPublicFormIntake(settings, token);
|
||||
setInstance(loaded.instance);
|
||||
setDefinition(loaded.definition);
|
||||
setValues(loaded.instance.values);
|
||||
setAttachmentRefs(loaded.instance.attachment_refs);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!instance || !token || !definition?.allow_drafts || !changed) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await savePublicFormDraft(
|
||||
settings,
|
||||
token,
|
||||
instance,
|
||||
values,
|
||||
attachmentRefs,
|
||||
"Public participant saved the draft."
|
||||
);
|
||||
await reload();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "The draft could not be saved.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!instance || !token || !editable || signatureBlocked) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await submitPublicFormIntake(settings, token, instance, values, attachmentRefs);
|
||||
await reload();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "The Form could not be submitted.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function upload(files: File[]) {
|
||||
if (!instance || !token || !definition) return;
|
||||
const available = Math.max(0, definition.max_attachments - attachmentRefs.length);
|
||||
const selected = files.slice(0, available);
|
||||
if (selected.length === 0) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const uploaded: EvidenceReference[] = [];
|
||||
const names: Record<string, string> = {};
|
||||
for (const file of selected) {
|
||||
const grant = await createPublicFormEvidenceGrant(
|
||||
settings,
|
||||
token,
|
||||
instance,
|
||||
crypto.randomUUID(),
|
||||
[...attachmentRefs, ...uploaded]
|
||||
);
|
||||
const result = await uploadFormEvidence(settings, grant, file);
|
||||
uploaded.push(result.evidence);
|
||||
names[result.evidence.evidence_id] = file.name;
|
||||
}
|
||||
setAttachmentRefs((current) => [...current, ...uploaded]);
|
||||
setAttachmentNames((current) => ({ ...current, ...names }));
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "The attachment could not be uploaded.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="forms-runtime-page forms-public-page">
|
||||
<div className="form-instance-shell">
|
||||
<div className="form-instance-toolbar">
|
||||
<strong>{localized.title || "Form"}</strong>
|
||||
{instance && <StatusBadge status={editable ? "active" : "inactive"} label={stateLabel(instance.status)} />}
|
||||
</div>
|
||||
<PageScrollViewport className="form-instance-viewport">
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||
{loading && <LoadingIndicator label="Loading Form" />}
|
||||
{!loading && instance && definition &&
|
||||
<div className="form-public-content">
|
||||
<header>
|
||||
<h1>{localized.title}</h1>
|
||||
{localized.description && <p>{localized.description}</p>}
|
||||
</header>
|
||||
{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>
|
||||
}
|
||||
<div className="form-fields">
|
||||
{group.fields.map((field) =>
|
||||
<FormField
|
||||
key={field.key}
|
||||
field={{
|
||||
...field,
|
||||
label: localized.fieldLabels[field.key] ?? field.label,
|
||||
help_text: localized.fieldHelpTexts[field.key] ?? field.help_text
|
||||
}}
|
||||
optionLabels={localized.optionLabels[field.key] ?? {}}
|
||||
value={values[field.key]}
|
||||
disabled={!editable || busy}
|
||||
diagnostics={diagnostics.get(field.key) ?? []}
|
||||
onChange={(value) => setValues((current) => {
|
||||
const next = { ...current };
|
||||
if (value === undefined || value === "") delete next[field.key];
|
||||
else next[field.key] = value;
|
||||
return next;
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{definition.max_attachments > 0 && editable &&
|
||||
<section className="form-public-attachments">
|
||||
<h2>Attachments</h2>
|
||||
<FileDropZone
|
||||
multiple
|
||||
busy={busy}
|
||||
disabled={attachmentLimitReached}
|
||||
note={`${attachmentRefs.length} of ${definition.max_attachments} attachments`}
|
||||
onFiles={upload}
|
||||
/>
|
||||
<div className="form-public-attachment-list">
|
||||
{attachmentRefs.map((reference) =>
|
||||
<div key={`${reference.evidence_id}:${reference.version ?? ""}`}>
|
||||
<span>{attachmentNames[reference.evidence_id] ?? `Managed attachment ${reference.evidence_id.slice(0, 8)}`}</span>
|
||||
<Button
|
||||
variant="danger"
|
||||
aria-label="Remove attachment"
|
||||
disabled={busy}
|
||||
onClick={() => setAttachmentRefs((current) => current.filter((item) => item !== reference))}>
|
||||
<Trash2 size={15} aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
{signatureBlocked &&
|
||||
<ActionBlockerHint
|
||||
tone="warning"
|
||||
reason={{
|
||||
summary: "This Form requires an authenticated or external signature.",
|
||||
details: "The anonymous/invitation profile cannot silently substitute a lower-assurance acknowledgement.",
|
||||
requiredAction: "Use an authenticated service entry or a configured signature provider.",
|
||||
actor: "Service owner",
|
||||
target: "Form intake and signature policy"
|
||||
}}
|
||||
/>
|
||||
}
|
||||
{editable &&
|
||||
<div className="form-instance-actions">
|
||||
{definition.allow_drafts &&
|
||||
<Button disabled={busy || !changed} onClick={() => void save()}>
|
||||
<Save size={16} aria-hidden="true" />
|
||||
Save draft
|
||||
</Button>
|
||||
}
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={busy || signatureBlocked}
|
||||
disabledReason={signatureBlocked ? "The required signature profile is unavailable for this public link." : undefined}
|
||||
onClick={() => setConfirmingSubmit(true)}>
|
||||
<Send size={16} aria-hidden="true" />
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
{!editable && instance.receipt_id &&
|
||||
<div className="form-receipt">
|
||||
<strong>Submission received</strong>
|
||||
<span>Receipt</span>
|
||||
<code>{instance.receipt_id}</code>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</PageScrollViewport>
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
open={confirmingSubmit}
|
||||
title="Submit Form"
|
||||
message="Submit this Form? The values and managed attachment references become an immutable submission revision."
|
||||
confirmLabel="Submit"
|
||||
busy={busy}
|
||||
onCancel={() => setConfirmingSubmit(false)}
|
||||
onConfirm={() => {
|
||||
setConfirmingSubmit(false);
|
||||
void submit();
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function anonymousStartAttempt(publicId: string): AnonymousStartAttempt {
|
||||
const key = `govoplan.forms-runtime.start.${publicId}`;
|
||||
try {
|
||||
const stored = sessionStorage.getItem(key);
|
||||
if (stored) return JSON.parse(stored) as AnonymousStartAttempt;
|
||||
const attempt = {
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
recordedAt: new Date().toISOString()
|
||||
};
|
||||
sessionStorage.setItem(key, JSON.stringify(attempt));
|
||||
return attempt;
|
||||
} catch {
|
||||
return {
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
recordedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function clearAnonymousStartAttempt(publicId: string) {
|
||||
try {
|
||||
sessionStorage.removeItem(`govoplan.forms-runtime.start.${publicId}`);
|
||||
} catch {
|
||||
// Private browsing may deny session storage; the active token still works.
|
||||
}
|
||||
}
|
||||
|
||||
function readSessionToken(publicId: string): string {
|
||||
try {
|
||||
return sessionStorage.getItem(`govoplan.forms-runtime.token.${publicId}`) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function rememberSessionToken(publicId: string, token: string) {
|
||||
try {
|
||||
sessionStorage.setItem(`govoplan.forms-runtime.token.${publicId}`, token);
|
||||
} catch {
|
||||
// Keep the token in component memory when session storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
function stateLabel(value: string): string {
|
||||
return `i18n:govoplan-forms-runtime.state_${value}`;
|
||||
}
|
||||
@@ -73,7 +73,54 @@ const en = {
|
||||
"Select": "Select",
|
||||
"Compensate handoff": "Compensate handoff",
|
||||
"Confirm absent and compensate": "Confirm absent and compensate",
|
||||
"Read-only Form": "Read-only Form"
|
||||
"Read-only Form": "Read-only Form",
|
||||
"Public intake": "Public intake",
|
||||
"Public Form intake": "Public Form intake",
|
||||
"Loading public intake profiles": "Loading public intake profiles",
|
||||
"Add intake profile": "Add intake profile",
|
||||
"Published Form": "Published Form",
|
||||
"Access mode": "Access mode",
|
||||
"Invitation link": "Invitation link",
|
||||
"Open anonymous link": "Open anonymous link",
|
||||
"Draft retention (days)": "Draft retention (days)",
|
||||
"Invitation validity (days)": "Invitation validity (days)",
|
||||
"Starts per minute": "Starts per minute",
|
||||
"Add profile": "Add profile",
|
||||
"Configured profiles": "Configured profiles",
|
||||
"No public intake profile has been configured.": "No public intake profile has been configured.",
|
||||
"Anonymous link": "Anonymous link",
|
||||
"Invitation links": "Invitation links",
|
||||
"Active": "Active",
|
||||
"Inactive": "Inactive",
|
||||
"Profile active": "Profile active",
|
||||
"Issue invitation": "Issue invitation",
|
||||
"Copy link": "Copy link",
|
||||
"Reusable public link": "Reusable public link",
|
||||
"The public intake profile was created.": "The public intake profile was created.",
|
||||
"The invitation was issued. Copy its link now; the token is not stored in readable form.": "The invitation was issued. Copy its link now; the token is not stored in readable form.",
|
||||
"The intake link was copied.": "The intake link was copied.",
|
||||
"Evidence and acknowledgement": "Evidence and acknowledgement",
|
||||
"Attachments": "Attachments",
|
||||
"Remove attachment": "Remove attachment",
|
||||
"Authenticated acknowledgement": "Authenticated acknowledgement",
|
||||
"Records your account, the exact values, and the exact managed attachments. It is not a qualified electronic signature.": "Records your account, the exact values, and the exact managed attachments. It is not a qualified electronic signature.",
|
||||
"Renew acknowledgement": "Renew acknowledgement",
|
||||
"Acknowledge": "Acknowledge",
|
||||
"Record acknowledgement": "Record acknowledgement",
|
||||
"File in eAkte": "File in eAkte",
|
||||
"Record the required acknowledgement before submitting.": "Record the required acknowledgement before submitting.",
|
||||
"Confirm that the displayed values and managed attachments are correct and complete. This records a payload-bound authenticated acknowledgement; it is not a qualified electronic signature.": "Confirm that the displayed values and managed attachments are correct and complete. This records a payload-bound authenticated acknowledgement; it is not a qualified electronic signature.",
|
||||
"Form": "Form",
|
||||
"This Form requires an authenticated or external signature.": "This Form requires an authenticated or external signature.",
|
||||
"The anonymous/invitation profile cannot silently substitute a lower-assurance acknowledgement.": "The anonymous/invitation profile cannot silently substitute a lower-assurance acknowledgement.",
|
||||
"Use an authenticated service entry or a configured signature provider.": "Use an authenticated service entry or a configured signature provider.",
|
||||
"Service owner": "Service owner",
|
||||
"Form intake and signature policy": "Form intake and signature policy",
|
||||
"The required signature profile is unavailable for this public link.": "The required signature profile is unavailable for this public link.",
|
||||
"Submission received": "Submission received",
|
||||
"Receipt": "Receipt",
|
||||
"Submit Form": "Submit Form",
|
||||
"Submit this Form? The values and managed attachment references become an immutable submission revision.": "Submit this Form? The values and managed attachment references become an immutable submission revision."
|
||||
} as const;
|
||||
|
||||
const de: Record<keyof typeof en, string> = {
|
||||
@@ -149,7 +196,54 @@ const de: Record<keyof typeof en, string> = {
|
||||
"Select": "Auswählen",
|
||||
"Compensate handoff": "Übergabe kompensieren",
|
||||
"Confirm absent and compensate": "Fehlen bestätigen und kompensieren",
|
||||
"Read-only Form": "Schreibgeschütztes Formular"
|
||||
"Read-only Form": "Schreibgeschütztes Formular",
|
||||
"Public intake": "Öffentlicher Formulareingang",
|
||||
"Public Form intake": "Öffentlicher Formulareingang",
|
||||
"Loading public intake profiles": "Profile für den öffentlichen Formulareingang werden geladen",
|
||||
"Add intake profile": "Eingangsprofil hinzufügen",
|
||||
"Published Form": "Veröffentlichtes Formular",
|
||||
"Access mode": "Zugangsart",
|
||||
"Invitation link": "Einladungslink",
|
||||
"Open anonymous link": "Offener anonymer Link",
|
||||
"Draft retention (days)": "Entwurfsaufbewahrung (Tage)",
|
||||
"Invitation validity (days)": "Gültigkeit der Einladung (Tage)",
|
||||
"Starts per minute": "Starts pro Minute",
|
||||
"Add profile": "Profil hinzufügen",
|
||||
"Configured profiles": "Konfigurierte Profile",
|
||||
"No public intake profile has been configured.": "Es ist kein Profil für den öffentlichen Formulareingang konfiguriert.",
|
||||
"Anonymous link": "Anonymer Link",
|
||||
"Invitation links": "Einladungslinks",
|
||||
"Active": "Aktiv",
|
||||
"Inactive": "Inaktiv",
|
||||
"Profile active": "Profil aktiv",
|
||||
"Issue invitation": "Einladung ausstellen",
|
||||
"Copy link": "Link kopieren",
|
||||
"Reusable public link": "Wiederverwendbarer öffentlicher Link",
|
||||
"The public intake profile was created.": "Das Profil für den öffentlichen Formulareingang wurde erstellt.",
|
||||
"The invitation was issued. Copy its link now; the token is not stored in readable form.": "Die Einladung wurde ausgestellt. Kopieren Sie den Link jetzt; das Token wird nicht lesbar gespeichert.",
|
||||
"The intake link was copied.": "Der Eingangslink wurde kopiert.",
|
||||
"Evidence and acknowledgement": "Nachweise und Bestätigung",
|
||||
"Attachments": "Anhänge",
|
||||
"Remove attachment": "Anhang entfernen",
|
||||
"Authenticated acknowledgement": "Authentifizierte Bestätigung",
|
||||
"Records your account, the exact values, and the exact managed attachments. It is not a qualified electronic signature.": "Erfasst Ihr Konto, die exakten Werte und die exakten verwalteten Anhänge. Dies ist keine qualifizierte elektronische Signatur.",
|
||||
"Renew acknowledgement": "Bestätigung erneuern",
|
||||
"Acknowledge": "Bestätigen",
|
||||
"Record acknowledgement": "Bestätigung erfassen",
|
||||
"File in eAkte": "In eAkte verakten",
|
||||
"Record the required acknowledgement before submitting.": "Erfassen Sie vor dem Absenden die erforderliche Bestätigung.",
|
||||
"Confirm that the displayed values and managed attachments are correct and complete. This records a payload-bound authenticated acknowledgement; it is not a qualified electronic signature.": "Bestätigen Sie, dass die angezeigten Werte und verwalteten Anhänge richtig und vollständig sind. Dies erfasst eine an die Nutzdaten gebundene authentifizierte Bestätigung; es ist keine qualifizierte elektronische Signatur.",
|
||||
"Form": "Formular",
|
||||
"This Form requires an authenticated or external signature.": "Dieses Formular erfordert eine authentifizierte oder externe Signatur.",
|
||||
"The anonymous/invitation profile cannot silently substitute a lower-assurance acknowledgement.": "Das anonyme beziehungsweise Einladungsprofil darf nicht stillschweigend eine Bestätigung mit geringerem Vertrauensniveau einsetzen.",
|
||||
"Use an authenticated service entry or a configured signature provider.": "Verwenden Sie einen authentifizierten Diensteinstieg oder einen konfigurierten Signaturanbieter.",
|
||||
"Service owner": "Dienstverantwortliche Stelle",
|
||||
"Form intake and signature policy": "Richtlinie für Formulareingang und Signaturen",
|
||||
"The required signature profile is unavailable for this public link.": "Das erforderliche Signaturprofil ist für diesen öffentlichen Link nicht verfügbar.",
|
||||
"Submission received": "Übermittlung eingegangen",
|
||||
"Receipt": "Beleg",
|
||||
"Submit Form": "Formular absenden",
|
||||
"Submit this Form? The values and managed attachment references become an immutable submission revision.": "Dieses Formular absenden? Die Werte und Referenzen auf verwaltete Anhänge werden zu einer unveränderlichen Übermittlungsrevision."
|
||||
};
|
||||
|
||||
export const generatedTranslations: PlatformTranslations = { en, de };
|
||||
|
||||
+15
-2
@@ -6,6 +6,7 @@ import "./styles/forms-runtime.css";
|
||||
|
||||
const FormsRuntimePage = lazy(() => import("./features/forms/FormsRuntimePage"));
|
||||
const FormInstancePage = lazy(() => import("./features/forms/FormInstancePage"));
|
||||
const PublicFormPage = lazy(() => import("./features/forms/PublicFormPage"));
|
||||
const routeScopes = [
|
||||
"forms_runtime:submission:participate",
|
||||
"forms_runtime:workspace:read"
|
||||
@@ -14,9 +15,9 @@ const routeScopes = [
|
||||
export const formsRuntimeModule: PlatformWebModule = {
|
||||
id: "forms_runtime",
|
||||
label: "i18n:govoplan-forms-runtime.forms",
|
||||
version: "0.1.14",
|
||||
version: "0.1.18",
|
||||
dependencies: ["access", "forms"],
|
||||
optionalDependencies: ["files", "approvals", "workflow_engine", "portal", "cases", "policy", "audit"],
|
||||
optionalDependencies: ["files", "approvals", "workflow_engine", "portal", "cases", "records", "policy", "audit"],
|
||||
translations: generatedTranslations,
|
||||
routes: [
|
||||
{
|
||||
@@ -34,6 +35,18 @@ export const formsRuntimeModule: PlatformWebModule = {
|
||||
render: (context) => createElement(FormInstancePage, context)
|
||||
}
|
||||
],
|
||||
publicRoutes: [
|
||||
{
|
||||
path: "/forms/public/:publicId",
|
||||
order: 10,
|
||||
render: (context) => createElement(PublicFormPage, context)
|
||||
},
|
||||
{
|
||||
path: "/forms/intake/:token",
|
||||
order: 11,
|
||||
render: (context) => createElement(PublicFormPage, context)
|
||||
}
|
||||
],
|
||||
navItems: [
|
||||
{
|
||||
to: "/forms-runtime",
|
||||
|
||||
@@ -124,6 +124,66 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.form-public-content {
|
||||
width: min(100%, 900px);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.form-public-content > header {
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.form-public-content > header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.45rem;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.form-public-content > header p {
|
||||
max-width: 70ch;
|
||||
margin: 7px 0 0;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
.form-public-attachments {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 16px 0;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.form-public-attachments h2 {
|
||||
margin: 0;
|
||||
font-size: 0.98rem;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.form-public-attachments .file-drop-zone {
|
||||
min-height: 118px;
|
||||
}
|
||||
|
||||
.form-public-attachment-list {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.form-public-attachment-list > div {
|
||||
display: flex;
|
||||
min-height: 42px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.form-public-attachment-list > div > span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.form-instance-main > header {
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
@@ -230,6 +290,135 @@
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.form-instance-evidence {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px 0;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.form-evidence-heading,
|
||||
.form-acknowledgement-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-evidence-heading h2,
|
||||
.form-intake-dialog h3 {
|
||||
margin: 0;
|
||||
font-size: 0.98rem;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.form-evidence-heading > span,
|
||||
.form-acknowledgement-row small {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.form-instance-evidence .file-drop-zone {
|
||||
min-height: 104px;
|
||||
}
|
||||
|
||||
.form-acknowledgement-row {
|
||||
padding: 12px 0;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.form-acknowledgement-row > span {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.form-intake-dialog {
|
||||
width: min(980px, calc(100vw - 40px));
|
||||
}
|
||||
|
||||
.form-intake-create,
|
||||
.form-intake-profiles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-intake-profiles {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.form-intake-create-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
align-items: end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-intake-create-grid label {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.form-intake-create-grid label > span {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.form-intake-create-button {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.form-intake-profile-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1fr) auto auto auto;
|
||||
align-items: center;
|
||||
gap: 10px 14px;
|
||||
min-height: 62px;
|
||||
padding: 10px 0;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.form-intake-profile-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.form-intake-profile-main strong,
|
||||
.form-intake-profile-main small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.form-intake-profile-main small,
|
||||
.form-intake-empty {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.form-intake-profile-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.form-intake-link {
|
||||
grid-column: 1 / -1;
|
||||
overflow: hidden;
|
||||
color: var(--text-soft);
|
||||
font-size: 0.75rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.form-receipt {
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -385,6 +574,25 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-evidence-heading,
|
||||
.form-acknowledgement-row {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-intake-create-grid,
|
||||
.form-intake-profile-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-intake-profile-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.form-intake-link {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.form-handoff-create,
|
||||
.form-handoff-row {
|
||||
align-items: stretch;
|
||||
|
||||
Reference in New Issue
Block a user