Workflow-App/Workflow/components/ClientToggle.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

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