200 lines
7.4 KiB
TypeScript
200 lines
7.4 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import Link from "next/link";
|
|
import { useRouter } from "next/navigation";
|
|
import { toast } from "sonner";
|
|
import { CalendarIcon, PlayIcon, CheckCircleIcon, PlusIcon, TimerIcon } from "lucide-react";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Item,
|
|
ItemContent,
|
|
ItemDescription,
|
|
ItemFooter,
|
|
ItemHeader,
|
|
ItemMedia,
|
|
ItemTitle,
|
|
} from "@/components/ui/item";
|
|
import { Progress } from "@/components/ui/progress";
|
|
import { CreateSprintDialog } from "@/components/frontend/projects/CreateSprintDialog";
|
|
import { startSprint, completeSprint } from "@/app/(frontend)/projects/actions";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export interface SprintItem {
|
|
readonly id: number;
|
|
readonly name: string;
|
|
readonly status: string;
|
|
readonly goal?: string | null;
|
|
readonly startDate?: string | null;
|
|
readonly endDate?: string | null;
|
|
readonly capacity?: number | null;
|
|
readonly storyPointsCompleted: number;
|
|
readonly storyPointsTotal: number;
|
|
}
|
|
|
|
export interface SprintsViewProps {
|
|
readonly project: { id: number; name: string; key: string };
|
|
readonly sprints: readonly SprintItem[];
|
|
readonly isAdmin: boolean;
|
|
}
|
|
|
|
const SPRINT_STATUS_STYLES: Readonly<Record<string, string>> = {
|
|
planning: "bg-zinc-500/15 text-zinc-400 border-zinc-500/30",
|
|
active: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
|
|
completed: "bg-violet-500/15 text-violet-400 border-violet-500/30",
|
|
};
|
|
|
|
function labelFor(value: string): string {
|
|
return value
|
|
.replaceAll("_", " ")
|
|
.replace(/\b\w/g, (character) => character.toUpperCase());
|
|
}
|
|
|
|
function dateFor(value: string | null | undefined): string {
|
|
if (!value) return "TBD";
|
|
return new Date(value).toLocaleDateString("en-US", {
|
|
month: "short",
|
|
day: "numeric",
|
|
year: "numeric",
|
|
});
|
|
}
|
|
|
|
export function SprintsView({ project, sprints, isAdmin }: SprintsViewProps) {
|
|
const router = useRouter();
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
|
|
async function handleStartSprint(sprintId: number) {
|
|
const result = await startSprint({ sprintId });
|
|
if (result.success) {
|
|
toast.success("Sprint started");
|
|
router.refresh();
|
|
} else {
|
|
toast.error(result.error ?? "Failed to start sprint");
|
|
}
|
|
}
|
|
|
|
async function handleCompleteSprint(sprintId: number) {
|
|
const result = await completeSprint({ sprintId });
|
|
if (result.success) {
|
|
toast.success("Sprint completed");
|
|
router.refresh();
|
|
} else {
|
|
toast.error(result.error ?? "Failed to complete sprint");
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-5">
|
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
|
<div className="flex flex-col gap-1">
|
|
<div className="flex items-center gap-2">
|
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Sprints</h2>
|
|
<span className="text-xs text-muted-foreground">{sprints.length} total</span>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground">Plan focused delivery windows and track progress.</p>
|
|
</div>
|
|
{isAdmin && (
|
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
|
<PlusIcon data-icon="inline-start" />
|
|
Create sprint
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{sprints.length === 0 ? (
|
|
<Item variant="outline">
|
|
<ItemMedia variant="icon">
|
|
<TimerIcon className="text-muted-foreground" />
|
|
</ItemMedia>
|
|
<ItemContent className="items-center gap-1">
|
|
<ItemTitle>No sprints yet</ItemTitle>
|
|
<ItemDescription>Create a sprint to start planning your work.</ItemDescription>
|
|
</ItemContent>
|
|
</Item>
|
|
) : (
|
|
<div className="flex flex-col gap-2" role="list" aria-label={`${project.name} sprints`}>
|
|
{sprints.map((sprint) => {
|
|
const hasCapacity = sprint.capacity != null && sprint.capacity > 0;
|
|
const usedPoints = hasCapacity ? Math.min(sprint.storyPointsTotal, sprint.capacity!) : 0;
|
|
const progressPercent = hasCapacity ? Math.round((usedPoints / sprint.capacity!) * 100) : 0;
|
|
|
|
return (
|
|
<Item key={sprint.id} variant="outline" size="sm" role="listitem" className="items-start gap-3">
|
|
<ItemHeader className="items-center">
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<ItemTitle className="truncate text-base font-semibold">{sprint.name}</ItemTitle>
|
|
<Badge
|
|
variant="outline"
|
|
className={cn(
|
|
"text-[10px] uppercase tracking-wide",
|
|
SPRINT_STATUS_STYLES[sprint.status] ?? "bg-muted/50 text-muted-foreground border-border",
|
|
)}
|
|
>
|
|
{labelFor(sprint.status)}
|
|
</Badge>
|
|
</div>
|
|
{isAdmin && (
|
|
<div className="flex gap-1">
|
|
{sprint.status === "planning" && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => void handleStartSprint(sprint.id)}
|
|
className="h-6 px-2 text-xs"
|
|
>
|
|
<PlayIcon className="size-3" />
|
|
Start
|
|
</Button>
|
|
)}
|
|
{sprint.status === "active" && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => void handleCompleteSprint(sprint.id)}
|
|
className="h-6 px-2 text-xs"
|
|
>
|
|
<CheckCircleIcon className="size-3" />
|
|
Complete
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</ItemHeader>
|
|
|
|
<ItemContent className="w-full min-w-0 gap-2">
|
|
{sprint.goal && <ItemDescription>{sprint.goal}</ItemDescription>}
|
|
|
|
{hasCapacity && (
|
|
<div className="flex flex-col gap-1">
|
|
<div className="flex justify-between text-xs text-muted-foreground">
|
|
<span>Story points: {sprint.storyPointsTotal} / {sprint.capacity}</span>
|
|
<span>{progressPercent}%</span>
|
|
</div>
|
|
<Progress value={progressPercent} className="h-1.5" />
|
|
</div>
|
|
)}
|
|
|
|
{!hasCapacity && (
|
|
<div className="text-xs text-muted-foreground">
|
|
Story points: {sprint.storyPointsTotal}
|
|
</div>
|
|
)}
|
|
</ItemContent>
|
|
|
|
<ItemFooter className="w-full flex-wrap justify-start gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
|
<span className="flex items-center gap-1">
|
|
<CalendarIcon className="size-3" />
|
|
{dateFor(sprint.startDate)} - {dateFor(sprint.endDate)}
|
|
</span>
|
|
</ItemFooter>
|
|
</Item>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
<CreateSprintDialog projectId={project.id} open={createOpen} onOpenChange={setCreateOpen} />
|
|
</div>
|
|
);
|
|
}
|