208 lines
8.1 KiB
TypeScript
208 lines
8.1 KiB
TypeScript
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`;
|
|
}
|