tahamajs/IE / Projects /front /src /components /project /ProjectTasks.tsx
tahamajs's picture
download
raw
9.57 kB
// 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.