feat(ST-358): add unassigned stories sheet with claim-on-click

- UnassignedStoriesSheet: slide-in sheet listing unassigned sprint stories
- ClaimStoryRow: form action + ClaimButton with useFormStatus pending state
- Successful claim removes story from local list and shows success toast
- Empty state: "Geen ongeclaimde stories. Lekker bezig!"
- Demo: DemoTooltip wraps Pak op button, claim button disabled
- Page now fetches stories with _count.tasks instead of just count
- claimStoryAction also revalidates /products/[id]/solo path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-04-26 16:55:55 +02:00
parent 1341163e34
commit 89e5164a28
3 changed files with 128 additions and 3 deletions

View file

@ -0,0 +1,111 @@
'use client'
import { useState } from 'react'
import { useFormStatus } from 'react-dom'
import { toast } from 'sonner'
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
} from '@/components/ui/sheet'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import { claimStoryAction } from '@/actions/stories'
export interface UnassignedStory {
id: string
title: string
task_count: number
}
interface UnassignedStoriesSheetProps {
stories: UnassignedStory[]
productId: string
isDemo: boolean
open: boolean
onOpenChange: (open: boolean) => void
onClaim: (storyId: string) => void
}
function ClaimButton({ isDemo }: { isDemo: boolean }) {
const { pending } = useFormStatus()
return (
<DemoTooltip show={isDemo}>
<button
type="submit"
disabled={isDemo || pending}
className="text-xs text-primary hover:underline disabled:text-muted-foreground disabled:cursor-default"
>
{pending ? '…' : 'Pak op'}
</button>
</DemoTooltip>
)
}
function ClaimStoryRow({
story, productId, isDemo, onClaim,
}: { story: UnassignedStory; productId: string; isDemo: boolean; onClaim: (id: string) => void }) {
async function formAction() {
const result = await claimStoryAction(story.id, productId)
if (result && 'error' in result) {
toast.error(result.error)
} else {
onClaim(story.id)
toast.success(`"${story.title}" geclaimd`)
}
}
return (
<div className="flex items-center gap-3 px-3 py-2.5 rounded-lg border border-border bg-surface-container">
<div className="flex-1 min-w-0">
<p className="text-sm text-foreground truncate">{story.title}</p>
<p className="text-xs text-muted-foreground">
{story.task_count} {story.task_count === 1 ? 'taak' : 'taken'}
</p>
</div>
<form action={formAction}>
<ClaimButton isDemo={isDemo} />
</form>
</div>
)
}
export function UnassignedStoriesSheet({
stories: initialStories, productId, isDemo, open, onOpenChange, onClaim,
}: UnassignedStoriesSheetProps) {
const [stories, setStories] = useState(initialStories)
function handleClaim(storyId: string) {
setStories(prev => prev.filter(s => s.id !== storyId))
onClaim(storyId)
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right">
<SheetHeader>
<SheetTitle>Openstaande stories</SheetTitle>
</SheetHeader>
<div className="flex-1 overflow-y-auto">
{stories.length === 0 ? (
<div className="flex items-center justify-center h-32">
<p className="text-sm text-muted-foreground text-center">
Geen ongeclaimde stories. Lekker bezig!
</p>
</div>
) : (
<div className="flex flex-col gap-2">
{stories.map(story => (
<ClaimStoryRow
key={story.id}
story={story}
productId={productId}
isDemo={isDemo}
onClaim={handleClaim}
/>
))}
</div>
)}
</div>
</SheetContent>
</Sheet>
)
}