1
0
Fork 0
polaris-task-force/src/components/frontend/projects/SprintsView.tsx

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>
);
}