feat: enhance agent creation with human-like naming and improve task assignment notifications
This commit is contained in:
@@ -25,6 +25,11 @@ type Approval = {
|
||||
|
||||
type BoardApprovalsPanelProps = {
|
||||
boardId: string;
|
||||
approvals?: Approval[];
|
||||
isLoading?: boolean;
|
||||
error?: string | null;
|
||||
onRefresh?: () => void;
|
||||
onDecision?: (approvalId: string, status: "approved" | "rejected") => void;
|
||||
};
|
||||
|
||||
const formatTimestamp = (value?: string | null) => {
|
||||
@@ -51,14 +56,71 @@ const confidenceVariant = (confidence: number) => {
|
||||
return "warning";
|
||||
};
|
||||
|
||||
export function BoardApprovalsPanel({ boardId }: BoardApprovalsPanelProps) {
|
||||
const humanizeAction = (value: string) =>
|
||||
value
|
||||
.split(".")
|
||||
.map((part) =>
|
||||
part
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase())
|
||||
)
|
||||
.join(" · ");
|
||||
|
||||
const payloadValue = (payload: Approval["payload"], key: string) => {
|
||||
if (!payload) return null;
|
||||
const value = payload[key as keyof typeof payload];
|
||||
if (typeof value === "string" || typeof value === "number") {
|
||||
return String(value);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const approvalSummary = (approval: Approval) => {
|
||||
const payload = approval.payload ?? {};
|
||||
const taskId =
|
||||
payloadValue(payload, "task_id") ??
|
||||
payloadValue(payload, "taskId") ??
|
||||
payloadValue(payload, "taskID");
|
||||
const assignedAgentId =
|
||||
payloadValue(payload, "assigned_agent_id") ??
|
||||
payloadValue(payload, "assignedAgentId");
|
||||
const reason = payloadValue(payload, "reason");
|
||||
const title = payloadValue(payload, "title");
|
||||
const role = payloadValue(payload, "role");
|
||||
const isAssign = approval.action_type.includes("assign");
|
||||
const rows: Array<{ label: string; value: string }> = [];
|
||||
if (taskId) rows.push({ label: "Task", value: taskId });
|
||||
if (isAssign) {
|
||||
rows.push({
|
||||
label: "Assignee",
|
||||
value: assignedAgentId ?? "Unassigned",
|
||||
});
|
||||
}
|
||||
if (title) rows.push({ label: "Title", value: title });
|
||||
if (role) rows.push({ label: "Role", value: role });
|
||||
return { taskId, reason, rows };
|
||||
};
|
||||
|
||||
export function BoardApprovalsPanel({
|
||||
boardId,
|
||||
approvals: externalApprovals,
|
||||
isLoading: externalLoading,
|
||||
error: externalError,
|
||||
onRefresh,
|
||||
onDecision,
|
||||
}: BoardApprovalsPanelProps) {
|
||||
const { getToken, isSignedIn } = useAuth();
|
||||
const [approvals, setApprovals] = useState<Approval[]>([]);
|
||||
const [internalApprovals, setInternalApprovals] = useState<Approval[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [updatingId, setUpdatingId] = useState<string | null>(null);
|
||||
const usingExternal = Array.isArray(externalApprovals);
|
||||
const approvals = usingExternal ? externalApprovals ?? [] : internalApprovals;
|
||||
const loadingState = usingExternal ? externalLoading ?? false : isLoading;
|
||||
const errorState = usingExternal ? externalError ?? null : error;
|
||||
|
||||
const loadApprovals = useCallback(async () => {
|
||||
if (usingExternal) return;
|
||||
if (!isSignedIn || !boardId) return;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
@@ -71,23 +133,29 @@ export function BoardApprovalsPanel({ boardId }: BoardApprovalsPanelProps) {
|
||||
});
|
||||
if (!res.ok) throw new Error("Unable to load approvals.");
|
||||
const data = (await res.json()) as Approval[];
|
||||
setApprovals(data);
|
||||
setInternalApprovals(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Unable to load approvals.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [boardId, getToken, isSignedIn]);
|
||||
}, [boardId, getToken, isSignedIn, usingExternal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (usingExternal) return;
|
||||
loadApprovals();
|
||||
if (!isSignedIn || !boardId) return;
|
||||
const interval = setInterval(loadApprovals, 15000);
|
||||
return () => clearInterval(interval);
|
||||
}, [boardId, isSignedIn, loadApprovals]);
|
||||
}, [boardId, isSignedIn, loadApprovals, usingExternal]);
|
||||
|
||||
const handleDecision = useCallback(
|
||||
async (approvalId: string, status: "approved" | "rejected") => {
|
||||
if (onDecision) {
|
||||
onDecision(approvalId, status);
|
||||
return;
|
||||
}
|
||||
if (usingExternal) return;
|
||||
if (!isSignedIn || !boardId) return;
|
||||
setUpdatingId(approvalId);
|
||||
setError(null);
|
||||
@@ -106,7 +174,7 @@ export function BoardApprovalsPanel({ boardId }: BoardApprovalsPanelProps) {
|
||||
);
|
||||
if (!res.ok) throw new Error("Unable to update approval.");
|
||||
const updated = (await res.json()) as Approval;
|
||||
setApprovals((prev) =>
|
||||
setInternalApprovals((prev) =>
|
||||
prev.map((item) => (item.id === approvalId ? updated : item))
|
||||
);
|
||||
} catch (err) {
|
||||
@@ -117,19 +185,23 @@ export function BoardApprovalsPanel({ boardId }: BoardApprovalsPanelProps) {
|
||||
setUpdatingId(null);
|
||||
}
|
||||
},
|
||||
[boardId, getToken, isSignedIn]
|
||||
[boardId, getToken, isSignedIn, onDecision, usingExternal]
|
||||
);
|
||||
|
||||
const sortedApprovals = useMemo(() => {
|
||||
const pending = approvals.filter((item) => item.status === "pending");
|
||||
const resolved = approvals.filter((item) => item.status !== "pending");
|
||||
const sortByTime = (items: Approval[]) =>
|
||||
[...items].sort((a, b) => {
|
||||
const aTime = new Date(a.created_at).getTime();
|
||||
const bTime = new Date(b.created_at).getTime();
|
||||
return bTime - aTime;
|
||||
});
|
||||
return [...sortByTime(pending), ...sortByTime(resolved)];
|
||||
const pending = sortByTime(
|
||||
approvals.filter((item) => item.status === "pending")
|
||||
);
|
||||
const resolved = sortByTime(
|
||||
approvals.filter((item) => item.status !== "pending")
|
||||
);
|
||||
return { pending, resolved };
|
||||
}, [approvals]);
|
||||
|
||||
return (
|
||||
@@ -141,10 +213,14 @@ export function BoardApprovalsPanel({ boardId }: BoardApprovalsPanelProps) {
|
||||
Approvals
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-semibold text-strong">
|
||||
Pending decisions
|
||||
{sortedApprovals.pending.length} pending
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={loadApprovals}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onRefresh ?? loadApprovals}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
@@ -153,82 +229,179 @@ export function BoardApprovalsPanel({ boardId }: BoardApprovalsPanelProps) {
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 pt-5">
|
||||
{error ? (
|
||||
{errorState ? (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{error}
|
||||
{errorState}
|
||||
</div>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
{loadingState ? (
|
||||
<p className="text-sm text-muted">Loading approvals…</p>
|
||||
) : sortedApprovals.length === 0 ? (
|
||||
) : sortedApprovals.pending.length === 0 &&
|
||||
sortedApprovals.resolved.length === 0 ? (
|
||||
<p className="text-sm text-muted">No approvals yet.</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{sortedApprovals.map((approval) => (
|
||||
<div
|
||||
key={approval.id}
|
||||
className="space-y-2 rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-strong">
|
||||
{approval.action_type.replace(/_/g, " ")}
|
||||
</p>
|
||||
<p className="text-xs text-muted">
|
||||
Requested {formatTimestamp(approval.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={confidenceVariant(approval.confidence)}>
|
||||
{approval.confidence}% confidence
|
||||
</Badge>
|
||||
<Badge variant={statusBadgeVariant(approval.status)}>
|
||||
{approval.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{approval.payload || approval.rubric_scores ? (
|
||||
<details className="rounded-xl border border-dashed border-[color:var(--border)] px-3 py-2 text-xs text-muted">
|
||||
<summary className="cursor-pointer font-semibold text-strong">
|
||||
Details
|
||||
</summary>
|
||||
{approval.payload ? (
|
||||
<pre className="mt-2 whitespace-pre-wrap text-xs text-muted">
|
||||
Payload: {JSON.stringify(approval.payload, null, 2)}
|
||||
</pre>
|
||||
) : null}
|
||||
{approval.rubric_scores ? (
|
||||
<pre className="mt-2 whitespace-pre-wrap text-xs text-muted">
|
||||
Rubric: {JSON.stringify(approval.rubric_scores, null, 2)}
|
||||
</pre>
|
||||
) : null}
|
||||
</details>
|
||||
) : null}
|
||||
{approval.status === "pending" ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => handleDecision(approval.id, "approved")}
|
||||
disabled={updatingId === approval.id}
|
||||
<div className="space-y-6">
|
||||
{sortedApprovals.pending.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted">
|
||||
Pending
|
||||
</p>
|
||||
{sortedApprovals.pending.map((approval) => {
|
||||
const summary = approvalSummary(approval);
|
||||
return (
|
||||
<div
|
||||
key={approval.id}
|
||||
className="space-y-3 rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4"
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDecision(approval.id, "rejected")}
|
||||
disabled={updatingId === approval.id}
|
||||
className={cn(
|
||||
"border-[color:var(--danger)] text-[color:var(--danger)] hover:text-[color:var(--danger)]"
|
||||
)}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-strong">
|
||||
{humanizeAction(approval.action_type)}
|
||||
</p>
|
||||
<p className="text-xs text-muted">
|
||||
Requested {formatTimestamp(approval.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={confidenceVariant(approval.confidence)}>
|
||||
{approval.confidence}% confidence
|
||||
</Badge>
|
||||
<Badge variant={statusBadgeVariant(approval.status)}>
|
||||
{approval.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{summary.rows.length > 0 ? (
|
||||
<div className="grid gap-2 text-sm text-strong sm:grid-cols-2">
|
||||
{summary.rows.map((row) => (
|
||||
<div key={`${approval.id}-${row.label}`}>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted">
|
||||
{row.label}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-strong">
|
||||
{row.value}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{summary.reason ? (
|
||||
<p className="text-sm text-muted">{summary.reason}</p>
|
||||
) : null}
|
||||
{approval.payload || approval.rubric_scores ? (
|
||||
<details className="rounded-xl border border-dashed border-[color:var(--border)] px-3 py-2 text-xs text-muted">
|
||||
<summary className="cursor-pointer font-semibold text-strong">
|
||||
Details
|
||||
</summary>
|
||||
{approval.payload ? (
|
||||
<pre className="mt-2 whitespace-pre-wrap text-xs text-muted">
|
||||
Payload: {JSON.stringify(approval.payload, null, 2)}
|
||||
</pre>
|
||||
) : null}
|
||||
{approval.rubric_scores ? (
|
||||
<pre className="mt-2 whitespace-pre-wrap text-xs text-muted">
|
||||
Rubric:{" "}
|
||||
{JSON.stringify(approval.rubric_scores, null, 2)}
|
||||
</pre>
|
||||
) : null}
|
||||
</details>
|
||||
) : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => handleDecision(approval.id, "approved")}
|
||||
disabled={updatingId === approval.id}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDecision(approval.id, "rejected")}
|
||||
disabled={updatingId === approval.id}
|
||||
className={cn(
|
||||
"border-[color:var(--danger)] text-[color:var(--danger)] hover:text-[color:var(--danger)]"
|
||||
)}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
) : null}
|
||||
{sortedApprovals.resolved.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted">
|
||||
Resolved
|
||||
</p>
|
||||
{sortedApprovals.resolved.map((approval) => {
|
||||
const summary = approvalSummary(approval);
|
||||
return (
|
||||
<div
|
||||
key={approval.id}
|
||||
className="space-y-3 rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-strong">
|
||||
{humanizeAction(approval.action_type)}
|
||||
</p>
|
||||
<p className="text-xs text-muted">
|
||||
Requested {formatTimestamp(approval.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={confidenceVariant(approval.confidence)}>
|
||||
{approval.confidence}% confidence
|
||||
</Badge>
|
||||
<Badge variant={statusBadgeVariant(approval.status)}>
|
||||
{approval.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{summary.rows.length > 0 ? (
|
||||
<div className="grid gap-2 text-sm text-strong sm:grid-cols-2">
|
||||
{summary.rows.map((row) => (
|
||||
<div key={`${approval.id}-${row.label}`}>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted">
|
||||
{row.label}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-strong">
|
||||
{row.value}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{summary.reason ? (
|
||||
<p className="text-sm text-muted">{summary.reason}</p>
|
||||
) : null}
|
||||
{approval.payload || approval.rubric_scores ? (
|
||||
<details className="rounded-xl border border-dashed border-[color:var(--border)] px-3 py-2 text-xs text-muted">
|
||||
<summary className="cursor-pointer font-semibold text-strong">
|
||||
Details
|
||||
</summary>
|
||||
{approval.payload ? (
|
||||
<pre className="mt-2 whitespace-pre-wrap text-xs text-muted">
|
||||
Payload: {JSON.stringify(approval.payload, null, 2)}
|
||||
</pre>
|
||||
) : null}
|
||||
{approval.rubric_scores ? (
|
||||
<pre className="mt-2 whitespace-pre-wrap text-xs text-muted">
|
||||
Rubric:{" "}
|
||||
{JSON.stringify(approval.rubric_scores, null, 2)}
|
||||
</pre>
|
||||
) : null}
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -4,71 +4,53 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
interface TaskCardProps {
|
||||
title: string;
|
||||
status: string;
|
||||
priority?: string;
|
||||
assignee?: string;
|
||||
due?: string;
|
||||
onClick?: () => void;
|
||||
draggable?: boolean;
|
||||
isDragging?: boolean;
|
||||
onDragStart?: (event: React.DragEvent<HTMLDivElement>) => void;
|
||||
onDragEnd?: (event: React.DragEvent<HTMLDivElement>) => void;
|
||||
}
|
||||
|
||||
export function TaskCard({
|
||||
title,
|
||||
status,
|
||||
priority,
|
||||
assignee,
|
||||
due,
|
||||
onClick,
|
||||
draggable = false,
|
||||
isDragging = false,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
}: TaskCardProps) {
|
||||
const statusConfig: Record<
|
||||
string,
|
||||
{ label: string; dot: string; badge: string; text: string }
|
||||
> = {
|
||||
inbox: {
|
||||
label: "Inbox",
|
||||
dot: "bg-slate-400",
|
||||
badge: "bg-slate-100",
|
||||
text: "text-slate-600",
|
||||
},
|
||||
assigned: {
|
||||
label: "Assigned",
|
||||
dot: "bg-blue-500",
|
||||
badge: "bg-blue-50",
|
||||
text: "text-blue-700",
|
||||
},
|
||||
in_progress: {
|
||||
label: "In progress",
|
||||
dot: "bg-purple-500",
|
||||
badge: "bg-purple-50",
|
||||
text: "text-purple-700",
|
||||
},
|
||||
testing: {
|
||||
label: "Testing",
|
||||
dot: "bg-amber-500",
|
||||
badge: "bg-amber-50",
|
||||
text: "text-amber-700",
|
||||
},
|
||||
review: {
|
||||
label: "Review",
|
||||
dot: "bg-indigo-500",
|
||||
badge: "bg-indigo-50",
|
||||
text: "text-indigo-700",
|
||||
},
|
||||
done: {
|
||||
label: "Done",
|
||||
dot: "bg-green-500",
|
||||
badge: "bg-green-50",
|
||||
text: "text-green-700",
|
||||
},
|
||||
const priorityBadge = (value?: string) => {
|
||||
if (!value) return null;
|
||||
const normalized = value.toLowerCase();
|
||||
if (normalized === "high") {
|
||||
return "bg-rose-100 text-rose-700";
|
||||
}
|
||||
if (normalized === "medium") {
|
||||
return "bg-amber-100 text-amber-700";
|
||||
}
|
||||
if (normalized === "low") {
|
||||
return "bg-emerald-100 text-emerald-700";
|
||||
}
|
||||
return "bg-slate-100 text-slate-600";
|
||||
};
|
||||
|
||||
const config = statusConfig[status] ?? {
|
||||
label: status,
|
||||
dot: "bg-slate-400",
|
||||
badge: "bg-slate-100",
|
||||
text: "text-slate-600",
|
||||
};
|
||||
const priorityLabel = priority ? priority.toUpperCase() : "MEDIUM";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group cursor-pointer rounded-lg border border-slate-200 bg-white p-4 shadow-sm transition-all hover:-translate-y-0.5 hover:border-slate-300 hover:shadow-md"
|
||||
className={cn(
|
||||
"group cursor-pointer rounded-lg border border-slate-200 bg-white p-4 shadow-sm transition-all hover:-translate-y-0.5 hover:border-slate-300 hover:shadow-md",
|
||||
isDragging && "opacity-60 shadow-none",
|
||||
)}
|
||||
draggable={draggable}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onClick={onClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
@@ -81,18 +63,16 @@ export function TaskCard({
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 rounded-full px-2.5 py-1 text-[10px] font-semibold uppercase tracking-wide",
|
||||
config.badge,
|
||||
config.text,
|
||||
)}
|
||||
>
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full", config.dot)} />
|
||||
{config.label}
|
||||
</span>
|
||||
<p className="text-sm font-medium text-slate-900">{title}</p>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full px-2 py-1 text-[10px] font-semibold uppercase tracking-wide",
|
||||
priorityBadge(priority) ?? "bg-slate-100 text-slate-600",
|
||||
)}
|
||||
>
|
||||
{priorityLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between text-xs text-slate-500">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { TaskCard } from "@/components/molecules/TaskCard";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -21,6 +21,7 @@ type TaskBoardProps = {
|
||||
onCreateTask: () => void;
|
||||
isCreateDisabled?: boolean;
|
||||
onTaskSelect?: (task: Task) => void;
|
||||
onTaskMove?: (taskId: string, status: string) => void;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
@@ -30,6 +31,7 @@ const columns = [
|
||||
dot: "bg-slate-400",
|
||||
accent: "hover:border-slate-400 hover:bg-slate-50",
|
||||
text: "group-hover:text-slate-700 text-slate-500",
|
||||
badge: "bg-slate-100 text-slate-600",
|
||||
},
|
||||
{
|
||||
title: "In Progress",
|
||||
@@ -37,6 +39,7 @@ const columns = [
|
||||
dot: "bg-purple-500",
|
||||
accent: "hover:border-purple-400 hover:bg-purple-50",
|
||||
text: "group-hover:text-purple-600 text-slate-500",
|
||||
badge: "bg-purple-100 text-purple-700",
|
||||
},
|
||||
{
|
||||
title: "Review",
|
||||
@@ -44,6 +47,7 @@ const columns = [
|
||||
dot: "bg-indigo-500",
|
||||
accent: "hover:border-indigo-400 hover:bg-indigo-50",
|
||||
text: "group-hover:text-indigo-600 text-slate-500",
|
||||
badge: "bg-indigo-100 text-indigo-700",
|
||||
},
|
||||
{
|
||||
title: "Done",
|
||||
@@ -51,6 +55,7 @@ const columns = [
|
||||
dot: "bg-green-500",
|
||||
accent: "hover:border-green-400 hover:bg-green-50",
|
||||
text: "group-hover:text-green-600 text-slate-500",
|
||||
badge: "bg-emerald-100 text-emerald-700",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -69,7 +74,11 @@ export function TaskBoard({
|
||||
onCreateTask,
|
||||
isCreateDisabled = false,
|
||||
onTaskSelect,
|
||||
onTaskMove,
|
||||
}: TaskBoardProps) {
|
||||
const [draggingId, setDraggingId] = useState<string | null>(null);
|
||||
const [activeColumn, setActiveColumn] = useState<string | null>(null);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const buckets: Record<string, Task[]> = {};
|
||||
for (const column of columns) {
|
||||
@@ -82,12 +91,67 @@ export function TaskBoard({
|
||||
return buckets;
|
||||
}, [tasks]);
|
||||
|
||||
const handleDragStart =
|
||||
(task: Task) => (event: React.DragEvent<HTMLDivElement>) => {
|
||||
setDraggingId(task.id);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData(
|
||||
"text/plain",
|
||||
JSON.stringify({ taskId: task.id, status: task.status }),
|
||||
);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDraggingId(null);
|
||||
setActiveColumn(null);
|
||||
};
|
||||
|
||||
const handleDrop =
|
||||
(status: string) => (event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
setActiveColumn(null);
|
||||
const raw = event.dataTransfer.getData("text/plain");
|
||||
if (!raw) return;
|
||||
try {
|
||||
const payload = JSON.parse(raw) as { taskId?: string; status?: string };
|
||||
if (!payload.taskId || !payload.status) return;
|
||||
if (payload.status === status) return;
|
||||
onTaskMove?.(payload.taskId, status);
|
||||
} catch {
|
||||
// Ignore malformed payloads.
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver =
|
||||
(status: string) => (event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
if (activeColumn !== status) {
|
||||
setActiveColumn(status);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave =
|
||||
(status: string) => (_event: React.DragEvent<HTMLDivElement>) => {
|
||||
if (activeColumn === status) {
|
||||
setActiveColumn(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-flow-col auto-cols-[minmax(260px,320px)] gap-4 overflow-x-auto pb-6">
|
||||
{columns.map((column) => {
|
||||
const columnTasks = grouped[column.status] ?? [];
|
||||
return (
|
||||
<div key={column.title} className="kanban-column min-h-[calc(100vh-260px)]">
|
||||
<div
|
||||
key={column.title}
|
||||
className={cn(
|
||||
"kanban-column min-h-[calc(100vh-260px)]",
|
||||
activeColumn === column.status && "ring-2 ring-slate-200",
|
||||
)}
|
||||
onDrop={handleDrop(column.status)}
|
||||
onDragOver={handleDragOver(column.status)}
|
||||
onDragLeave={handleDragLeave(column.status)}
|
||||
>
|
||||
<div className="column-header sticky top-0 z-10 rounded-t-xl border border-b-0 border-slate-200 bg-white/80 px-4 py-3 backdrop-blur">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -96,37 +160,30 @@ export function TaskBoard({
|
||||
{column.title}
|
||||
</h3>
|
||||
</div>
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-slate-100 text-xs font-semibold text-slate-600">
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-6 w-6 items-center justify-center rounded-full text-xs font-semibold",
|
||||
column.badge,
|
||||
)}
|
||||
>
|
||||
{columnTasks.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-b-xl border border-t-0 border-slate-200 bg-white p-3">
|
||||
{column.status === "inbox" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreateTask}
|
||||
disabled={isCreateDisabled}
|
||||
className={cn(
|
||||
"group mb-3 flex w-full items-center justify-center rounded-lg border-2 border-dashed border-slate-300 px-4 py-4 text-sm font-medium transition",
|
||||
column.accent,
|
||||
isCreateDisabled && "cursor-not-allowed opacity-60"
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex items-center gap-2", column.text)}>
|
||||
<span className="text-sm font-medium">New task</span>
|
||||
</div>
|
||||
</button>
|
||||
) : null}
|
||||
<div className="space-y-3">
|
||||
{columnTasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
title={task.title}
|
||||
status={column.status}
|
||||
priority={task.priority}
|
||||
assignee={task.assignee}
|
||||
due={formatDueDate(task.due_at)}
|
||||
onClick={() => onTaskSelect?.(task)}
|
||||
draggable
|
||||
isDragging={draggingId === task.id}
|
||||
onDragStart={handleDragStart(task)}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user