// CIL[BEGIN chunk_id="SprintsPage_module_header_1784761401" type="module_header" identity="module_header" parent="none"] // CIL[DOC-MACRO purpose="List engineering sprints with drag-sortable upcoming order and team filter"] // CIL[DOC-RESPONSIBILITY responsibility="Fetch workbench_sprint records, filter by team, pin the active sprint, and allow drag reordering of upcoming sprints"] // CIL[DOC-CONTEXT module="workbench.pages.work.SprintsPage" layer="frontend-page" related_files="[\"custom/apps/workbench/components/records/RecordListPage.tsx\",\"custom/apps/workbench/pages/work/SprintDetailPage.tsx\",\"custom/apps/workbench/components/work/TeamSwitcher.tsx\"]"] // CIL[DOC-CONTRACT inputs="none" outputs="JSX page" errors="fetch/patch errors rendered inline" side_effects="calls /api/admin-data, PATCHes order on drop"] // CIL[DOC-MICRO logic_summary="Loads sprints, filters by selected team, shows active at top, completed at bottom when toggled, and lets users drag upcoming sprints to update order" notable_decisions=["Active and completed sprints cannot be reordered; only upcoming sprints are draggable"]"] // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"] // CIL[DOC-STABILITY stability="experimental"] // CIL[DOC-AUDIENCE audience="developer"] import { useEffect, useMemo, useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' import { useAppPath } from '@core/hooks/useAppPath' import { apiFetch } from '@core/lib/api' import { cn } from '@core/lib/utils' import { Button } from '@core/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@core/components/ui/card' import { Badge } from '@core/components/ui/badge' import { Checkbox } from '@core/components/ui/checkbox' import { GripVertical, Plus } from 'lucide-react' import { useWorkbenchRole } from '../../hooks/useWorkbenchRole' // CIL[END chunk_id="SprintsPage_module_header_1784761401"] // CIL[BEGIN chunk_id="SprintsPage_sprintsPage_1784761401" type="function" identity="SprintsPage" parent="none"] // CIL[DOC-MACRO description="Implementation of SprintsPage" macro="Renders and manages SprintsPage UI state"] // CIL[DOC-CONTRACT input_spec="See module header or type signature" output_spec="JSX or value" side_effects="none"] // CIL[DOC-STABILITY stability="experimental"] function isActivePhase(phase?: string): boolean { return phase === 'active' || phase === 'current' } function isCompletedPhase(phase?: string): boolean { return phase === 'completed' } function formatDate(value?: string): string { if (!value) return '—' try { return new Date(value).toLocaleDateString() } catch { return value } } export default function SprintsPage() { const navigate = useNavigate() const appPath = useAppPath() const { role } = useWorkbenchRole() const selectedTeam = role const [sprints, setSprints] = useState([]) const [loading, setLoading] = useState(true) const [showCompleted, setShowCompleted] = useState(false) const [draggedId, setDraggedId] = useState(null) const draggedIdRef = useRef(null) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) const preventClickRef = useRef(false) const load = async () => { setLoading(true) setError(null) try { const res = await apiFetch('/api/admin-data?entity=items&type_slug=workbench_sprint&limit=500') const json = await res.json() setSprints(Array.isArray(json?.data) ? json.data : []) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load sprints') } finally { setLoading(false) } } useEffect(() => { load() }, []) const filtered = useMemo(() => { if (selectedTeam === '*') return sprints return sprints.filter(s => s.data?.team === selectedTeam || s.data?.team === '*') }, [sprints, selectedTeam]) const { active, future, completed } = useMemo(() => { const active = filtered.find(s => isActivePhase(s.data?.phase)) || null const completed = filtered .filter(s => isCompletedPhase(s.data?.phase) && (!active || s.id !== active.id)) .sort((a, b) => String(b.data?.end_at || b.updated_at || '').localeCompare(String(a.data?.end_at || a.updated_at || ''))) const future = filtered .filter(s => (!active || s.id !== active.id) && !isCompletedPhase(s.data?.phase)) .sort((a, b) => { const oa = a.data?.order ?? Infinity const ob = b.data?.order ?? Infinity if (oa !== ob) return oa - ob return String(a.data?.start_at || a.created_at || '').localeCompare(String(b.data?.start_at || b.created_at || '')) }) return { active, future, completed } }, [filtered]) const handleDragStart = (id: string) => { preventClickRef.current = false draggedIdRef.current = id setDraggedId(id) } const handleDragEnd = () => { draggedIdRef.current = null setDraggedId(null) } const handleDragOver = (e: React.DragEvent) => { e.preventDefault() } const handleDrop = async (e: React.DragEvent, targetId: string) => { e.preventDefault() preventClickRef.current = true window.setTimeout(() => { preventClickRef.current = false }, 200) const draggedId = draggedIdRef.current if (!draggedId || draggedId === targetId) { draggedIdRef.current = null setDraggedId(null) return } const dragged = future.find(s => s.id === draggedId) if (!dragged) { draggedIdRef.current = null setDraggedId(null) return } const target = future.find(s => s.id === targetId) if (!target) { draggedIdRef.current = null setDraggedId(null) return } const rect = e.currentTarget.getBoundingClientRect() const before = e.clientY < rect.top + rect.height / 2 const others = future.filter(s => s.id !== draggedId) const idx = others.findIndex(s => s.id === targetId) const insert = before ? idx : idx + 1 const reordered = [...others] reordered.splice(insert === -1 ? others.length : insert, 0, dragged) const updates = reordered.map((s, i) => ({ id: s.id, order: i, data: s.data })) setSaving(true) setError(null) try { await Promise.all( updates.map(u => apiFetch(`/api/admin-data?entity=items&id=${u.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ data: { ...u.data, order: u.order } }), }) ) ) setSprints(prev => prev.map(s => { const u = updates.find(x => x.id === s.id) if (!u) return s return { ...s, data: { ...s.data, order: u.order } } }) ) } catch (err) { setError(err instanceof Error ? err.message : 'Failed to save order') } finally { setSaving(false) draggedIdRef.current = null setDraggedId(null) } } const SprintRow = ({ sprint, draggable }: { sprint: any; draggable?: boolean }) => (
draggable && handleDragStart(sprint.id)} onDragEnd={() => draggable && handleDragEnd()} onDragOver={draggable ? handleDragOver : undefined} onDrop={draggable ? e => handleDrop(e, sprint.id) : undefined} className={cn( 'flex items-center gap-2 p-3 rounded-lg border border-border/60 bg-card', draggable && 'cursor-grab active:cursor-grabbing hover:bg-muted/30 select-none', draggedId === sprint.id && 'opacity-50' )} onClick={e => { if (preventClickRef.current) { e.preventDefault() return } navigate(appPath(`/work/${role}/prioritization/sprints/${sprint.id}`)) }} > {draggable && }
{sprint.title || 'Untitled Sprint'} {sprint.data?.phase && {sprint.data.phase}}
{sprint.data?.team || '—'} · {formatDate(sprint.data?.start_at)} → {formatDate(sprint.data?.end_at)}
) return (

Sprints

setShowCompleted(v === true)} />
{error &&
{error}
} {loading ? (
Loading sprints…
) : (
{active && ( Active )} Upcoming {saving && '· saving…'} {future.length === 0 ? (
No upcoming sprints.
) : ( future.map(sprint => ) )}
{showCompleted && ( Completed {completed.length === 0 ? (
No completed sprints.
) : ( completed.map(sprint => ) )}
)}
)}
) } // CIL[END chunk_id="SprintsPage_sprintsPage_1784761401"]