feat: add resident application status portal

This commit is contained in:
2026-08-19 12:33:34 +02:00
parent a15f599756
commit 89a142ead1
8 changed files with 512 additions and 3 deletions
+79
View File
@@ -1,6 +1,11 @@
import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui";
function publicSettings(settings: ApiSettings): ApiSettings {
return { ...settings, accessToken: "", apiKey: "" };
}
export type PortalServiceBinding = {
kind: string;
reference: string;
@@ -65,6 +70,26 @@ export type PortalServiceLaunchResult = {
metadata: Record<string, unknown>;
};
export type PortalApplicationStatusAccess = {
tracking_id: string;
mode: "authenticated" | "email_link" | "permanent_link";
authenticated_available: boolean;
email_link_available: boolean;
token_ttl_seconds?: number | null;
};
export type PortalApplicationStatus = {
tracking_id: string;
title: string;
status: string;
updated_at: string;
receipt_id?: string | null;
timeline: Array<{
status: string;
occurred_at: string;
}>;
};
export function listPortalServices(
settings: ApiSettings,
options: {
@@ -115,3 +140,57 @@ export function launchPortalService(
}
);
}
export function getApplicationStatusAccess(
settings: ApiSettings,
trackingId: string,
signal?: AbortSignal
): Promise<PortalApplicationStatusAccess> {
return apiFetch(
publicSettings(settings),
`/api/v1/forms-runtime/public/status/${encodeURIComponent(trackingId)}/access`,
{ signal }
);
}
export function getPublicApplicationStatus(
settings: ApiSettings,
trackingId: string,
token?: string,
signal?: AbortSignal
): Promise<PortalApplicationStatus> {
return apiFetch(
publicSettings(settings),
apiPath(`/api/v1/forms-runtime/public/status/${encodeURIComponent(trackingId)}`, { token }),
{ signal }
);
}
export function getAuthenticatedApplicationStatus(
settings: ApiSettings,
trackingId: string,
signal?: AbortSignal
): Promise<PortalApplicationStatus> {
return apiFetch(
settings,
`/api/v1/forms-runtime/status/${encodeURIComponent(trackingId)}`,
{ signal }
);
}
export function requestApplicationStatusEmailLink(
settings: ApiSettings,
trackingId: string,
email: string
): Promise<{ accepted: boolean; message: string }> {
return apiFetch(
publicSettings(settings),
`/api/v1/forms-runtime/public/status/${encodeURIComponent(trackingId)}/email-links`,
{
method: "POST",
body: JSON.stringify({
email
})
}
);
}
@@ -0,0 +1,207 @@
import { Clock3, LogIn, RefreshCw, Send } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useParams, useSearchParams } from "react-router";
import {
ActionToolbar,
Button,
Card,
DescriptionItem,
DescriptionList,
DismissibleAlert,
FormField,
LoadingIndicator,
PageScrollViewport,
StatusBadge,
WorkspaceFrame,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
getApplicationStatusAccess,
getAuthenticatedApplicationStatus,
getPublicApplicationStatus,
requestApplicationStatusEmailLink,
type PortalApplicationStatus,
type PortalApplicationStatusAccess
} from "../../api/portal";
export default function PortalStatusPage({ settings }: PlatformRouteContext) {
const { trackingId = "" } = useParams();
const [searchParams] = useSearchParams();
const token = searchParams.get("token") ?? "";
const [access, setAccess] = useState<PortalApplicationStatusAccess | null>(null);
const [status, setStatus] = useState<PortalApplicationStatus | null>(null);
const [email, setEmail] = useState("");
const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const load = useCallback(async (signal?: AbortSignal) => {
if (!trackingId) return;
setLoading(true);
setError("");
try {
const nextAccess = await getApplicationStatusAccess(settings, trackingId, signal);
setAccess(nextAccess);
if (token) {
setStatus(await getPublicApplicationStatus(settings, trackingId, token, signal));
return;
}
if (nextAccess.mode === "permanent_link") {
setStatus(await getPublicApplicationStatus(settings, trackingId, undefined, signal));
return;
}
if (settings.accessToken || settings.apiKey) {
try {
setStatus(await getAuthenticatedApplicationStatus(settings, trackingId, signal));
return;
} catch {
setStatus(null);
}
} else {
setStatus(null);
}
} catch (reason) {
if ((reason as Error).name !== "AbortError") {
setError(reason instanceof Error ? reason.message : "Application status could not be loaded.");
}
} finally {
setLoading(false);
}
}, [settings, token, trackingId]);
useEffect(() => {
const controller = new AbortController();
void load(controller.signal);
return () => controller.abort();
}, [load]);
async function requestLink() {
if (!email.trim()) return;
setSending(true);
setError("");
setNotice("");
try {
const response = await requestApplicationStatusEmailLink(settings, trackingId, email.trim());
setNotice(response.message);
} catch {
setNotice("If the application and email address match, a new short-lived status link will be sent.");
} finally {
setSending(false);
}
}
return (
<main className="portal-status-page">
<WorkspaceFrame
height="container"
label="Application status"
interfaceId="portal.application-status"
helpModuleId="portal"
helpTopicId="portal.application-status"
helpContextId="portal.application-status">
<ActionToolbar surface="workspace" className="portal-status-toolbar">
<strong>Application status</strong>
<Button onClick={() => void load()} disabled={loading}>
<RefreshCw size={16} aria-hidden="true" />
Reload
</Button>
</ActionToolbar>
<PageScrollViewport className="portal-status-viewport">
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{notice && <DismissibleAlert tone="info" resetKey={notice}>{notice}</DismissibleAlert>}
{loading && <LoadingIndicator label="Loading application status" />}
{!loading && status && <StatusProjection status={status} />}
{!loading && !status && access?.mode === "authenticated" &&
<Card title="Sign in to view status" className="portal-status-access-card">
<p>This application is configured for authenticated access only. Sign in with the account linked to the submission.</p>
<a className="btn btn-primary" href={`/login?next=${encodeURIComponent(window.location.pathname)}`}>
<LogIn size={16} aria-hidden="true" />
Sign in
</a>
</Card>
}
{!loading && !status && access?.mode === "email_link" &&
<Card title="Request a short-lived status link" className="portal-status-access-card">
<p>Enter the email address linked to the application. The response is identical whether or not the details match.</p>
<form onSubmit={(event) => { event.preventDefault(); void requestLink(); }}>
<FormField label="Linked email address">
<input type="email" value={email} onChange={(event) => setEmail(event.target.value)} disabled={sending} required autoComplete="email" />
</FormField>
<Button type="submit" variant="primary" disabled={sending || !email.trim()}>
<Send size={16} aria-hidden="true" />
Send new link
</Button>
</form>
{access.token_ttl_seconds &&
<p className="portal-status-expiry"><Clock3 size={15} aria-hidden="true" />The link remains valid for {durationLabel(access.token_ttl_seconds)} and replaces the previous link.</p>
}
</Card>
}
</PageScrollViewport>
</WorkspaceFrame>
</main>
);
}
function StatusProjection({ status }: { status: PortalApplicationStatus }) {
return (
<div className="portal-status-content">
<Card
title={status.title}
actions={<StatusBadge status={statusTone(status.status)} label={statusLabel(status.status)} />}>
<DescriptionList columns={3} collapseAt="workspace" density="compact">
<DescriptionItem term="Tracking ID"><code>{status.tracking_id}</code></DescriptionItem>
<DescriptionItem term="Last updated">{formatDate(status.updated_at)}</DescriptionItem>
{status.receipt_id && <DescriptionItem term="Submission receipt"><code>{status.receipt_id}</code></DescriptionItem>}
</DescriptionList>
</Card>
<Card title="Timeline">
{status.timeline.length === 0 && <p>No public status event is available yet.</p>}
<ol className="portal-status-timeline">
{status.timeline.map((item, index) =>
<li key={`${item.status}:${item.occurred_at}:${index}`}>
<span className="portal-status-marker" aria-hidden="true" />
<div>
<strong>{statusLabel(item.status)}</strong>
<time dateTime={item.occurred_at}>{formatDate(item.occurred_at)}</time>
</div>
</li>
)}
</ol>
</Card>
<DismissibleAlert tone="info">
This page intentionally shows only the public lifecycle, update times, and receipt reference. Form values, evidence, internal notes, actors, and handoff details remain private.
</DismissibleAlert>
</div>
);
}
function statusLabel(value: string): string {
const labels: Record<string, string> = {
submitted: "Application received",
validated: "Completeness checked",
needs_review: "Under review",
accepted: "Approved",
rejected: "Decision issued",
handed_off: "Further processing",
archived: "Procedure closed"
};
return labels[value] ?? value.replaceAll("_", " ");
}
function statusTone(value: string): string {
if (value === "accepted" || value === "archived") return "active";
if (value === "rejected") return "warning";
return "pending";
}
function formatDate(value: string): string {
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
}
function durationLabel(seconds: number): string {
if (seconds % 3600 === 0) return `${seconds / 3600} hour${seconds === 3600 ? "" : "s"}`;
return `${Math.round(seconds / 60)} minutes`;
}
+16 -1
View File
@@ -4,12 +4,13 @@ import "./styles/portal.css";
const PortalPage = lazy(() => import("./features/portal/PortalPage"));
const PortalStatusPage = lazy(() => import("./features/portal/PortalStatusPage"));
export const portalModule: PlatformWebModule = {
id: "portal",
label: "Services",
version: "0.1.8",
optionalDependencies: ["access", "services", "cases", "forms", "workflow_engine"],
optionalDependencies: ["access", "services", "cases", "forms", "forms_runtime", "workflow_engine"],
routes: [
{
path: "/portal",
@@ -19,6 +20,13 @@ export const portalModule: PlatformWebModule = {
render: (context) => createElement(PortalPage, context)
}
],
publicRoutes: [
{
path: "/portal/status/:trackingId",
order: 12,
render: (context) => createElement(PortalStatusPage, context)
}
],
navItems: [
{
to: "/portal",
@@ -43,6 +51,13 @@ export const portalModule: PlatformWebModule = {
kind: "route",
label: "Service directory",
order: 20
},
{
id: "portal.application-status",
moduleId: "portal",
kind: "route",
label: "Applicant status",
order: 30
}
]
};
+90
View File
@@ -4,6 +4,96 @@
overflow: hidden;
}
.portal-status-page {
height: 100%;
min-height: 0;
}
.portal-status-toolbar {
justify-content: space-between;
}
.portal-status-viewport {
padding: 18px;
}
.portal-status-content {
display: grid;
width: min(920px, 100%);
margin: 0 auto;
gap: 14px;
}
.portal-status-access-card {
width: min(620px, 100%);
margin: 32px auto;
}
.portal-status-access-card form {
display: grid;
gap: 12px;
}
.portal-status-access-card .btn,
.portal-status-access-card .btn-primary {
width: fit-content;
}
.portal-status-expiry {
display: flex;
align-items: center;
gap: 6px;
color: var(--text-soft);
}
.portal-status-timeline {
position: relative;
display: grid;
gap: 0;
margin: 0;
padding: 0;
list-style: none;
}
.portal-status-timeline::before {
position: absolute;
top: 11px;
bottom: 11px;
left: 7px;
width: 2px;
background: var(--border-strong);
content: "";
}
.portal-status-timeline li {
position: relative;
display: grid;
grid-template-columns: 16px minmax(0, 1fr);
gap: 12px;
padding: 8px 0;
}
.portal-status-marker {
z-index: 1;
width: 16px;
height: 16px;
margin-top: 2px;
border: 3px solid var(--accent);
border-radius: var(--radius-round);
background: var(--surface-raised);
}
.portal-status-timeline li > div {
display: flex;
flex-direction: column;
gap: 2px;
}
.portal-status-timeline time {
color: var(--text-soft);
font-size: 0.82rem;
}
.portal-search { flex: 1 1 620px; }
.portal-result-summary {