2026-02-11 17:55:26 -08:00
|
|
|
'use client';
|
|
|
|
|
|
2026-02-11 18:38:51 -08:00
|
|
|
import { AnimatePresence } from 'framer-motion';
|
2026-02-11 19:59:55 -08:00
|
|
|
import type { DragEvent } from 'react';
|
2026-02-11 18:38:51 -08:00
|
|
|
|
2026-02-11 19:59:55 -08:00
|
|
|
import { KANBAN_STATUSES, type KanbanStatus } from '../../lib/kanban';
|
2026-02-11 17:55:26 -08:00
|
|
|
import type { BeadIssue } from '../../lib/types';
|
|
|
|
|
|
|
|
|
|
import { KanbanCard } from './kanban-card';
|
|
|
|
|
|
|
|
|
|
interface KanbanBoardProps {
|
|
|
|
|
columns: Record<(typeof KANBAN_STATUSES)[number], BeadIssue[]>;
|
|
|
|
|
selectedIssueId: string | null;
|
2026-02-11 19:59:55 -08:00
|
|
|
pendingIssueIds: Set<string>;
|
|
|
|
|
activeStatus: KanbanStatus | null;
|
|
|
|
|
onActivateStatus: (status: KanbanStatus | null) => void;
|
|
|
|
|
onMoveIssue: (issue: BeadIssue, targetStatus: KanbanStatus) => void;
|
2026-02-11 17:55:26 -08:00
|
|
|
onSelect: (issue: BeadIssue) => void;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-11 18:38:51 -08:00
|
|
|
const STATUS_META: Record<(typeof KANBAN_STATUSES)[number], { label: string; dot: string }> = {
|
2026-02-11 19:59:55 -08:00
|
|
|
open: { label: 'Open', dot: 'bg-zinc-300' },
|
2026-02-11 18:38:51 -08:00
|
|
|
in_progress: { label: 'In Progress', dot: 'bg-amber-300' },
|
|
|
|
|
blocked: { label: 'Blocked', dot: 'bg-rose-300' },
|
2026-02-11 19:59:55 -08:00
|
|
|
deferred: { label: 'Deferred', dot: 'bg-stone-400' },
|
2026-02-11 18:38:51 -08:00
|
|
|
closed: { label: 'Done', dot: 'bg-emerald-300' },
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-11 19:01:34 -08:00
|
|
|
const STATUS_COLUMN_CLASS: Record<(typeof KANBAN_STATUSES)[number], string> = {
|
2026-02-11 19:59:55 -08:00
|
|
|
open: 'bg-zinc-500/10',
|
2026-02-11 19:01:34 -08:00
|
|
|
in_progress: 'bg-amber-500/10',
|
|
|
|
|
blocked: 'bg-rose-500/10',
|
2026-02-11 19:59:55 -08:00
|
|
|
deferred: 'bg-stone-500/10',
|
2026-02-11 19:01:34 -08:00
|
|
|
closed: 'bg-emerald-500/10',
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-11 19:59:55 -08:00
|
|
|
export function KanbanBoard({ columns, selectedIssueId, pendingIssueIds, activeStatus, onActivateStatus, onMoveIssue, onSelect }: KanbanBoardProps) {
|
|
|
|
|
const allIssues = KANBAN_STATUSES.flatMap((status) => columns[status]);
|
|
|
|
|
|
|
|
|
|
const issueLookup = new Map(allIssues.map((issue) => [issue.id, issue]));
|
|
|
|
|
|
|
|
|
|
const handleExpandAndSelect = (status: KanbanStatus, issue: BeadIssue) => {
|
|
|
|
|
onActivateStatus(status);
|
|
|
|
|
onSelect(issue);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onDragStart = (issue: BeadIssue, event: DragEvent<HTMLButtonElement>) => {
|
|
|
|
|
event.dataTransfer.setData('application/x-bead-id', issue.id);
|
|
|
|
|
event.dataTransfer.setData('application/x-bead-status', issue.status);
|
|
|
|
|
event.dataTransfer.effectAllowed = 'move';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onDropLane = (targetStatus: KanbanStatus, event: DragEvent<HTMLDivElement>) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
const issueId = event.dataTransfer.getData('application/x-bead-id');
|
|
|
|
|
const sourceStatus = event.dataTransfer.getData('application/x-bead-status') as KanbanStatus;
|
|
|
|
|
if (!issueId || !sourceStatus || sourceStatus === targetStatus) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const issue = issueLookup.get(issueId);
|
|
|
|
|
if (!issue) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onMoveIssue(issue, targetStatus);
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-11 17:55:26 -08:00
|
|
|
return (
|
2026-02-11 19:59:55 -08:00
|
|
|
<section className="grid min-h-[58vh] gap-2.5">
|
2026-02-11 17:55:26 -08:00
|
|
|
{KANBAN_STATUSES.map((status) => (
|
2026-02-11 19:01:34 -08:00
|
|
|
<div
|
|
|
|
|
key={status}
|
2026-02-11 19:59:55 -08:00
|
|
|
onDragOver={(event) => event.preventDefault()}
|
|
|
|
|
onDrop={(event) => onDropLane(status, event)}
|
|
|
|
|
className={`rounded-2xl border border-border-soft ${STATUS_COLUMN_CLASS[status]} p-2.5 transition ${
|
|
|
|
|
activeStatus === status ? 'shadow-card' : 'opacity-90'
|
|
|
|
|
}`}
|
2026-02-11 19:01:34 -08:00
|
|
|
>
|
2026-02-11 19:59:55 -08:00
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
aria-expanded={activeStatus === status}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
onActivateStatus(status);
|
|
|
|
|
const firstIssue = columns[status][0];
|
|
|
|
|
if (firstIssue) {
|
|
|
|
|
onSelect(firstIssue);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
className="flex w-full items-center justify-between rounded-lg px-1 py-0.5 text-left"
|
|
|
|
|
>
|
|
|
|
|
<strong className="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.14em] text-text-body">
|
|
|
|
|
<span className={`h-2 w-2 rounded-full ${STATUS_META[status].dot}`} />
|
|
|
|
|
{STATUS_META[status].label}
|
|
|
|
|
</strong>
|
|
|
|
|
<span className="font-mono text-xs text-text-muted">{columns[status].length}</span>
|
|
|
|
|
</button>
|
|
|
|
|
{activeStatus === status ? (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
aria-label={`Minimize ${STATUS_META[status].label} lane`}
|
|
|
|
|
onClick={() => onActivateStatus(null)}
|
|
|
|
|
className="inline-flex h-6 w-6 items-center justify-center rounded-md border border-border-soft bg-surface-muted/60 text-sm text-text-muted hover:border-border-strong hover:text-text-body"
|
|
|
|
|
>
|
|
|
|
|
-
|
|
|
|
|
</button>
|
|
|
|
|
) : null}
|
2026-02-11 17:55:26 -08:00
|
|
|
</div>
|
2026-02-11 19:59:55 -08:00
|
|
|
{activeStatus === status ? (
|
|
|
|
|
<div className="mt-2 grid max-h-[50vh] gap-2 overflow-y-auto pr-1 sm:grid-cols-2 2xl:grid-cols-3">
|
|
|
|
|
<AnimatePresence initial={false}>
|
|
|
|
|
{columns[status].map((issue) => (
|
|
|
|
|
<KanbanCard
|
|
|
|
|
key={issue.id}
|
|
|
|
|
issue={issue}
|
|
|
|
|
pending={pendingIssueIds.has(issue.id)}
|
|
|
|
|
selected={selectedIssueId === issue.id}
|
|
|
|
|
draggable={!pendingIssueIds.has(issue.id)}
|
|
|
|
|
onNativeDragStart={onDragStart}
|
|
|
|
|
onSelect={onSelect}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</AnimatePresence>
|
|
|
|
|
{columns[status].length === 0 ? (
|
|
|
|
|
<div className="flex h-24 w-full items-center justify-center rounded-xl border border-dashed border-border-soft/80 bg-surface/35 text-xs text-text-muted">
|
|
|
|
|
No beads
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
|
|
|
|
{columns[status].slice(0, 6).map((issue) => (
|
|
|
|
|
<button
|
|
|
|
|
key={issue.id}
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => handleExpandAndSelect(status, issue)}
|
|
|
|
|
className="max-w-full rounded-lg border border-border-soft bg-surface-muted/60 px-2 py-1 text-left hover:border-border-strong hover:bg-surface-raised/70"
|
|
|
|
|
title={issue.title}
|
|
|
|
|
>
|
|
|
|
|
<div className="font-mono text-[10px] text-text-muted">{issue.id}</div>
|
|
|
|
|
<div className="line-clamp-1 text-xs font-medium text-text-body">{issue.title}</div>
|
|
|
|
|
</button>
|
2026-02-11 18:38:51 -08:00
|
|
|
))}
|
2026-02-11 19:59:55 -08:00
|
|
|
{columns[status].length > 6 ? (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => onActivateStatus(status)}
|
|
|
|
|
className="rounded-lg border border-border-soft bg-surface/50 px-2 py-1 text-xs text-text-muted hover:bg-surface-muted/70"
|
|
|
|
|
>
|
|
|
|
|
+{columns[status].length - 6} more
|
|
|
|
|
</button>
|
|
|
|
|
) : null}
|
|
|
|
|
{columns[status].length === 0 ? (
|
|
|
|
|
<span className="rounded-lg border border-dashed border-border-soft/80 bg-surface/30 px-2 py-1 text-xs text-text-muted">
|
|
|
|
|
No beads
|
|
|
|
|
</span>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-02-11 17:55:26 -08:00
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</section>
|
|
|
|
|
);
|
|
|
|
|
}
|