Files
govoplan-portal/webui/src/features/portal/PortalStatusPage.tsx
T

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`;
}