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.
183 lines
7.2 KiB
TypeScript
183 lines
7.2 KiB
TypeScript
// app/tasks/TaskDetail.tsx
|
||
// Expanded panel for a single task. Shows log, result, error. Has
|
||
// retry, cancel, and delete buttons.
|
||
|
||
"use client";
|
||
|
||
import { useState } from "react";
|
||
import { TASK_TYPE_META, STATUS_LABEL, PRIORITY_LABEL } from "./columns";
|
||
import { type Task, retryTask, cancelTask, deleteTask } from "./api";
|
||
|
||
interface TaskDetailProps {
|
||
task: Task;
|
||
onClose: () => void;
|
||
onMutated: () => void;
|
||
onToast: (kind: "info" | "success" | "error" | "warn", title: string, body?: string) => void;
|
||
}
|
||
|
||
export default function TaskDetail({ task, onClose, onMutated, onToast }: TaskDetailProps) {
|
||
const [busy, setBusy] = useState<string | null>(null);
|
||
const meta = TASK_TYPE_META[task.task_type];
|
||
|
||
async function act(label: string, fn: () => Promise<any>) {
|
||
setBusy(label);
|
||
try {
|
||
await fn();
|
||
onMutated();
|
||
onToast("success", `${label} done`);
|
||
} catch (err: any) {
|
||
onToast("error", `${label} failed`, err.message);
|
||
} finally {
|
||
setBusy(null);
|
||
}
|
||
}
|
||
|
||
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-2xl"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="p-5 space-y-4">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex flex-wrap items-center gap-2 mb-2">
|
||
<span className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded border ${meta.tone}`}>
|
||
{meta.icon} {meta.label}
|
||
</span>
|
||
<span className="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded border bg-blue-50 text-blue-700 border-blue-200">
|
||
{STATUS_LABEL[task.status]}
|
||
</span>
|
||
<span className="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded border bg-amber-50 text-amber-800 border-amber-200">
|
||
{PRIORITY_LABEL[task.priority]}
|
||
</span>
|
||
{task.retry_count > 0 ? (
|
||
<span className="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded border bg-orange-50 text-orange-800 border-orange-300">
|
||
retry ×{task.retry_count}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
<h2 className="text-xl font-bold text-fg leading-tight">{task.title}</h2>
|
||
{task.description ? (
|
||
<p className="text-sm text-muted mt-2 whitespace-pre-wrap">{task.description}</p>
|
||
) : null}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="text-muted hover:text-fg text-sm"
|
||
aria-label="Close"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-3 text-xs">
|
||
<Meta label="Assignee" value={task.assignee ?? "—"} />
|
||
<Meta label="Claimed by" value={task.claimed_by ?? "—"} />
|
||
<Meta label="Created" value={formatTime(task.created)} />
|
||
<Meta label="Claimed at" value={formatTime(task.claimed_at)} />
|
||
<Meta label="Started" value={formatTime(task.started_at)} />
|
||
<Meta label="Completed" value={formatTime(task.completed_at)} />
|
||
<Meta label="Duration" value={task.duration_ms != null ? `${(task.duration_ms / 1000).toFixed(2)}s` : "—"} />
|
||
<Meta label="Created by" value={task.created_by ?? "—"} />
|
||
</div>
|
||
|
||
{task.payload && Object.keys(task.payload as object).length > 0 ? (
|
||
<Section title="Payload">
|
||
<pre className="text-xs font-mono bg-bg p-3 rounded border border-border overflow-x-auto">
|
||
{JSON.stringify(task.payload, null, 2)}
|
||
</pre>
|
||
</Section>
|
||
) : null}
|
||
|
||
{task.error ? (
|
||
<Section title="Error">
|
||
<p className="text-xs text-red-700 bg-red-50 border border-red-300 p-3 rounded">
|
||
{task.error}
|
||
</p>
|
||
</Section>
|
||
) : null}
|
||
|
||
{task.log && task.log.length > 0 ? (
|
||
<Section title={`Log (${task.log.length} entries)`}>
|
||
<pre className="text-[11px] font-mono bg-bg p-3 rounded border border-border overflow-x-auto whitespace-pre-wrap max-h-60 overflow-y-auto">
|
||
{task.log.join("\n")}
|
||
</pre>
|
||
</Section>
|
||
) : null}
|
||
|
||
{task.result ? (
|
||
<Section title="Result">
|
||
<pre className="text-xs font-mono bg-bg p-3 rounded border border-border overflow-x-auto max-h-60 overflow-y-auto">
|
||
{JSON.stringify(task.result, null, 2)}
|
||
</pre>
|
||
</Section>
|
||
) : null}
|
||
|
||
<div className="flex flex-wrap gap-2 pt-2 border-t border-border">
|
||
{(task.status === "failed" || task.status === "done") && (
|
||
<button
|
||
onClick={() => act("Retry", () => retryTask(task.id))}
|
||
disabled={busy !== null}
|
||
className="px-3 py-1.5 rounded-md bg-blue-600 text-white text-xs font-semibold hover:bg-blue-700 disabled:opacity-50"
|
||
>
|
||
{busy === "Retry" ? "Retrying…" : "↻ Retry"}
|
||
</button>
|
||
)}
|
||
{task.status === "in_progress" && (
|
||
<button
|
||
onClick={() => act("Cancel", () => cancelTask(task.id))}
|
||
disabled={busy !== null}
|
||
className="px-3 py-1.5 rounded-md bg-amber-500 text-white text-xs font-semibold hover:bg-amber-600 disabled:opacity-50"
|
||
>
|
||
{busy === "Cancel" ? "Cancelling…" : "✕ Cancel"}
|
||
</button>
|
||
)}
|
||
<button
|
||
onClick={() => {
|
||
if (confirm(`Delete "${task.title}"? This cannot be undone.`)) {
|
||
act("Delete", () => deleteTask(task.id).then(onClose));
|
||
}
|
||
}}
|
||
disabled={busy !== null}
|
||
className="px-3 py-1.5 rounded-md border border-red-300 text-red-700 text-xs font-semibold hover:bg-red-50 disabled:opacity-50"
|
||
>
|
||
{busy === "Delete" ? "Deleting…" : "🗑 Delete"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Meta({ label, value }: { label: string; value: string }) {
|
||
return (
|
||
<div>
|
||
<p className="text-[10px] font-semibold text-muted uppercase tracking-wider mb-0.5">{label}</p>
|
||
<p className="text-fg">{value}</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||
return (
|
||
<div>
|
||
<h3 className="text-xs font-semibold text-muted uppercase tracking-wider mb-1.5">{title}</h3>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function formatTime(iso: string | null): string {
|
||
if (!iso) return "—";
|
||
const t = new Date(iso.includes("T") ? iso : iso.replace(" ", "T") + "Z");
|
||
if (isNaN(t.getTime())) return "—";
|
||
return t.toLocaleString();
|
||
}
|