| // src/components/project/ProjectTasks.tsx | |
| import { useState, useEffect, useCallback } from "react"; | |
| import { CheckCircle, Circle, Trash2, Plus, Clock, User, Calendar, Flag, Loader2, AlertCircle, CheckSquare } from "lucide-react"; | |
| import { motion, AnimatePresence } from "framer-motion"; | |
| import { formatDistanceToNow } from "@/lib/date"; | |
| import api from "@/lib/api"; | |
| import { toast } from "react-toastify"; | |
| interface Task { | |
| id: number; | |
| title: string; | |
| description: string; | |
| status: "pending" | "in_progress" | "completed"; | |
| priority: "low" | "medium" | "high"; | |
| assigneeId?: number; | |
| assigneeUsername?: string; | |
| dueDate?: string; | |
| createdAt: string; | |
| updatedAt: string; | |
| } | |
| interface ProjectTasksProps { | |
| projectId: number; | |
| isOwner: boolean; | |
| onTaskUpdate: () => void; | |
| onCreateTask: () => void; | |
| } | |
| const normalizeStatus = (s: string): Task["status"] => { | |
| if (!s) return "pending"; | |
| const low = s.toLowerCase().replace(/[-\s]/g, "_"); | |
| if (low.includes("complet")) return "completed"; | |
| if (low.includes("progress")) return "in_progress"; | |
| return "pending"; | |
| }; | |
| const PRIORITY_STYLES: Record<string, string> = { | |
| high: "text-red-600 bg-red-50 dark:bg-red-900/20 dark:text-red-400 border border-red-200 dark:border-red-800", | |
| medium: "text-amber-600 bg-amber-50 dark:bg-amber-900/20 dark:text-amber-400 border border-amber-200 dark:border-amber-800", | |
| low: "text-emerald-600 bg-emerald-50 dark:bg-emerald-900/20 dark:text-emerald-400 border border-emerald-200 dark:border-emerald-800", | |
| }; | |
| const FILTER_TABS = ["all", "pending", "in_progress", "completed"]; | |
| export function ProjectTasks({ projectId, isOwner, onTaskUpdate, onCreateTask }: ProjectTasksProps) { | |
| const [tasks, setTasks] = useState<Task[]>([]); | |
| const [loading, setLoading] = useState(true); | |
| const [filter, setFilter] = useState<"all" | Task["status"]>("all"); | |
| const fetchTasks = useCallback(async () => { | |
| setLoading(true); | |
| try { | |
| const response = await api.get(`/projects/${projectId}/tasks`); | |
| const raw = response.data?.tasks ?? response.data?.items ?? response.data ?? []; | |
| setTasks(raw.map((t: any) => ({ ...t, status: normalizeStatus(t.status) }))); | |
| } catch { | |
| toast.error("Failed to load tasks"); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }, [projectId]); | |
| useEffect(() => { fetchTasks(); }, [fetchTasks]); | |
| const handleToggle = async (task: Task) => { | |
| const next = task.status === "completed" ? "pending" : "completed"; | |
| try { | |
| await api.put(`/projects/${projectId}/tasks/${task.id}`, { status: next.toUpperCase() }); | |
| await fetchTasks(); | |
| onTaskUpdate(); | |
| } catch { | |
| toast.error("Failed to update task status"); | |
| } | |
| }; | |
| const handleDelete = async (taskId: number) => { | |
| if (!confirm("Delete this task?")) return; | |
| try { | |
| await api.delete(`/projects/${projectId}/tasks/${taskId}`); | |
| await fetchTasks(); | |
| onTaskUpdate(); | |
| toast.success("Task deleted"); | |
| } catch { | |
| toast.error("Failed to delete task"); | |
| } | |
| }; | |
| const filtered = tasks.filter(t => filter === "all" || (t.status as string) === filter); | |
| const counts: Record<string, number> = { | |
| all: tasks.length, | |
| pending: tasks.filter(t => t.status === "pending").length, | |
| in_progress: tasks.filter(t => t.status === "in_progress").length, | |
| completed: tasks.filter(t => t.status === "completed").length, | |
| }; | |
| if (loading) { | |
| return ( | |
| <div className="flex items-center justify-center py-16"> | |
| <Loader2 className="w-8 h-8 animate-spin text-primary" /> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div className="space-y-5"> | |
| {/* Toolbar */} | |
| <div className="flex flex-wrap items-center justify-between gap-4"> | |
| <div className="flex gap-1.5 bg-gray-100 dark:bg-gray-800 p-1 rounded-xl"> | |
| {FILTER_TABS.map(f => ( | |
| <button | |
| key={f} | |
| onClick={() => setFilter(f as any)} | |
| className={`px-3 py-1.5 text-xs font-semibold rounded-lg transition-all capitalize ${ | |
| filter === f | |
| ? "bg-white dark:bg-gray-700 shadow-sm text-primary" | |
| : "text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300" | |
| }`} | |
| > | |
| {f.replace("_", " ")} {counts[f] > 0 && <span className="ml-1 opacity-60">({counts[f]})</span>} | |
| </button> | |
| ))} | |
| </div> | |
| <motion.button | |
| whileHover={{ scale: 1.03 }} | |
| whileTap={{ scale: 0.97 }} | |
| onClick={onCreateTask} | |
| className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-primary to-indigo-600 text-white rounded-xl text-sm font-semibold shadow-sm shadow-primary/20 hover:shadow-primary/40 transition-all" | |
| > | |
| <Plus className="w-4 h-4" /> | |
| New Task | |
| </motion.button> | |
| </div> | |
| {/* Task List */} | |
| <div className="space-y-2.5"> | |
| <AnimatePresence> | |
| {filtered.length === 0 ? ( | |
| <motion.div | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| className="text-center py-16 text-gray-500 dark:text-gray-400" | |
| > | |
| <CheckSquare className="w-12 h-12 mx-auto mb-3 opacity-30" /> | |
| <p className="text-base font-medium"> | |
| {filter === "all" ? "No tasks yet" : `No ${filter.replace("_", " ")} tasks`} | |
| </p> | |
| <button | |
| onClick={onCreateTask} | |
| className="mt-4 text-sm text-primary hover:underline font-medium" | |
| > | |
| + Create the first task | |
| </button> | |
| </motion.div> | |
| ) : ( | |
| filtered.map((task, i) => ( | |
| <motion.div | |
| key={task.id} | |
| layout | |
| initial={{ opacity: 0, x: -20 }} | |
| animate={{ opacity: 1, x: 0 }} | |
| exit={{ opacity: 0, x: 20 }} | |
| transition={{ delay: i * 0.04 }} | |
| className={`group bg-white/70 dark:bg-gray-900/70 backdrop-blur-md rounded-2xl border transition-all hover:shadow-md ${ | |
| task.status === "completed" | |
| ? "border-emerald-100 dark:border-emerald-900/30 opacity-75" | |
| : "border-white/50 dark:border-gray-700/50 hover:border-primary/20" | |
| } p-4`} | |
| > | |
| <div className="flex items-start gap-3"> | |
| {/* Toggle Button */} | |
| <button | |
| onClick={() => handleToggle(task)} | |
| className="mt-0.5 flex-shrink-0 transition-transform hover:scale-110" | |
| > | |
| {task.status === "completed" | |
| ? <CheckCircle className="w-5 h-5 text-emerald-500" /> | |
| : task.status === "in_progress" | |
| ? <Clock className="w-5 h-5 text-amber-500" /> | |
| : <Circle className="w-5 h-5 text-gray-300 dark:text-gray-600 group-hover:text-gray-400" /> | |
| } | |
| </button> | |
| {/* Task Info */} | |
| <div className="flex-1 min-w-0"> | |
| <div className="flex items-center gap-2 flex-wrap"> | |
| <h4 className={`font-semibold text-sm ${ | |
| task.status === "completed" ? "line-through text-gray-400 dark:text-gray-600" : "text-gray-900 dark:text-white" | |
| }`}> | |
| {task.title} | |
| </h4> | |
| <span className={`px-2 py-0.5 text-xs rounded-full font-medium capitalize ${PRIORITY_STYLES[task.priority] ?? PRIORITY_STYLES.low}`}> | |
| <Flag className="w-2.5 h-2.5 inline mr-0.5" />{task.priority} | |
| </span> | |
| </div> | |
| {task.description && ( | |
| <p className="text-xs text-gray-500 dark:text-gray-400 mt-1 line-clamp-2">{task.description}</p> | |
| )} | |
| <div className="flex items-center gap-4 mt-2 text-xs text-gray-400 dark:text-gray-500 flex-wrap"> | |
| {task.assigneeUsername && ( | |
| <span className="flex items-center gap-1"> | |
| <User className="w-3 h-3" /> {task.assigneeUsername} | |
| </span> | |
| )} | |
| {task.dueDate && ( | |
| <span className={`flex items-center gap-1 ${new Date(task.dueDate) < new Date() && task.status !== "completed" ? "text-red-500" : ""}`}> | |
| <Calendar className="w-3 h-3" /> | |
| Due {formatDistanceToNow(new Date(task.dueDate), { addSuffix: true })} | |
| </span> | |
| )} | |
| <span>Created {formatDistanceToNow(new Date(task.createdAt), { addSuffix: true })}</span> | |
| </div> | |
| </div> | |
| {/* Delete */} | |
| {isOwner && ( | |
| <button | |
| onClick={() => handleDelete(task.id)} | |
| className="p-1.5 rounded-lg opacity-0 group-hover:opacity-100 hover:bg-red-50 dark:hover:bg-red-900/20 text-gray-300 hover:text-red-500 transition-all" | |
| > | |
| <Trash2 className="w-4 h-4" /> | |
| </button> | |
| )} | |
| </div> | |
| </motion.div> | |
| )) | |
| )} | |
| </AnimatePresence> | |
| </div> | |
| </div> | |
| ); | |
| } | |
Xet Storage Details
- Size:
- 9.57 kB
- Xet hash:
- 006200a1071c7a020407996f0eb7e01508b73efb56eefd4db828dd1f34a12c01
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.