Workflow-App/Workflow/app/onboarding/page.tsx
sjcoetzeesj-coder 11d0178ab1 feat: commit Workflow app source; move CIPC credentials to env
Adds the full Next.js app (app/, components/, lib/, scripts/, assets/,
docs/, Dockerfile, compose, configs) to version control.

Security:
- CIPC BizPortal customer-code logins (Login 1 + Login 3) now read
  CIPC_LOGIN1_CODE / CIPC_LOGIN1_PASSWORD / CIPC_LOGIN3_CODE /
  CIPC_LOGIN3_PASSWORD from .env.local instead of source.
- BizPortal personal login in apply-bizportal route reads
  BIZPORTAL_ID_NO / BIZPORTAL_PASSWORD.
- Added .env.example as the committed template.

.gitignore excludes .env*, node_modules, .next, logs, sqlite, Playwright
storage_state.json (live CIPC cookies), scratch dirs, client PDFs and
debug screenshots.
2026-10-18 15:21:45 +02:00

173 lines
6.2 KiB
TypeScript

// app/onboarding/page.tsx
// Internal inbox of all onboarding submissions. Status filter + simple
// table. Server Component for first paint, TanStack Query lives in the
// explorer for client-side refresh.
import Link from "next/link";
import PageHeader from "@/components/PageHeader";
import StatusBadge from "@/components/StatusBadge";
import { fetchCollection } from "@/lib/pb";
import type { OnboardingSubmission } from "@/lib/types";
export const dynamic = "force-dynamic";
const STATUS_FILTERS = [
{ value: "all", label: "All" },
{ value: "submitted", label: "New" },
{ value: "processing", label: "Processing" },
{ value: "processed", label: "Processed" },
{ value: "rejected", label: "Archived" },
] as const;
type StatusFilter = (typeof STATUS_FILTERS)[number]["value"];
function statusToVariant(status: OnboardingSubmission["status"]): "success" | "warn" | "danger" | "muted" | "active" | "inactive" | "in_process" | "deregistered" {
switch (status) {
case "submitted": return "active";
case "processing": return "in_process";
case "processed": return "success";
case "rejected": return "danger";
case "draft": return "muted";
default: return "muted";
}
}
function formatDate(iso: string | null): string {
if (!iso) return "—";
return new Date(iso).toLocaleString("en-ZA", {
year: "numeric", month: "short", day: "2-digit",
hour: "2-digit", minute: "2-digit",
});
}
export default async function OnboardingInboxPage({
searchParams,
}: {
searchParams: { status?: string };
}) {
const filter = (STATUS_FILTERS.some((f) => f.value === searchParams.status)
? searchParams.status
: "all") as StatusFilter;
const pbFilter = filter === "all" ? undefined : `status = "${filter}"`;
const result = await fetchCollection<OnboardingSubmission>(
"ysterbaard_onboarding_submissions",
{
page: 1,
perPage: 50,
sort: "-submitted_at",
filter: pbFilter,
},
);
return (
<div data-od-id="onboarding-inbox">
<PageHeader
eyebrow="Workflow"
title="Onboard"
subtitle={
<>
<span>Submissions from new clients.</span>
<span>{result.totalItems} total</span>
<span>{filter === "all" ? "all statuses" : filter}</span>
</>
}
action={
<div className="flex items-center gap-3">
<Link
href="/onboarding/new"
className="inline-flex items-center px-4 py-2.5 rounded-md bg-accent text-accent-on text-sm font-bold hover:bg-accent-hover transition-colors"
>
+ New resolution link
</Link>
<Link
href="/"
className="inline-flex items-center px-4 py-2.5 rounded-md border border-border bg-surface text-fg text-sm font-semibold no-underline hover:bg-surface-warm transition-colors"
>
← Back to overview
</Link>
</div>
}
/>
<div className="container-x py-10">
<div className="flex flex-wrap items-center gap-2 mb-6">
{STATUS_FILTERS.map((f) => {
const isActive = f.value === filter;
const href = f.value === "all" ? "/onboarding" : `/onboarding?status=${f.value}`;
return (
<Link
key={f.value}
href={href}
className={
"px-3 py-1.5 rounded-pill text-sm font-semibold no-underline transition-colors " +
(isActive
? "bg-surface-warm text-fg border border-border"
: "text-muted hover:bg-surface-warm border border-transparent")
}
>
{f.label}
</Link>
);
})}
</div>
{result.items.length === 0 ? (
<div className="bg-surface-warm border border-border rounded-lg p-12 text-center">
<p className="text-base text-muted">No submissions match this filter.</p>
<p className="text-sm text-muted mt-2">
Mint a new onboarding link to send to a client.
</p>
</div>
) : (
<div className="bg-surface border border-border rounded-lg overflow-hidden">
<div className="hidden md:grid grid-cols-[1fr_1fr_140px_180px_120px] gap-4 px-5 py-3 bg-surface-warm border-b border-border text-xs font-semibold text-muted uppercase tracking-wider">
<div>Reference</div>
<div>Client</div>
<div>Status</div>
<div>Submitted</div>
<div className="text-right">Action</div>
</div>
<ul>
{result.items.map((sub) => (
<li
key={sub.id}
className="border-b border-border-soft last:border-b-0"
>
<Link
href={`/onboarding/${sub.id}`}
className="grid grid-cols-1 md:grid-cols-[1fr_1fr_140px_180px_120px] gap-2 md:gap-4 px-5 py-4 hover:bg-surface-warm transition-colors no-underline"
>
<div>
<p className="font-mono text-sm font-semibold text-fg">{sub.reference_no}</p>
<p className="text-xs text-muted md:hidden mt-0.5">{sub.client_email}</p>
</div>
<div className="hidden md:block">
<p className="text-sm font-semibold text-fg">{sub.client_name || "—"}</p>
<p className="text-xs text-muted">{sub.client_email}</p>
</div>
<div>
<StatusBadge variant={statusToVariant(sub.status)}>
{sub.status}
</StatusBadge>
</div>
<div className="text-sm text-muted md:block hidden">
{formatDate(sub.submitted_at)}
</div>
<div className="md:hidden text-xs text-muted">
{formatDate(sub.submitted_at)}
</div>
<div className="text-right">
<span className="text-sm font-semibold text-fg">View →</span>
</div>
</Link>
</li>
))}
</ul>
</div>
)}
</div>
</div>
);
}