feat(PBI-63): meerdere sprints per product + EXCLUDED + sprint-switcher (#161)
- Sprint lifecycle: ACTIVE→OPEN, COMPLETED→CLOSED, +ARCHIVED (FAILED behouden) - TaskStatus: +EXCLUDED (overgeslagen door agent-loop via bestaande TO_DO filter) - Cookie-gebaseerde actieve sprint per product (lib/active-sprint.ts) - Route splitsen: /products/[id]/sprint/[sprintId] + /sprint redirect-page - NavBar: gestapelde product/sprint dropdowns + BUILDING-badge derivatie - Backlog selectie-modus + nieuwe-sprint-dialog (createSprintWithPbisAction) - Migratie 20260507210000_sprint_lifecycle: ALTER TYPE RENAME (geen data-rewrite) - Version bump 1.0.0 → 1.2.0 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d68aa1e5e6
commit
4a9db57e94
43 changed files with 966 additions and 290 deletions
|
|
@ -21,6 +21,7 @@ import {
|
|||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { toast } from 'sonner'
|
||||
import { CheckSquare, Square } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
|
|
@ -33,6 +34,7 @@ import { cn } from '@/lib/utils'
|
|||
import { PbiDialog, type PbiDialogState } from './pbi-dialog'
|
||||
import { BacklogCard } from './backlog-card'
|
||||
import { EmptyPanel } from './empty-panel'
|
||||
import { NewSprintDialog } from '@/components/sprint/new-sprint-dialog'
|
||||
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
||||
import { PRIORITY_COLORS } from '@/components/shared/priority-select'
|
||||
import { PBI_STATUS_LABELS, PBI_STATUS_COLORS } from '@/components/shared/pbi-status-select'
|
||||
|
|
@ -124,19 +126,26 @@ function SortablePbiRow({
|
|||
pbi,
|
||||
isSelected,
|
||||
isDemo,
|
||||
selectionMode,
|
||||
isChecked,
|
||||
onSelect,
|
||||
onToggleCheck,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
pbi: Pbi
|
||||
isSelected: boolean
|
||||
isDemo: boolean
|
||||
selectionMode: boolean
|
||||
isChecked: boolean
|
||||
onSelect: () => void
|
||||
onToggleCheck: () => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: pbi.id,
|
||||
disabled: selectionMode,
|
||||
})
|
||||
|
||||
const style = {
|
||||
|
|
@ -144,6 +153,37 @@ function SortablePbiRow({
|
|||
transition,
|
||||
}
|
||||
|
||||
if (selectionMode) {
|
||||
return (
|
||||
<BacklogCard
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
title={pbi.title}
|
||||
code={pbi.code}
|
||||
priority={pbi.priority}
|
||||
isSelected={isChecked}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={isChecked}
|
||||
onClick={onToggleCheck}
|
||||
onKeyDown={(e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggleCheck() } }}
|
||||
badge={
|
||||
<Badge className={cn('text-xs font-normal', PBI_STATUS_COLORS[pbi.status])}>
|
||||
{PBI_STATUS_LABELS[pbi.status]}
|
||||
</Badge>
|
||||
}
|
||||
actions={
|
||||
<div
|
||||
className="inline-flex items-center justify-center min-h-7 min-w-7 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{isChecked ? <CheckSquare size={18} className="text-primary" /> : <Square size={18} />}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<BacklogCard
|
||||
ref={setNodeRef}
|
||||
|
|
@ -207,8 +247,25 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
|
|||
const [prefsLoaded, setPrefsLoaded] = useState(false)
|
||||
const [dialogState, setDialogState] = useState<PbiDialogState | null>(null)
|
||||
const [activeDragId, setActiveDragId] = useState<string | null>(null)
|
||||
const [selectionMode, setSelectionMode] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [newSprintOpen, setNewSprintOpen] = useState(false)
|
||||
const [, startTransition] = useTransition()
|
||||
|
||||
function exitSelection() {
|
||||
setSelectionMode(false)
|
||||
setSelectedIds(new Set())
|
||||
}
|
||||
|
||||
function toggleCheck(id: string) {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Load persisted preferences once after mount (client-only).
|
||||
// setState calls here are intentional: hydrating from localStorage on first paint.
|
||||
useEffect(() => {
|
||||
|
|
@ -452,8 +509,23 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
|
|||
<DemoTooltip show={isDemo}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={selectionMode ? 'default' : 'outline'}
|
||||
className="h-7 text-xs"
|
||||
disabled={isDemo}
|
||||
onClick={() => {
|
||||
if (isDemo) return
|
||||
if (selectionMode) exitSelection()
|
||||
else setSelectionMode(true)
|
||||
}}
|
||||
>
|
||||
{selectionMode ? 'Selecteren stoppen' : "Selecteer PBI's"}
|
||||
</Button>
|
||||
</DemoTooltip>
|
||||
<DemoTooltip show={isDemo}>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
disabled={isDemo || selectionMode}
|
||||
onClick={() => !isDemo && setDialogState({ mode: 'create', productId, defaultPriority: 2 })}
|
||||
>
|
||||
+ PBI
|
||||
|
|
@ -486,7 +558,10 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
|
|||
pbi={pbi}
|
||||
isSelected={selectedPbiId === pbi.id}
|
||||
isDemo={isDemo}
|
||||
selectionMode={selectionMode}
|
||||
isChecked={selectedIds.has(pbi.id)}
|
||||
onSelect={() => selectPbi(pbi.id)}
|
||||
onToggleCheck={() => toggleCheck(pbi.id)}
|
||||
onEdit={() => setDialogState({ mode: 'edit', productId, pbi })}
|
||||
onDelete={() => handleDelete(pbi.id)}
|
||||
/>
|
||||
|
|
@ -507,11 +582,53 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{selectionMode && (
|
||||
<div className="border-t border-border bg-surface-container px-4 py-2 flex items-center justify-between gap-2 shrink-0">
|
||||
<span className="text-sm text-foreground">
|
||||
{selectedIds.size} geselecteerd
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 text-xs"
|
||||
onClick={exitSelection}
|
||||
>
|
||||
Annuleer
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
disabled={selectedIds.size === 0}
|
||||
onClick={() => setNewSprintOpen(true)}
|
||||
>
|
||||
Nieuwe sprint
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PbiDialog
|
||||
state={dialogState}
|
||||
onClose={() => setDialogState(null)}
|
||||
isDemo={isDemo}
|
||||
/>
|
||||
|
||||
<NewSprintDialog
|
||||
open={newSprintOpen}
|
||||
productId={productId}
|
||||
pbiIds={Array.from(selectedIds)}
|
||||
onOpenChange={(open) => {
|
||||
setNewSprintOpen(open)
|
||||
if (!open) {
|
||||
// Sluit selectie bij geslaagde aanmaak; bij annuleren laat de selectie staan
|
||||
}
|
||||
}}
|
||||
onCreated={() => {
|
||||
setNewSprintOpen(false)
|
||||
exitSelection()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ import { NotificationsBell } from '@/components/shared/notifications-bell'
|
|||
import { SoloNavStatusIndicators } from '@/components/solo/nav-status-indicators'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { setActiveProductAction } from '@/actions/active-product'
|
||||
import { setActiveSprintAction } from '@/actions/active-sprint'
|
||||
import type { SprintStatusApi } from '@/lib/task-status'
|
||||
|
||||
type SprintItem = { id: string; code: string; status: SprintStatusApi }
|
||||
|
||||
interface NavBarProps {
|
||||
isDemo: boolean
|
||||
|
|
@ -29,10 +33,26 @@ interface NavBarProps {
|
|||
email: string | null
|
||||
activeProduct: { id: string; name: string } | null
|
||||
products: { id: string; name: string }[]
|
||||
hasActiveSprint: boolean
|
||||
sprints: SprintItem[]
|
||||
activeSprint: SprintItem | null
|
||||
buildingSprintIds: string[]
|
||||
minQuotaPct: number
|
||||
}
|
||||
|
||||
const SPRINT_STATUS_LABEL: Record<SprintStatusApi, string> = {
|
||||
open: 'Open',
|
||||
closed: 'Gesloten',
|
||||
archived: 'Gearchiveerd',
|
||||
failed: 'Mislukt',
|
||||
}
|
||||
|
||||
const SPRINT_STATUS_BADGE: Record<SprintStatusApi, string> = {
|
||||
open: 'bg-status-in-progress text-foreground',
|
||||
closed: 'bg-status-done text-foreground',
|
||||
archived: 'bg-surface-container text-muted-foreground',
|
||||
failed: 'bg-status-failed text-foreground',
|
||||
}
|
||||
|
||||
export function NavBar({
|
||||
isDemo,
|
||||
roles,
|
||||
|
|
@ -41,12 +61,16 @@ export function NavBar({
|
|||
email,
|
||||
activeProduct,
|
||||
products,
|
||||
hasActiveSprint,
|
||||
sprints,
|
||||
activeSprint,
|
||||
buildingSprintIds,
|
||||
minQuotaPct,
|
||||
}: NavBarProps) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const buildingSet = new Set(buildingSprintIds)
|
||||
const hasActiveSprint = !!activeSprint
|
||||
|
||||
function handleSwitchProduct(productId: string) {
|
||||
startTransition(async () => {
|
||||
|
|
@ -61,6 +85,23 @@ export function NavBar({
|
|||
})
|
||||
}
|
||||
|
||||
function handleSwitchSprint(sprintId: string) {
|
||||
if (!activeProduct) return
|
||||
if (sprintId === activeSprint?.id) return
|
||||
startTransition(async () => {
|
||||
const result = await setActiveSprintAction(activeProduct.id, sprintId)
|
||||
if (result?.error) {
|
||||
toast.error(typeof result.error === 'string' ? result.error : 'Wisselen mislukt')
|
||||
return
|
||||
}
|
||||
if (pathname.includes('/sprint')) {
|
||||
router.push(`/products/${activeProduct.id}/sprint/${sprintId}`)
|
||||
} else {
|
||||
router.refresh()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const activeId = activeProduct?.id ?? null
|
||||
|
||||
// Nav link helpers
|
||||
|
|
@ -90,7 +131,6 @@ export function NavBar({
|
|||
|
||||
const sprintNode = () => {
|
||||
if (!activeId) return disabledSpan('Sprint')
|
||||
const href = `/products/${activeId}/sprint`
|
||||
const isActive = pathname.includes('/sprint')
|
||||
if (!hasActiveSprint) {
|
||||
return (
|
||||
|
|
@ -107,6 +147,7 @@ export function NavBar({
|
|||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
const href = `/products/${activeId}/sprint/${activeSprint!.id}`
|
||||
return navLink(href, 'Sprint', isActive)
|
||||
}
|
||||
|
||||
|
|
@ -149,8 +190,8 @@ export function NavBar({
|
|||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Midden: actief product dropdown */}
|
||||
<div className="flex items-center justify-center">
|
||||
{/* Midden: actief product + sprint, gestapeld */}
|
||||
<div className="flex flex-col items-center justify-center gap-0.5">
|
||||
{activeProduct ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
|
|
@ -187,6 +228,70 @@ export function NavBar({
|
|||
) : (
|
||||
<span className="text-sm text-muted-foreground/50 select-none">Geen actief product</span>
|
||||
)}
|
||||
|
||||
{activeProduct && (
|
||||
sprints.length === 0 ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
className="text-xs text-muted-foreground/50 px-2 cursor-not-allowed select-none"
|
||||
aria-disabled="true"
|
||||
>
|
||||
Geen sprints
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Maak een sprint aan vanuit de Product Backlog</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
disabled={isPending}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors px-2 rounded-md hover:bg-surface-container focus:outline-none"
|
||||
>
|
||||
<span className="truncate max-w-[160px]">
|
||||
{activeSprint ? activeSprint.code : 'Selecteer sprint'}
|
||||
</span>
|
||||
{activeSprint && (
|
||||
<Badge
|
||||
className={cn(
|
||||
'text-[10px] px-1.5 py-0',
|
||||
buildingSet.has(activeSprint.id)
|
||||
? 'bg-warning text-warning-foreground'
|
||||
: SPRINT_STATUS_BADGE[activeSprint.status],
|
||||
)}
|
||||
>
|
||||
{buildingSet.has(activeSprint.id) ? 'BUILDING' : SPRINT_STATUS_LABEL[activeSprint.status]}
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronDown className="w-3 h-3 shrink-0 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="w-64">
|
||||
{sprints.map(s => (
|
||||
<DropdownMenuItem
|
||||
key={s.id}
|
||||
onClick={() => handleSwitchSprint(s.id)}
|
||||
className={cn(
|
||||
'flex items-center justify-between gap-2',
|
||||
s.id === activeSprint?.id && 'bg-primary-container text-primary-container-foreground font-medium',
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{s.code}</span>
|
||||
<Badge
|
||||
className={cn(
|
||||
'text-[10px] px-1.5 py-0 shrink-0',
|
||||
buildingSet.has(s.id)
|
||||
? 'bg-warning text-warning-foreground'
|
||||
: SPRINT_STATUS_BADGE[s.status],
|
||||
)}
|
||||
>
|
||||
{buildingSet.has(s.id) ? 'BUILDING' : SPRINT_STATUS_LABEL[s.status]}
|
||||
</Badge>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Rechts: solo-status + notifications + account-menu */}
|
||||
|
|
|
|||
187
components/sprint/new-sprint-dialog.tsx
Normal file
187
components/sprint/new-sprint-dialog.tsx
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useTransition, useRef } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
useDirtyCloseGuard,
|
||||
DirtyCloseGuardDialog,
|
||||
} from '@/components/shared/use-dirty-close-guard'
|
||||
import { useDialogSubmitShortcut } from '@/components/shared/use-dialog-submit-shortcut'
|
||||
import {
|
||||
entityDialogContentClasses,
|
||||
entityDialogFooterClasses,
|
||||
entityDialogHeaderClasses,
|
||||
} from '@/components/shared/entity-dialog-layout'
|
||||
import { createSprintWithPbisAction } from '@/actions/sprints'
|
||||
|
||||
interface NewSprintDialogProps {
|
||||
open: boolean
|
||||
productId: string
|
||||
pbiIds: string[]
|
||||
onOpenChange: (open: boolean) => void
|
||||
onCreated?: (sprintId: string) => void
|
||||
}
|
||||
|
||||
function todayLocalDate() {
|
||||
return new Date().toLocaleDateString('en-CA')
|
||||
}
|
||||
|
||||
export function NewSprintDialog({
|
||||
open,
|
||||
productId,
|
||||
pbiIds,
|
||||
onOpenChange,
|
||||
onCreated,
|
||||
}: NewSprintDialogProps) {
|
||||
const [sprintGoal, setSprintGoal] = useState('')
|
||||
const [startDate, setStartDate] = useState(todayLocalDate())
|
||||
const [endDate, setEndDate] = useState(todayLocalDate())
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const formRef = useRef<HTMLFormElement>(null)
|
||||
const router = useRouter()
|
||||
|
||||
function reset() {
|
||||
setSprintGoal('')
|
||||
setStartDate(todayLocalDate())
|
||||
setEndDate(todayLocalDate())
|
||||
setError(null)
|
||||
setDirty(false)
|
||||
}
|
||||
|
||||
const closeGuard = useDirtyCloseGuard(dirty, () => {
|
||||
onOpenChange(false)
|
||||
reset()
|
||||
})
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!sprintGoal.trim() || pbiIds.length === 0) return
|
||||
setError(null)
|
||||
startTransition(async () => {
|
||||
const result = await createSprintWithPbisAction({
|
||||
productId,
|
||||
sprint_goal: sprintGoal.trim(),
|
||||
start_date: startDate || null,
|
||||
end_date: endDate || null,
|
||||
pbi_ids: pbiIds,
|
||||
})
|
||||
if ('error' in result) {
|
||||
setError(result.error)
|
||||
toast.error(result.error)
|
||||
return
|
||||
}
|
||||
toast.success('Nieuwe sprint aangemaakt')
|
||||
reset()
|
||||
onCreated?.(result.sprintId)
|
||||
router.push(`/products/${productId}/sprint/${result.sprintId}`)
|
||||
})
|
||||
}
|
||||
|
||||
const handleKeyDown = useDialogSubmitShortcut(() => formRef.current?.requestSubmit())
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) closeGuard.attemptClose()
|
||||
else onOpenChange(o)
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={entityDialogContentClasses}
|
||||
>
|
||||
<div className={entityDialogHeaderClasses}>
|
||||
<DialogTitle className="text-xl font-semibold">Nieuwe sprint</DialogTitle>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{pbiIds.length} PBI{pbiIds.length === 1 ? '' : "'s"} worden in deze sprint geplaatst
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
ref={formRef}
|
||||
id="new-sprint-form"
|
||||
onSubmit={handleSubmit}
|
||||
onChange={() => setDirty(true)}
|
||||
className="flex-1 overflow-y-auto px-6 py-6 space-y-6"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
Sprint Goal <span className="text-error">*</span>
|
||||
</label>
|
||||
<Textarea
|
||||
value={sprintGoal}
|
||||
onChange={(e) => setSprintGoal(e.target.value)}
|
||||
required
|
||||
rows={3}
|
||||
placeholder="Wat wil je aan het einde van deze Sprint bereikt hebben?"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-foreground">Startdatum</label>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-foreground">Einddatum</label>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-error-container text-error-container-foreground rounded-lg px-3 py-2 text-sm border-l-4 border-error">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className={entityDialogFooterClasses}>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={closeGuard.attemptClose}
|
||||
disabled={isPending}
|
||||
>
|
||||
Annuleren
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="new-sprint-form"
|
||||
disabled={isPending || !sprintGoal.trim() || pbiIds.length === 0}
|
||||
>
|
||||
{isPending ? 'Aanmaken…' : 'Sprint aanmaken'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<DirtyCloseGuardDialog guard={closeGuard} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -20,7 +20,7 @@ import {
|
|||
} from '@/components/ui/dialog'
|
||||
import { type PauseContext, pauseReasonLabel } from '@/lib/pause-context'
|
||||
|
||||
type SprintStatusValue = 'ACTIVE' | 'COMPLETED' | 'FAILED'
|
||||
type SprintStatusValue = 'OPEN' | 'CLOSED' | 'ARCHIVED' | 'FAILED'
|
||||
type SprintRunStatusValue =
|
||||
| 'QUEUED'
|
||||
| 'RUNNING'
|
||||
|
|
@ -78,7 +78,7 @@ export function SprintRunControls({
|
|||
activeSprintRunStatus === 'RUNNING' ||
|
||||
activeSprintRunStatus === 'PAUSED')
|
||||
|
||||
const canStart = sprintStatus === 'ACTIVE' && !hasActiveRun
|
||||
const canStart = sprintStatus === 'OPEN' && !hasActiveRun
|
||||
const canResume = sprintStatus === 'FAILED'
|
||||
const canResumePaused =
|
||||
activeSprintRunStatus === 'PAUSED' && pauseContext !== null
|
||||
|
|
|
|||
|
|
@ -27,13 +27,24 @@ const STATUS_CYCLE: Record<string, 'TO_DO' | 'IN_PROGRESS' | 'DONE'> = {
|
|||
TO_DO: 'IN_PROGRESS',
|
||||
IN_PROGRESS: 'DONE',
|
||||
DONE: 'TO_DO',
|
||||
EXCLUDED: 'TO_DO',
|
||||
}
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
TO_DO: 'bg-status-todo/15 text-status-todo border-status-todo/30',
|
||||
IN_PROGRESS: 'bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30',
|
||||
DONE: 'bg-status-done/15 text-status-done border-status-done/30',
|
||||
EXCLUDED: 'bg-surface-container-low text-muted-foreground border-border',
|
||||
FAILED: 'bg-status-failed/15 text-status-failed border-status-failed/30',
|
||||
REVIEW: 'bg-status-review/15 text-status-review border-status-review/30',
|
||||
}
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
TO_DO: 'To Do',
|
||||
IN_PROGRESS: 'Bezig',
|
||||
REVIEW: 'Review',
|
||||
DONE: 'Klaar',
|
||||
FAILED: 'Mislukt',
|
||||
EXCLUDED: 'Uitgesloten',
|
||||
}
|
||||
const STATUS_LABELS: Record<string, string> = { TO_DO: 'To Do', IN_PROGRESS: 'Bezig', DONE: 'Klaar' }
|
||||
|
||||
|
||||
export interface Task {
|
||||
|
|
@ -101,6 +112,7 @@ function SortableTaskRow({
|
|||
<p className={cn(
|
||||
'text-sm leading-snug line-clamp-2 flex-1',
|
||||
task.status === 'DONE' && 'line-through text-muted-foreground',
|
||||
task.status === 'EXCLUDED' && 'text-muted-foreground/70 italic',
|
||||
)}>
|
||||
{task.title}
|
||||
</p>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue