feat(PBI-79): Product Backlog sprint-membership via vinkjes (#190)
* feat(PBI-79/ST-1333): active-sprint null-contract + clearActiveSprintAction
- lib/user-settings.ts: activeSprints values nullable in Zod-schema.
Key-aanwezigheid heeft nu betekenis (key+null = bewust geen sprint;
key ontbreekt = fallback-cascade).
- lib/active-sprint.ts: nieuwe readStoredActiveSprintState helper +
resolveActiveSprint respecteert expliciet 'cleared' state zonder fallback.
clearActiveSprintInSettings schrijft null i.p.v. de key te verwijderen.
- actions/active-sprint.ts: nieuwe clearActiveSprintAction met auth +
membership-check.
- components/shared/sprint-switcher.tsx: '— Geen actieve sprint —'-optie
in dropdown, disabled wanneer er geen actieve sprint is.
- Tests: nieuwe active-sprint.test.ts (resolver-paden + clear),
active-sprint-action.test.ts (action-laag), uitbreiding user-settings.test.ts.
Plan: docs/plans/PBI-79-backlog-sprint-workflow.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-79/ST-1334): user-settings pendingSprintDraft-slot
- lib/user-settings.ts: nieuw workflow.pendingSprintDraft veld met
compacte intent-shape (pbiIntent + per-PBI storyOverrides).
- actions/sprint-draft.ts: setPendingSprintDraftAction +
clearPendingSprintDraftAction met product-membership-check + Zod-validatie.
- stores/user-settings/store.ts: setPendingSprintDraft / clearPendingSprintDraft
optimistic acties + fine-grained mutators upsertPbiIntent / upsertStoryOverride.
Sprint-draft actions worden dynamisch geïmporteerd zodat jsdom-tests
zonder DATABASE_URL niet falen.
- Tests: nieuwe sprint-draft.test.ts (action-laag), uitbreiding
user-settings store-tests (5 nieuwe cases) en schema-tests (4 cases).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-79/ST-1343): sprint-conflicts helper-library
- lib/sprint-conflicts.ts: drie pure/server-side helpers voor eligibility
+ cross-sprint detectie.
- isEligibleForSprint(story): sprint_id IS NULL en status != DONE
- partitionByEligibility(prisma, storyIds, excludeSprintId): split in
eligible / notEligible / crossSprint met reden per story
- getBlockingSprintMap(prisma, productId, storyIds, excludeSprintId):
map storyId → { sprintId, sprintName } voor stories in andere OPEN sprint
- Tests: __tests__/lib/sprint-conflicts.test.ts (16 cases) — alle eligibility
paden + cross-sprint scoping + CLOSED-sprint filtering.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-79/ST-1335): sprint-membership-summary + cross-sprint-blocks endpoints
Twee nieuwe GET-route handlers, beide verplicht gescoped op pbiIds (geen
product-brede aanroepen).
- app/api/products/[id]/sprint-membership-summary/route.ts
Response: { [pbiId]: { total, inSprint } } via twee prisma.groupBy calls
(totaal + binnen actieve sprint). Voor state-B tri-state.
- app/api/products/[id]/cross-sprint-blocks/route.ts
Response: { [storyId]: { sprintId, sprintName } } voor stories in andere
OPEN sprints. UX-hint voor disabled-vinkjes; commit-acties blijven
autoritatief.
Tests: 13 cases dekken happy path, 400 zonder pbiIds, 400 zonder sprintId,
404 zonder product-access, auth-fail, en NOT-clause voor excludeSprintId.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-79/ST-1336): product-workspace sprint-membership slice + selectors
Datalaag voor de vinkje-UI van state A′ en state B.
types.ts:
- PbiSummaryEntry, CrossSprintBlock, SprintMembershipSlice toegevoegd.
store.ts:
- Nieuwe slice `sprintMembership` met pbiSummary, crossSprintBlocks,
pending: { adds[], removes[] }, loadedSummaryForSprintId.
- Acties: setPbiSummary, setCrossSprintBlocks, toggleStorySprintMembership
(cancel-out logic), resetSprintMembershipPending, fetchSprintMembershipSummary,
fetchCrossSprintBlocks.
- hydrateSnapshot reset óók de membership-slice.
selectors.ts:
- selectPbiTriState (aggregate-only zolang stories niet geladen; rekent
pending mee bij loaded PBI's).
- selectStoryEffectiveInSprint (DB ⊕ pending).
- selectStoryIsBlocked (cross-sprint hint).
- selectIsDirty, selectPendingCount.
Tests: 25 cases in nieuwe sprint-membership.test.ts dekken alle selector-
paden, toggle-cancel-out, fetch-helpers, en pbiId-scoping.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-79/ST-1337): state A′ UI — metadata dialog + sticky banner + PbiList ombouw
UI-laag voor de sprint-definitie-flow (state A′).
Nieuw:
- NewSprintMetadataDialog (stap 1): sprint_goal + optionele dates;
'Verder' schrijft via useUserSettingsStore.setPendingSprintDraft.
- SprintDefinitionBanner (sticky): toont doel + X PBI's / Y stories teller;
'Annuleren' → AlertDialog confirm → clearPendingSprintDraft;
'Sprint aanmaken' nog niet aangesloten (wacht op ST-1339).
- NewSprintTrigger: button in page header die de metadata-dialog opent;
verbergt zichzelf zolang er al een draft loopt.
- SprintDraftBanner: client-wrapper, rendert banner alleen als draft bestaat.
Wijzigingen:
- lib/user-settings.ts: pendingSprintDraft startAt/endAt → z.string().date().
- PbiList: oude selectionMode + selectedIds + NewSprintDialog vervangen door
hasDraft-afgeleide A′-mode met tri-state vinkjes; togglen muteert
upsertPbiIntent('all'|'none') en wist storyOverrides per PBI.
- StoryPanel: in A′-mode toont elke story een cherrypick-checkbox die
upsertStoryOverride('add'/'remove'/'clear') aanroept; cross-sprint-blocked
stories krijgen disabled-icoon met sprint-naam tooltip.
- app/(app)/products/[id]/page.tsx: StartSprintButton vervangen door
NewSprintTrigger; SprintDraftBanner gepositioneerd boven split-pane.
Tests: bestaande tests blijven groen (806 cases) — UI-specifieke component
tests volgen later. ST-1339 sluit createSprintWithSelectionAction aan.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-79/ST-1339): createSprintWithSelectionAction + banner wire-up
actions/sprints.ts:
- Nieuwe createSprintWithSelectionAction(productId, metadata, pbiIntent,
storyOverrides).
- Server-side intent-resolve:
1. Voor elke PBI met intent='all': fetch child-story-IDs minus
storyOverrides[pbi].remove.
2. Plus storyOverrides[*].add (cross-PBI cherrypick toegestaan).
- Eligibility-filter via partitionByEligibility (sprint_id IS NULL + status
!= DONE; stories in andere OPEN sprint → conflicts.crossSprint).
- Transactie wrapt sprint.create + story.updateMany (status='IN_SPRINT') +
task.updateMany (sprint_id cascade) — alles atomair.
- setActiveSprintInSettings na success.
- Return: { success, sprintId, affectedStoryIds, affectedPbiIds,
affectedTaskIds, conflicts: { notEligible, crossSprint } } of error.
components/backlog/sprint-definition-banner.tsx:
- 'Sprint aanmaken'-knop sluit aan op createSprintWithSelectionAction;
toast bij conflicts, success-toast anders, router.refresh() voor SSR
cycle. Pending draft wordt door de action zelf nog niet expliciet gewist
— dat gebeurt via revalidatePath en kan in ST-1340 finetuned worden.
Tests: __tests__/actions/create-sprint-with-selection.test.ts (6 cases)
dekken intent-resolve, override-respect, cross-sprint conflict, transactie-
binding van story.status + task.sprint_id, return-shape, en error-pad.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-79/ST-1340): commitSprintMembershipAction + gerichte client-store patches
actions/sprints.ts:
- Nieuwe commitSprintMembershipAction(activeSprintId, adds[], removes[]).
- Eligibility-filter voor adds via partitionByEligibility (sprint_id IS NULL
en niet DONE; cross-sprint conflicts → notEligible).
- Race-safety voor removes: alleen stories met huidige sprint_id ==
activeSprintId; rest → conflicts.alreadyRemoved.
- Transactie wrapt twee updateMany-paren (story status mee, task.sprint_id
cascade). Update-paren overgeslagen wanneer leeg.
- Return: { success, affectedStoryIds, affectedPbiIds, affectedTaskIds,
conflicts: { notEligible, alreadyRemoved } }.
stores/product-workspace/store.ts:
- applyMembershipCommitResult({ activeSprintId, addedStoryIds,
removedStoryIds }) patcht entities.storiesById met juiste sprint_id +
status; ledigt sprintMembership.pending. Geen task-veld omdat
BacklogTask geen sprint_id-kolom heeft in de store.
Tests: __tests__/actions/commit-sprint-membership.test.ts (8 cases) — happy
path, DONE-conflict, cross-sprint, race-safety voor removes, transactie-
inhoud (status='IN_SPRINT'/'OPEN'), task-cascade, return-shape, auth-fail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-79/ST-1338): state B vinkjes-UI + 'Sprint opslaan'-knop met teller
State B (actieve sprint geselecteerd, geen draft) hangt nu aan dezelfde
vinkje-UI als state A′, maar muteert de transient pending-buffer in plaats
van de draft.
- PbiList: nieuwe prop activeSprintId. selectionMode = hasDraft ||
stateBMode. togglePbiInDraft routeert naar upsertPbiIntent (A′) of bulk-
toggleStorySprintMembership over eligible child-stories (B, skip blocked).
- StoryPanel: idem prop activeSprintId. StoryBlockWithCherrypick muteert
draft via upsertStoryOverride in A′ of pending buffer via
toggleStorySprintMembership in B (cross-sprint blocked = disabled).
- SaveSprintButton (nieuw): client component in page header, alleen
zichtbaar als er een actieve sprint is. Disabled bij clean buffer,
enabled met teller bij dirty. Klikken calls commitSprintMembershipAction
→ applyMembershipCommitResult gericht in store + toast bij conflicts.
- page.tsx: activeSprintItem.id wordt doorgegeven aan PbiList, StoryPanel
en SaveSprintButton.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-79/ST-1341+ST-1342): SprintEditDialog metadata-edit + multi-OPEN sprints
ST-1341 (T-946):
- actions/sprints.ts: nieuwe updateSprintAction(sprintId, fields) — JSON
input, accepteert optionele goal/startAt/endAt; auth + product-access
check, prisma.sprint.update, revalidatePath. Type-safe return.
- components/backlog/sprint-edit-dialog.tsx: Entity-Dialog-pattern voor
metadata-edit van een sprint. Velden: sprint_goal, start_date, end_date.
Link 'Sprint afronden… →' naar bestaande /products/[id]/sprint/[sprintId]
zodat de completion-flow (per-story DONE/OPEN beslissing + PBI-promotie)
niet wordt geduplicereerd. useDirtyCloseGuard.
ST-1342 (T-947):
- actions/sprints.ts: OPEN-uniqueness check in createSprintAction
verwijderd. Een product mag nu meerdere OPEN sprints tegelijk hebben;
cross-sprint-conflicts per story worden afgevangen door
partitionByEligibility in de membership-commit-flow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(PBI-79/ST-1344): updateSprintAction regression coverage
Audits van de geplande non-regressie-tests laten zien dat alle invarianten
uit het ST-1344 plan reeds gedekt zijn door eerder toegevoegde tests:
- clearActiveSprintAction null-not-delete → __tests__/lib/active-sprint.test.ts
+ __tests__/actions/active-sprint-action.test.ts
- Endpoints rejecten zonder pbiIds (400) → __tests__/api/sprint-membership-summary.test.ts
+ __tests__/api/cross-sprint-blocks.test.ts
- Status-mutaties story.status=IN_SPRINT/OPEN met task.sprint_id cascade
in dezelfde transactie → __tests__/actions/create-sprint-with-selection.test.ts
+ __tests__/actions/commit-sprint-membership.test.ts
- Cross-sprint conflicts + DONE-eligibility → __tests__/lib/sprint-conflicts.test.ts
Nieuw: __tests__/actions/update-sprint.test.ts (6 cases) dekt
updateSprintAction die nog geen tests had — goal alleen, dates alleen,
null-clear, 403 zonder access, lege goal weigering, leeg fields-object
weigering.
Handmatige E2E checklist (T-949) blijft staan voor menselijke browser-
validatie tijdens PR-review.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(PBI-79): PBI-rij selecteert weer in A′/B-modus; vinkje is aparte trigger
Voor PBI-79 maakte het hele PBI-kaartje in selectionMode (state A′ én B)
de toggle. Daardoor:
- klik op rij = bulk-toggle stories (teller liep op);
- geen setActivePbi, dus StoryPanel kreeg geen content.
Fix: in selectionMode wordt onClick = onSelect (PBI activeren → stories
laden) en de tri-state-iconen verhuizen naar een eigen <button> in de
actions-slot met stopPropagation. Toggle gedrag (bulk add/remove in B,
upsertPbiIntent in A′) blijft ongewijzigd via die knop.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(PBI-79): cascade-restore alleen als hint-story bij nieuwe PBI hoort
Bug: setActivePbi reset activeStoryId/activeTaskId, maar het cascade-
restore-pad zette daarna een hint-story actief zonder te valideren of die
story bij de nieuw-geselecteerde PBI hoort. Bij PBI-switch bleef daardoor
de task-kolom de taken van de vorige story tonen.
Fix: alleen setActiveStory(hint) als entities.storiesById[hint].pbi_id ===
pbiId. Bij mismatch blijft activeStoryId null en is de task-kolom leeg
totdat de gebruiker een story uit de nieuwe PBI kiest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-79): sprint-switch auto-select PBI/story + user-settings persist
Bij sprint-switch wordt de sprint-content server-side opgevraagd. Wanneer
de sprint precies één PBI (en die PBI exact één story binnen de sprint)
heeft, worden PBI en story automatisch geselecteerd. Alle drie keuzes
(sprint, pbi, story) worden atomair in user-settings opgeslagen zodat ze
cross-device blijven hangen.
- lib/user-settings.ts: layout krijgt nullable activePbis +
activeStories per product.
- lib/active-sprint.ts: setActiveSelectionInSettings schrijft de drie
keys atomair + notify pg_notify.
- actions/active-sprint.ts: switchActiveSprintAction(productId, sprintId)
doet de server-side auto-select-resolutie (single PBI → single story)
en returnt { sprintId, pbiId, storyId }.
- components/shared/sprint-switcher.tsx: handleSwitchSprint roept de
nieuwe action aan en synchroniseert de workspace-store gelijk zodat
de UI geen flash krijgt voor de SSR-refresh.
- components/backlog/active-selection-hydrator.tsx (nieuw): client-side
effect dat user-settings.activePbis/activeStories naar workspace-store
spiegelt; wint van de localStorage hint-restore.
- app/(app)/products/[id]/page.tsx: ActiveSelectionHydrator gemount
binnen BacklogHydrationWrapper.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(PBI-79): plan-update met implementatie-stand + scope-aanpassing
Documenteert wat er sinds de eerste implementatie-pass is gebeurd:
- Tabel van 14 commits met hun rol.
- Twee bugs die tijdens testen boven kwamen (PBI-rij-klik, cascade-restore).
- Nieuwe feature sprint-switch auto-select (server resolveert single-PBI/
single-story; user-settings persist).
En kondigt scope-aanpassing aan voor de volgende implementatie-ronde:
- pendingSprintDraft wordt session-only (geen server-persist meer).
- useDirtyCloseGuard wist draft op leave-with-confirm.
- Sprint-switcher krijgt concept-entry zolang er een draft loopt.
De rest van het plan beneden blijft van kracht behalve waar deze sectie
het overruled.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-79): pendingSprintDraft session-only + concept-entry + leave-guard
Scope-aanpassing uit plan-revisie: drafts persisten niet meer server-side.
Wijzigingen:
- stores/user-settings/store.ts:
- hydrate() strip nu workflow.pendingSprintDraft uit serverstate
(legacy DB-entries blijven harmless aanwezig maar worden niet
gehydreerd → effectief unreachable voor de UI).
- setPendingSprintDraft / clearPendingSprintDraft worden lokale-only;
geen import van sprint-draft-actions, geen server-roundtrip.
- upsertPbiIntent / upsertStoryOverride blijven via setPendingSprintDraft
routeren → ook session-only.
- components/shared/sprint-switcher.tsx: leest draft-goal uit user-settings
store en toont '⚙ Concept — [goal]' als niet-selecteerbare entry
bovenaan de dropdown zolang er een draft loopt.
- components/backlog/sprint-draft-leave-guard.tsx (nieuw): registreert
een beforeunload-listener zolang er een draft is. Browser-refresh,
tab-close en back-navigatie tonen daarmee de standaard confirm. In-app
route-changes blijven via de banner-Annuleren-knop lopen.
- app/(app)/products/[id]/page.tsx: SprintDraftLeaveGuard gemount naast
de banner.
- Tests: user-settings store-tests aangepast (geen server-call assert
meer, hydrate strip-assert toegevoegd; upsert-tests seed nu via
setPendingSprintDraft i.p.v. legacy hydrate).
setPendingSprintDraftAction + clearPendingSprintDraftAction blijven bestaan
voor eventuele toekomstige opruim-flows, maar worden niet meer aangeroepen
vanuit de UI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(PBI-79): mark scope-aanpassing afgerond + localStorage overzicht
- Drie open punten uit plan-revisie afgevinkt (commit 2a4ee6a).
- Sectie 'Bewust niet geïmplementeerd': server-persist van manuele
PBI/story-klikken — op vraag van user nu out-of-scope voor deze PR.
- Tabel localStorage-gebruik in de codebase voor toekomstige referentie.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bf7162a5fc
commit
d587be2fb3
39 changed files with 5404 additions and 133 deletions
53
components/backlog/active-selection-hydrator.tsx
Normal file
53
components/backlog/active-selection-hydrator.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
||||
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
|
||||
|
||||
interface ActiveSelectionHydratorProps {
|
||||
productId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* PBI-79: hydrateert de workspace-store met de actieve PBI/story die in
|
||||
* user-settings staan opgeslagen. Loopt na elke (re)hydratatie en bij
|
||||
* mutaties van de user-settings (bv. na sprint-switch). Wint van de
|
||||
* localStorage hint-restore — user-settings is de cross-device source of
|
||||
* truth.
|
||||
*/
|
||||
export function ActiveSelectionHydrator({ productId }: ActiveSelectionHydratorProps) {
|
||||
const hydrated = useUserSettingsStore((s) => s.context.hydrated)
|
||||
const persistedPbiId = useUserSettingsStore(
|
||||
(s) => s.entities.settings.layout?.activePbis?.[productId] ?? undefined,
|
||||
)
|
||||
const persistedStoryId = useUserSettingsStore(
|
||||
(s) => s.entities.settings.layout?.activeStories?.[productId] ?? undefined,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) return
|
||||
const store = useProductWorkspaceStore.getState()
|
||||
// Schrijf alleen wanneer user-settings expliciet iets gekozen heeft
|
||||
// (key aanwezig met string-waarde). null-key betekent 'bewust leeg' →
|
||||
// we wissen lokale state. undefined-key (geen voorkeur) → niets doen.
|
||||
if (persistedPbiId === undefined && persistedStoryId === undefined) return
|
||||
|
||||
if (persistedPbiId === null) {
|
||||
store.setActivePbi(null)
|
||||
return
|
||||
}
|
||||
if (persistedPbiId && store.context.activePbiId !== persistedPbiId) {
|
||||
store.setActivePbi(persistedPbiId)
|
||||
}
|
||||
if (persistedStoryId && store.context.activeStoryId !== persistedStoryId) {
|
||||
// setActivePbi triggert async cascade-restore die de oude hint kan
|
||||
// herstellen; de daarop volgende setActiveStory bumpt activeRequestId
|
||||
// en ongeldigt de cascade.
|
||||
store.setActiveStory(persistedStoryId)
|
||||
} else if (persistedStoryId === null) {
|
||||
store.setActiveStory(null)
|
||||
}
|
||||
}, [hydrated, persistedPbiId, persistedStoryId])
|
||||
|
||||
return null
|
||||
}
|
||||
203
components/backlog/new-sprint-metadata-dialog.tsx
Normal file
203
components/backlog/new-sprint-metadata-dialog.tsx
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
'use client'
|
||||
|
||||
import { useRef, useState, useTransition } from 'react'
|
||||
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 { useUserSettingsStore } from '@/stores/user-settings/store'
|
||||
import { debugProps } from '@/lib/debug'
|
||||
|
||||
interface NewSprintMetadataDialogProps {
|
||||
open: boolean
|
||||
productId: string
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
function todayLocalDate(): string {
|
||||
return new Date().toLocaleDateString('en-CA')
|
||||
}
|
||||
|
||||
function plusWeeks(weeks: number): string {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() + weeks * 7)
|
||||
return d.toLocaleDateString('en-CA')
|
||||
}
|
||||
|
||||
export function NewSprintMetadataDialog({
|
||||
open,
|
||||
productId,
|
||||
onOpenChange,
|
||||
}: NewSprintMetadataDialogProps) {
|
||||
const [sprintGoal, setSprintGoal] = useState('')
|
||||
const [startDate, setStartDate] = useState(todayLocalDate())
|
||||
const [endDate, setEndDate] = useState(plusWeeks(2))
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const formRef = useRef<HTMLFormElement>(null)
|
||||
const setPendingSprintDraft = useUserSettingsStore(
|
||||
(s) => s.setPendingSprintDraft,
|
||||
)
|
||||
|
||||
function reset() {
|
||||
setSprintGoal('')
|
||||
setStartDate(todayLocalDate())
|
||||
setEndDate(plusWeeks(2))
|
||||
setError(null)
|
||||
setDirty(false)
|
||||
}
|
||||
|
||||
const closeGuard = useDirtyCloseGuard(dirty, () => {
|
||||
onOpenChange(false)
|
||||
reset()
|
||||
})
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
const goal = sprintGoal.trim()
|
||||
if (!goal) return
|
||||
setError(null)
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await setPendingSprintDraft(productId, {
|
||||
goal,
|
||||
startAt: startDate || undefined,
|
||||
endAt: endDate || undefined,
|
||||
pbiIntent: {},
|
||||
storyOverrides: {},
|
||||
})
|
||||
reset()
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : 'Onbekende fout bij opslaan'
|
||||
setError(message)
|
||||
toast.error(message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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}
|
||||
{...debugProps(
|
||||
'new-sprint-metadata-dialog',
|
||||
'NewSprintMetadataDialog',
|
||||
'components/backlog/new-sprint-metadata-dialog.tsx',
|
||||
)}
|
||||
>
|
||||
<div className={entityDialogHeaderClasses}>
|
||||
<DialogTitle className="text-xl font-semibold">
|
||||
Nieuwe sprint
|
||||
</DialogTitle>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Geef het sprint-doel en periode op. Je selecteert daarna PBI's
|
||||
en stories via vinkjes in de backlog.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
ref={formRef}
|
||||
id="new-sprint-metadata-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-metadata-form"
|
||||
disabled={isPending || !sprintGoal.trim()}
|
||||
data-debug-id="new-sprint-metadata-dialog__submit"
|
||||
>
|
||||
{isPending ? 'Opslaan…' : 'Verder'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<DirtyCloseGuardDialog guard={closeGuard} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
46
components/backlog/new-sprint-trigger.tsx
Normal file
46
components/backlog/new-sprint-trigger.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
||||
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
||||
import { NewSprintMetadataDialog } from './new-sprint-metadata-dialog'
|
||||
|
||||
interface NewSprintTriggerProps {
|
||||
productId: string
|
||||
isDemo: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* PBI-79 / ST-1337: trigger-knop voor de nieuwe sprint-flow.
|
||||
* Verbergt zichzelf wanneer er al een pendingSprintDraft loopt — dan
|
||||
* staat de SprintDefinitionBanner zelf de afronding te regelen.
|
||||
*/
|
||||
export function NewSprintTrigger({ productId, isDemo }: NewSprintTriggerProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const hasDraft = useUserSettingsStore(
|
||||
(s) => !!s.entities.settings.workflow?.pendingSprintDraft?.[productId],
|
||||
)
|
||||
|
||||
if (hasDraft) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<DemoTooltip show={isDemo}>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={isDemo}
|
||||
data-debug-id="new-sprint-trigger"
|
||||
>
|
||||
Nieuwe sprint
|
||||
</Button>
|
||||
</DemoTooltip>
|
||||
<NewSprintMetadataDialog
|
||||
open={open}
|
||||
productId={productId}
|
||||
onOpenChange={setOpen}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -21,7 +21,7 @@ import {
|
|||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { toast } from 'sonner'
|
||||
import { CheckSquare, Square } from 'lucide-react'
|
||||
import { CheckSquare, MinusSquare, Square } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
|
|
@ -32,7 +32,11 @@ import {
|
|||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
||||
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
|
||||
import { selectVisiblePbis } from '@/stores/product-workspace/selectors'
|
||||
import {
|
||||
selectPbiTriState,
|
||||
selectVisiblePbis,
|
||||
type PbiTriState,
|
||||
} from '@/stores/product-workspace/selectors'
|
||||
import type { BacklogPbi as WorkspacePbi } from '@/stores/product-workspace/types'
|
||||
import { deletePbiAction } from '@/actions/pbis'
|
||||
import { reorderPbisAction, updatePbiPriorityAction } from '@/actions/stories'
|
||||
|
|
@ -41,7 +45,6 @@ import { debugProps } from '@/lib/debug'
|
|||
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'
|
||||
|
|
@ -77,15 +80,24 @@ interface Pbi {
|
|||
interface PbiListProps {
|
||||
productId: string
|
||||
isDemo: boolean
|
||||
activeSprintId?: string | null
|
||||
}
|
||||
|
||||
// --- Sortable PBI row ---
|
||||
function TriStateIcon({ state }: { state: PbiTriState }) {
|
||||
if (state === 'full')
|
||||
return <CheckSquare size={18} className="text-primary" />
|
||||
if (state === 'partial')
|
||||
return <MinusSquare size={18} className="text-primary" />
|
||||
return <Square size={18} />
|
||||
}
|
||||
|
||||
function SortablePbiRow({
|
||||
pbi,
|
||||
isSelected,
|
||||
isDemo,
|
||||
selectionMode,
|
||||
isChecked,
|
||||
triState,
|
||||
onSelect,
|
||||
onToggleCheck,
|
||||
onEdit,
|
||||
|
|
@ -95,7 +107,7 @@ function SortablePbiRow({
|
|||
isSelected: boolean
|
||||
isDemo: boolean
|
||||
selectionMode: boolean
|
||||
isChecked: boolean
|
||||
triState: PbiTriState
|
||||
onSelect: () => void
|
||||
onToggleCheck: () => void
|
||||
onEdit: () => void
|
||||
|
|
@ -119,24 +131,39 @@ function SortablePbiRow({
|
|||
title={pbi.title}
|
||||
code={pbi.code}
|
||||
priority={pbi.priority}
|
||||
isSelected={isChecked}
|
||||
isSelected={isSelected}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={isChecked}
|
||||
onClick={onToggleCheck}
|
||||
onKeyDown={(e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggleCheck() } }}
|
||||
aria-pressed={isSelected}
|
||||
onClick={onSelect}
|
||||
onKeyDown={(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onSelect()
|
||||
}
|
||||
}}
|
||||
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"
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggleCheck()
|
||||
}}
|
||||
aria-pressed={triState !== 'empty'}
|
||||
aria-label={
|
||||
triState === 'full'
|
||||
? 'Stories uit sprint halen'
|
||||
: 'Stories aan sprint toevoegen'
|
||||
}
|
||||
className="inline-flex items-center justify-center min-h-7 min-w-7 text-muted-foreground hover:text-foreground rounded transition-colors"
|
||||
>
|
||||
{isChecked ? <CheckSquare size={18} className="text-primary" /> : <Square size={18} />}
|
||||
</div>
|
||||
<TriStateIcon state={triState} />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
|
@ -194,7 +221,7 @@ function SortablePbiRow({
|
|||
// --- Main component ---
|
||||
// PBI-74 / T-849: leest pbis + actieve selectie uit workspace-store via
|
||||
// useShallow-selector. DnD-mutaties via applyOptimisticMutation/rollback/settle.
|
||||
export function PbiList({ productId, isDemo }: PbiListProps) {
|
||||
export function PbiList({ productId, isDemo, activeSprintId = null }: PbiListProps) {
|
||||
// selectVisiblePbis is gesorteerd op priority/sort_order; useShallow
|
||||
// voorkomt re-render op ongerelateerde store-mutaties (G2).
|
||||
const pbis = useProductWorkspaceStore(useShallow(selectVisiblePbis)) as WorkspacePbi[]
|
||||
|
|
@ -216,23 +243,49 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
|
|||
const [filterPopoverOpen, setFilterPopoverOpen] = 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())
|
||||
}
|
||||
// PBI-79 / ST-1337+ST-1338: selectionMode is afgeleid uit drie staten:
|
||||
// A′ (pendingSprintDraft) → vinkjes muteren de draft via upsertPbiIntent.
|
||||
// B (activeSprintId zonder draft) → vinkjes muteren de membership-buffer
|
||||
// via toggleStorySprintMembership per child story (bulk).
|
||||
// A (geen sprint, geen draft) → geen vinkjes.
|
||||
const hasDraft = useUserSettingsStore(
|
||||
(s) => !!s.entities.settings.workflow?.pendingSprintDraft?.[productId],
|
||||
)
|
||||
const upsertPbiIntent = useUserSettingsStore((s) => s.upsertPbiIntent)
|
||||
const toggleStorySprintMembership = useProductWorkspaceStore(
|
||||
(s) => s.toggleStorySprintMembership,
|
||||
)
|
||||
const stateBMode = !hasDraft && !!activeSprintId
|
||||
const selectionMode = hasDraft || stateBMode
|
||||
|
||||
function toggleCheck(id: string) {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
function togglePbiInDraft(id: string, currentState: PbiTriState) {
|
||||
if (hasDraft) {
|
||||
// A′: empty/partial → all; full → none.
|
||||
const nextIntent = currentState === 'full' ? 'none' : 'all'
|
||||
void upsertPbiIntent(productId, id, nextIntent)
|
||||
return
|
||||
}
|
||||
if (stateBMode && activeSprintId) {
|
||||
// State B: bulk-toggle alle child-stories naar/uit de pending buffer.
|
||||
const store = useProductWorkspaceStore.getState()
|
||||
const storyIds = store.relations.storyIdsByPbi[id] ?? []
|
||||
const goingFull = currentState !== 'full'
|
||||
for (const storyId of storyIds) {
|
||||
const story = store.entities.storiesById[storyId]
|
||||
if (!story) continue
|
||||
const blocked = store.sprintMembership.crossSprintBlocks[storyId]
|
||||
if (blocked) continue
|
||||
const inSprint = story.sprint_id === activeSprintId
|
||||
if (goingFull && !inSprint) {
|
||||
toggleStorySprintMembership(storyId, false)
|
||||
}
|
||||
if (!goingFull && inSprint) {
|
||||
toggleStorySprintMembership(storyId, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pbis komen al gesorteerd binnen via selectVisiblePbis (priority + sort_order).
|
||||
|
|
@ -398,21 +451,6 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
|
|||
setSortDir('asc')
|
||||
}}
|
||||
/>
|
||||
<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"
|
||||
|
|
@ -445,15 +483,15 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
|
|||
>
|
||||
<div className="p-3 flex flex-col gap-2" {...debugProps('pbi-list__items')}>
|
||||
{filtered.map(pbi => (
|
||||
<SortablePbiRow
|
||||
<SortablePbiRowWithTriState
|
||||
key={pbi.id}
|
||||
pbi={pbi}
|
||||
isSelected={selectedPbiId === pbi.id}
|
||||
isDemo={isDemo}
|
||||
selectionMode={selectionMode}
|
||||
isChecked={selectedIds.has(pbi.id)}
|
||||
productId={productId}
|
||||
onSelect={() => useProductWorkspaceStore.getState().setActivePbi(pbi.id)}
|
||||
onToggleCheck={() => toggleCheck(pbi.id)}
|
||||
onToggle={togglePbiInDraft}
|
||||
onEdit={() => setDialogState({ mode: 'edit', productId, pbi })}
|
||||
onDelete={() => handleDelete(pbi.id)}
|
||||
/>
|
||||
|
|
@ -474,53 +512,72 @@ 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>
|
||||
)
|
||||
}
|
||||
|
||||
// PBI-79 / ST-1337: wrapper rond SortablePbiRow die zijn tri-state uit de
|
||||
// workspace-store leest. Subscribed per PBI zodat alleen de relevante rij
|
||||
// re-rendert bij pbiIntent/storyOverrides-mutaties.
|
||||
function SortablePbiRowWithTriState({
|
||||
pbi,
|
||||
isSelected,
|
||||
isDemo,
|
||||
selectionMode,
|
||||
productId,
|
||||
onSelect,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
pbi: Pbi
|
||||
isSelected: boolean
|
||||
isDemo: boolean
|
||||
selectionMode: boolean
|
||||
productId: string
|
||||
onSelect: () => void
|
||||
onToggle: (id: string, currentState: PbiTriState) => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
// Tri-state uit pendingSprintDraft (state A′) of pbiSummary (state B).
|
||||
// Wanneer geen draft: leid af van pbiSummary; wanneer wel: uit pbiIntent.
|
||||
const triState = useUserSettingsStore((s) => {
|
||||
const draft = s.entities.settings.workflow?.pendingSprintDraft?.[productId]
|
||||
if (draft) {
|
||||
const intent = draft.pbiIntent[pbi.id] ?? 'none'
|
||||
const override = draft.storyOverrides[pbi.id]
|
||||
if (intent === 'all') {
|
||||
if (override?.remove.length) return 'partial'
|
||||
return 'full'
|
||||
}
|
||||
if (override?.add.length) return 'partial'
|
||||
return 'empty'
|
||||
}
|
||||
return null
|
||||
})
|
||||
const summaryTriState = useProductWorkspaceStore((s) =>
|
||||
selectPbiTriState(s, pbi.id),
|
||||
)
|
||||
const effectiveTriState: PbiTriState =
|
||||
triState ?? (selectionMode ? summaryTriState : 'empty')
|
||||
|
||||
return (
|
||||
<SortablePbiRow
|
||||
pbi={pbi}
|
||||
isSelected={isSelected}
|
||||
isDemo={isDemo}
|
||||
selectionMode={selectionMode}
|
||||
triState={effectiveTriState}
|
||||
onSelect={onSelect}
|
||||
onToggleCheck={() => onToggle(pbi.id, effectiveTriState)}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
89
components/backlog/save-sprint-button.tsx
Normal file
89
components/backlog/save-sprint-button.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
'use client'
|
||||
|
||||
import { useTransition } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
|
||||
import {
|
||||
selectIsDirty,
|
||||
selectPendingCount,
|
||||
} from '@/stores/product-workspace/selectors'
|
||||
import { commitSprintMembershipAction } from '@/actions/sprints'
|
||||
|
||||
interface SaveSprintButtonProps {
|
||||
activeSprintId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* PBI-79 / ST-1338 / T-940: 'Sprint opslaan'-knop voor state B.
|
||||
* Altijd zichtbaar zolang er een actieve sprint is. Disabled bij clean,
|
||||
* enabled met teller bij dirty. Commit gebeurt via
|
||||
* commitSprintMembershipAction; client patcht gericht via
|
||||
* applyMembershipCommitResult. Geen router.refresh.
|
||||
*/
|
||||
export function SaveSprintButton({ activeSprintId }: SaveSprintButtonProps) {
|
||||
const router = useRouter()
|
||||
const isDirty = useProductWorkspaceStore(selectIsDirty)
|
||||
const count = useProductWorkspaceStore(selectPendingCount)
|
||||
const adds = useProductWorkspaceStore((s) => s.sprintMembership.pending.adds)
|
||||
const removes = useProductWorkspaceStore(
|
||||
(s) => s.sprintMembership.pending.removes,
|
||||
)
|
||||
const applyMembershipCommitResult = useProductWorkspaceStore(
|
||||
(s) => s.applyMembershipCommitResult,
|
||||
)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
function handleSave() {
|
||||
startTransition(async () => {
|
||||
const result = await commitSprintMembershipAction({
|
||||
activeSprintId,
|
||||
adds: [...adds],
|
||||
removes: [...removes],
|
||||
})
|
||||
if ('error' in result) {
|
||||
toast.error(result.error)
|
||||
return
|
||||
}
|
||||
applyMembershipCommitResult({
|
||||
activeSprintId,
|
||||
addedStoryIds: adds.filter((id) =>
|
||||
result.affectedStoryIds.includes(id),
|
||||
),
|
||||
removedStoryIds: removes.filter((id) =>
|
||||
result.affectedStoryIds.includes(id),
|
||||
),
|
||||
})
|
||||
const skipped =
|
||||
result.conflicts.notEligible.length +
|
||||
result.conflicts.alreadyRemoved.length
|
||||
if (skipped > 0) {
|
||||
toast.warning(
|
||||
`${skipped} wijziging${skipped === 1 ? '' : 'en'} overgeslagen — story al in andere sprint of inmiddels verwijderd.`,
|
||||
)
|
||||
} else {
|
||||
toast.success('Sprint opgeslagen')
|
||||
}
|
||||
// Gericht patchen voldoende voor lokale UI; refresh haalt server-side
|
||||
// counts opnieuw op zodat tri-state in volgende renders klopt.
|
||||
router.refresh()
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={!isDirty || isPending}
|
||||
data-debug-id="save-sprint-button"
|
||||
>
|
||||
{isPending
|
||||
? 'Opslaan…'
|
||||
: isDirty
|
||||
? `Sprint opslaan (${count})`
|
||||
: 'Sprint opslaan'}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
202
components/backlog/sprint-definition-banner.tsx
Normal file
202
components/backlog/sprint-definition-banner.tsx
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
'use client'
|
||||
|
||||
import { useMemo, useState, useTransition } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
||||
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
|
||||
import type { PendingSprintDraft } from '@/lib/user-settings'
|
||||
import { createSprintWithSelectionAction } from '@/actions/sprints'
|
||||
import { debugProps } from '@/lib/debug'
|
||||
|
||||
interface SprintDefinitionBannerProps {
|
||||
productId: string
|
||||
draft: PendingSprintDraft
|
||||
}
|
||||
|
||||
type DraftCounts = {
|
||||
pbiCount: number
|
||||
storyCount: number
|
||||
hasUnknownTotal: boolean
|
||||
}
|
||||
|
||||
function computeCounts(
|
||||
draft: PendingSprintDraft,
|
||||
pbiSummary: Record<
|
||||
string,
|
||||
{ totalStoryCount: number; inActiveSprintStoryCount: number }
|
||||
>,
|
||||
): DraftCounts {
|
||||
let pbiCount = 0
|
||||
let storyCount = 0
|
||||
let hasUnknownTotal = false
|
||||
|
||||
const seenPbis = new Set<string>()
|
||||
|
||||
for (const [pbiId, intent] of Object.entries(draft.pbiIntent)) {
|
||||
if (intent === 'all') {
|
||||
seenPbis.add(pbiId)
|
||||
const summary = pbiSummary[pbiId]
|
||||
const override = draft.storyOverrides[pbiId]
|
||||
if (!summary) {
|
||||
hasUnknownTotal = true
|
||||
continue
|
||||
}
|
||||
const removed = override?.remove.length ?? 0
|
||||
storyCount += Math.max(0, summary.totalStoryCount - removed)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [pbiId, override] of Object.entries(draft.storyOverrides)) {
|
||||
if (override.add.length === 0) continue
|
||||
seenPbis.add(pbiId)
|
||||
storyCount += override.add.length
|
||||
}
|
||||
|
||||
pbiCount = seenPbis.size
|
||||
return { pbiCount, storyCount, hasUnknownTotal }
|
||||
}
|
||||
|
||||
export function SprintDefinitionBanner({
|
||||
productId,
|
||||
draft,
|
||||
}: SprintDefinitionBannerProps) {
|
||||
const clearPendingSprintDraft = useUserSettingsStore(
|
||||
(s) => s.clearPendingSprintDraft,
|
||||
)
|
||||
const pbiSummary = useProductWorkspaceStore((s) => s.sprintMembership.pbiSummary)
|
||||
const router = useRouter()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [confirmCancel, setConfirmCancel] = useState(false)
|
||||
|
||||
const counts = useMemo(
|
||||
() => computeCounts(draft, pbiSummary),
|
||||
[draft, pbiSummary],
|
||||
)
|
||||
|
||||
function handleCancel() {
|
||||
setConfirmCancel(true)
|
||||
}
|
||||
|
||||
function confirmCancelAction() {
|
||||
setConfirmCancel(false)
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await clearPendingSprintDraft(productId)
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : 'Annuleren mislukt'
|
||||
toast.error(message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
startTransition(async () => {
|
||||
const result = await createSprintWithSelectionAction({
|
||||
productId,
|
||||
metadata: {
|
||||
goal: draft.goal,
|
||||
startAt: draft.startAt,
|
||||
endAt: draft.endAt,
|
||||
},
|
||||
pbiIntent: draft.pbiIntent,
|
||||
storyOverrides: draft.storyOverrides,
|
||||
})
|
||||
if ('error' in result) {
|
||||
toast.error(result.error)
|
||||
return
|
||||
}
|
||||
const { conflicts } = result
|
||||
if (conflicts.notEligible.length > 0) {
|
||||
toast.warning(
|
||||
`${conflicts.notEligible.length} stor${
|
||||
conflicts.notEligible.length === 1 ? 'y is' : 'ies zijn'
|
||||
} overgeslagen (al in een andere sprint of afgerond).`,
|
||||
)
|
||||
} else {
|
||||
toast.success('Sprint aangemaakt')
|
||||
}
|
||||
router.refresh()
|
||||
})
|
||||
}
|
||||
|
||||
const storyLabel = counts.hasUnknownTotal
|
||||
? `${counts.storyCount}+`
|
||||
: counts.storyCount
|
||||
const pbiSuffix = counts.pbiCount === 1 ? '' : "'s"
|
||||
|
||||
return (
|
||||
<div
|
||||
className="sticky top-0 z-30 bg-tertiary-container text-tertiary-container-foreground border-b border-tertiary px-4 py-2.5 flex items-center gap-4"
|
||||
{...debugProps('sprint-definition-banner')}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-sm font-medium shrink-0">
|
||||
Sprint definiëren —
|
||||
</span>
|
||||
<span className="text-sm truncate" title={draft.goal}>
|
||||
{draft.goal}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs opacity-80 mt-0.5">
|
||||
{counts.pbiCount} PBI{pbiSuffix} · {storyLabel} stor
|
||||
{counts.storyCount === 1 ? 'y' : 'ies'} geselecteerd
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={handleCancel}
|
||||
disabled={isPending}
|
||||
data-debug-id="sprint-definition-banner__cancel"
|
||||
>
|
||||
Annuleren
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleCreate}
|
||||
disabled={isPending || counts.pbiCount === 0}
|
||||
data-debug-id="sprint-definition-banner__create"
|
||||
>
|
||||
Sprint aanmaken
|
||||
</Button>
|
||||
</div>
|
||||
<AlertDialog open={confirmCancel} onOpenChange={setConfirmCancel}>
|
||||
<AlertDialogContent size="sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Sprint-definitie annuleren?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Je conceptselectie gaat verloren. Het sprint-doel en de
|
||||
gemarkeerde PBI/stories worden verwijderd.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => setConfirmCancel(false)}>
|
||||
Doorgaan
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={confirmCancelAction}
|
||||
>
|
||||
Ja, annuleren
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
22
components/backlog/sprint-draft-banner.tsx
Normal file
22
components/backlog/sprint-draft-banner.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
'use client'
|
||||
|
||||
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
||||
import { SprintDefinitionBanner } from './sprint-definition-banner'
|
||||
|
||||
interface SprintDraftBannerProps {
|
||||
productId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* PBI-79 / ST-1337: client-wrapper die de SprintDefinitionBanner alleen rendert
|
||||
* als er een pendingSprintDraft voor dit product staat. Hydratatie loopt via
|
||||
* UserSettingsBridge — dit component subscribt op die store en is daarmee
|
||||
* automatisch reactief op draft-mutaties (set/clear).
|
||||
*/
|
||||
export function SprintDraftBanner({ productId }: SprintDraftBannerProps) {
|
||||
const draft = useUserSettingsStore(
|
||||
(s) => s.entities.settings.workflow?.pendingSprintDraft?.[productId],
|
||||
)
|
||||
if (!draft) return null
|
||||
return <SprintDefinitionBanner productId={productId} draft={draft} />
|
||||
}
|
||||
37
components/backlog/sprint-draft-leave-guard.tsx
Normal file
37
components/backlog/sprint-draft-leave-guard.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
||||
|
||||
interface SprintDraftLeaveGuardProps {
|
||||
productId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* PBI-79: window.beforeunload-waarschuwing zolang er een pendingSprintDraft
|
||||
* loopt voor dit product. De draft is session-only en gaat verloren bij
|
||||
* refresh/close — deze guard zorgt dat de gebruiker dat eerst bevestigt.
|
||||
* Voor in-app route-changes (klikken op een andere product) doet Next.js
|
||||
* geen onbeforeunload; daar vangen we het op via de banner-Annuleren-flow.
|
||||
*/
|
||||
export function SprintDraftLeaveGuard({
|
||||
productId,
|
||||
}: SprintDraftLeaveGuardProps) {
|
||||
const hasDraft = useUserSettingsStore(
|
||||
(s) => !!s.entities.settings.workflow?.pendingSprintDraft?.[productId],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasDraft) return
|
||||
function handler(e: BeforeUnloadEvent) {
|
||||
e.preventDefault()
|
||||
// Moderne browsers tonen een eigen vertaalde tekst; returnValue is
|
||||
// alleen nodig voor legacy compat.
|
||||
e.returnValue = ''
|
||||
}
|
||||
window.addEventListener('beforeunload', handler)
|
||||
return () => window.removeEventListener('beforeunload', handler)
|
||||
}, [hasDraft])
|
||||
|
||||
return null
|
||||
}
|
||||
217
components/backlog/sprint-edit-dialog.tsx
Normal file
217
components/backlog/sprint-edit-dialog.tsx
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
'use client'
|
||||
|
||||
import { useRef, useState, useTransition } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
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 { updateSprintAction } from '@/actions/sprints'
|
||||
import { debugProps } from '@/lib/debug'
|
||||
|
||||
interface SprintEditDialogProps {
|
||||
open: boolean
|
||||
productId: string
|
||||
sprint: {
|
||||
id: string
|
||||
code: string
|
||||
sprint_goal: string
|
||||
start_date?: string | null
|
||||
end_date?: string | null
|
||||
}
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
function toDateInput(value: string | null | undefined): string {
|
||||
if (!value) return ''
|
||||
// Accept ISO datetime or YYYY-MM-DD; output YYYY-MM-DD.
|
||||
const d = new Date(value)
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
return d.toLocaleDateString('en-CA')
|
||||
}
|
||||
|
||||
export function SprintEditDialog({
|
||||
open,
|
||||
productId,
|
||||
sprint,
|
||||
onOpenChange,
|
||||
}: SprintEditDialogProps) {
|
||||
const [goal, setGoal] = useState(sprint.sprint_goal)
|
||||
const [startDate, setStartDate] = useState(toDateInput(sprint.start_date))
|
||||
const [endDate, setEndDate] = useState(toDateInput(sprint.end_date))
|
||||
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() {
|
||||
setGoal(sprint.sprint_goal)
|
||||
setStartDate(toDateInput(sprint.start_date))
|
||||
setEndDate(toDateInput(sprint.end_date))
|
||||
setError(null)
|
||||
setDirty(false)
|
||||
}
|
||||
|
||||
const closeGuard = useDirtyCloseGuard(dirty, () => {
|
||||
onOpenChange(false)
|
||||
reset()
|
||||
})
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
const trimmed = goal.trim()
|
||||
if (!trimmed) return
|
||||
setError(null)
|
||||
startTransition(async () => {
|
||||
const result = await updateSprintAction({
|
||||
sprintId: sprint.id,
|
||||
fields: {
|
||||
goal: trimmed,
|
||||
startAt: startDate || null,
|
||||
endAt: endDate || null,
|
||||
},
|
||||
})
|
||||
if ('error' in result) {
|
||||
setError(result.error)
|
||||
toast.error(result.error)
|
||||
return
|
||||
}
|
||||
toast.success('Sprint bijgewerkt')
|
||||
onOpenChange(false)
|
||||
router.refresh()
|
||||
})
|
||||
}
|
||||
|
||||
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}
|
||||
{...debugProps(
|
||||
'sprint-edit-dialog',
|
||||
'SprintEditDialog',
|
||||
'components/backlog/sprint-edit-dialog.tsx',
|
||||
)}
|
||||
>
|
||||
<div className={entityDialogHeaderClasses}>
|
||||
<DialogTitle className="text-xl font-semibold">
|
||||
Sprint {sprint.code} bewerken
|
||||
</DialogTitle>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Wijzig sprint-doel en datums. Voor afronding (per-story DONE/OPEN
|
||||
beslissing) ga naar de sprint-pagina.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
ref={formRef}
|
||||
id="sprint-edit-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={goal}
|
||||
onChange={(e) => setGoal(e.target.value)}
|
||||
required
|
||||
rows={3}
|
||||
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>
|
||||
|
||||
<div className="pt-2 border-t border-border">
|
||||
<Link
|
||||
href={`/products/${productId}/sprint/${sprint.id}`}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
Sprint afronden… →
|
||||
</Link>
|
||||
</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="sprint-edit-form"
|
||||
disabled={isPending || !goal.trim()}
|
||||
data-debug-id="sprint-edit-dialog__submit"
|
||||
>
|
||||
{isPending ? 'Opslaan…' : 'Opslaan'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<DirtyCloseGuardDialog guard={closeGuard} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -21,6 +21,13 @@ import {
|
|||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { toast } from 'sonner'
|
||||
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'
|
||||
|
|
@ -28,7 +35,10 @@ 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 } from '@/stores/product-workspace/selectors'
|
||||
import {
|
||||
selectStoriesForActivePbi,
|
||||
selectStoryIsBlocked,
|
||||
} 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'
|
||||
|
|
@ -67,17 +77,24 @@ export interface Story {
|
|||
interface StoryPanelProps {
|
||||
productId: string
|
||||
isDemo: boolean
|
||||
activeSprintId?: string | null
|
||||
}
|
||||
|
||||
// --- Sortable story block ---
|
||||
function SortableStoryBlock({
|
||||
story,
|
||||
isSelected,
|
||||
cherrypick,
|
||||
onSelect,
|
||||
onEdit,
|
||||
}: {
|
||||
story: Story
|
||||
isSelected: boolean
|
||||
cherrypick: {
|
||||
checked: boolean
|
||||
blocked: { sprintName: string } | null
|
||||
onToggle: () => void
|
||||
} | null
|
||||
onSelect: () => void
|
||||
onEdit: () => void
|
||||
}) {
|
||||
|
|
@ -109,25 +126,79 @@ function SortableStoryBlock({
|
|||
</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>
|
||||
<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 ---
|
||||
// PBI-74 / T-850: leest stories voor active PBI via selectStoriesForActivePbi
|
||||
// (useShallow). DnD via applyOptimisticMutation('story-order').
|
||||
export function StoryPanel({ productId, isDemo }: StoryPanelProps) {
|
||||
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[]
|
||||
|
|
@ -300,9 +371,11 @@ export function StoryPanel({ productId, isDemo }: StoryPanelProps) {
|
|||
<SortableContext items={filtered.map(s => s.id)} strategy={rectSortingStrategy}>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{filtered.map(story => (
|
||||
<SortableStoryBlock
|
||||
<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 })}
|
||||
|
|
@ -332,3 +405,96 @@ export function StoryPanel({ productId, isDemo }: StoryPanelProps) {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// PBI-79 / ST-1337: wrapper rond SortableStoryBlock met cherrypick-handling.
|
||||
// Subscribed per story zodat enkel de relevante rij re-rendert bij draft- of
|
||||
// crossSprintBlocks-mutaties.
|
||||
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) {
|
||||
// State A′: muteer draft via per-PBI overrides.
|
||||
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) {
|
||||
// State B: muteer pending buffer via toggleStorySprintMembership.
|
||||
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 (
|
||||
<SortableStoryBlock
|
||||
story={story}
|
||||
isSelected={isSelected}
|
||||
cherrypick={cherrypick}
|
||||
onSelect={onSelect}
|
||||
onEdit={onEdit}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,12 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { setActiveSprintAction } from '@/actions/active-sprint'
|
||||
import {
|
||||
clearActiveSprintAction,
|
||||
switchActiveSprintAction,
|
||||
} from '@/actions/active-sprint'
|
||||
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
|
||||
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
||||
import type { SprintStatusApi } from '@/lib/task-status'
|
||||
import { debugProps } from '@/lib/debug'
|
||||
|
||||
|
|
@ -45,6 +50,13 @@ export function SprintSwitcher({
|
|||
const [showClosed, setShowClosed] = useState(false)
|
||||
const buildingSet = new Set(buildingSprintIds)
|
||||
|
||||
// PBI-79: zolang er een sprint-draft loopt tonen we 'Concept — [goal]'
|
||||
// bovenaan de dropdown. De draft staat alleen in deze session-store; bij
|
||||
// page-refresh/leave is hij weg.
|
||||
const draftGoal = useUserSettingsStore(
|
||||
(s) => s.entities.settings.workflow?.pendingSprintDraft?.[productId]?.goal ?? null,
|
||||
)
|
||||
|
||||
const visibleSprints = sprints.filter(s => {
|
||||
if (showClosed) return true
|
||||
if (s.id === activeSprint?.id) return true
|
||||
|
|
@ -54,13 +66,43 @@ export function SprintSwitcher({
|
|||
function handleSwitchSprint(sprintId: string) {
|
||||
if (sprintId === activeSprint?.id) return
|
||||
startTransition(async () => {
|
||||
const result = await setActiveSprintAction(productId, sprintId)
|
||||
const result = await switchActiveSprintAction(productId, sprintId)
|
||||
if ('error' in result) {
|
||||
toast.error(
|
||||
typeof result.error === 'string' ? result.error : 'Wisselen mislukt',
|
||||
)
|
||||
return
|
||||
}
|
||||
// Synchroniseer de client-side workspace-store met de auto-select die
|
||||
// server-side is bepaald — voorkomt korte flash van vorige selectie
|
||||
// voordat router.refresh de SSR-render binnenhaalt.
|
||||
const store = useProductWorkspaceStore.getState()
|
||||
if (result.pbiId) {
|
||||
store.setActivePbi(result.pbiId)
|
||||
if (result.storyId) {
|
||||
store.setActiveStory(result.storyId)
|
||||
}
|
||||
} else {
|
||||
store.setActivePbi(null)
|
||||
}
|
||||
if (pathname.includes('/sprint')) {
|
||||
router.push(`/products/${productId}/sprint/${sprintId}`)
|
||||
} else {
|
||||
router.refresh()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleClearActiveSprint() {
|
||||
if (!activeSprint) return
|
||||
startTransition(async () => {
|
||||
const result = await clearActiveSprintAction(productId)
|
||||
if (result?.error) {
|
||||
toast.error(typeof result.error === 'string' ? result.error : 'Wisselen mislukt')
|
||||
return
|
||||
}
|
||||
if (pathname.includes('/sprint')) {
|
||||
router.push(`/products/${productId}/sprint/${sprintId}`)
|
||||
router.push(`/products/${productId}`)
|
||||
} else {
|
||||
router.refresh()
|
||||
}
|
||||
|
|
@ -127,6 +169,30 @@ export function SprintSwitcher({
|
|||
Toon afgeronde sprints
|
||||
</button>
|
||||
<DropdownMenuSeparator />
|
||||
{draftGoal && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
disabled
|
||||
className="italic text-tertiary opacity-90 cursor-default"
|
||||
data-debug-id="sprint-switcher__concept"
|
||||
>
|
||||
<span className="shrink-0">⚙ Concept —</span>
|
||||
<span className="truncate">{draftGoal}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={handleClearActiveSprint}
|
||||
disabled={!activeSprint || isPending}
|
||||
className={cn(
|
||||
'italic text-muted-foreground',
|
||||
!activeSprint && 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
— Geen actieve sprint —
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{visibleSprints.length === 0 ? (
|
||||
<div className="px-2 py-2 text-sm text-muted-foreground/70 italic">
|
||||
Geen open sprints
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue