Workflow-App/Workflow/app/tasks/CardModal.tsx

403 lines
14 KiB
TypeScript
Raw Permalink Normal View History

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