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