diff --git a/client/components/SprintManager.tsx b/client/components/SprintManager.tsx new file mode 100644 index 00000000..2e617b6f --- /dev/null +++ b/client/components/SprintManager.tsx @@ -0,0 +1,457 @@ +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Textarea } from "@/components/ui/textarea"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { aethexToast } from "@/lib/aethex-toast"; +import { supabase } from "@/lib/supabase"; +import { Plus, Users, Calendar, CheckCircle } from "lucide-react"; + +const API_BASE = import.meta.env.VITE_API_BASE || ""; + +interface Sprint { + id: string; + project_id: string; + sprint_number: number; + title: string; + description?: string; + goal?: string; + phase: "planning" | "active" | "completed" | "cancelled"; + status: "pending" | "active" | "on_hold" | "completed"; + start_date?: string; + end_date?: string; + planned_velocity?: number; + actual_velocity?: number; + gameforge_projects?: { name: string }; + gameforge_sprint_members?: Array<{ user_id: string }>; +} + +interface SprintManagerProps { + projectId: string; + projectName?: string; + isProjectLead?: boolean; + onSprintJoined?: (sprint: Sprint) => void; +} + +export default function SprintManager({ + projectId, + projectName, + isProjectLead = false, + onSprintJoined, +}: SprintManagerProps) { + const [sprints, setSprints] = useState([]); + const [loading, setLoading] = useState(false); + const [createOpen, setCreateOpen] = useState(false); + const [userSprints, setUserSprints] = useState>(new Set()); + + const [formData, setFormData] = useState({ + title: "", + description: "", + goal: "", + startDate: "", + endDate: "", + plannedVelocity: "", + }); + + useEffect(() => { + loadSprints(); + }, [projectId]); + + const loadSprints = async () => { + setLoading(true); + try { + const { + data: { session }, + } = await supabase.auth.getSession(); + const token = session?.access_token; + if (!token) throw new Error("No auth token"); + + const res = await fetch( + `${API_BASE}/api/gameforge/sprint?projectId=${projectId}`, + { + headers: { Authorization: `Bearer ${token}` }, + } + ); + + if (res.ok) { + const data = await res.json(); + setSprints(Array.isArray(data) ? data : []); + + // Track which sprints user is already in + const userSprintIds = new Set( + data + .filter((sprint: Sprint) => + sprint.gameforge_sprint_members?.some( + (m: any) => m.user_id === session?.user?.id + ) + ) + .map((s: Sprint) => s.id) + ); + setUserSprints(userSprintIds); + } + } catch (error) { + console.error("Failed to load sprints:", error); + } finally { + setLoading(false); + } + }; + + const handleCreateSprint = async () => { + if (!formData.title.trim()) { + aethexToast.error({ + title: "Error", + description: "Sprint title is required", + }); + return; + } + + try { + const { + data: { session }, + } = await supabase.auth.getSession(); + const token = session?.access_token; + if (!token) throw new Error("No auth token"); + + const res = await fetch(`${API_BASE}/api/gameforge/sprint`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + projectId, + title: formData.title, + description: formData.description, + goal: formData.goal, + startDate: formData.startDate, + endDate: formData.endDate, + plannedVelocity: formData.plannedVelocity + ? parseInt(formData.plannedVelocity) + : null, + }), + }); + + if (!res.ok) { + throw new Error(await res.text()); + } + + const newSprint = await res.json(); + setSprints([newSprint, ...sprints]); + setCreateOpen(false); + setFormData({ + title: "", + description: "", + goal: "", + startDate: "", + endDate: "", + plannedVelocity: "", + }); + + aethexToast.success({ + title: "Sprint created", + description: `Sprint ${newSprint.sprint_number}: ${newSprint.title}`, + }); + } catch (error: any) { + aethexToast.error({ + title: "Failed to create sprint", + description: error.message, + }); + } + }; + + const handleJoinSprint = async (sprintId: string) => { + try { + const { + data: { session }, + } = await supabase.auth.getSession(); + const token = session?.access_token; + if (!token) throw new Error("No auth token"); + + const res = await fetch(`${API_BASE}/api/gameforge/sprint-join`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ sprintId }), + }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.error || "Failed to join sprint"); + } + + // Add to user sprints set and reload + setUserSprints((prev) => new Set([...prev, sprintId])); + + const sprint = sprints.find((s) => s.id === sprintId); + if (sprint && onSprintJoined) { + onSprintJoined(sprint); + } + + aethexToast.success({ + title: "Joined sprint", + description: "You've been added to the sprint", + }); + } catch (error: any) { + aethexToast.error({ + title: "Failed to join sprint", + description: error.message, + }); + } + }; + + const getPhaseColor = (phase: string) => { + switch (phase) { + case "planning": + return "bg-blue-500/20 text-blue-200 border-blue-500/40"; + case "active": + return "bg-green-500/20 text-green-200 border-green-500/40"; + case "completed": + return "bg-purple-500/20 text-purple-200 border-purple-500/40"; + default: + return "bg-gray-500/20 text-gray-200 border-gray-500/40"; + } + }; + + const getStatusColor = (status: string) => { + switch (status) { + case "active": + return "bg-green-600"; + case "completed": + return "bg-blue-600"; + case "on_hold": + return "bg-orange-600"; + default: + return "bg-gray-600"; + } + }; + + return ( +
+ {/* Create Sprint Button (Project Leads Only) */} + {isProjectLead && ( + + + + + + + Create New Sprint + + Create a new sprint for {projectName || "this project"} + + + +
+
+ + + setFormData({ ...formData, title: e.target.value }) + } + placeholder="e.g., Sprint 1: Core Mechanics" + className="bg-slate-900 border-slate-700" + /> +
+ +
+ + + setFormData({ ...formData, goal: e.target.value }) + } + placeholder="e.g., Implement character movement and combat" + className="bg-slate-900 border-slate-700" + /> +
+ +
+ +