added project
Signed-off-by: Max <ai-agent@topdoglabs.com>
This commit is contained in:
parent
60aed51f3c
commit
2797256bd7
@ -63,9 +63,8 @@ const TASK_PRIORITIES: Task["priority"][] = ["low", "medium", "high", "urgent"];
|
|||||||
const SPRINT_STATUSES: Sprint["status"][] = ["planning", "active", "completed"];
|
const SPRINT_STATUSES: Sprint["status"][] = ["planning", "active", "completed"];
|
||||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
// Optimized field selection - only fetch fields needed for board display
|
// Optimized field selection - fetch all fields needed for board and detail display
|
||||||
// Full task details (description, comments, attachments) fetched lazily
|
const TASK_FIELDS = [
|
||||||
const TASK_FIELDS_LIGHT = [
|
|
||||||
"id",
|
"id",
|
||||||
"title",
|
"title",
|
||||||
"type",
|
"type",
|
||||||
@ -80,6 +79,9 @@ const TASK_FIELDS_LIGHT = [
|
|||||||
"assignee_id",
|
"assignee_id",
|
||||||
"due_date",
|
"due_date",
|
||||||
"tags",
|
"tags",
|
||||||
|
"comments",
|
||||||
|
"attachments",
|
||||||
|
"description",
|
||||||
];
|
];
|
||||||
|
|
||||||
class HttpError extends Error {
|
class HttpError extends Error {
|
||||||
@ -287,7 +289,7 @@ export async function GET() {
|
|||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
supabase.from("projects").select("id, name, description, color, created_at").order("created_at", { ascending: true }),
|
supabase.from("projects").select("id, name, description, color, created_at").order("created_at", { ascending: true }),
|
||||||
supabase.from("sprints").select("id, name, goal, start_date, end_date, status, project_id, created_at").order("start_date", { ascending: true }),
|
supabase.from("sprints").select("id, name, goal, start_date, end_date, status, project_id, created_at").order("start_date", { ascending: true }),
|
||||||
supabase.from("tasks").select(TASK_FIELDS_LIGHT.join(", ")).order("updated_at", { ascending: false }),
|
supabase.from("tasks").select(TASK_FIELDS.join(", ")).order("updated_at", { ascending: false }),
|
||||||
supabase.from("users").select("id, name, email, avatar_url"),
|
supabase.from("users").select("id, name, email, avatar_url"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
760
src/app/projects/[id]/page.tsx
Normal file
760
src/app/projects/[id]/page.tsx
Normal file
@ -0,0 +1,760 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState, useMemo } from "react"
|
||||||
|
import { useRouter, useParams } from "next/navigation"
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
DragEndEvent,
|
||||||
|
DragOverlay,
|
||||||
|
DragStartEvent,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
closestCorners,
|
||||||
|
} from "@dnd-kit/core"
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
useSortable,
|
||||||
|
} from "@dnd-kit/sortable"
|
||||||
|
import { CSS } from "@dnd-kit/utilities"
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
FolderKanban,
|
||||||
|
Plus,
|
||||||
|
Edit3,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
GripVertical,
|
||||||
|
LayoutGrid,
|
||||||
|
ListTodo,
|
||||||
|
Trash2,
|
||||||
|
MessageSquare,
|
||||||
|
Inbox
|
||||||
|
} from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
|
import { useTaskStore, Task, Project } from "@/stores/useTaskStore"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { parseSprintStart } from "@/lib/utils"
|
||||||
|
|
||||||
|
const PRESET_COLORS = [
|
||||||
|
"#3b82f6", "#ef4444", "#22c55e", "#f59e0b", "#8b5cf6",
|
||||||
|
"#ec4899", "#06b6d4", "#f97316", "#84cc16", "#6366f1",
|
||||||
|
]
|
||||||
|
|
||||||
|
const priorityColors: Record<string, string> = {
|
||||||
|
low: "bg-slate-600",
|
||||||
|
medium: "bg-blue-600",
|
||||||
|
high: "bg-orange-600",
|
||||||
|
urgent: "bg-red-600",
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
open: "Open",
|
||||||
|
todo: "To Do",
|
||||||
|
blocked: "Blocked",
|
||||||
|
"in-progress": "In Progress",
|
||||||
|
review: "Review",
|
||||||
|
validate: "Validate",
|
||||||
|
archived: "Archived",
|
||||||
|
canceled: "Canceled",
|
||||||
|
done: "Done",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sortable Task Card Component
|
||||||
|
function SortableTaskCard({
|
||||||
|
task,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
task: Task
|
||||||
|
onClick: () => void
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: task.id })
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className="bg-slate-800 border-slate-700 cursor-pointer hover:border-slate-600 transition-colors group"
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
className="mt-0.5 opacity-0 group-hover:opacity-100 transition-opacity cursor-grab active:cursor-grabbing"
|
||||||
|
>
|
||||||
|
<GripVertical className="w-4 h-4 text-slate-500" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<h4 className="text-sm font-medium text-slate-200 line-clamp-2">
|
||||||
|
{task.title}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
||||||
|
<Badge
|
||||||
|
className={`${priorityColors[task.priority]} text-white text-xs`}
|
||||||
|
>
|
||||||
|
{task.priority}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="text-xs border-slate-600 text-slate-400">
|
||||||
|
{task.type}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="secondary" className="text-xs bg-slate-700 text-slate-300">
|
||||||
|
{statusLabels[task.status]}
|
||||||
|
</Badge>
|
||||||
|
{task.comments && task.comments.length > 0 && (
|
||||||
|
<span className="text-xs text-slate-500 flex items-center gap-1">
|
||||||
|
<MessageSquare className="w-3 h-3" />
|
||||||
|
{task.comments.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{task.assigneeName && (
|
||||||
|
<p className="text-xs text-slate-500 mt-2">
|
||||||
|
Assigned to: {task.assigneeName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag Overlay Task Card
|
||||||
|
function DragOverlayTaskCard({ task }: { task: Task }) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-slate-800 border-slate-600 shadow-xl rotate-2">
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<GripVertical className="w-4 h-4 text-slate-500 mt-0.5" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h4 className="text-sm font-medium text-slate-200 line-clamp-2">
|
||||||
|
{task.title}
|
||||||
|
</h4>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<Badge className={`${priorityColors[task.priority]} text-white text-xs`}>
|
||||||
|
{task.priority}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="text-xs border-slate-600 text-slate-400">
|
||||||
|
{task.type}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unassigned Tasks Panel Component
|
||||||
|
function UnassignedPanel({
|
||||||
|
tasks,
|
||||||
|
onTaskClick,
|
||||||
|
}: {
|
||||||
|
tasks: Task[]
|
||||||
|
onTaskClick: (taskId: string) => void
|
||||||
|
}) {
|
||||||
|
const { setNodeRef, isOver } = useSortable({
|
||||||
|
id: "unassigned-drop-zone",
|
||||||
|
data: { type: "unassigned" },
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
className={`bg-slate-900/50 rounded-lg p-4 border-2 border-dashed transition-colors ${
|
||||||
|
isOver ? "border-blue-500 bg-blue-500/10" : "border-slate-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Inbox className="w-4 h-4 text-slate-400" />
|
||||||
|
<h3 className="font-medium text-slate-300">Unassigned Tasks</h3>
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary" className="text-xs bg-slate-800 text-slate-400">
|
||||||
|
{tasks.length}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SortableContext items={tasks.map((t) => t.id)} strategy={verticalListSortingStrategy}>
|
||||||
|
<div className="space-y-2 min-h-[100px]">
|
||||||
|
{tasks.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-slate-500 text-sm">
|
||||||
|
<p>Drop tasks here to unassign</p>
|
||||||
|
<p className="text-xs mt-1">from this project</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
tasks.map((task) => (
|
||||||
|
<SortableTaskCard
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
onClick={() => onTaskClick(task.id)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProjectDetailPage() {
|
||||||
|
const params = useParams<{ id: string }>()
|
||||||
|
const router = useRouter()
|
||||||
|
const projectId = params.id
|
||||||
|
|
||||||
|
const {
|
||||||
|
projects,
|
||||||
|
tasks,
|
||||||
|
sprints,
|
||||||
|
currentUser,
|
||||||
|
updateTask,
|
||||||
|
updateProject,
|
||||||
|
deleteProject,
|
||||||
|
syncFromServer,
|
||||||
|
addTask,
|
||||||
|
} = useTaskStore()
|
||||||
|
|
||||||
|
const [authReady, setAuthReady] = useState(false)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [project, setProject] = useState<Project | null>(null)
|
||||||
|
|
||||||
|
// Edit state
|
||||||
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
|
const [editName, setEditName] = useState("")
|
||||||
|
const [editDescription, setEditDescription] = useState("")
|
||||||
|
const [editColor, setEditColor] = useState("")
|
||||||
|
|
||||||
|
// Drag and drop
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// New task dialog
|
||||||
|
const [newTaskOpen, setNewTaskOpen] = useState(false)
|
||||||
|
const [newTask, setNewTask] = useState<Partial<Task>>({
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
type: "task",
|
||||||
|
priority: "medium",
|
||||||
|
status: "open",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sensors for drag detection
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: { distance: 8 },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check auth
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true
|
||||||
|
const loadSession = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/session", { cache: "no-store" })
|
||||||
|
if (!res.ok) {
|
||||||
|
if (isMounted) router.replace("/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isMounted) setAuthReady(true)
|
||||||
|
} catch {
|
||||||
|
if (isMounted) router.replace("/login")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadSession()
|
||||||
|
return () => { isMounted = false }
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
// Sync and find project
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authReady) return
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
await syncFromServer()
|
||||||
|
const found = projects.find((p) => p.id === projectId)
|
||||||
|
if (found) {
|
||||||
|
setProject(found)
|
||||||
|
setEditName(found.name)
|
||||||
|
setEditDescription(found.description || "")
|
||||||
|
setEditColor(found.color)
|
||||||
|
}
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
}, [authReady, projectId, projects, syncFromServer])
|
||||||
|
|
||||||
|
// Get project tasks
|
||||||
|
const projectTasks = useMemo(() => {
|
||||||
|
return tasks
|
||||||
|
.filter((t) => t.projectId === projectId)
|
||||||
|
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
|
||||||
|
}, [tasks, projectId])
|
||||||
|
|
||||||
|
// Get unassigned tasks (for this user's view - tasks without project)
|
||||||
|
const unassignedTasks = useMemo(() => {
|
||||||
|
return tasks
|
||||||
|
.filter((t) => !t.projectId && t.createdById === currentUser.id)
|
||||||
|
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
|
||||||
|
}, [tasks, currentUser.id])
|
||||||
|
|
||||||
|
// Get project sprints
|
||||||
|
const projectSprints = useMemo(() => {
|
||||||
|
return sprints
|
||||||
|
.filter((s) => s.projectId === projectId)
|
||||||
|
.sort((a, b) => parseSprintStart(a.startDate).getTime() - parseSprintStart(b.startDate).getTime())
|
||||||
|
}, [sprints, projectId])
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const total = projectTasks.length
|
||||||
|
const completed = projectTasks.filter((t) => t.status === "done" || t.status === "archived").length
|
||||||
|
const inProgress = projectTasks.filter((t) => t.status === "in-progress" || t.status === "review").length
|
||||||
|
const todo = projectTasks.filter((t) => t.status === "open" || t.status === "todo").length
|
||||||
|
return { total, completed, inProgress, todo }
|
||||||
|
}, [projectTasks])
|
||||||
|
|
||||||
|
const handleSaveEdit = async () => {
|
||||||
|
if (!project || !editName.trim()) {
|
||||||
|
toast.error("Project name is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateProject(project.id, {
|
||||||
|
name: editName.trim(),
|
||||||
|
description: editDescription.trim() || undefined,
|
||||||
|
color: editColor,
|
||||||
|
})
|
||||||
|
toast.success("Project updated successfully")
|
||||||
|
setIsEditing(false)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to update project")
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteProject = async () => {
|
||||||
|
if (!project) return
|
||||||
|
if (!window.confirm("Are you sure you want to delete this project? This cannot be undone.")) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteProject(project.id)
|
||||||
|
toast.success("Project deleted successfully")
|
||||||
|
router.push("/projects")
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to delete project")
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragStart = (event: DragStartEvent) => {
|
||||||
|
setActiveId(event.active.id as string)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event
|
||||||
|
setActiveId(null)
|
||||||
|
|
||||||
|
if (!over) return
|
||||||
|
|
||||||
|
const taskId = active.id as string
|
||||||
|
const overId = over.id as string
|
||||||
|
|
||||||
|
// If dropped on unassigned zone, remove project association
|
||||||
|
if (overId === "unassigned-drop-zone") {
|
||||||
|
const task = tasks.find((t) => t.id === taskId)
|
||||||
|
if (task && task.projectId === projectId) {
|
||||||
|
updateTask(taskId, { projectId: undefined, sprintId: undefined })
|
||||||
|
toast.success("Task unassigned from project")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If dropped on project zone, assign to this project
|
||||||
|
if (overId === "project-drop-zone") {
|
||||||
|
const task = tasks.find((t) => t.id === taskId)
|
||||||
|
if (task && task.projectId !== projectId) {
|
||||||
|
updateTask(taskId, { projectId })
|
||||||
|
toast.success("Task assigned to project")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If dropped on another task, we could reorder (future enhancement)
|
||||||
|
const overTask = tasks.find((t) => t.id === overId)
|
||||||
|
if (overTask) {
|
||||||
|
// Check if we need to move between projects
|
||||||
|
const activeTask = tasks.find((t) => t.id === taskId)
|
||||||
|
if (activeTask && overTask.projectId !== activeTask.projectId) {
|
||||||
|
if (overTask.projectId === projectId) {
|
||||||
|
updateTask(taskId, { projectId })
|
||||||
|
toast.success("Task assigned to project")
|
||||||
|
} else if (!overTask.projectId) {
|
||||||
|
updateTask(taskId, { projectId: undefined, sprintId: undefined })
|
||||||
|
toast.success("Task unassigned from project")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddTask = async () => {
|
||||||
|
if (!newTask.title?.trim() || !project) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await addTask({
|
||||||
|
title: newTask.title.trim(),
|
||||||
|
description: newTask.description?.trim() || undefined,
|
||||||
|
type: (newTask.type || "task") as Task["type"],
|
||||||
|
priority: (newTask.priority || "medium") as Task["priority"],
|
||||||
|
status: (newTask.status || "open") as Task["status"],
|
||||||
|
projectId: project.id,
|
||||||
|
tags: [],
|
||||||
|
})
|
||||||
|
toast.success("Task created successfully")
|
||||||
|
setNewTaskOpen(false)
|
||||||
|
setNewTask({
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
type: "task",
|
||||||
|
priority: "medium",
|
||||||
|
status: "open",
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to create task")
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeTask = activeId ? tasks.find((t) => t.id === activeId) : null
|
||||||
|
|
||||||
|
if (!authReady) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center">
|
||||||
|
<p className="text-sm text-slate-400">Checking session...</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center">
|
||||||
|
<div className="flex items-center gap-2 text-slate-400">
|
||||||
|
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<span>Loading project...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<FolderKanban className="w-16 h-16 mx-auto mb-4 text-slate-600" />
|
||||||
|
<h2 className="text-xl font-semibold text-white mb-2">Project Not Found</h2>
|
||||||
|
<p className="text-slate-400 mb-4">The project you're looking for doesn't exist.</p>
|
||||||
|
<Button onClick={() => router.push("/projects")}>
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
Back to Projects
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCorners}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<div className="min-h-screen bg-slate-950 text-slate-100">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="border-b border-slate-800 bg-slate-900/50 backdrop-blur">
|
||||||
|
<div className="max-w-[1800px] mx-auto px-4 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="border-slate-700 text-slate-200 hover:bg-slate-800"
|
||||||
|
onClick={() => router.push("/projects")}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Input
|
||||||
|
value={editName}
|
||||||
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
|
className="bg-slate-800 border-slate-700 text-white w-64"
|
||||||
|
placeholder="Project name"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{PRESET_COLORS.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditColor(color)}
|
||||||
|
className={`w-6 h-6 rounded-full border-2 transition-all ${
|
||||||
|
editColor === color ? "border-white" : "border-transparent"
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={handleSaveEdit}>
|
||||||
|
<Check className="w-4 h-4 mr-1" />
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setIsEditing(false)}>
|
||||||
|
<X className="w-4 h-4 mr-1" />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="w-4 h-4 rounded-full"
|
||||||
|
style={{ backgroundColor: project.color }}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl md:text-2xl font-bold text-white">
|
||||||
|
{project.name}
|
||||||
|
</h1>
|
||||||
|
{project.description && (
|
||||||
|
<p className="text-sm text-slate-400">{project.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
className="p-2 rounded hover:bg-slate-800 text-slate-400 hover:text-blue-400 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
title="Edit project"
|
||||||
|
>
|
||||||
|
<Edit3 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" onClick={handleDeleteProject} className="text-red-400 hover:text-red-300">
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setNewTaskOpen(true)}>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Add Task
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="max-w-[1800px] mx-auto px-4 py-6">
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-lg p-4">
|
||||||
|
<p className="text-3xl font-bold text-white">{stats.total}</p>
|
||||||
|
<p className="text-sm text-slate-400">Total Tasks</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-lg p-4">
|
||||||
|
<p className="text-3xl font-bold text-emerald-400">{stats.completed}</p>
|
||||||
|
<p className="text-sm text-slate-400">Completed</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-lg p-4">
|
||||||
|
<p className="text-3xl font-bold text-blue-400">{stats.inProgress}</p>
|
||||||
|
<p className="text-sm text-slate-400">In Progress</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-lg p-4">
|
||||||
|
<p className="text-3xl font-bold text-slate-400">{stats.todo}</p>
|
||||||
|
<p className="text-sm text-slate-400">To Do</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
{stats.total > 0 && (
|
||||||
|
<div className="mb-6 bg-slate-900 border border-slate-800 rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm text-slate-400">Project Progress</span>
|
||||||
|
<span className="text-sm text-white font-medium">
|
||||||
|
{Math.round((stats.completed / stats.total) * 100)}% Complete
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-3 bg-slate-800 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${(stats.completed / stats.total) * 100}%`,
|
||||||
|
backgroundColor: project.color
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Project Tasks */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<div
|
||||||
|
className="bg-slate-900/50 rounded-lg p-4 border border-slate-800"
|
||||||
|
id="project-drop-zone"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<LayoutGrid className="w-4 h-4 text-slate-400" />
|
||||||
|
<h3 className="font-medium text-slate-300">Project Tasks</h3>
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary" className="text-xs bg-slate-800 text-slate-400">
|
||||||
|
{projectTasks.length}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{projectSprints.length > 0 && (
|
||||||
|
<div className="mb-4 flex flex-wrap gap-2">
|
||||||
|
<span className="text-xs text-slate-500">Sprints:</span>
|
||||||
|
{projectSprints.map((sprint) => (
|
||||||
|
<Badge
|
||||||
|
key={sprint.id}
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs border-slate-700 text-slate-400"
|
||||||
|
>
|
||||||
|
{sprint.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SortableContext items={projectTasks.map((t) => t.id)} strategy={verticalListSortingStrategy}>
|
||||||
|
<div className="space-y-2 min-h-[200px]">
|
||||||
|
{projectTasks.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-slate-500 border-2 border-dashed border-slate-800 rounded-lg">
|
||||||
|
<ListTodo className="w-12 h-12 mx-auto mb-3 text-slate-600" />
|
||||||
|
<p>No tasks in this project yet</p>
|
||||||
|
<p className="text-sm mt-1">Drag tasks here or click "Add Task"</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
projectTasks.map((task) => (
|
||||||
|
<SortableTaskCard
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
onClick={() => router.push(`/tasks/${task.id}`)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Unassigned Tasks Panel */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<UnassignedPanel
|
||||||
|
tasks={unassignedTasks}
|
||||||
|
onTaskClick={(taskId) => router.push(`/tasks/${taskId}`)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* New Task Dialog */}
|
||||||
|
<Dialog open={newTaskOpen} onOpenChange={setNewTaskOpen}>
|
||||||
|
<DialogContent className="bg-slate-900 border-slate-800 text-white w-[95vw] max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create New Task</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-slate-400">Title *</label>
|
||||||
|
<Input
|
||||||
|
value={newTask.title}
|
||||||
|
onChange={(e) => setNewTask({ ...newTask, title: e.target.value })}
|
||||||
|
placeholder="Task title"
|
||||||
|
className="mt-1 bg-slate-800 border-slate-700 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-slate-400">Description</label>
|
||||||
|
<Textarea
|
||||||
|
value={newTask.description}
|
||||||
|
onChange={(e) => setNewTask({ ...newTask, description: e.target.value })}
|
||||||
|
placeholder="Task description"
|
||||||
|
rows={3}
|
||||||
|
className="mt-1 bg-slate-800 border-slate-700 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-slate-400">Type</label>
|
||||||
|
<select
|
||||||
|
value={newTask.type}
|
||||||
|
onChange={(e) => setNewTask({ ...newTask, type: e.target.value as Task["type"] })}
|
||||||
|
className="w-full mt-1 bg-slate-800 border border-slate-700 rounded px-3 py-2 text-white"
|
||||||
|
>
|
||||||
|
<option value="task">📋 Task</option>
|
||||||
|
<option value="idea">💡 Idea</option>
|
||||||
|
<option value="bug">🐛 Bug</option>
|
||||||
|
<option value="research">🔬 Research</option>
|
||||||
|
<option value="plan">📐 Plan</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-slate-400">Priority</label>
|
||||||
|
<select
|
||||||
|
value={newTask.priority}
|
||||||
|
onChange={(e) => setNewTask({ ...newTask, priority: e.target.value as Task["priority"] })}
|
||||||
|
className="w-full mt-1 bg-slate-800 border border-slate-700 rounded px-3 py-2 text-white"
|
||||||
|
>
|
||||||
|
<option value="low">Low</option>
|
||||||
|
<option value="medium">Medium</option>
|
||||||
|
<option value="high">High</option>
|
||||||
|
<option value="urgent">Urgent</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button variant="outline" onClick={() => setNewTaskOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleAddTask} disabled={!newTask.title?.trim()}>
|
||||||
|
Create Task
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<DragOverlay>
|
||||||
|
{activeTask ? <DragOverlayTaskCard task={activeTask} /> : null}
|
||||||
|
</DragOverlay>
|
||||||
|
</div>
|
||||||
|
</DndContext>
|
||||||
|
)
|
||||||
|
}
|
||||||
478
src/app/projects/page.tsx
Normal file
478
src/app/projects/page.tsx
Normal file
@ -0,0 +1,478 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { FolderKanban, Plus, ArrowLeft, LayoutGrid, Trash2, Edit3, Check, X } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Card, CardContent, CardHeader } from "@/components/ui/card"
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { useTaskStore, Project } from "@/stores/useTaskStore"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
interface ProjectWithStats extends Project {
|
||||||
|
taskCount: number
|
||||||
|
completedTasks: number
|
||||||
|
inProgressTasks: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRESET_COLORS = [
|
||||||
|
"#3b82f6", // blue
|
||||||
|
"#ef4444", // red
|
||||||
|
"#22c55e", // green
|
||||||
|
"#f59e0b", // amber
|
||||||
|
"#8b5cf6", // violet
|
||||||
|
"#ec4899", // pink
|
||||||
|
"#06b6d4", // cyan
|
||||||
|
"#f97316", // orange
|
||||||
|
"#84cc16", // lime
|
||||||
|
"#6366f1", // indigo
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function ProjectsPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const { projects, tasks, addProject, updateProject, deleteProject, syncFromServer } = useTaskStore()
|
||||||
|
|
||||||
|
|
||||||
|
const [authReady, setAuthReady] = useState(false)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [projectsWithStats, setProjectsWithStats] = useState<ProjectWithStats[]>([])
|
||||||
|
|
||||||
|
// Create project dialog state
|
||||||
|
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||||
|
const [newProjectName, setNewProjectName] = useState("")
|
||||||
|
const [newProjectDescription, setNewProjectDescription] = useState("")
|
||||||
|
const [newProjectColor, setNewProjectColor] = useState(PRESET_COLORS[0])
|
||||||
|
const [isCreating, setIsCreating] = useState(false)
|
||||||
|
|
||||||
|
// Edit state
|
||||||
|
const [editingProjectId, setEditingProjectId] = useState<string | null>(null)
|
||||||
|
const [editName, setEditName] = useState("")
|
||||||
|
const [editDescription, setEditDescription] = useState("")
|
||||||
|
const [editColor, setEditColor] = useState("")
|
||||||
|
|
||||||
|
// Delete confirmation
|
||||||
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Check auth
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true
|
||||||
|
const loadSession = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/session", { cache: "no-store" })
|
||||||
|
if (!res.ok) {
|
||||||
|
if (isMounted) router.replace("/login")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isMounted) setAuthReady(true)
|
||||||
|
} catch {
|
||||||
|
if (isMounted) router.replace("/login")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadSession()
|
||||||
|
return () => { isMounted = false }
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
// Sync data when auth is ready
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authReady) return
|
||||||
|
syncFromServer().then(() => setIsLoading(false))
|
||||||
|
}, [authReady, syncFromServer])
|
||||||
|
|
||||||
|
// Calculate project stats
|
||||||
|
useEffect(() => {
|
||||||
|
const stats = projects.map(project => {
|
||||||
|
const projectTasks = tasks.filter(t => t.projectId === project.id)
|
||||||
|
return {
|
||||||
|
...project,
|
||||||
|
taskCount: projectTasks.length,
|
||||||
|
completedTasks: projectTasks.filter(t => t.status === "done" || t.status === "archived").length,
|
||||||
|
inProgressTasks: projectTasks.filter(t => t.status === "in-progress" || t.status === "review").length,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setProjectsWithStats(stats)
|
||||||
|
}, [projects, tasks])
|
||||||
|
|
||||||
|
const handleCreateProject = async () => {
|
||||||
|
if (!newProjectName.trim()) {
|
||||||
|
toast.error("Project name is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsCreating(true)
|
||||||
|
try {
|
||||||
|
// addProject only accepts name and description, color is assigned randomly
|
||||||
|
// We'll update the color immediately after creation
|
||||||
|
await addProject(
|
||||||
|
newProjectName.trim(),
|
||||||
|
newProjectDescription.trim() || undefined
|
||||||
|
)
|
||||||
|
|
||||||
|
// The project was created, now we need to sync and update the color
|
||||||
|
await syncFromServer()
|
||||||
|
|
||||||
|
// Find the newly created project (most recent one with matching name)
|
||||||
|
const newProject = projects
|
||||||
|
.filter(p => p.name === newProjectName.trim())
|
||||||
|
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0]
|
||||||
|
|
||||||
|
if (newProject && newProject.color !== newProjectColor) {
|
||||||
|
await updateProject(newProject.id, { color: newProjectColor })
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Project created successfully")
|
||||||
|
setCreateDialogOpen(false)
|
||||||
|
setNewProjectName("")
|
||||||
|
setNewProjectDescription("")
|
||||||
|
setNewProjectColor(PRESET_COLORS[0])
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to create project")
|
||||||
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startEdit = (project: Project) => {
|
||||||
|
setEditingProjectId(project.id)
|
||||||
|
setEditName(project.name)
|
||||||
|
setEditDescription(project.description || "")
|
||||||
|
setEditColor(project.color)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelEdit = () => {
|
||||||
|
setEditingProjectId(null)
|
||||||
|
setEditName("")
|
||||||
|
setEditDescription("")
|
||||||
|
setEditColor("")
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveEdit = async (projectId: string) => {
|
||||||
|
if (!editName.trim()) {
|
||||||
|
toast.error("Project name is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateProject(projectId, {
|
||||||
|
name: editName.trim(),
|
||||||
|
description: editDescription.trim() || undefined,
|
||||||
|
color: editColor,
|
||||||
|
})
|
||||||
|
toast.success("Project updated successfully")
|
||||||
|
setEditingProjectId(null)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to update project")
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteProject = async (projectId: string) => {
|
||||||
|
try {
|
||||||
|
await deleteProject(projectId)
|
||||||
|
toast.success("Project deleted successfully")
|
||||||
|
setDeleteConfirmId(null)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to delete project")
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authReady) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center">
|
||||||
|
<p className="text-sm text-slate-400">Checking session...</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-950 text-slate-100">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="border-b border-slate-800 bg-slate-900/50 backdrop-blur">
|
||||||
|
<div className="max-w-[1800px] mx-auto px-4 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="border-slate-700 text-slate-200 hover:bg-slate-800"
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
Back to Board
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl md:text-2xl font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent">
|
||||||
|
Projects
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs md:text-sm text-slate-400 mt-1">
|
||||||
|
Manage your projects and view task statistics
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
New Project
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="max-w-[1800px] mx-auto px-4 py-6">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="flex items-center gap-2 text-slate-400">
|
||||||
|
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<span>Loading projects...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : projectsWithStats.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-slate-400">
|
||||||
|
<FolderKanban className="w-16 h-16 mb-4 text-slate-600" />
|
||||||
|
<h3 className="text-lg font-medium text-slate-300 mb-2">No Projects Yet</h3>
|
||||||
|
<p className="text-sm mb-6 text-center max-w-md">
|
||||||
|
Create your first project to start organizing your tasks and sprints.
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Create First Project
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
|
{projectsWithStats.map((project) => (
|
||||||
|
<Card
|
||||||
|
key={project.id}
|
||||||
|
className="bg-slate-900 border-slate-800 hover:border-slate-600 transition-all group overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Color bar */}
|
||||||
|
<div
|
||||||
|
className="h-2 w-full"
|
||||||
|
style={{ backgroundColor: project.color }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
{editingProjectId === project.id ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Input
|
||||||
|
value={editName}
|
||||||
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
|
className="bg-slate-800 border-slate-700 text-white"
|
||||||
|
placeholder="Project name"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
value={editDescription}
|
||||||
|
onChange={(e) => setEditDescription(e.target.value)}
|
||||||
|
className="bg-slate-800 border-slate-700 text-white text-sm"
|
||||||
|
placeholder="Description (optional)"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{PRESET_COLORS.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditColor(color)}
|
||||||
|
className={`w-6 h-6 rounded-full border-2 transition-all ${
|
||||||
|
editColor === color ? "border-white scale-110" : "border-transparent"
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" onClick={() => saveEdit(project.id)}>
|
||||||
|
<Check className="w-3 h-3 mr-1" />
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={cancelEdit}>
|
||||||
|
<X className="w-3 h-3 mr-1" />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div
|
||||||
|
className="flex-1 min-w-0 cursor-pointer"
|
||||||
|
onClick={() => router.push(`/projects/${project.id}`)}
|
||||||
|
>
|
||||||
|
<h3 className="font-semibold text-white truncate group-hover:text-blue-400 transition-colors">
|
||||||
|
{project.name}
|
||||||
|
</h3>
|
||||||
|
{project.description && (
|
||||||
|
<p className="text-sm text-slate-400 mt-1 line-clamp-2">
|
||||||
|
{project.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 ml-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={() => startEdit(project)}
|
||||||
|
className="p-1.5 rounded hover:bg-slate-800 text-slate-400 hover:text-blue-400"
|
||||||
|
title="Edit project"
|
||||||
|
>
|
||||||
|
<Edit3 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteConfirmId(project.id)}
|
||||||
|
className="p-1.5 rounded hover:bg-slate-800 text-slate-400 hover:text-red-400"
|
||||||
|
title="Delete project"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent
|
||||||
|
className="pt-0 cursor-pointer"
|
||||||
|
onClick={() => router.push(`/projects/${project.id}`)}
|
||||||
|
>
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-3 gap-2 mb-4">
|
||||||
|
<div className="bg-slate-800/50 rounded-lg p-2 text-center">
|
||||||
|
<p className="text-xl font-bold text-white">{project.taskCount}</p>
|
||||||
|
<p className="text-xs text-slate-400">Total</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-800/50 rounded-lg p-2 text-center">
|
||||||
|
<p className="text-xl font-bold text-emerald-400">{project.completedTasks}</p>
|
||||||
|
<p className="text-xs text-slate-400">Done</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-800/50 rounded-lg p-2 text-center">
|
||||||
|
<p className="text-xl font-bold text-blue-400">{project.inProgressTasks}</p>
|
||||||
|
<p className="text-xs text-slate-400">Active</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
{project.taskCount > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-slate-400">Progress</span>
|
||||||
|
<span className="text-slate-300">
|
||||||
|
{Math.round((project.completedTasks / project.taskCount) * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-slate-800 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all bg-emerald-500"
|
||||||
|
style={{
|
||||||
|
width: `${(project.completedTasks / project.taskCount) * 100}%`,
|
||||||
|
backgroundColor: project.color
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* View link */}
|
||||||
|
<div className="flex items-center justify-center gap-1 text-xs text-slate-500 group-hover:text-blue-400 transition-colors mt-4 pt-3 border-t border-slate-800">
|
||||||
|
<LayoutGrid className="w-3 h-3" />
|
||||||
|
<span>View Details</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create Project Dialog */}
|
||||||
|
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||||
|
<DialogContent className="bg-slate-900 border-slate-800 text-white w-[95vw] max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create New Project</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-slate-400">Project Name *</label>
|
||||||
|
<Input
|
||||||
|
value={newProjectName}
|
||||||
|
onChange={(e) => setNewProjectName(e.target.value)}
|
||||||
|
placeholder="e.g., Website Redesign"
|
||||||
|
className="mt-1 bg-slate-800 border-slate-700 text-white"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleCreateProject()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-slate-400">Description</label>
|
||||||
|
<Textarea
|
||||||
|
value={newProjectDescription}
|
||||||
|
onChange={(e) => setNewProjectDescription(e.target.value)}
|
||||||
|
placeholder="What is this project about?"
|
||||||
|
rows={3}
|
||||||
|
className="mt-1 bg-slate-800 border-slate-700 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-slate-400 mb-2 block">Color</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{PRESET_COLORS.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setNewProjectColor(color)}
|
||||||
|
className={`w-8 h-8 rounded-full border-2 transition-all ${
|
||||||
|
newProjectColor === color
|
||||||
|
? "border-white scale-110 ring-2 ring-white/20"
|
||||||
|
: "border-transparent hover:scale-105"
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCreateProject} disabled={isCreating || !newProjectName.trim()}>
|
||||||
|
{isCreating ? "Creating..." : "Create Project"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<Dialog open={!!deleteConfirmId} onOpenChange={() => setDeleteConfirmId(null)}>
|
||||||
|
<DialogContent className="bg-slate-900 border-slate-800 text-white w-[95vw] max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete Project?</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-4">
|
||||||
|
<p className="text-slate-300">
|
||||||
|
Are you sure you want to delete this project? This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
<p className="text-slate-400 text-sm mt-2">
|
||||||
|
Tasks in this project will remain but will no longer be associated with a project.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button variant="outline" onClick={() => setDeleteConfirmId(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => deleteConfirmId && handleDeleteProject(deleteConfirmId)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
22
src/components/ui/input.tsx
Normal file
22
src/components/ui/input.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded-md border border-slate-700 bg-slate-800 px-3 py-2 text-sm text-white ring-offset-slate-900 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Input.displayName = "Input"
|
||||||
|
|
||||||
|
export { Input }
|
||||||
Loading…
Reference in New Issue
Block a user