feat: enhanced create modal, project/assignee badges, status filter, keyboard shortcuts
- 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
This commit is contained in:
@@ -1,4 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { fetchProjects } from "../lib/api";
|
||||||
|
import type { Project } from "../lib/types";
|
||||||
|
|
||||||
interface CreateTaskModalProps {
|
interface CreateTaskModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -8,6 +10,8 @@ interface CreateTaskModalProps {
|
|||||||
description?: string;
|
description?: string;
|
||||||
source?: string;
|
source?: string;
|
||||||
priority?: string;
|
priority?: string;
|
||||||
|
projectId?: string;
|
||||||
|
dueDate?: string;
|
||||||
}) => void;
|
}) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,6 +20,26 @@ export function CreateTaskModal({ open, onClose, onCreate }: CreateTaskModalProp
|
|||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [source, setSource] = useState("donovan");
|
const [source, setSource] = useState("donovan");
|
||||||
const [priority, setPriority] = useState("medium");
|
const [priority, setPriority] = useState("medium");
|
||||||
|
const [projectId, setProjectId] = useState("");
|
||||||
|
const [dueDate, setDueDate] = useState("");
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
fetchProjects().then(setProjects).catch(() => {});
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Keyboard shortcut: Escape to close
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKey);
|
||||||
|
return () => window.removeEventListener("keydown", handleKey);
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
@@ -27,75 +51,166 @@ export function CreateTaskModal({ open, onClose, onCreate }: CreateTaskModalProp
|
|||||||
description: description.trim() || undefined,
|
description: description.trim() || undefined,
|
||||||
source,
|
source,
|
||||||
priority,
|
priority,
|
||||||
|
projectId: projectId || undefined,
|
||||||
|
dueDate: dueDate ? new Date(dueDate).toISOString() : undefined,
|
||||||
});
|
});
|
||||||
|
// Reset form
|
||||||
setTitle("");
|
setTitle("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
setSource("donovan");
|
setSource("donovan");
|
||||||
setPriority("medium");
|
setPriority("medium");
|
||||||
|
setProjectId("");
|
||||||
|
setDueDate("");
|
||||||
|
setShowAdvanced(false);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50">
|
||||||
<div className="bg-white rounded-xl shadow-2xl p-6 w-full max-w-md mx-4">
|
<div className="bg-white rounded-xl shadow-2xl p-6 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
<h2 className="text-lg font-bold mb-4">New Task</h2>
|
<div className="flex items-center justify-between mb-4">
|
||||||
<form onSubmit={handleSubmit} className="space-y-3">
|
<h2 className="text-lg font-bold">New Task</h2>
|
||||||
<input
|
<button
|
||||||
type="text"
|
onClick={onClose}
|
||||||
placeholder="Task title..."
|
className="text-gray-400 hover:text-gray-600 p-1 rounded-lg hover:bg-gray-100 transition"
|
||||||
value={title}
|
>
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-400"
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
autoFocus
|
</svg>
|
||||||
/>
|
</button>
|
||||||
<textarea
|
</div>
|
||||||
placeholder="Description / context (optional)"
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
value={description}
|
{/* Title */}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
<div>
|
||||||
rows={3}
|
<label className="text-xs font-medium text-gray-500 block mb-1">Title</label>
|
||||||
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-400"
|
<input
|
||||||
/>
|
type="text"
|
||||||
|
placeholder="What needs to be done?"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-amber-400 focus:border-amber-400"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-gray-500 block mb-1">Description</label>
|
||||||
|
<textarea
|
||||||
|
placeholder="Context, requirements, links... (optional)"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-400 focus:border-amber-400 resize-y"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Priority & Source row */}
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<label className="text-xs text-gray-500 block mb-1">Source</label>
|
<label className="text-xs font-medium text-gray-500 block mb-1">Priority</label>
|
||||||
<select
|
<div className="flex gap-1">
|
||||||
value={source}
|
{(["critical", "high", "medium", "low"] as const).map((p) => (
|
||||||
onChange={(e) => setSource(e.target.value)}
|
<button
|
||||||
className="w-full border rounded-lg px-3 py-2 text-sm"
|
key={p}
|
||||||
>
|
type="button"
|
||||||
<option value="donovan">Donovan</option>
|
onClick={() => setPriority(p)}
|
||||||
<option value="david">David</option>
|
className={`flex-1 text-xs py-2 rounded-lg font-medium transition border ${
|
||||||
<option value="hammer">Hammer</option>
|
priority === p
|
||||||
<option value="heartbeat">Heartbeat</option>
|
? p === "critical" ? "bg-red-500 text-white border-red-500"
|
||||||
<option value="cron">Cron</option>
|
: p === "high" ? "bg-orange-500 text-white border-orange-500"
|
||||||
<option value="other">Other</option>
|
: p === "medium" ? "bg-blue-500 text-white border-blue-500"
|
||||||
</select>
|
: "bg-gray-500 text-white border-gray-500"
|
||||||
</div>
|
: "bg-white text-gray-500 border-gray-200 hover:border-gray-300 hover:bg-gray-50"
|
||||||
<div className="flex-1">
|
}`}
|
||||||
<label className="text-xs text-gray-500 block mb-1">Priority</label>
|
>
|
||||||
<select
|
{p === "critical" ? "🔴" : p === "high" ? "🟠" : p === "medium" ? "🔵" : "⚪"} {p[0].toUpperCase() + p.slice(1)}
|
||||||
value={priority}
|
</button>
|
||||||
onChange={(e) => setPriority(e.target.value)}
|
))}
|
||||||
className="w-full border rounded-lg px-3 py-2 text-sm"
|
</div>
|
||||||
>
|
|
||||||
<option value="critical">Critical</option>
|
|
||||||
<option value="high">High</option>
|
|
||||||
<option value="medium">Medium</option>
|
|
||||||
<option value="low">Low</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Project selector */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-gray-500 block mb-1">Project</label>
|
||||||
|
<select
|
||||||
|
value={projectId}
|
||||||
|
onChange={(e) => setProjectId(e.target.value)}
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-400 bg-white"
|
||||||
|
>
|
||||||
|
<option value="">No project</option>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>{p.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Advanced toggle */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||||
|
className="text-xs text-gray-400 hover:text-gray-600 flex items-center gap-1 transition"
|
||||||
|
>
|
||||||
|
{showAdvanced ? "▾" : "▸"} More options
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showAdvanced && (
|
||||||
|
<div className="space-y-3 pl-2 border-l-2 border-gray-100">
|
||||||
|
{/* Due Date */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-gray-500 block mb-1">Due Date</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={dueDate}
|
||||||
|
onChange={(e) => setDueDate(e.target.value)}
|
||||||
|
className="text-sm border border-gray-200 rounded-lg px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-amber-400 bg-white"
|
||||||
|
/>
|
||||||
|
{dueDate && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDueDate("")}
|
||||||
|
className="text-xs text-gray-400 hover:text-red-500 transition"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Source */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-gray-500 block mb-1">Source</label>
|
||||||
|
<select
|
||||||
|
value={source}
|
||||||
|
onChange={(e) => setSource(e.target.value)}
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-400 bg-white"
|
||||||
|
>
|
||||||
|
<option value="donovan">Donovan</option>
|
||||||
|
<option value="david">David</option>
|
||||||
|
<option value="hammer">Hammer</option>
|
||||||
|
<option value="heartbeat">Heartbeat</option>
|
||||||
|
<option value="cron">Cron</option>
|
||||||
|
<option value="other">Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submit buttons */}
|
||||||
<div className="flex gap-2 pt-2">
|
<div className="flex gap-2 pt-2">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="flex-1 bg-amber-500 text-white rounded-lg py-2 text-sm font-medium hover:bg-amber-600 transition"
|
disabled={!title.trim()}
|
||||||
|
className="flex-1 bg-amber-500 text-white rounded-lg py-2.5 text-sm font-semibold hover:bg-amber-600 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
Create Task
|
Create Task
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900"
|
className="px-4 py-2.5 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ interface TaskCardProps {
|
|||||||
isLast?: boolean;
|
isLast?: boolean;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
projectName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TaskCard({
|
export function TaskCard({
|
||||||
@@ -64,6 +65,7 @@ export function TaskCard({
|
|||||||
isLast,
|
isLast,
|
||||||
isActive,
|
isActive,
|
||||||
onClick,
|
onClick,
|
||||||
|
projectName,
|
||||||
}: TaskCardProps) {
|
}: TaskCardProps) {
|
||||||
const actions = statusActions[task.status] || [];
|
const actions = statusActions[task.status] || [];
|
||||||
const noteCount = task.progressNotes?.length || 0;
|
const noteCount = task.progressNotes?.length || 0;
|
||||||
@@ -110,6 +112,18 @@ export function TaskCard({
|
|||||||
<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}`}>
|
<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}
|
{task.source}
|
||||||
</span>
|
</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">
|
<span className="text-[10px] sm:text-xs text-gray-400">
|
||||||
{timeAgo(task.createdAt)}
|
{timeAgo(task.createdAt)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export async function reorderTasks(ids: string[], token?: string): Promise<void>
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createTask(
|
export async function createTask(
|
||||||
task: { title: string; description?: string; source?: string; priority?: string; status?: string },
|
task: { title: string; description?: string; source?: string; priority?: string; status?: string; projectId?: string; dueDate?: string },
|
||||||
token?: string
|
token?: string
|
||||||
): Promise<Task> {
|
): Promise<Task> {
|
||||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo, useEffect, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useTasks } from "../hooks/useTasks";
|
import { useTasks } from "../hooks/useTasks";
|
||||||
import type { Task, ProgressNote } from "../lib/types";
|
import { fetchProjects } from "../lib/api";
|
||||||
|
import type { Task, ProgressNote, Project } from "../lib/types";
|
||||||
|
|
||||||
function StatCard({ label, value, icon, color }: { label: string; value: number; icon: string; color: string }) {
|
function StatCard({ label, value, icon, color }: { label: string; value: number; icon: string; color: string }) {
|
||||||
return (
|
return (
|
||||||
@@ -74,6 +75,17 @@ function RecentActivity({ tasks }: { tasks: Task[] }) {
|
|||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const { tasks, loading } = useTasks(10000);
|
const { tasks, loading } = useTasks(10000);
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchProjects().then(setProjects).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const projectMap = useMemo(() => {
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
for (const p of projects) map[p.id] = p.name;
|
||||||
|
return map;
|
||||||
|
}, [projects]);
|
||||||
|
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
const active = tasks.filter((t) => t.status === "active").length;
|
const active = tasks.filter((t) => t.status === "active").length;
|
||||||
@@ -146,6 +158,12 @@ export function DashboardPage() {
|
|||||||
</span>
|
</span>
|
||||||
<span className="text-xs font-bold text-amber-700 font-mono">HQ-{task.taskNumber}</span>
|
<span className="text-xs font-bold text-amber-700 font-mono">HQ-{task.taskNumber}</span>
|
||||||
<span className="text-xs text-amber-600 capitalize px-1.5 py-0.5 bg-amber-200/50 rounded-full">{task.priority}</span>
|
<span className="text-xs text-amber-600 capitalize px-1.5 py-0.5 bg-amber-200/50 rounded-full">{task.priority}</span>
|
||||||
|
{task.projectId && projectMap[task.projectId] && (
|
||||||
|
<span className="text-xs text-sky-600 px-1.5 py-0.5 bg-sky-100 rounded-full">📁 {projectMap[task.projectId]}</span>
|
||||||
|
)}
|
||||||
|
{task.assigneeName && (
|
||||||
|
<span className="text-xs text-emerald-600 px-1.5 py-0.5 bg-emerald-100 rounded-full">👤 {task.assigneeName}</span>
|
||||||
|
)}
|
||||||
{task.dueDate && (() => {
|
{task.dueDate && (() => {
|
||||||
const due = new Date(task.dueDate);
|
const due = new Date(task.dueDate);
|
||||||
const diffMs = due.getTime() - Date.now();
|
const diffMs = due.getTime() - Date.now();
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo, useEffect } from "react";
|
||||||
import { useTasks } from "../hooks/useTasks";
|
import { useTasks } from "../hooks/useTasks";
|
||||||
import { useCurrentUser } from "../hooks/useCurrentUser";
|
import { useCurrentUser } from "../hooks/useCurrentUser";
|
||||||
import { TaskCard } from "../components/TaskCard";
|
import { TaskCard } from "../components/TaskCard";
|
||||||
import { TaskDetailPanel } from "../components/TaskDetailPanel";
|
import { TaskDetailPanel } from "../components/TaskDetailPanel";
|
||||||
import { CreateTaskModal } from "../components/CreateTaskModal";
|
import { CreateTaskModal } from "../components/CreateTaskModal";
|
||||||
import { useToast } from "../components/Toast";
|
import { useToast } from "../components/Toast";
|
||||||
import { updateTask, reorderTasks, createTask } from "../lib/api";
|
import { updateTask, reorderTasks, createTask, fetchProjects } from "../lib/api";
|
||||||
import type { TaskStatus } from "../lib/types";
|
import type { TaskStatus, Project } from "../lib/types";
|
||||||
|
|
||||||
export function QueuePage() {
|
export function QueuePage() {
|
||||||
const { tasks, loading, error, refresh } = useTasks(5000);
|
const { tasks, loading, error, refresh } = useTasks(5000);
|
||||||
@@ -16,8 +16,35 @@ export function QueuePage() {
|
|||||||
const [selectedTask, setSelectedTask] = useState<string | null>(null);
|
const [selectedTask, setSelectedTask] = useState<string | null>(null);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [filterPriority, setFilterPriority] = useState<string>("");
|
const [filterPriority, setFilterPriority] = useState<string>("");
|
||||||
|
const [filterStatus, setFilterStatus] = useState<string>("");
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// Load projects for name display
|
||||||
|
useEffect(() => {
|
||||||
|
fetchProjects().then(setProjects).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const projectMap = useMemo(() => {
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
for (const p of projects) {
|
||||||
|
map[p.id] = p.name;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [projects]);
|
||||||
|
|
||||||
|
// Keyboard shortcut: Ctrl+N to create new task
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === "n" && !showCreate) {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowCreate(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKey);
|
||||||
|
return () => window.removeEventListener("keydown", handleKey);
|
||||||
|
}, [showCreate]);
|
||||||
|
|
||||||
const filteredTasks = useMemo(() => {
|
const filteredTasks = useMemo(() => {
|
||||||
let filtered = tasks;
|
let filtered = tasks;
|
||||||
if (search.trim()) {
|
if (search.trim()) {
|
||||||
@@ -26,14 +53,18 @@ export function QueuePage() {
|
|||||||
(t) =>
|
(t) =>
|
||||||
t.title.toLowerCase().includes(q) ||
|
t.title.toLowerCase().includes(q) ||
|
||||||
(t.description && t.description.toLowerCase().includes(q)) ||
|
(t.description && t.description.toLowerCase().includes(q)) ||
|
||||||
(t.taskNumber && `hq-${t.taskNumber}`.includes(q))
|
(t.taskNumber && `hq-${t.taskNumber}`.includes(q)) ||
|
||||||
|
(t.assigneeName && t.assigneeName.toLowerCase().includes(q))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (filterPriority) {
|
if (filterPriority) {
|
||||||
filtered = filtered.filter((t) => t.priority === filterPriority);
|
filtered = filtered.filter((t) => t.priority === filterPriority);
|
||||||
}
|
}
|
||||||
|
if (filterStatus) {
|
||||||
|
filtered = filtered.filter((t) => t.status === filterStatus);
|
||||||
|
}
|
||||||
return filtered;
|
return filtered;
|
||||||
}, [tasks, search, filterPriority]);
|
}, [tasks, search, filterPriority, filterStatus]);
|
||||||
|
|
||||||
const selectedTaskData = useMemo(() => {
|
const selectedTaskData = useMemo(() => {
|
||||||
if (!selectedTask) return null;
|
if (!selectedTask) return null;
|
||||||
@@ -48,6 +79,9 @@ export function QueuePage() {
|
|||||||
[filteredTasks]
|
[filteredTasks]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// When filtering by status, determine which sections to show
|
||||||
|
const showSection = (status: string) => !filterStatus || filterStatus === status;
|
||||||
|
|
||||||
const handleStatusChange = async (id: string, status: TaskStatus) => {
|
const handleStatusChange = async (id: string, status: TaskStatus) => {
|
||||||
try {
|
try {
|
||||||
await updateTask(id, { status });
|
await updateTask(id, { status });
|
||||||
@@ -79,6 +113,8 @@ export function QueuePage() {
|
|||||||
description?: string;
|
description?: string;
|
||||||
source?: string;
|
source?: string;
|
||||||
priority?: string;
|
priority?: string;
|
||||||
|
projectId?: string;
|
||||||
|
dueDate?: string;
|
||||||
}) => {
|
}) => {
|
||||||
try {
|
try {
|
||||||
await createTask(task);
|
await createTask(task);
|
||||||
@@ -89,6 +125,8 @@ export function QueuePage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const activeFilters = [filterPriority, filterStatus].filter(Boolean).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
@@ -97,11 +135,17 @@ export function QueuePage() {
|
|||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg sm:text-xl font-bold text-gray-900">Task Queue</h1>
|
<h1 className="text-lg sm:text-xl font-bold text-gray-900">Task Queue</h1>
|
||||||
<p className="text-xs sm:text-sm text-gray-400">Manage what Hammer is working on</p>
|
<p className="text-xs sm:text-sm text-gray-400">
|
||||||
|
Manage what Hammer is working on
|
||||||
|
{filteredTasks.length !== tasks.length && (
|
||||||
|
<span className="ml-1 text-amber-500">· {filteredTasks.length} of {tasks.length} shown</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCreate(true)}
|
onClick={() => setShowCreate(true)}
|
||||||
className="text-sm bg-amber-500 text-white px-3 sm:px-4 py-2 rounded-lg hover:bg-amber-600 transition font-medium"
|
className="text-sm bg-amber-500 text-white px-3 sm:px-4 py-2 rounded-lg hover:bg-amber-600 transition font-medium"
|
||||||
|
title="Ctrl+N"
|
||||||
>
|
>
|
||||||
+ New
|
+ New
|
||||||
</button>
|
</button>
|
||||||
@@ -140,6 +184,27 @@ export function QueuePage() {
|
|||||||
<option value="medium">🔵 Medium</option>
|
<option value="medium">🔵 Medium</option>
|
||||||
<option value="low">⚪ Low</option>
|
<option value="low">⚪ Low</option>
|
||||||
</select>
|
</select>
|
||||||
|
<select
|
||||||
|
value={filterStatus}
|
||||||
|
onChange={(e) => setFilterStatus(e.target.value)}
|
||||||
|
className="text-sm border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-amber-200 bg-white"
|
||||||
|
>
|
||||||
|
<option value="">All statuses</option>
|
||||||
|
<option value="active">⚡ Active</option>
|
||||||
|
<option value="queued">📋 Queued</option>
|
||||||
|
<option value="blocked">🚫 Blocked</option>
|
||||||
|
<option value="completed">✅ Completed</option>
|
||||||
|
<option value="cancelled">❌ Cancelled</option>
|
||||||
|
</select>
|
||||||
|
{activeFilters > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setFilterPriority(""); setFilterStatus(""); }}
|
||||||
|
className="text-xs text-amber-600 hover:text-amber-700 font-medium px-2 py-2 shrink-0"
|
||||||
|
title="Clear all filters"
|
||||||
|
>
|
||||||
|
Clear ({activeFilters})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -161,31 +226,34 @@ export function QueuePage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Active Task */}
|
{/* Active Task */}
|
||||||
<section>
|
{showSection("active") && (
|
||||||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
<section>
|
||||||
⚡ Currently Working On
|
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||||
</h2>
|
⚡ Currently Working On
|
||||||
{activeTasks.length === 0 ? (
|
</h2>
|
||||||
<div className="border-2 border-dashed border-gray-200 rounded-lg p-8 text-center text-gray-400">
|
{activeTasks.length === 0 ? (
|
||||||
No active task — Hammer is idle
|
<div className="border-2 border-dashed border-gray-200 rounded-lg p-8 text-center text-gray-400">
|
||||||
</div>
|
No active task — Hammer is idle
|
||||||
) : (
|
</div>
|
||||||
<div className="space-y-3">
|
) : (
|
||||||
{activeTasks.map((task) => (
|
<div className="space-y-3">
|
||||||
<TaskCard
|
{activeTasks.map((task) => (
|
||||||
key={task.id}
|
<TaskCard
|
||||||
task={task}
|
key={task.id}
|
||||||
onStatusChange={handleStatusChange}
|
task={task}
|
||||||
isActive
|
onStatusChange={handleStatusChange}
|
||||||
onClick={() => setSelectedTask(task.id)}
|
isActive
|
||||||
/>
|
onClick={() => setSelectedTask(task.id)}
|
||||||
))}
|
projectName={task.projectId ? projectMap[task.projectId] : undefined}
|
||||||
</div>
|
/>
|
||||||
)}
|
))}
|
||||||
</section>
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Blocked */}
|
{/* Blocked */}
|
||||||
{blockedTasks.length > 0 && (
|
{showSection("blocked") && blockedTasks.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||||
🚫 Blocked ({blockedTasks.length})
|
🚫 Blocked ({blockedTasks.length})
|
||||||
@@ -197,6 +265,7 @@ export function QueuePage() {
|
|||||||
task={task}
|
task={task}
|
||||||
onStatusChange={handleStatusChange}
|
onStatusChange={handleStatusChange}
|
||||||
onClick={() => setSelectedTask(task.id)}
|
onClick={() => setSelectedTask(task.id)}
|
||||||
|
projectName={task.projectId ? projectMap[task.projectId] : undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -204,53 +273,80 @@ export function QueuePage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Queue */}
|
{/* Queue */}
|
||||||
<section>
|
{showSection("queued") && (
|
||||||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
<section>
|
||||||
📋 Queue ({queuedTasks.length})
|
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||||
</h2>
|
📋 Queue ({queuedTasks.length})
|
||||||
{queuedTasks.length === 0 ? (
|
</h2>
|
||||||
<div className="border-2 border-dashed border-gray-200 rounded-lg p-6 text-center text-gray-400">
|
{queuedTasks.length === 0 ? (
|
||||||
Queue is empty
|
<div className="border-2 border-dashed border-gray-200 rounded-lg p-6 text-center text-gray-400">
|
||||||
</div>
|
Queue is empty
|
||||||
) : (
|
</div>
|
||||||
<div className="space-y-2">
|
) : (
|
||||||
{queuedTasks.map((task, i) => (
|
<div className="space-y-2">
|
||||||
<TaskCard
|
{queuedTasks.map((task, i) => (
|
||||||
key={task.id}
|
<TaskCard
|
||||||
task={task}
|
key={task.id}
|
||||||
onStatusChange={handleStatusChange}
|
task={task}
|
||||||
onMoveUp={() => handleMoveUp(i)}
|
onStatusChange={handleStatusChange}
|
||||||
onMoveDown={() => handleMoveDown(i)}
|
onMoveUp={() => handleMoveUp(i)}
|
||||||
isFirst={i === 0}
|
onMoveDown={() => handleMoveDown(i)}
|
||||||
isLast={i === queuedTasks.length - 1}
|
isFirst={i === 0}
|
||||||
onClick={() => setSelectedTask(task.id)}
|
isLast={i === queuedTasks.length - 1}
|
||||||
/>
|
onClick={() => setSelectedTask(task.id)}
|
||||||
))}
|
projectName={task.projectId ? projectMap[task.projectId] : undefined}
|
||||||
</div>
|
/>
|
||||||
)}
|
))}
|
||||||
</section>
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Completed */}
|
{/* Completed */}
|
||||||
<section>
|
{(showSection("completed") || showSection("cancelled")) && (
|
||||||
<button
|
<section>
|
||||||
onClick={() => setShowCompleted(!showCompleted)}
|
{filterStatus ? (
|
||||||
className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3 flex items-center gap-1 hover:text-gray-700"
|
<>
|
||||||
>
|
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||||
{showCompleted ? "▾" : "▸"} Completed / Cancelled ({completedTasks.length})
|
{filterStatus === "completed" ? "✅" : "❌"} {filterStatus.charAt(0).toUpperCase() + filterStatus.slice(1)} ({completedTasks.length})
|
||||||
</button>
|
</h2>
|
||||||
{showCompleted && (
|
<div className="space-y-2 opacity-60">
|
||||||
<div className="space-y-2 opacity-60">
|
{completedTasks.map((task) => (
|
||||||
{completedTasks.map((task) => (
|
<TaskCard
|
||||||
<TaskCard
|
key={task.id}
|
||||||
key={task.id}
|
task={task}
|
||||||
task={task}
|
onStatusChange={handleStatusChange}
|
||||||
onStatusChange={handleStatusChange}
|
onClick={() => setSelectedTask(task.id)}
|
||||||
onClick={() => setSelectedTask(task.id)}
|
projectName={task.projectId ? projectMap[task.projectId] : undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</>
|
||||||
</section>
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCompleted(!showCompleted)}
|
||||||
|
className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3 flex items-center gap-1 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
{showCompleted ? "▾" : "▸"} Completed / Cancelled ({completedTasks.length})
|
||||||
|
</button>
|
||||||
|
{showCompleted && (
|
||||||
|
<div className="space-y-2 opacity-60">
|
||||||
|
{completedTasks.map((task) => (
|
||||||
|
<TaskCard
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
onStatusChange={handleStatusChange}
|
||||||
|
onClick={() => setSelectedTask(task.id)}
|
||||||
|
projectName={task.projectId ? projectMap[task.projectId] : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Task Detail Panel */}
|
{/* Task Detail Panel */}
|
||||||
|
|||||||
Reference in New Issue
Block a user