feat: add resident application status portal
This commit is contained in:
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user