diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3a7587f..2f0fe60 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,7 @@ import { ProjectsPage } from "./pages/ProjectsPage"; import { TaskPage } from "./pages/TaskPage"; import { AdminPage } from "./components/AdminPage"; import { LoginPage } from "./components/LoginPage"; +import { ToastProvider } from "./components/Toast"; import { useSession } from "./lib/auth-client"; function AuthenticatedApp() { @@ -42,7 +43,11 @@ function App() { return window.location.reload()} />; } - return ; + return ( + + + + ); } export default App; diff --git a/frontend/src/components/TaskCard.tsx b/frontend/src/components/TaskCard.tsx index 845c779..3f1ab7d 100644 --- a/frontend/src/components/TaskCard.tsx +++ b/frontend/src/components/TaskCard.tsx @@ -123,6 +123,37 @@ export function TaskCard({ {task.description && (

{task.description}

)} + + {/* Due date and subtask badges */} +
+ {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 ( + + 📅 {isOverdue ? `${Math.abs(diffDays)}d overdue` : diffDays === 0 ? "today" : diffDays === 1 ? "tomorrow" : `${diffDays}d`} + + ); + })()} + {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 ( + + + + + {done}/{total} + + ); + })()} +
{/* Expand chevron - always visible */} diff --git a/frontend/src/components/TaskDetailPanel.tsx b/frontend/src/components/TaskDetailPanel.tsx index 569ec38..732a291 100644 --- a/frontend/src/components/TaskDetailPanel.tsx +++ b/frontend/src/components/TaskDetailPanel.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useRef } from "react"; import type { Task, TaskStatus, TaskPriority, TaskSource, Project } from "../lib/types"; -import { updateTask, fetchProjects, addProgressNote, addSubtask, toggleSubtask, deleteSubtask } from "../lib/api"; +import { updateTask, fetchProjects, addProgressNote, addSubtask, toggleSubtask, deleteSubtask, deleteTask } from "../lib/api"; +import { useToast } from "./Toast"; const priorityColors: Record = { critical: "bg-red-500 text-white", @@ -246,6 +247,9 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, const [saving, setSaving] = useState(false); const [noteText, setNoteText] = useState(""); const [addingNote, setAddingNote] = useState(false); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [deleting, setDeleting] = useState(false); + const { toast } = useToast(); // Draft state for editable fields const [draftTitle, setDraftTitle] = useState(task.title); @@ -263,6 +267,15 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, fetchProjects().then(setProjects).catch(() => {}); }, []); + // Keyboard shortcut: Escape to close + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape" && !showDeleteConfirm) onClose(); + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [onClose, showDeleteConfirm]); + // Reset drafts when task changes useEffect(() => { setDraftTitle(task.title); @@ -305,13 +318,32 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, if (draftDueDate !== currentDueDate) updates.dueDate = draftDueDate ? new Date(draftDueDate).toISOString() : null; await updateTask(task.id, updates, token); onTaskUpdated(); + toast("Changes saved", "success"); } catch (e) { console.error("Failed to update:", e); + toast("Failed to save changes", "error"); } finally { setSaving(false); } }; + const handleDelete = async () => { + if (!hasToken) return; + setDeleting(true); + try { + await deleteTask(task.id, token); + toast("Task deleted", "success"); + onClose(); + onTaskUpdated(); + } catch (e) { + console.error("Failed to delete:", e); + toast("Failed to delete task", "error"); + } finally { + setDeleting(false); + setShowDeleteConfirm(false); + } + }; + // Legacy single-field save available if needed const _handleFieldSave = async (field: string, value: string) => { if (!hasToken) return; @@ -802,7 +834,7 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, {hasToken && actions.length > 0 && (

Actions

-
+
{actions.map((action) => ( + ) : ( +
+ Delete this task? + + +
+ )}
)} diff --git a/frontend/src/components/Toast.tsx b/frontend/src/components/Toast.tsx new file mode 100644 index 0000000..e781f15 --- /dev/null +++ b/frontend/src/components/Toast.tsx @@ -0,0 +1,67 @@ +import { createContext, useContext, useState, useCallback, type ReactNode } from "react"; + +interface Toast { + id: number; + message: string; + type: "success" | "error" | "info"; +} + +interface ToastContextValue { + toast: (message: string, type?: Toast["type"]) => void; +} + +const ToastContext = createContext({ toast: () => {} }); + +export function useToast() { + return useContext(ToastContext); +} + +let nextId = 0; + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]); + + const addToast = useCallback((message: string, type: Toast["type"] = "info") => { + const id = nextId++; + setToasts((prev) => [...prev, { id, message, type }]); + setTimeout(() => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, 3000); + }, []); + + const removeToast = useCallback((id: number) => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, []); + + const typeStyles: Record = { + success: "bg-green-600 text-white", + error: "bg-red-600 text-white", + info: "bg-gray-800 text-white", + }; + + const typeIcons: Record = { + success: "✓", + error: "✕", + info: "ℹ", + }; + + return ( + + {children} + {/* Toast container */} +
+ {toasts.map((t) => ( +
removeToast(t.id)} + role="alert" + > + {typeIcons[t.type]} + {t.message} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/pages/QueuePage.tsx b/frontend/src/pages/QueuePage.tsx index cdcffd7..cc3ce3c 100644 --- a/frontend/src/pages/QueuePage.tsx +++ b/frontend/src/pages/QueuePage.tsx @@ -4,6 +4,7 @@ import { useCurrentUser } from "../hooks/useCurrentUser"; import { TaskCard } from "../components/TaskCard"; import { TaskDetailPanel } from "../components/TaskDetailPanel"; import { CreateTaskModal } from "../components/CreateTaskModal"; +import { useToast } from "../components/Toast"; import { updateTask, reorderTasks, createTask } from "../lib/api"; import type { TaskStatus } from "../lib/types"; @@ -15,6 +16,7 @@ export function QueuePage() { const [selectedTask, setSelectedTask] = useState(null); const [search, setSearch] = useState(""); const [filterPriority, setFilterPriority] = useState(""); + const { toast } = useToast(); const filteredTasks = useMemo(() => { let filtered = tasks; @@ -50,8 +52,9 @@ export function QueuePage() { try { await updateTask(id, { status }); refresh(); + toast(`Task moved to ${status}`, "success"); } catch (e) { - alert("Failed to update task."); + toast("Failed to update task", "error"); } }; @@ -77,8 +80,13 @@ export function QueuePage() { source?: string; priority?: string; }) => { - await createTask(task); - refresh(); + try { + await createTask(task); + refresh(); + toast("Task created", "success"); + } catch (e) { + toast("Failed to create task", "error"); + } }; return ( diff --git a/frontend/src/pages/TaskPage.tsx b/frontend/src/pages/TaskPage.tsx index cc0f11d..6027548 100644 --- a/frontend/src/pages/TaskPage.tsx +++ b/frontend/src/pages/TaskPage.tsx @@ -1,7 +1,8 @@ import { useState, useEffect, useCallback } from "react"; -import { useParams, Link } from "react-router-dom"; +import { useParams, Link, useNavigate } from "react-router-dom"; import type { Task, TaskStatus, Project } from "../lib/types"; -import { updateTask, fetchProjects, addProgressNote, addSubtask, toggleSubtask, deleteSubtask } from "../lib/api"; +import { updateTask, fetchProjects, addProgressNote, addSubtask, toggleSubtask, deleteSubtask, deleteTask } from "../lib/api"; +import { useToast } from "../components/Toast"; const priorityColors: Record = { critical: "bg-red-500 text-white", @@ -73,6 +74,10 @@ export function TaskPage() { const [newSubtaskTitle, setNewSubtaskTitle] = useState(""); const [addingSubtask, setAddingSubtask] = useState(false); const [saving, setSaving] = useState(false); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [deleting, setDeleting] = useState(false); + const { toast } = useToast(); + const navigate = useNavigate(); const fetchTask = useCallback(async () => { if (!taskRef) return; @@ -106,13 +111,31 @@ export function TaskPage() { try { await updateTask(task.id, { status }); fetchTask(); + toast(`Task moved to ${status}`, "success"); } catch (e) { console.error("Failed to update status:", e); + toast("Failed to update status", "error"); } finally { setSaving(false); } }; + const handleDelete = async () => { + if (!task) return; + setDeleting(true); + try { + await deleteTask(task.id); + toast("Task deleted", "success"); + navigate("/queue"); + } catch (e) { + console.error("Failed to delete:", e); + toast("Failed to delete task", "error"); + } finally { + setDeleting(false); + setShowDeleteConfirm(false); + } + }; + if (loading) { return (
@@ -478,6 +501,7 @@ export function TaskPage() { onClick={() => { const url = `${window.location.origin}/task/HQ-${task.taskNumber}`; navigator.clipboard.writeText(url); + toast("Link copied", "info"); }} className="text-xs px-2 py-1 bg-gray-100 rounded hover:bg-gray-200 transition" > @@ -485,6 +509,38 @@ export function TaskPage() {
+ + {/* Danger zone */} +
+

Danger Zone

+ {!showDeleteConfirm ? ( + + ) : ( +
+

This cannot be undone. Are you sure?

+
+ + +
+
+ )} +