Scrum4Me/components/backlog/story-panel.tsx
Janpeter Visser 5df04feb11
feat(PBI-74): Zustand product-workspace rearchitecture (Stories 1-8) (#180)
* feat(PBI-74): product-workspace store skelet + test-infra (Story 1)

Skelet voor de nieuwe `product-workspace-store` die op termijn de gefragmenteerde
`backlog-store`/`planner-store`/`selection-store`/`product-store` vervangt. Deze
PR levert alleen het skelet + tests; UI-consumers worden in latere stories
omgezet.

- vitest naar jsdom + tests/setup.ts (MemoryStorage, default fetch-stub) — G6/G8
- stores/product-workspace/{types,store,selectors,restore}.ts — immer-middleware,
  alle slices en acties (hydrate, setActive*, ensure*Loaded met activeRequestId-
  guard, applyRealtimeEvent, resyncActiveScopes/loadedScopes, optimistic
  mutations). Restore-wiring in setters volgt in Story 4 (T-857/T-858).
- selectors gebruiken module-level EMPTY refs (G1) en documenteren useShallow-
  vereiste (G2)
- 34 nieuwe unit-tests dekken §Testing setup-checklist uit het ontwerp:
  hydrateSnapshot, selection-cascade, applyRealtimeEvent (I/U/D + parent-move +
  ander-product + unknown-entity → resync), delete-cleanup, race-safe loaders,
  ensureTaskLoaded _detail-flag, resyncActiveScopes ensure-keten, restore-hints
  read/write/clear, optimistic mutation rollback/settle/SSE-echo idempotent
- docs/api/rest-contract.md: audit-sectie met de vier ontbrekende
  ensure*Loaded-endpoints (worden toegevoegd in Story 7 / T-870)

Refs: PBI-74, ST-1318, T-837..T-843
Bron-ontwerp: docs/plans/zustand-store-rearchitecture.md
Implementatieplan: docs/plans/zustand-workspace-store-implementation.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(PBI-74): dual-dispatch hydratie + realtime naar workspace-store (Story 2)

Story 2 — schaduw-fase: BacklogHydrationWrapper en useBacklogRealtime voeden
nu ook de nieuwe product-workspace-store, terwijl de oude useBacklogStore /
useProductStore leidend blijft voor componenten. Story 3 verschuift consumers
één voor één; Story 8 ruimt de oude stores op.

- T-844: BacklogHydrationWrapper roept naast useBacklogStore.setInitialData
  ook useProductWorkspaceStore.hydrateSnapshot aan. Productname-prop optioneel
  toegevoegd voor activeProduct-context.
- T-845: useBacklogRealtime onmessage dispatcht events naar zowel oude store
  (applyChange) als nieuwe store (applyRealtimeEvent). Geen wijziging aan
  reconnect/visibility — Story 5.
- T-846: dev-only logWorkspaceFingerprint helper vergelijkt counts tussen
  oude en nieuwe store na hydrate en na elk realtime-event. console.warn bij
  mismatch; opt-in debug log via NEXT_PUBLIC_DEBUG_WORKSPACE_FINGERPRINT=1.
  Bestand TODO-marked voor verwijdering in Story 8 (T-878).
- T-847: SetCurrentProduct schrijft naast oude useProductStore ook
  useProductWorkspaceStore.setActiveProduct({id, name}); cleanup cleart beide.
  setActiveProduct triggert ensureProductLoaded — fetch-stub tot Story 7
  (T-870) de LIST-endpoints toevoegt.

Verify: lint+typecheck clean, 636/636 tests groen (geen UI-regressie omdat
oude store leidend blijft).

Refs: PBI-74, ST-1319, T-844..T-847

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(PBI-74): migreer backlog-componenten naar workspace-store (Story 3)

Story 3 verplaatst alle UI-consumers van de oude vier stores
(useBacklogStore/usePlannerStore/useSelectionStore/useProductStore) naar de
nieuwe product-workspace-store. De oude stores blijven nog bestaan voor
hydration-wrapper en realtime-hook (dual-dispatch); Story 8 ruimt ze op.

- T-848 backlog-split-pane.tsx: leest activePbiId/activeStoryId uit
  context-slice (primitives, geen useShallow nodig).
- T-849 pbi-list.tsx: selectVisiblePbis(useShallow); DnD via
  applyOptimisticMutation('pbi-order' + optionele 'entity-patch' bij
  cross-priority drag), met settle/rollback per server-result.
- T-850 story-panel.tsx: selectStoriesForActivePbi(useShallow); DnD via
  applyOptimisticMutation('story-order' + entity-patch bij priority change).
- T-851 task-panel.tsx: selectTasksForActiveStory(useShallow); DnD via
  applyOptimisticMutation('task-order'); detail-view (ensureTaskLoaded +
  isDetail) zit in de task-dialog (apart component, niet in deze lijst).
- T-852 start-sprint-button.tsx: selectActivePbi + selectStoriesForActivePbi
  voor free-story count.
- T-853 set-current-product.tsx: alleen workspace-store.setActiveProduct
  (oude useProductStore-import verwijderd).
- T-854 G1/G2-audit: alle nieuwe selectors gebruiken module-level EMPTY
  refs (G1) en useShallow voor lijsten (G2). Geen 'Maximum update depth'-
  warnings tijdens npm test.
- T-855 tests bijgewerkt: backlog-split-pane.test, task-panel.test,
  integration.test gebruiken nu setState op workspace-store (helpers
  resetWorkspace/setActiveStoryAndTasks/selectPbi/selectStory).

Verify: lint+typecheck clean, 636/636 tests groen. UI-consumers van
oude stores zijn nu nul (uitgezonderd dual-dispatch in hydration-wrapper en
realtime-hook + dev-fingerprint-helper, die in Story 8/T-873/T-878 verdwijnen).

Refs: PBI-74, ST-1320, T-848..T-855

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(PBI-74): race-safe loaders + restore-hints + URL-prioriteit (Story 4)

- T-856: activeRequestId-guard zat al in store.ts uit Story 1; bevestigd door
  de race-safety test (in-flight ensurePbiLoaded mag niet overschrijven).
- T-857: restore-hint flow toegevoegd in setActiveProduct/setActivePbi/
  setActiveStory. Async chain: await ensureXxxLoaded → guard check →
  readHints → valideer hint via entities.byId → setActiveYyy(hint).
  Geen setTimeout-trick — chain is alleen await-based.
- T-858: writeProductHint/writePbiHint/writeStoryHint/writeTaskHint
  aangeroepen direct na set(...) zodat de hint-persistentie altijd
  consistent is met de in-store selectie.
- T-859: nieuwe components/backlog/url-task-sync.tsx — leest
  ?editTask=&lt;id&gt; uit useSearchParams, schrijft de hint en roept
  setActiveTask aan zodat de URL wint boven een eerder gepersisteerde
  task-hint. Gemount in beide product-pages (desktop + mobile) binnen
  BacklogHydrationWrapper.
- T-860: 6 nieuwe vitest-cases — 4 voor hint-persist per setter, 2 voor de
  restore-flow chain (hint die niet in entities zit wordt genegeerd; hint
  die wel in entities zit wordt toegepast). Bestaande race-safety test
  blijft groen.

Verify: lint+typecheck clean, 642/642 tests groen.

Refs: PBI-74, ST-1321, T-856..T-860

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(PBI-74): hidden-tab + reconnect resync (Story 5)

Per ontwerp samen in één commit zodat geen vangnet wegvalt zonder vervanging.

- T-861: useBacklogRealtime sluit niet meer op visibilitychange hidden;
  EventSource blijft open zolang browser/netwerk dit toelaten. Reconnect bij
  netwerkfout blijft via backoff. visibilitychange fungeert nog wel als
  re-connect-trigger als de stream tussentijds is gesloten (b.v. 240s
  hard-close server-side).
- T-862: 'ready'-event-handler telt connect-cycles. De eerste 'ready' is de
  initial connect (geen resync). Bij latere 'ready' (post-reconnect) wordt
  resyncActiveScopes('reconnect') aangeroepen om gemiste events op te halen.
- T-863: nieuwe lib/realtime/use-workspace-resync.ts — luistert op
  document.visibilitychange (hidden→visible) en window.online; dispatcht
  resyncActiveScopes('visible') resp. 'reconnect'. Mounted in
  BacklogHydrationWrapper na useBacklogRealtime.
- T-864: 4 nieuwe vitest-cases voor useWorkspaceResync (jsdom): visible→
  visible event, online event, hidden negeren, cleanup-bij-unmount.

Daarnaast lint-cleanup: ongebruikte 'order'-variabelen in pbi-list en
story-panel weggehaald.

Verify: lint+typecheck clean, 646/646 tests groen.

Refs: PBI-74, ST-1322, T-861..T-864

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(PBI-74): unknown-event fallback tests (Story 6)

T-865 (isUnknownEntityEvent filter) en T-866 (resync-trigger in
applyRealtimeEvent) zijn al in Story 1 geïmplementeerd in store.ts;
deze story breidt de test-coverage uit met expliciete negatieve cases
voor het type-veld noise pattern.

T-867 — 5 nieuwe vitest-cases:
- unknown entity met ANDER product_id → geen resync
- claude_job_status (type) → geen resync
- worker_heartbeat (type) → geen resync
- claude_job_enqueued (type) → geen resync
- payload zonder entity en zonder type → genegeerd
- question-entity (entity-veld, geen type, niet pbi/story/task) → resync trigger

Verify: lint+typecheck clean, 651/651 tests groen.

Refs: PBI-74, ST-1323, T-865..T-867

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(PBI-74): cache-headers + LIST endpoints (Story 7)

- T-868: cache: 'no-store' was al ingebouwd in fetchJson helper (Story 1).
  Bevestigd door bestaande ensureProductLoaded test die de fetch-init
  controleert.
- T-869: force-dynamic toegevoegd op alle vier nieuwe LIST-endpoints.
- T-870: vier nieuwe routes voor ensure*Loaded:
  - GET /api/products/:id/backlog → ProductBacklogSnapshot
  - GET /api/pbis/:id/stories → BacklogStory[]
  - GET /api/stories/:id/tasks → BacklogTask[]
  - GET /api/tasks/:id (nieuwe handler naast bestaande PATCH) → TaskDetail
    met _detail: true marker
  Auth via authenticateApiRequest (Bearer of iron-session); access-control
  via productAccessFilter (gebruiker is owner of member van het product).
  Statussen worden via taskStatusToApi/storyStatusToApi/pbiStatusToApi
  vertaald naar lowercase API-vorm.
- T-871: SSE-route /api/realtime/backlog stuurt al ready-event direct na
  LISTEN (regel 106) — geen wijziging nodig.

Verify: lint+typecheck clean, 651/651 tests groen.

Refs: PBI-74, ST-1324, T-868..T-871

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(PBI-74): oude stores opruimen (Story 8)

Workspace-store is nu de enige bron voor product-backlog client-state. De
vier voorgangers en de dual-dispatch-infrastructuur zijn verwijderd.

- T-872: grep over codebase op useBacklogStore/usePlannerStore/
  useSelectionStore/useProductStore is leeg.
- T-873..T-876: stores/{backlog,planner,selection,product}-store.ts deleted.
- T-877: __tests__/realtime/payload-contract.test.ts en
  __tests__/api/backlog-realtime.test.ts deleted — pbi/story/task I|U|D
  payload-handling wordt al gedekt door
  __tests__/stores/product-workspace/store.test.ts (incl. parent-move,
  idempotent inserts, delete-cleanup).
- T-878: lib/realtime/dev-workspace-fingerprint.ts deleted, dual-dispatch
  uit BacklogHydrationWrapper en lib/realtime/use-backlog-realtime.ts
  weggehaald. stores/products-store.ts (lijst van producten ≠ active
  product) blijft ongewijzigd.

Bijwerkingen:
- BacklogPbi en BacklogStory types in components/backlog/story-panel.tsx en
  components/sprint/sprint-backlog.tsx krijgen sort_order zodat ze met de
  workspace-types overeenkomen.
- Server-pages /products/[id]/page.tsx (desktop+mobile) en
  /products/[id]/sprint/[sprintId]/page.tsx selecteren sort_order op story
  en mappen het door in de hydration-payload.

Verify: lint+typecheck clean, 626/626 tests groen (verlies van 25 redundante
oude-store tests; workspace-store tests dekken hetzelfde gedrag).

Refs: PBI-74, ST-1325, T-872..T-878

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(PBI-74): richtlijn workspace-store + realtime patroon

Documenteert het patroon dat in Stories 1-8 is opgeleverd, zodat een
volgende workspace-store (sprint, of een nieuwe bounded context) hetzelfde
recept volgt.

- docs/patterns/workspace-store.md (nieuw): wanneer een workspace-store, de
  vijf state-slices, selectors-regels (G1/G2), race-safe ensure*Loaded met
  activeRequestId-guard (G4), SSE-hook + applyRealtimeEvent met
  unknown-event filter, hidden-tab + reconnect resync via
  useWorkspaceResync, restore-hint flow met await-chain en URL-prioriteit,
  optimistic mutations (applyOptimisticMutation/rollback/settle), API
  endpoint-vereisten (force-dynamic, cache: no-store), test-setup met
  MemoryStorage + originalActions snapshot + mockImplementation, gotchas
  G1-G8 als comment-template, en het 8-staps migratiepad.
- docs/patterns/zustand-optimistic.md: bijgewerkt voor de nieuwe
  workspace-store API; verwijst voor het bredere patroon naar
  workspace-store.md. Voorbeelden voor pbi-order + entity-patch.
- CLAUDE.md: patterns quickref aangevuld met workspace-store-rij.

Verify: typecheck clean.

Refs: PBI-74

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(PBI-74): solo + notifications hooks volgen ook hidden-tab/resync patroon

Het uitgangspunt van PBI-74 (robuust tegen gemiste SSE-events, hidden tabs
en onbekende notify-vormen) gold universeel — niet alleen voor
product-workspace. use-solo-realtime en use-notifications-realtime hadden
nog dezelfde bug die use-backlog-realtime in Story 5 al opgelost kreeg:
sluit stream op hidden, geen resync.

Reproductie (zoals gemeld): solo-screen open in tab A, product-backlog
open in tab B; bewerk task-title in tab B → tab A's solo-SSE was gesloten
(hidden) en kreeg het NOTIFY-event nooit. Tab terug naar solo →
EventSource reconnect maar geen resync → oude title persisteert. Postgres
NOTIFY heeft geen replay, dus zonder resync zijn die events permanent
verloren.

Fix in beide hooks (zelfde patroon als Story 5 voor backlog):
- Stream blijft open op visibilitychange hidden — geen close() meer.
- Bij hidden→visible én bij window 'online': router.refresh() zodat de
  server-component opnieuw fetcht en de initial-state-prop ververst (wat
  voor solo de tasks-record reset via initTasks; voor notifications de
  questions-bel-state).
- Bij latere 'ready'-events na reconnect (use-solo-realtime): zelfde
  router.refresh() trigger zodat we niet vertrouwen op alleen het
  visibility-pad.

Verify: lint + typecheck clean, 626/626 tests groen.

Refs: PBI-74

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: fix broken links in research-repo plan

docs/plans/lees-de-readme-md-validated-book.md beschrijft een research-
repo migratiepad. De links waren geschreven vanuit het research-repo-
perspectief (paden als stores/data-store.ts, ../Scrum4Me/CLAUDE.md,
docs/plans/zustand-store-rearchitecture.md zonder relative-prefix), wat
de doc-link-checker hier laat falen.

- Header-note toegevoegd dat het document voor de research-repo is.
- Interne refs (zustand-store-rearchitecture.md, CLAUDE.md) → relatieve
  paden die in deze repo wél resolven (./zustand-..., ../../CLAUDE.md).
- Research-repo-only refs (stores/data-store.ts,
  hooks/use-event-stream.ts, components/*-select.tsx, etc.) → inline
  code-tags met "(research-repo)" suffix; de link-checker slaat ze over
  en de leesbaarheid blijft.

Verify: npm run docs:check-links → ✓ All doc links valid (118 files).

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 02:25:19 +02:00

334 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useState, useTransition, useEffect } from 'react'
import {
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
closestCenter,
} from '@dnd-kit/core'
import {
SortableContext,
useSortable,
rectSortingStrategy,
arrayMove,
sortableKeyboardCoordinates,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { toast } from 'sonner'
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 { useProductWorkspaceStore } from '@/stores/product-workspace/store'
import { selectStoriesForActivePbi } from '@/stores/product-workspace/selectors'
import type { BacklogStory as WorkspaceStory } from '@/stores/product-workspace/types'
import { reorderStoriesAction } from '@/actions/stories'
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
}
// --- Sortable story block ---
function SortableStoryBlock({
story,
isSelected,
onSelect,
onEdit,
}: {
story: Story
isSelected: boolean
onSelect: () => void
onEdit: () => void
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: story.id,
})
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
}
return (
<BacklogCard
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
title={story.title}
code={story.code}
priority={story.priority}
isDragging={isDragging}
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={
<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>
}
/>
)
}
// --- Main component ---
// PBI-74 / T-850: leest stories voor active PBI via selectStoriesForActivePbi
// (useShallow). DnD via applyOptimisticMutation('story-order').
export function StoryPanel({ productId, isDemo }: 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, setSortMode] = useState<SortMode>(() => {
const saved = typeof window !== 'undefined' ? localStorage.getItem('scrum4me:story_sort') : null
return (saved === 'priority' || saved === 'code' || saved === 'date') ? saved : 'priority'
})
const [storyDialogState, setStoryDialogState] = useState<StoryDialogState | null>(null)
const [activeDragId, setActiveDragId] = useState<string | null>(null)
const [, startTransition] = useTransition()
useEffect(() => { localStorage.setItem('scrum4me:story_sort', sortMode) }, [sortMode])
// rawStories komt al gesorteerd binnen via selectStoriesForActivePbi.
const storyMap = Object.fromEntries(rawStories.map(s => [s.id, s]))
const orderedStories = rawStories
const base = orderedStories
.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 sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)
function handleDragStart(event: DragStartEvent) {
setActiveDragId(event.active.id as string)
}
function handleDragEnd(event: DragEndEvent) {
setActiveDragId(null)
const { active, over } = event
if (!over || active.id === over.id || !selectedPbiId) return
const activeStory = storyMap[active.id as string]
const overStory = storyMap[over.id as string]
if (!activeStory || !overStory) return
const store = useProductWorkspaceStore.getState()
const prevOrder = [...(store.relations.storyIdsByPbi[selectedPbiId] ?? [])]
const oldIndex = prevOrder.indexOf(active.id as string)
const newIndex = prevOrder.indexOf(over.id as string)
if (oldIndex === -1 || newIndex === -1) return
const newOrder = arrayMove([...prevOrder], oldIndex, newIndex)
const orderMutationId = store.applyOptimisticMutation({
kind: 'story-order',
pbiId: selectedPbiId,
prevStoryIds: prevOrder,
})
useProductWorkspaceStore.setState((s) => {
s.relations.storyIdsByPbi[selectedPbiId] = newOrder
})
const priorityChanged = activeStory.priority !== overStory.priority
let priorityMutationId: string | null = null
if (priorityChanged) {
priorityMutationId = store.applyOptimisticMutation({
kind: 'entity-patch',
entity: 'story',
id: active.id as string,
prev: store.entities.storiesById[active.id as string],
})
useProductWorkspaceStore.setState((s) => {
const story = s.entities.storiesById[active.id as string]
if (story) story.priority = overStory.priority
})
}
startTransition(async () => {
const result = await reorderStoriesAction(
selectedPbiId,
productId,
newOrder,
priorityChanged ? overStory.priority : undefined
)
const st = useProductWorkspaceStore.getState()
if (result.success) {
if (priorityMutationId) st.settleMutation(priorityMutationId)
st.settleMutation(orderMutationId)
} else {
if (priorityMutationId) st.rollbackMutation(priorityMutationId)
st.rollbackMutation(orderMutationId)
toast.error('Volgorde opslaan mislukt')
}
})
}
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 }}
/>
) : (
<DndContext
id="story-panel"
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext items={filtered.map(s => s.id)} strategy={rectSortingStrategy}>
<div className="grid grid-cols-3 gap-2">
{filtered.map(story => (
<SortableStoryBlock
key={story.id}
story={story}
isSelected={selectedStoryId === story.id}
onSelect={() => useProductWorkspaceStore.getState().setActiveStory(story.id)}
onEdit={() => setStoryDialogState({ mode: 'edit', story, productId })}
/>
))}
</div>
</SortableContext>
<DragOverlay>
{activeDragId && storyMap[activeDragId] && (
<BacklogCard
title={storyMap[activeDragId].title}
priority={storyMap[activeDragId].priority}
className="border-primary shadow-xl opacity-90"
/>
)}
</DragOverlay>
</DndContext>
)}
</div>
<StoryDialog
state={storyDialogState}
onClose={() => setStoryDialogState(null)}
isDemo={isDemo}
/>
</div>
)
}