- CreateTaskModal: project selector, due date picker, source in 'more options' - TaskCard: project name badge (📁) and assignee badge (👤) - QueuePage: status filter dropdown, clear filters button, filter count indicator - QueuePage: Ctrl+N keyboard shortcut to create task - DashboardPage: project/assignee badges on active task cards - Search now also matches assignee name
219 lines
8.6 KiB
TypeScript
219 lines
8.6 KiB
TypeScript
import type { Task, TaskStatus, TaskPriority } from "../lib/types";
|
|
|
|
const priorityColors: Record<TaskPriority, string> = {
|
|
critical: "bg-red-500 text-white",
|
|
high: "bg-orange-500 text-white",
|
|
medium: "bg-blue-500 text-white",
|
|
low: "bg-gray-400 text-white",
|
|
};
|
|
|
|
const sourceColors: Record<string, string> = {
|
|
donovan: "bg-purple-100 text-purple-800",
|
|
david: "bg-green-100 text-green-800",
|
|
hammer: "bg-yellow-100 text-yellow-800",
|
|
heartbeat: "bg-pink-100 text-pink-800",
|
|
cron: "bg-indigo-100 text-indigo-800",
|
|
other: "bg-gray-100 text-gray-800",
|
|
};
|
|
|
|
const statusActions: Record<TaskStatus, { label: string; next: TaskStatus }[]> = {
|
|
active: [
|
|
{ label: "⏸ Pause", next: "queued" },
|
|
{ label: "✅ Complete", next: "completed" },
|
|
],
|
|
queued: [
|
|
{ label: "▶ Activate", next: "active" },
|
|
{ label: "❌ Cancel", next: "cancelled" },
|
|
],
|
|
blocked: [
|
|
{ label: "▶ Activate", next: "active" },
|
|
{ label: "📋 Queue", next: "queued" },
|
|
],
|
|
completed: [{ label: "🔄 Requeue", next: "queued" }],
|
|
cancelled: [{ label: "🔄 Requeue", next: "queued" }],
|
|
};
|
|
|
|
function timeAgo(dateStr: string): string {
|
|
const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
|
|
if (seconds < 60) return "just now";
|
|
const minutes = Math.floor(seconds / 60);
|
|
if (minutes < 60) return `${minutes}m ago`;
|
|
const hours = Math.floor(minutes / 60);
|
|
if (hours < 24) return `${hours}h ago`;
|
|
const days = Math.floor(hours / 24);
|
|
return `${days}d ago`;
|
|
}
|
|
|
|
interface TaskCardProps {
|
|
task: Task;
|
|
onStatusChange: (id: string, status: TaskStatus) => void;
|
|
onMoveUp?: () => void;
|
|
onMoveDown?: () => void;
|
|
isFirst?: boolean;
|
|
isLast?: boolean;
|
|
isActive?: boolean;
|
|
onClick?: () => void;
|
|
projectName?: string;
|
|
}
|
|
|
|
export function TaskCard({
|
|
task,
|
|
onStatusChange,
|
|
onMoveUp,
|
|
onMoveDown,
|
|
isFirst,
|
|
isLast,
|
|
isActive,
|
|
onClick,
|
|
projectName,
|
|
}: TaskCardProps) {
|
|
const actions = statusActions[task.status] || [];
|
|
const noteCount = task.progressNotes?.length || 0;
|
|
const displayId = task.taskNumber ? `HQ-${task.taskNumber}` : task.id.slice(0, 8);
|
|
|
|
return (
|
|
<div
|
|
onClick={onClick}
|
|
className={`rounded-xl border p-3 sm:p-4 transition-all cursor-pointer group ${
|
|
isActive
|
|
? "border-amber-300 bg-gradient-to-r from-amber-50 to-orange-50 shadow-lg shadow-amber-100/50 hover:shadow-xl hover:shadow-amber-200/50"
|
|
: "border-gray-200 bg-white shadow-sm hover:shadow-md hover:border-gray-300"
|
|
}`}
|
|
>
|
|
{/* Top row: title + expand chevron */}
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 flex-wrap mb-1.5">
|
|
{isActive && (
|
|
<span className="relative flex h-2.5 w-2.5 mr-0.5 shrink-0">
|
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span>
|
|
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-amber-500"></span>
|
|
</span>
|
|
)}
|
|
<h3 className={`font-semibold text-sm sm:text-base leading-snug ${isActive ? "text-amber-900" : "text-gray-900"}`} style={{ wordBreak: "break-word" }}>
|
|
{task.title}
|
|
</h3>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1.5 sm:gap-2 mb-1.5 flex-wrap">
|
|
<span
|
|
className="text-[10px] sm:text-xs px-1.5 py-0.5 rounded font-mono font-bold bg-amber-100 text-amber-700 cursor-pointer hover:bg-amber-200 transition"
|
|
title={`Click to copy: ${displayId}`}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
navigator.clipboard.writeText(displayId);
|
|
}}
|
|
>
|
|
{displayId}
|
|
</span>
|
|
<span className={`text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 rounded-full font-medium ${priorityColors[task.priority]}`}>
|
|
{task.priority}
|
|
</span>
|
|
<span className={`text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 rounded-full font-medium ${sourceColors[task.source] || sourceColors.other}`}>
|
|
{task.source}
|
|
</span>
|
|
{/* Project badge */}
|
|
{projectName && (
|
|
<span className="text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 rounded-full font-medium bg-sky-100 text-sky-700">
|
|
📁 {projectName}
|
|
</span>
|
|
)}
|
|
{/* Assignee badge */}
|
|
{task.assigneeName && (
|
|
<span className="text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 rounded-full font-medium bg-emerald-100 text-emerald-700">
|
|
👤 {task.assigneeName}
|
|
</span>
|
|
)}
|
|
<span className="text-[10px] sm:text-xs text-gray-400">
|
|
{timeAgo(task.createdAt)}
|
|
</span>
|
|
{noteCount > 0 && (
|
|
<span className="text-[10px] sm:text-xs text-gray-400 flex items-center gap-0.5">
|
|
💬 {noteCount}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{task.description && (
|
|
<p className="text-xs sm:text-sm text-gray-500 line-clamp-1">{task.description}</p>
|
|
)}
|
|
|
|
{/* Due date and subtask badges */}
|
|
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
|
|
{task.dueDate && (() => {
|
|
const due = new Date(task.dueDate);
|
|
const diffMs = due.getTime() - Date.now();
|
|
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
|
|
const isOverdue = diffMs < 0;
|
|
const isDueSoon = diffDays <= 2 && !isOverdue;
|
|
return (
|
|
<span className={`text-[10px] sm:text-xs px-1.5 py-0.5 rounded-full font-medium inline-flex items-center gap-0.5 ${
|
|
isOverdue ? "bg-red-100 text-red-700" : isDueSoon ? "bg-amber-100 text-amber-700" : "bg-gray-100 text-gray-500"
|
|
}`}>
|
|
📅 {isOverdue ? `${Math.abs(diffDays)}d overdue` : diffDays === 0 ? "today" : diffDays === 1 ? "tomorrow" : `${diffDays}d`}
|
|
</span>
|
|
);
|
|
})()}
|
|
{task.subtasks?.length > 0 && (() => {
|
|
const done = task.subtasks.filter(s => s.completed).length;
|
|
const total = task.subtasks.length;
|
|
const pct = Math.round((done / total) * 100);
|
|
return (
|
|
<span className="text-[10px] sm:text-xs text-gray-400 inline-flex items-center gap-1.5">
|
|
<span className="inline-block w-12 h-1 bg-gray-200 rounded-full overflow-hidden">
|
|
<span className={`block h-full rounded-full ${pct === 100 ? "bg-green-500" : "bg-amber-400"}`} style={{ width: `${pct}%` }} />
|
|
</span>
|
|
{done}/{total}
|
|
</span>
|
|
);
|
|
})()}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Expand chevron - always visible */}
|
|
<div className="shrink-0 mt-1 text-gray-300 group-hover:text-gray-500 transition">
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Action buttons row - hidden on mobile, shown on sm+ */}
|
|
<div className="hidden sm:flex items-center gap-1 mt-2 pt-2 border-t border-gray-100" onClick={(e) => e.stopPropagation()}>
|
|
{/* Reorder buttons for queued tasks */}
|
|
{task.status === "queued" && (
|
|
<div className="flex gap-1 mr-1">
|
|
<button
|
|
onClick={onMoveUp}
|
|
disabled={isFirst}
|
|
className="text-xs px-1.5 py-0.5 rounded-md bg-gray-100 hover:bg-gray-200 disabled:opacity-30 disabled:cursor-not-allowed transition"
|
|
title="Move up"
|
|
>
|
|
↑
|
|
</button>
|
|
<button
|
|
onClick={onMoveDown}
|
|
disabled={isLast}
|
|
className="text-xs px-1.5 py-0.5 rounded-md bg-gray-100 hover:bg-gray-200 disabled:opacity-30 disabled:cursor-not-allowed transition"
|
|
title="Move down"
|
|
>
|
|
↓
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Quick status actions */}
|
|
{actions.slice(0, 2).map((action) => (
|
|
<button
|
|
key={action.next}
|
|
onClick={() => onStatusChange(task.id, action.next)}
|
|
className="text-xs px-2.5 py-1.5 rounded-lg border border-gray-200 hover:bg-gray-50 whitespace-nowrap text-gray-600 hover:text-gray-800 transition font-medium"
|
|
>
|
|
{action.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|