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.
150 lines
4.8 KiB
TypeScript
150 lines
4.8 KiB
TypeScript
"use client";
|
|
|
|
// components/ClientToggle.tsx
|
|
// Toggles `is_client` on a director or company row.
|
|
//
|
|
// Optimistic: flips UI immediately, reverts and shows an inline
|
|
// error banner if the API call fails. Used both inline (row hover)
|
|
// and on the detail page (full button + label).
|
|
|
|
import { useState } from "react";
|
|
import { pbToggleClient } from "@/lib/api";
|
|
|
|
type Collection = "ysterbaard_directors" | "ysterbaard_companies";
|
|
|
|
interface Props {
|
|
collection: Collection;
|
|
id: string;
|
|
isClient: boolean;
|
|
/** "pill" hides the text label so it can sit inline in a row;
|
|
* "button" shows the label + on/off state. */
|
|
variant?: "pill" | "button";
|
|
/** Optional className passthrough. */
|
|
className?: string;
|
|
/** Called after a successful server-side toggle. Use to invalidate
|
|
* the surrounding query so PB truth is reflected. */
|
|
onAfterToggle?: () => void;
|
|
}
|
|
|
|
export default function ClientToggle({
|
|
collection,
|
|
id,
|
|
isClient,
|
|
variant = "pill",
|
|
className = "",
|
|
onAfterToggle
|
|
}: Props) {
|
|
const [optimistic, setOptimistic] = useState(isClient);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// When the upstream `isClient` prop changes (e.g. after a refetch),
|
|
// keep optimistic in sync.
|
|
if (!busy && optimistic !== isClient) {
|
|
setOptimistic(isClient);
|
|
}
|
|
|
|
async function toggle() {
|
|
const next = !optimistic;
|
|
// Confirm before making the change
|
|
const confirmed = window.confirm(
|
|
next
|
|
? "Mark this as a client?\n\nClick OK to confirm, Cancel to abort."
|
|
: "Remove client status?\n\nClick OK to confirm, Cancel to abort."
|
|
);
|
|
if (!confirmed) return;
|
|
|
|
setOptimistic(next);
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
await pbToggleClient({
|
|
collection,
|
|
id,
|
|
is_client: next
|
|
});
|
|
onAfterToggle?.();
|
|
} catch (e) {
|
|
// Revert and surface.
|
|
setOptimistic(!next);
|
|
setError(e instanceof Error ? e.message : "Toggle failed");
|
|
// Auto-clear the inline error after a beat.
|
|
setTimeout(() => setError(null), 5000);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
if (variant === "button") {
|
|
return (
|
|
<div className={`flex flex-col gap-2 ${className}`}>
|
|
<button
|
|
type="button"
|
|
role="switch"
|
|
aria-checked={optimistic}
|
|
onClick={toggle}
|
|
disabled={busy}
|
|
className={
|
|
"inline-flex items-center gap-3 px-4 py-2.5 rounded-md border text-sm font-semibold transition-colors disabled:opacity-60 disabled:cursor-not-allowed " +
|
|
(optimistic
|
|
? "bg-[color-mix(in_oklch,var(--accent)_15%,white)] text-[color:var(--accent)] border-[color:var(--accent)]/45 hover:bg-[color-mix(in_oklch,var(--accent)_22%,white)]"
|
|
: "bg-surface text-fg border-border hover:bg-surface-warm")
|
|
}
|
|
>
|
|
<span
|
|
aria-hidden="true"
|
|
className={
|
|
"w-9 h-5 rounded-pill relative transition-colors " +
|
|
(optimistic ? "bg-[color:var(--accent)]" : "bg-border")
|
|
}
|
|
>
|
|
<span
|
|
className={
|
|
"absolute top-0.5 w-4 h-4 rounded-pill bg-white shadow transition-transform " +
|
|
(optimistic ? "translate-x-[18px]" : "translate-x-0.5")
|
|
}
|
|
/>
|
|
</span>
|
|
<span>{optimistic ? "Active client" : "Mark as client"}</span>
|
|
</button>
|
|
{error ? (
|
|
<p className="text-xs text-[color:var(--danger)]" role="alert">
|
|
{error}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// pill (row-inline) variant
|
|
return (
|
|
<span className={`inline-flex items-center gap-2 ${className}`}>
|
|
<button
|
|
type="button"
|
|
role="switch"
|
|
aria-checked={optimistic}
|
|
title={optimistic ? "Active client — click to remove" : "Click to mark as a client"}
|
|
onClick={toggle}
|
|
disabled={busy}
|
|
className={
|
|
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-pill border text-xs font-semibold whitespace-nowrap transition-colors disabled:opacity-60 disabled:cursor-not-allowed " +
|
|
(optimistic
|
|
? "bg-[color-mix(in_oklch,var(--accent)_15%,white)] text-[color:var(--accent)] border-[color:var(--accent)]/45 hover:bg-[color-mix(in_oklch,var(--accent)_22%,white)]"
|
|
: "bg-surface-warm text-muted border-border hover:bg-surface hover:text-fg")
|
|
}
|
|
>
|
|
<span
|
|
aria-hidden="true"
|
|
className="w-1.5 h-1.5 rounded-pill"
|
|
style={{ backgroundColor: "currentColor" }}
|
|
/>
|
|
{optimistic ? "Client" : "Make client"}
|
|
</button>
|
|
{error ? (
|
|
<span className="text-xs text-[color:var(--danger)]" role="alert">
|
|
⚠ {error}
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
);
|
|
}
|