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.
402 lines
14 KiB
TypeScript
402 lines
14 KiB
TypeScript
// app/tasks/CardModal.tsx
|
|
// New / edit card modal. Fields adapt to the selected task_type.
|
|
|
|
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import {
|
|
TASK_TYPE_LABEL,
|
|
PRIORITY_LABEL,
|
|
type TaskType,
|
|
type TaskPriority,
|
|
type TaskStatus,
|
|
} from "./columns";
|
|
import type { Task, CreateTaskInput } from "./api";
|
|
|
|
interface CardModalProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
onSubmit: (input: CreateTaskInput) => Promise<void>;
|
|
initial?: Partial<Task>;
|
|
mode?: "create" | "edit";
|
|
}
|
|
|
|
const TASK_TYPES: TaskType[] = [
|
|
"bizportal_search",
|
|
"bizportal_reserve",
|
|
"pdf_generate",
|
|
"email_send",
|
|
"custom",
|
|
];
|
|
|
|
const PRIORITIES: TaskPriority[] = ["low", "normal", "high", "urgent"];
|
|
const STATUSES: TaskStatus[] = ["backlog", "ready", "blocked"];
|
|
|
|
export default function CardModal({ open, onClose, onSubmit, initial, mode = "create" }: CardModalProps) {
|
|
const [title, setTitle] = useState("");
|
|
const [description, setDescription] = useState("");
|
|
const [taskType, setTaskType] = useState<TaskType>("bizportal_search");
|
|
const [priority, setPriority] = useState<TaskPriority>("normal");
|
|
const [status, setStatus] = useState<TaskStatus>("backlog");
|
|
const [assignee, setAssignee] = useState("agent");
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// Payload fields per task type
|
|
const [bpType, setBpType] = useState<"id" | "ck">("ck");
|
|
const [bpQuery, setBpQuery] = useState("");
|
|
const [bpNames, setBpNames] = useState<string>("");
|
|
const [bpDryRun, setBpDryRun] = useState(false);
|
|
const [pdfTemplate, setPdfTemplate] = useState("");
|
|
const [pdfSource, setPdfSource] = useState("");
|
|
const [emailTo, setEmailTo] = useState("");
|
|
const [emailSubject, setEmailSubject] = useState("");
|
|
const [emailBody, setEmailBody] = useState("");
|
|
const [customJson, setCustomJson] = useState("");
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
setError(null);
|
|
if (initial) {
|
|
setTitle(initial.title ?? "");
|
|
setDescription(initial.description ?? "");
|
|
setTaskType(initial.task_type ?? "bizportal_search");
|
|
setPriority(initial.priority ?? "normal");
|
|
setStatus(initial.status ?? "backlog");
|
|
setAssignee(initial.assignee ?? "agent");
|
|
const p = (initial.payload ?? {}) as Record<string, any>;
|
|
setBpType(p.type === "id" ? "id" : "ck");
|
|
setBpQuery(p.query ?? "");
|
|
setBpNames((p.names ?? []).join("\n"));
|
|
setBpDryRun(!!p.dry_run);
|
|
setPdfTemplate(p.template ?? "");
|
|
setPdfSource(p.source ?? "");
|
|
setEmailTo(p.to ?? "");
|
|
setEmailSubject(p.subject ?? "");
|
|
setEmailBody(p.body ?? "");
|
|
setCustomJson(p.raw ? JSON.stringify(p.raw, null, 2) : "");
|
|
} else {
|
|
setTitle("");
|
|
setDescription("");
|
|
setTaskType("bizportal_search");
|
|
setPriority("normal");
|
|
setStatus("backlog");
|
|
setAssignee("agent");
|
|
setBpType("ck");
|
|
setBpQuery("");
|
|
setBpNames("");
|
|
setBpDryRun(false);
|
|
setPdfTemplate("");
|
|
setPdfSource("");
|
|
setEmailTo("");
|
|
setEmailSubject("");
|
|
setEmailBody("");
|
|
setCustomJson("");
|
|
}
|
|
}, [open, initial]);
|
|
|
|
if (!open) return null;
|
|
|
|
function buildPayload(): Record<string, unknown> {
|
|
switch (taskType) {
|
|
case "bizportal_search":
|
|
return { type: bpType, query: bpQuery.trim() };
|
|
case "bizportal_reserve":
|
|
return {
|
|
names: bpNames.split("\n").map((s) => s.trim()).filter(Boolean),
|
|
dry_run: bpDryRun,
|
|
};
|
|
case "pdf_generate":
|
|
return { template: pdfTemplate.trim(), source: pdfSource.trim() };
|
|
case "email_send":
|
|
return { to: emailTo.trim(), subject: emailSubject.trim(), body: emailBody };
|
|
case "custom":
|
|
try {
|
|
return { raw: customJson ? JSON.parse(customJson) : null };
|
|
} catch (e: any) {
|
|
throw new Error(`Custom JSON is invalid: ${e.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setError(null);
|
|
if (!title.trim()) {
|
|
setError("Title is required");
|
|
return;
|
|
}
|
|
setSubmitting(true);
|
|
try {
|
|
const payload = buildPayload();
|
|
await onSubmit({
|
|
title: title.trim(),
|
|
description: description.trim() || undefined,
|
|
task_type: taskType,
|
|
payload,
|
|
priority,
|
|
status,
|
|
assignee,
|
|
created_by: "user",
|
|
});
|
|
onClose();
|
|
} catch (err: any) {
|
|
setError(err.message ?? String(err));
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-40 bg-black/40 flex items-start justify-center pt-12 px-4 overflow-y-auto"
|
|
onClick={onClose}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
>
|
|
<div
|
|
className="bg-surface rounded-lg border border-border shadow-xl w-full max-w-lg"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<form onSubmit={handleSubmit} className="p-5 space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-lg font-bold text-fg">
|
|
{mode === "edit" ? "Edit task" : "New task"}
|
|
</h2>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="text-muted hover:text-fg text-sm"
|
|
aria-label="Close"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
|
|
<Field label="Title">
|
|
<input
|
|
type="text"
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
placeholder="e.g. Reserve name AMPLEFORTH PTY LTD"
|
|
className="w-full px-3 py-2 rounded-md border border-border bg-bg text-fg text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
|
autoFocus
|
|
required
|
|
/>
|
|
</Field>
|
|
|
|
<Field label="Description">
|
|
<textarea
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
placeholder="Optional — context for the agent"
|
|
rows={2}
|
|
className="w-full px-3 py-2 rounded-md border border-border bg-bg text-fg text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
|
/>
|
|
</Field>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Field label="Type">
|
|
<select
|
|
value={taskType}
|
|
onChange={(e) => setTaskType(e.target.value as TaskType)}
|
|
className="w-full px-3 py-2 rounded-md border border-border bg-bg text-fg text-sm"
|
|
>
|
|
{TASK_TYPES.map((t) => (
|
|
<option key={t} value={t}>
|
|
{TASK_TYPE_LABEL[t]}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</Field>
|
|
<Field label="Priority">
|
|
<select
|
|
value={priority}
|
|
onChange={(e) => setPriority(e.target.value as TaskPriority)}
|
|
className="w-full px-3 py-2 rounded-md border border-border bg-bg text-fg text-sm"
|
|
>
|
|
{PRIORITIES.map((p) => (
|
|
<option key={p} value={p}>
|
|
{PRIORITY_LABEL[p]}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</Field>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Field label="Status">
|
|
<select
|
|
value={status}
|
|
onChange={(e) => setStatus(e.target.value as TaskStatus)}
|
|
className="w-full px-3 py-2 rounded-md border border-border bg-bg text-fg text-sm"
|
|
>
|
|
{STATUSES.map((s) => (
|
|
<option key={s} value={s}>
|
|
{s.replace("_", " ")}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</Field>
|
|
<Field label="Assignee">
|
|
<input
|
|
type="text"
|
|
value={assignee}
|
|
onChange={(e) => setAssignee(e.target.value)}
|
|
className="w-full px-3 py-2 rounded-md border border-border bg-bg text-fg text-sm"
|
|
/>
|
|
</Field>
|
|
</div>
|
|
|
|
<hr className="border-border" />
|
|
<h3 className="text-sm font-semibold text-fg">Payload</h3>
|
|
|
|
{taskType === "bizportal_search" ? (
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<Field label="Search by">
|
|
<select
|
|
value={bpType}
|
|
onChange={(e) => setBpType(e.target.value as "id" | "ck")}
|
|
className="w-full px-3 py-2 rounded-md border border-border bg-bg text-fg text-sm"
|
|
>
|
|
<option value="ck">CK No.</option>
|
|
<option value="id">Director ID</option>
|
|
</select>
|
|
</Field>
|
|
<div className="col-span-2">
|
|
<Field label="Query">
|
|
<input
|
|
type="text"
|
|
value={bpQuery}
|
|
onChange={(e) => setBpQuery(e.target.value)}
|
|
placeholder={bpType === "ck" ? "K2022808158" : "8709225206083"}
|
|
className="w-full px-3 py-2 rounded-md border border-border bg-bg text-fg text-sm"
|
|
/>
|
|
</Field>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{taskType === "bizportal_reserve" ? (
|
|
<div className="space-y-3">
|
|
<Field label="Names (one per line, in priority order)">
|
|
<textarea
|
|
value={bpNames}
|
|
onChange={(e) => setBpNames(e.target.value)}
|
|
rows={4}
|
|
placeholder={"AMPLEFORTH PTY LTD\nAMPLEFORTH TRADING\nAMPLEFORTH SA\nAMPLEFORTH HOLDINGS"}
|
|
className="w-full px-3 py-2 rounded-md border border-border bg-bg text-fg text-sm font-mono"
|
|
/>
|
|
</Field>
|
|
<label className="flex items-center gap-2 text-sm text-fg">
|
|
<input
|
|
type="checkbox"
|
|
checked={bpDryRun}
|
|
onChange={(e) => setBpDryRun(e.target.checked)}
|
|
/>
|
|
Dry run (stop before payment — no R50 spent)
|
|
</label>
|
|
</div>
|
|
) : null}
|
|
|
|
{taskType === "pdf_generate" ? (
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Field label="Template">
|
|
<input
|
|
type="text"
|
|
value={pdfTemplate}
|
|
onChange={(e) => setPdfTemplate(e.target.value)}
|
|
placeholder="e.g. cor9_1_approval"
|
|
className="w-full px-3 py-2 rounded-md border border-border bg-bg text-fg text-sm"
|
|
/>
|
|
</Field>
|
|
<Field label="Source">
|
|
<input
|
|
type="text"
|
|
value={pdfSource}
|
|
onChange={(e) => setPdfSource(e.target.value)}
|
|
placeholder="path or record id"
|
|
className="w-full px-3 py-2 rounded-md border border-border bg-bg text-fg text-sm"
|
|
/>
|
|
</Field>
|
|
</div>
|
|
) : null}
|
|
|
|
{taskType === "email_send" ? (
|
|
<div className="space-y-3">
|
|
<Field label="To">
|
|
<input
|
|
type="email"
|
|
value={emailTo}
|
|
onChange={(e) => setEmailTo(e.target.value)}
|
|
placeholder="client@example.com"
|
|
className="w-full px-3 py-2 rounded-md border border-border bg-bg text-fg text-sm"
|
|
/>
|
|
</Field>
|
|
<Field label="Subject">
|
|
<input
|
|
type="text"
|
|
value={emailSubject}
|
|
onChange={(e) => setEmailSubject(e.target.value)}
|
|
className="w-full px-3 py-2 rounded-md border border-border bg-bg text-fg text-sm"
|
|
/>
|
|
</Field>
|
|
<Field label="Body">
|
|
<textarea
|
|
value={emailBody}
|
|
onChange={(e) => setEmailBody(e.target.value)}
|
|
rows={4}
|
|
className="w-full px-3 py-2 rounded-md border border-border bg-bg text-fg text-sm"
|
|
/>
|
|
</Field>
|
|
</div>
|
|
) : null}
|
|
|
|
{taskType === "custom" ? (
|
|
<Field label="Custom payload (JSON)">
|
|
<textarea
|
|
value={customJson}
|
|
onChange={(e) => setCustomJson(e.target.value)}
|
|
rows={6}
|
|
placeholder='{ "instruction": "do this and that" }'
|
|
className="w-full px-3 py-2 rounded-md border border-border bg-bg text-fg text-xs font-mono"
|
|
/>
|
|
</Field>
|
|
) : null}
|
|
|
|
{error ? (
|
|
<div className="text-xs text-red-700 bg-red-50 border border-red-300 rounded px-3 py-2">
|
|
{error}
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="flex justify-end gap-2 pt-2">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="px-4 py-2 rounded-md text-sm text-muted hover:text-fg"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={submitting}
|
|
className="px-5 py-2 rounded-md bg-accent text-accent-on text-sm font-semibold hover:bg-accent-hover disabled:opacity-60"
|
|
>
|
|
{submitting ? "Saving…" : mode === "edit" ? "Save" : "Create task"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
|
return (
|
|
<label className="block">
|
|
<span className="text-xs font-semibold text-muted mb-1 block">{label}</span>
|
|
{children}
|
|
</label>
|
|
);
|
|
}
|