- Remove reorderStoriesAction, reorderTasksAction, reorderSprintStoriesAction - Delete REST route app/api/stories/[id]/tasks/reorder/route.ts - Remove DnD from backlog story-panel and task-panel (flat list) - Remove reorder-within-sprint branch from sprint-board-client handleDragEnd - Switch SortableSprintRow to plain SprintRow using useDraggable (membership drag kept) - Remove all DnD from task-list (status toggle + edit kept) - Remove story-order/task-order/sprint-story-order/sprint-task-order mutation types and store handlers - Remove related tests for deleted reorder route; fix sprint store tests
363 lines
12 KiB
TypeScript
363 lines
12 KiB
TypeScript
'use client'
|
||
|
||
import { useState } from 'react'
|
||
import { CheckSquare, Square } from 'lucide-react'
|
||
import {
|
||
Tooltip,
|
||
TooltipContent,
|
||
TooltipProvider,
|
||
TooltipTrigger,
|
||
} from '@/components/ui/tooltip'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Badge } from '@/components/ui/badge'
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||
import { PanelNavBar } from '@/components/shared/panel-nav-bar'
|
||
import { useShallow } from 'zustand/react/shallow'
|
||
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
||
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
|
||
import {
|
||
selectStoriesForActivePbi,
|
||
selectStoryIsBlocked,
|
||
} from '@/stores/product-workspace/selectors'
|
||
import type { BacklogStory as WorkspaceStory } from '@/stores/product-workspace/types'
|
||
import { StoryDialog, type StoryDialogState } from './story-dialog'
|
||
import { debugProps } from '@/lib/debug'
|
||
import { BacklogCard } from './backlog-card'
|
||
import { EmptyPanel } from './empty-panel'
|
||
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
||
import { cn } from '@/lib/utils'
|
||
|
||
type SortMode = 'priority' | 'code' | 'date'
|
||
const STATUS_COLORS: Record<string, string> = {
|
||
OPEN: 'bg-status-todo/15 text-status-todo border-status-todo/30',
|
||
IN_SPRINT: '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',
|
||
}
|
||
const STATUS_LABELS: Record<string, string> = {
|
||
OPEN: 'Open',
|
||
IN_SPRINT: 'In Sprint',
|
||
DONE: 'Klaar',
|
||
}
|
||
|
||
export interface Story {
|
||
id: string
|
||
code: string | null
|
||
title: string
|
||
description: string | null
|
||
acceptance_criteria: string | null
|
||
priority: number
|
||
sort_order: number
|
||
status: string
|
||
pbi_id: string
|
||
sprint_id: string | null
|
||
created_at: Date
|
||
}
|
||
|
||
interface StoryPanelProps {
|
||
productId: string
|
||
isDemo: boolean
|
||
activeSprintId?: string | null
|
||
}
|
||
|
||
function StoryBlock({
|
||
story,
|
||
isSelected,
|
||
cherrypick,
|
||
onSelect,
|
||
onEdit,
|
||
}: {
|
||
story: Story
|
||
isSelected: boolean
|
||
cherrypick: {
|
||
checked: boolean
|
||
blocked: { sprintName: string } | null
|
||
onToggle: () => void
|
||
} | null
|
||
onSelect: () => void
|
||
onEdit: () => void
|
||
}) {
|
||
return (
|
||
<BacklogCard
|
||
title={story.title}
|
||
code={story.code}
|
||
priority={story.priority}
|
||
isSelected={isSelected}
|
||
onClick={onSelect}
|
||
badge={
|
||
<Badge className={cn('text-[10px] px-1.5 py-0 border', STATUS_COLORS[story.status])}>
|
||
{STATUS_LABELS[story.status] ?? story.status}
|
||
</Badge>
|
||
}
|
||
actions={
|
||
<div className="flex items-center gap-1">
|
||
{cherrypick && <StoryCherrypickButton {...cherrypick} />}
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); onEdit() }}
|
||
className="inline-flex items-center justify-center min-h-7 min-w-7 text-muted-foreground hover:text-foreground rounded transition-colors"
|
||
aria-label="Story bewerken"
|
||
>
|
||
<svg className="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
}
|
||
/>
|
||
)
|
||
}
|
||
|
||
function StoryCherrypickButton({
|
||
checked,
|
||
blocked,
|
||
onToggle,
|
||
}: {
|
||
checked: boolean
|
||
blocked: { sprintName: string } | null
|
||
onToggle: () => void
|
||
}) {
|
||
const icon = checked ? (
|
||
<CheckSquare size={16} className="text-primary" />
|
||
) : (
|
||
<Square size={16} />
|
||
)
|
||
if (blocked) {
|
||
return (
|
||
<TooltipProvider>
|
||
<Tooltip>
|
||
<TooltipTrigger
|
||
data-disabled="true"
|
||
aria-disabled="true"
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="inline-flex items-center justify-center min-h-7 min-w-7 rounded opacity-40 cursor-not-allowed text-muted-foreground"
|
||
>
|
||
{icon}
|
||
</TooltipTrigger>
|
||
<TooltipContent>Zit in sprint {blocked.sprintName}</TooltipContent>
|
||
</Tooltip>
|
||
</TooltipProvider>
|
||
)
|
||
}
|
||
return (
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
onToggle()
|
||
}}
|
||
aria-pressed={checked}
|
||
aria-label={
|
||
checked ? 'Story uit sprint halen' : 'Story aan sprint toevoegen'
|
||
}
|
||
className={cn(
|
||
'inline-flex items-center justify-center min-h-7 min-w-7 rounded transition-colors',
|
||
'text-muted-foreground hover:text-foreground',
|
||
)}
|
||
>
|
||
{icon}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
// --- Main component ---
|
||
export function StoryPanel({ productId, isDemo, activeSprintId = null }: StoryPanelProps) {
|
||
const selectedPbiId = useProductWorkspaceStore((s) => s.context.activePbiId)
|
||
const selectedStoryId = useProductWorkspaceStore((s) => s.context.activeStoryId)
|
||
const rawStories = useProductWorkspaceStore(useShallow(selectStoriesForActivePbi)) as WorkspaceStory[]
|
||
const [filterStatus, setFilterStatus] = useState<string | null>(null)
|
||
const [filterPriority, setFilterPriority] = useState<number | null>(null)
|
||
const sortMode: SortMode = useUserSettingsStore(
|
||
(s) => s.entities.settings.views?.storyPanel?.sort ?? 'priority',
|
||
)
|
||
const setPref = useUserSettingsStore((s) => s.setPref)
|
||
const setSortMode = (v: SortMode) => void setPref(['views', 'storyPanel', 'sort'], v)
|
||
const [storyDialogState, setStoryDialogState] = useState<StoryDialogState | null>(null)
|
||
|
||
const base = rawStories
|
||
.filter(s => !filterStatus || s.status === filterStatus)
|
||
.filter(s => !filterPriority || s.priority === filterPriority)
|
||
|
||
const filtered = [...base].sort((a, b) => {
|
||
if (sortMode === 'code') {
|
||
return (a.code ?? '').localeCompare(b.code ?? '', 'nl', { numeric: true })
|
||
}
|
||
if (sortMode === 'date') {
|
||
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||
}
|
||
return a.priority !== b.priority ? a.priority - b.priority : 0
|
||
})
|
||
|
||
const hasActiveFilters = filterStatus !== null || filterPriority !== null
|
||
|
||
return (
|
||
<div className="flex flex-col h-full" {...debugProps('story-panel', 'StoryPanel', 'components/backlog/story-panel.tsx')}>
|
||
<PanelNavBar
|
||
title="Stories"
|
||
actions={
|
||
<>
|
||
{hasActiveFilters && (
|
||
<button onClick={() => { setFilterStatus(null); setFilterPriority(null) }} className="text-xs text-primary hover:underline">
|
||
Filter wissen ×
|
||
</button>
|
||
)}
|
||
<Select value={sortMode} onValueChange={(v) => setSortMode(v as SortMode)}>
|
||
<SelectTrigger className="h-7 w-28 text-xs">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="priority">Prioriteit</SelectItem>
|
||
<SelectItem value="code">Code</SelectItem>
|
||
<SelectItem value="date">Datum</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<Select
|
||
value={filterStatus ?? 'all'}
|
||
onValueChange={(v) => setFilterStatus(!v || v === 'all' ? null : v)}
|
||
>
|
||
<SelectTrigger className="h-7 w-24 text-xs">
|
||
<SelectValue placeholder="Status" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">Alle</SelectItem>
|
||
<SelectItem value="OPEN">Open</SelectItem>
|
||
<SelectItem value="IN_SPRINT">In Sprint</SelectItem>
|
||
<SelectItem value="DONE">Klaar</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
{selectedPbiId && (
|
||
<DemoTooltip show={isDemo}>
|
||
<Button
|
||
size="sm"
|
||
className="h-7 text-xs"
|
||
disabled={isDemo}
|
||
onClick={() => !isDemo && setStoryDialogState({ mode: 'create', pbiId: selectedPbiId, productId, defaultPriority: 2 })}
|
||
>
|
||
+ Story
|
||
</Button>
|
||
</DemoTooltip>
|
||
)}
|
||
</>
|
||
}
|
||
/>
|
||
|
||
<div className="flex-1 overflow-y-auto p-4" {...debugProps('story-panel__tasks')}>
|
||
{selectedPbiId === null ? (
|
||
<EmptyPanel message="Selecteer een PBI om de stories te bekijken." />
|
||
) : rawStories.length === 0 ? (
|
||
<EmptyPanel
|
||
message="Nog geen stories voor dit PBI."
|
||
action={{ label: 'Maak je eerste story aan', onClick: () => setStoryDialogState({ mode: 'create', pbiId: selectedPbiId, productId, defaultPriority: 2 }), disabled: isDemo }}
|
||
/>
|
||
) : (
|
||
<div className="grid grid-cols-3 gap-2">
|
||
{filtered.map(story => (
|
||
<StoryBlockWithCherrypick
|
||
key={story.id}
|
||
story={story}
|
||
productId={productId}
|
||
activeSprintId={activeSprintId}
|
||
isSelected={selectedStoryId === story.id}
|
||
onSelect={() => useProductWorkspaceStore.getState().setActiveStory(story.id)}
|
||
onEdit={() => setStoryDialogState({ mode: 'edit', story, productId })}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<StoryDialog
|
||
state={storyDialogState}
|
||
onClose={() => setStoryDialogState(null)}
|
||
isDemo={isDemo}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// PBI-79 / ST-1337: wrapper rond StoryBlock met cherrypick-handling.
|
||
function StoryBlockWithCherrypick({
|
||
story,
|
||
productId,
|
||
activeSprintId,
|
||
isSelected,
|
||
onSelect,
|
||
onEdit,
|
||
}: {
|
||
story: Story
|
||
productId: string
|
||
activeSprintId: string | null
|
||
isSelected: boolean
|
||
onSelect: () => void
|
||
onEdit: () => void
|
||
}) {
|
||
const draft = useUserSettingsStore(
|
||
(s) => s.entities.settings.workflow?.pendingSprintDraft?.[productId],
|
||
)
|
||
const upsertStoryOverride = useUserSettingsStore((s) => s.upsertStoryOverride)
|
||
const toggleStorySprintMembership = useProductWorkspaceStore(
|
||
(s) => s.toggleStorySprintMembership,
|
||
)
|
||
const pending = useProductWorkspaceStore((s) => s.sprintMembership.pending)
|
||
const blocked = useProductWorkspaceStore((s) =>
|
||
selectStoryIsBlocked(s, story.id),
|
||
)
|
||
|
||
let cherrypick: {
|
||
checked: boolean
|
||
blocked: { sprintName: string } | null
|
||
onToggle: () => void
|
||
} | null = null
|
||
|
||
if (draft) {
|
||
const intent = draft.pbiIntent[story.pbi_id] ?? 'none'
|
||
const override = draft.storyOverrides[story.pbi_id] ?? {
|
||
add: [],
|
||
remove: [],
|
||
}
|
||
const checked =
|
||
(intent === 'all' && !override.remove.includes(story.id)) ||
|
||
override.add.includes(story.id)
|
||
cherrypick = {
|
||
checked,
|
||
blocked: blocked ? { sprintName: blocked.sprintName } : null,
|
||
onToggle: () => {
|
||
if (intent === 'all') {
|
||
void upsertStoryOverride(
|
||
productId,
|
||
story.pbi_id,
|
||
story.id,
|
||
checked ? 'remove' : 'clear',
|
||
)
|
||
} else {
|
||
void upsertStoryOverride(
|
||
productId,
|
||
story.pbi_id,
|
||
story.id,
|
||
checked ? 'clear' : 'add',
|
||
)
|
||
}
|
||
},
|
||
}
|
||
} else if (activeSprintId) {
|
||
const inSprintDb = story.sprint_id === activeSprintId
|
||
const inAdds = pending.adds.includes(story.id)
|
||
const inRemoves = pending.removes.includes(story.id)
|
||
const checked = inAdds || (inSprintDb && !inRemoves)
|
||
cherrypick = {
|
||
checked,
|
||
blocked: blocked ? { sprintName: blocked.sprintName } : null,
|
||
onToggle: () => {
|
||
toggleStorySprintMembership(story.id, inSprintDb)
|
||
},
|
||
}
|
||
}
|
||
|
||
return (
|
||
<StoryBlock
|
||
story={story}
|
||
isSelected={isSelected}
|
||
cherrypick={cherrypick}
|
||
onSelect={onSelect}
|
||
onEdit={onEdit}
|
||
/>
|
||
)
|
||
}
|