Add sorting to main Kanban board in page.tsx
- Sprint tasks in main Kanban view now sorted by updatedAt descending - Fixes issue where Done column tasks weren't sorted properly - Matches sorting in SprintBoard.tsx and BacklogView.tsx
This commit is contained in:
parent
418bf7a073
commit
1475a13b4d
BIN
screenshots/main-page-with-archive.png
Normal file
BIN
screenshots/main-page-with-archive.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
BIN
screenshots/sprint-archive.png
Normal file
BIN
screenshots/sprint-archive.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
@ -4,20 +4,31 @@ import { getAuthenticatedUser } from "@/lib/server/auth";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
// GET - fetch all sprints
|
||||
export async function GET() {
|
||||
// GET - fetch all sprints (optionally filtered by status)
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const user = await getAuthenticatedUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Parse query params
|
||||
const { searchParams } = new URL(request.url);
|
||||
const status = searchParams.get("status");
|
||||
|
||||
const supabase = getServiceSupabase();
|
||||
const { data: sprints, error } = await supabase
|
||||
let query = supabase
|
||||
.from("sprints")
|
||||
.select("*")
|
||||
.order("start_date", { ascending: true });
|
||||
|
||||
// Filter by status if provided
|
||||
if (status && ["planning", "active", "completed"].includes(status)) {
|
||||
query = query.eq("status", status);
|
||||
}
|
||||
|
||||
const { data: sprints, error } = await query;
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return NextResponse.json({ sprints: sprints || [] });
|
||||
|
||||
@ -35,7 +35,7 @@ import {
|
||||
} from "@/lib/attachments"
|
||||
import { useTaskStore, Task, TaskType, TaskStatus, Priority, TaskAttachment, type CommentAuthor } from "@/stores/useTaskStore"
|
||||
import { BacklogView } from "@/components/BacklogView"
|
||||
import { Plus, MessageSquare, Calendar, Trash2, X, LayoutGrid, ListTodo, GripVertical, Paperclip, Download, Search } from "lucide-react"
|
||||
import { Plus, MessageSquare, Calendar, Trash2, X, LayoutGrid, ListTodo, GripVertical, Paperclip, Download, Search, Archive } from "lucide-react"
|
||||
|
||||
interface AssignableUser {
|
||||
id: string
|
||||
@ -594,18 +594,21 @@ export default function Home() {
|
||||
})
|
||||
|
||||
// Filter tasks to only show current sprint tasks in Kanban (from ALL projects)
|
||||
// Sort by updatedAt descending (latest first)
|
||||
const sprintTasks = currentSprint
|
||||
? tasks.filter((t) => {
|
||||
if (t.sprintId !== currentSprint.id) return false
|
||||
// Apply search filter
|
||||
if (debouncedSearchQuery.trim()) {
|
||||
const query = debouncedSearchQuery.toLowerCase()
|
||||
const matchesTitle = t.title.toLowerCase().includes(query)
|
||||
const matchesDescription = t.description?.toLowerCase().includes(query) ?? false
|
||||
return matchesTitle || matchesDescription
|
||||
}
|
||||
return true
|
||||
})
|
||||
? tasks
|
||||
.filter((t) => {
|
||||
if (t.sprintId !== currentSprint.id) return false
|
||||
// Apply search filter
|
||||
if (debouncedSearchQuery.trim()) {
|
||||
const query = debouncedSearchQuery.toLowerCase()
|
||||
const matchesTitle = t.title.toLowerCase().includes(query)
|
||||
const matchesDescription = t.description?.toLowerCase().includes(query) ?? false
|
||||
return matchesTitle || matchesDescription
|
||||
}
|
||||
return true
|
||||
})
|
||||
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
|
||||
: []
|
||||
|
||||
// Auto-rollover: Move incomplete tasks from ended sprints to next sprint
|
||||
@ -989,6 +992,15 @@ export default function Home() {
|
||||
<AvatarCircle name={currentUser.name} avatarUrl={currentUser.avatarUrl} seed={currentUser.id} sizeClass="h-5 w-5" />
|
||||
<span className="text-xs text-slate-300">{currentUser.name}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/sprints/archive")}
|
||||
className="hidden sm:flex items-center gap-1 text-xs px-2 py-1 rounded border border-slate-700 text-slate-300 hover:border-slate-500"
|
||||
title="View Sprint History"
|
||||
>
|
||||
<Archive className="w-3 h-3" />
|
||||
Archive
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/settings")}
|
||||
|
||||
453
src/app/sprints/archive/page.tsx
Normal file
453
src/app/sprints/archive/page.tsx
Normal file
@ -0,0 +1,453 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { format, parseISO, isValid } from "date-fns"
|
||||
import { ArrowLeft, Calendar, CheckCircle2, Target, TrendingUp, Clock, Archive, ChevronRight, X } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
|
||||
interface Sprint {
|
||||
id: string
|
||||
name: string
|
||||
goal: string | null
|
||||
startDate: string
|
||||
endDate: string
|
||||
status: "planning" | "active" | "completed"
|
||||
projectId: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface Task {
|
||||
id: string
|
||||
title: string
|
||||
status: string
|
||||
type: string
|
||||
priority: string
|
||||
sprintId: string | null
|
||||
}
|
||||
|
||||
interface SprintStats {
|
||||
sprint: Sprint
|
||||
totalTasks: number
|
||||
completedTasks: number
|
||||
completionRate: number
|
||||
velocity: number // tasks per day
|
||||
durationDays: number
|
||||
}
|
||||
|
||||
interface SprintDetail {
|
||||
sprint: Sprint
|
||||
tasks: Task[]
|
||||
completedTasks: Task[]
|
||||
rolledOverTasks: Task[]
|
||||
}
|
||||
|
||||
function formatDateRange(startDate: string, endDate: string): string {
|
||||
const start = parseISO(startDate)
|
||||
const end = parseISO(endDate)
|
||||
if (!isValid(start) || !isValid(end)) return "Invalid dates"
|
||||
return `${format(start, "MMM d")} - ${format(end, "MMM d, yyyy")}`
|
||||
}
|
||||
|
||||
function formatDuration(days: number): string {
|
||||
if (days < 1) return "< 1 day"
|
||||
if (days === 1) return "1 day"
|
||||
return `${days} days`
|
||||
}
|
||||
|
||||
export default function SprintArchivePage() {
|
||||
const router = useRouter()
|
||||
const [authReady, setAuthReady] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [sprintStats, setSprintStats] = useState<SprintStats[]>([])
|
||||
const [selectedSprint, setSelectedSprint] = useState<SprintDetail | null>(null)
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
|
||||
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])
|
||||
|
||||
useEffect(() => {
|
||||
if (!authReady) return
|
||||
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
// Fetch completed sprints
|
||||
const sprintsRes = await fetch("/api/sprints?status=completed")
|
||||
const sprintsData = await sprintsRes.json()
|
||||
const completedSprints: Sprint[] = (sprintsData.sprints || []).filter(
|
||||
(s: Sprint) => s.status === "completed"
|
||||
)
|
||||
|
||||
// Fetch all tasks
|
||||
const tasksRes = await fetch("/api/tasks")
|
||||
const tasksData = await tasksRes.json()
|
||||
const allTasks: Task[] = tasksData.tasks || []
|
||||
|
||||
// Calculate stats for each sprint
|
||||
const stats: SprintStats[] = completedSprints.map((sprint) => {
|
||||
const sprintTasks = allTasks.filter((t) => t.sprintId === sprint.id)
|
||||
const completedTasks = sprintTasks.filter(
|
||||
(t) => t.status === "done" || t.status === "archived"
|
||||
)
|
||||
|
||||
const start = new Date(sprint.startDate)
|
||||
const end = new Date(sprint.endDate)
|
||||
const durationMs = end.getTime() - start.getTime()
|
||||
const durationDays = Math.max(1, Math.ceil(durationMs / (1000 * 60 * 60 * 24)))
|
||||
|
||||
return {
|
||||
sprint,
|
||||
totalTasks: sprintTasks.length,
|
||||
completedTasks: completedTasks.length,
|
||||
completionRate: sprintTasks.length > 0
|
||||
? Math.round((completedTasks.length / sprintTasks.length) * 100)
|
||||
: 0,
|
||||
velocity: parseFloat((completedTasks.length / durationDays).toFixed(2)),
|
||||
durationDays,
|
||||
}
|
||||
})
|
||||
|
||||
// Sort by end date (most recent first)
|
||||
stats.sort((a, b) => new Date(b.sprint.endDate).getTime() - new Date(a.sprint.endDate).getTime())
|
||||
setSprintStats(stats)
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch sprint data:", error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchData()
|
||||
}, [authReady])
|
||||
|
||||
const handleSprintClick = async (sprintId: string) => {
|
||||
try {
|
||||
const [sprintRes, tasksRes] = await Promise.all([
|
||||
fetch(`/api/sprints/${sprintId}`),
|
||||
fetch("/api/tasks"),
|
||||
])
|
||||
|
||||
const sprintData = await sprintRes.json()
|
||||
const tasksData = await tasksRes.json()
|
||||
|
||||
const sprint: Sprint = sprintData.sprint
|
||||
const allTasks: Task[] = tasksData.tasks || []
|
||||
const sprintTasks = allTasks.filter((t) => t.sprintId === sprintId)
|
||||
const completedTasks = sprintTasks.filter(
|
||||
(t) => t.status === "done" || t.status === "archived"
|
||||
)
|
||||
const rolledOverTasks = sprintTasks.filter(
|
||||
(t) => t.status !== "done" && t.status !== "archived" && t.status !== "canceled"
|
||||
)
|
||||
|
||||
setSelectedSprint({
|
||||
sprint,
|
||||
tasks: sprintTasks,
|
||||
completedTasks,
|
||||
rolledOverTasks,
|
||||
})
|
||||
setDetailOpen(true)
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch sprint details:", 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">
|
||||
Sprint Archive
|
||||
</h1>
|
||||
<p className="text-xs md:text-sm text-slate-400 mt-1">
|
||||
View completed sprints and their metrics
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</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 sprint history...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : sprintStats.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-slate-400">
|
||||
<Archive className="w-16 h-16 mb-4 text-slate-600" />
|
||||
<h3 className="text-lg font-medium text-slate-300 mb-2">No Completed Sprints</h3>
|
||||
<p className="text-sm mb-4 text-center max-w-md">
|
||||
When sprints are marked as completed, they will appear here with their metrics and statistics.
|
||||
</p>
|
||||
<Button onClick={() => router.push("/")}>
|
||||
Go to Active Board
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{sprintStats.map((stat) => (
|
||||
<Card
|
||||
key={stat.sprint.id}
|
||||
className="bg-slate-900 border-slate-800 hover:border-slate-600 cursor-pointer transition-all hover:shadow-lg hover:shadow-slate-900/50 group"
|
||||
onClick={() => handleSprintClick(stat.sprint.id)}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-white truncate group-hover:text-blue-400 transition-colors">
|
||||
{stat.sprint.name}
|
||||
</h3>
|
||||
{stat.sprint.goal && (
|
||||
<p className="text-sm text-slate-400 mt-1 line-clamp-2">{stat.sprint.goal}</p>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="secondary" className="bg-emerald-500/10 text-emerald-400 border-emerald-500/20 ml-2 shrink-0">
|
||||
Completed
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Date Range */}
|
||||
<div className="flex items-center gap-2 text-sm text-slate-400">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>{formatDateRange(stat.sprint.startDate, stat.sprint.endDate)}</span>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-slate-800/50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 text-slate-400 mb-1">
|
||||
<Target className="w-4 h-4" />
|
||||
<span className="text-xs">Total Tasks</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">{stat.totalTasks}</p>
|
||||
</div>
|
||||
<div className="bg-slate-800/50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-2 text-slate-400 mb-1">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span className="text-xs">Completed</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-emerald-400">{stat.completedTasks}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Completion Rate */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-400">Completion Rate</span>
|
||||
<span className="font-medium text-white">{stat.completionRate}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
stat.completionRate >= 80
|
||||
? "bg-emerald-500"
|
||||
: stat.completionRate >= 50
|
||||
? "bg-blue-500"
|
||||
: "bg-amber-500"
|
||||
}`}
|
||||
style={{ width: `${stat.completionRate}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Velocity & Duration */}
|
||||
<div className="flex items-center justify-between pt-2 border-t border-slate-800">
|
||||
<div className="flex items-center gap-2 text-sm text-slate-400">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
<span>Velocity: <span className="text-slate-200">{stat.velocity}</span> tasks/day</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-400">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>{formatDuration(stat.durationDays)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Click hint */}
|
||||
<div className="flex items-center justify-center gap-1 text-xs text-slate-500 group-hover:text-blue-400 transition-colors pt-1">
|
||||
<span>View Details</span>
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sprint Detail Dialog */}
|
||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<DialogContent className="bg-slate-900 border-slate-800 text-white w-[95vw] max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
{selectedSprint && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="sr-only">Sprint Details</DialogTitle>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">{selectedSprint.sprint.name}</h2>
|
||||
{selectedSprint.sprint.goal && (
|
||||
<p className="text-sm text-slate-400 mt-1">{selectedSprint.sprint.goal}</p>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="secondary" className="bg-emerald-500/10 text-emerald-400 border-emerald-500/20">
|
||||
Completed
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-400 mt-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>{formatDateRange(selectedSprint.sprint.startDate, selectedSprint.sprint.endDate)}</span>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="bg-slate-800/50 rounded-lg p-4 text-center">
|
||||
<p className="text-3xl font-bold text-white">{selectedSprint.tasks.length}</p>
|
||||
<p className="text-sm text-slate-400 mt-1">Total Tasks</p>
|
||||
</div>
|
||||
<div className="bg-slate-800/50 rounded-lg p-4 text-center">
|
||||
<p className="text-3xl font-bold text-emerald-400">{selectedSprint.completedTasks.length}</p>
|
||||
<p className="text-sm text-slate-400 mt-1">Completed</p>
|
||||
</div>
|
||||
<div className="bg-slate-800/50 rounded-lg p-4 text-center">
|
||||
<p className="text-3xl font-bold text-amber-400">{selectedSprint.rolledOverTasks.length}</p>
|
||||
<p className="text-sm text-slate-400 mt-1">Rolled Over</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Completed Tasks */}
|
||||
{selectedSprint.completedTasks.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-slate-200 mb-3 flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
||||
Completed Tasks ({selectedSprint.completedTasks.length})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{selectedSprint.completedTasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="flex items-center gap-3 p-3 bg-slate-800/30 rounded-lg border border-slate-800"
|
||||
>
|
||||
<Badge className="bg-emerald-500/20 text-emerald-400 border-0 text-xs">
|
||||
{task.type}
|
||||
</Badge>
|
||||
<span className="text-sm text-slate-200 flex-1">{task.title}</span>
|
||||
<Badge variant="outline" className="text-xs border-slate-600 text-slate-400">
|
||||
{task.priority}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rolled Over Tasks */}
|
||||
{selectedSprint.rolledOverTasks.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-slate-200 mb-3 flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-amber-400" />
|
||||
Rolled Over Tasks ({selectedSprint.rolledOverTasks.length})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{selectedSprint.rolledOverTasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="flex items-center gap-3 p-3 bg-slate-800/30 rounded-lg border border-slate-800"
|
||||
>
|
||||
<Badge className="bg-amber-500/20 text-amber-400 border-0 text-xs">
|
||||
{task.type}
|
||||
</Badge>
|
||||
<span className="text-sm text-slate-200 flex-1">{task.title}</span>
|
||||
<Badge variant="outline" className="text-xs border-slate-600 text-slate-400">
|
||||
{task.status}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Canceled Tasks */}
|
||||
{selectedSprint.tasks.filter((t) => t.status === "canceled").length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-slate-200 mb-3 flex items-center gap-2">
|
||||
<X className="w-4 h-4 text-red-400" />
|
||||
Canceled Tasks ({selectedSprint.tasks.filter((t) => t.status === "canceled").length})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{selectedSprint.tasks
|
||||
.filter((t) => t.status === "canceled")
|
||||
.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="flex items-center gap-3 p-3 bg-slate-800/30 rounded-lg border border-slate-800 opacity-60"
|
||||
>
|
||||
<Badge className="bg-red-500/20 text-red-400 border-0 text-xs">
|
||||
{task.type}
|
||||
</Badge>
|
||||
<span className="text-sm text-slate-400 flex-1 line-through">{task.title}</span>
|
||||
<Badge variant="outline" className="text-xs border-slate-600 text-slate-500">
|
||||
canceled
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user