feat(ST-507): show code badges on cards, lists and dialogs across the app

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-04-26 20:36:59 +02:00
parent 66063f035a
commit b71eb53fa8
15 changed files with 122 additions and 38 deletions

View file

@ -7,6 +7,7 @@ import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-
import { CSS } from '@dnd-kit/utilities'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
import { CodeBadge } from '@/components/shared/code-badge'
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent,
@ -35,6 +36,7 @@ const PRIORITY_LABELS: Record<number, string> = { 1: 'Kritiek', 2: 'Hoog', 3: 'G
export interface SprintStory {
id: string
code: string | null
title: string
priority: number
status: string
@ -51,6 +53,7 @@ export interface ProductMember {
export interface PbiWithStories {
id: string
code: string | null
title: string
stories: SprintStory[]
}
@ -140,7 +143,10 @@ function SortableSprintRow({
</span>
)}
<div className="flex-1 min-w-0">
<p className="text-sm truncate">{story.title}</p>
<div className="flex items-start justify-between gap-2">
<p className="text-sm truncate flex-1">{story.title}</p>
{story.code && <CodeBadge code={story.code} className="shrink-0 mt-0.5" />}
</div>
<div className="flex items-center gap-1.5 mt-0.5">
<Badge className={cn('text-[10px] px-1.5 py-0 border', PRIORITY_COLORS[story.priority])}>
{PRIORITY_LABELS[story.priority]}
@ -338,7 +344,10 @@ function DraggablePbiStoryRow({
</span>
)}
<div className="flex-1 min-w-0">
<p className="text-sm truncate">{story.title}</p>
<div className="flex items-start justify-between gap-2">
<p className="text-sm truncate flex-1">{story.title}</p>
{story.code && <CodeBadge code={story.code} className="shrink-0 mt-0.5" />}
</div>
<Badge className={cn('text-[10px] px-1.5 py-0 border mt-0.5', STATUS_COLORS[story.status])}>
{STATUS_LABELS[story.status]}
</Badge>
@ -387,6 +396,7 @@ export function SprintBacklogRight({ pbisWithStories, sprintStoryIds, isDemo, on
>
<span className="text-xs">{collapsed.has(pbi.id) ? '▶' : '▼'}</span>
<span className="text-sm font-medium truncate flex-1">{pbi.title}</span>
{pbi.code && <CodeBadge code={pbi.code} />}
<span className="text-xs text-muted-foreground">{pbi.stories.length}</span>
</button>
@ -400,7 +410,10 @@ export function SprintBacklogRight({ pbisWithStories, sprintStoryIds, isDemo, on
>
<div className="w-[14px] shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm truncate">{story.title}</p>
<div className="flex items-start justify-between gap-2">
<p className="text-sm truncate flex-1">{story.title}</p>
{story.code && <CodeBadge code={story.code} className="shrink-0 mt-0.5" />}
</div>
<Badge className={cn('text-[10px] px-1.5 py-0 border mt-0.5', STATUS_COLORS[story.status])}>
{STATUS_LABELS[story.status]}
</Badge>

View file

@ -228,6 +228,7 @@ export function SprintBoardClient({
selectedStoryId ? (
<TaskList
storyId={selectedStoryId}
storyCode={stories.find(s => s.id === selectedStoryId)?.code ?? null}
sprintId={sprintId}
productId={productId}
tasks={selectedTasks}

View file

@ -116,6 +116,7 @@ export function SprintHeader({ productId: _productId, productName, sprint, isDem
<div className="space-y-2 max-h-64 overflow-y-auto">
{sprintStories.map(story => (
<div key={story.id} className="flex items-center justify-between gap-3 p-2 bg-surface-container-low rounded-lg">
{story.code && <span className="font-mono text-[11px] text-muted-foreground shrink-0">{story.code}</span>}
<span className="text-sm truncate flex-1">{story.title}</span>
<div className="flex gap-1 shrink-0">
<button

View file

@ -15,7 +15,9 @@ import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { CodeBadge } from '@/components/shared/code-badge'
import { PanelNavBar } from '@/components/shared/panel-nav-bar'
import { deriveTaskCode } from '@/lib/code'
import { useSprintStore } from '@/stores/sprint-store'
import {
createTaskAction, updateTaskStatusAction, updateTaskAction,
@ -49,6 +51,7 @@ export interface Task {
interface TaskListProps {
storyId: string
storyCode: string | null
sprintId: string
productId: string
tasks: Task[]
@ -56,8 +59,8 @@ interface TaskListProps {
}
function SortableTaskRow({
task, isDemo, onStatusToggle, onDelete,
}: { task: Task; isDemo: boolean; onStatusToggle: () => void; onDelete: () => void }) {
task, code, isDemo, onStatusToggle, onDelete,
}: { task: Task; code: string | null; isDemo: boolean; onStatusToggle: () => void; onDelete: () => void }) {
const [editing, setEditing] = useState(false)
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id })
const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.4 : 1 }
@ -88,23 +91,26 @@ function SortableTaskRow({
}
return (
<div ref={setNodeRef} style={style} className="group flex items-center gap-3 px-4 py-2.5 border-b border-border hover:bg-surface-container/50 transition-colors">
<div ref={setNodeRef} style={style} className="group flex items-start gap-3 px-4 py-2.5 border-b border-border hover:bg-surface-container/50 transition-colors">
{!isDemo && (
<span {...attributes} {...listeners} className="text-muted-foreground cursor-grab active:cursor-grabbing shrink-0 text-sm select-none"></span>
<span {...attributes} {...listeners} className="text-muted-foreground cursor-grab active:cursor-grabbing shrink-0 text-sm select-none mt-0.5"></span>
)}
<div className="flex-1 min-w-0">
<p className={cn('text-sm truncate', task.status === 'DONE' && 'line-through text-muted-foreground')}>
{task.title}
</p>
<div className="flex items-start justify-between gap-2">
<p className={cn('text-sm truncate flex-1', task.status === 'DONE' && 'line-through text-muted-foreground')}>
{task.title}
</p>
{code && <CodeBadge code={code} className="shrink-0 mt-0.5" />}
</div>
<span className="text-xs text-muted-foreground">{PRIORITY_LABELS[task.priority]}</span>
</div>
<button onClick={onStatusToggle} disabled={isDemo} aria-label={`Status: ${STATUS_LABELS[task.status]}`}>
<button onClick={onStatusToggle} disabled={isDemo} aria-label={`Status: ${STATUS_LABELS[task.status]}`} className="mt-0.5">
<Badge className={cn('text-xs border cursor-pointer hover:opacity-80 transition-opacity', STATUS_COLORS[task.status])}>
{STATUS_LABELS[task.status]}
</Badge>
</button>
{!isDemo && (
<div className="opacity-0 group-hover:opacity-100 flex gap-1">
<div className="opacity-0 group-hover:opacity-100 flex gap-1 mt-0.5">
<button onClick={() => setEditing(true)} className="text-xs text-muted-foreground hover:text-foreground">Bewerk</button>
<button onClick={onDelete} aria-label="Verwijder taak" className="text-xs text-muted-foreground hover:text-error">×</button>
</div>
@ -150,7 +156,7 @@ function CreateSubmitButton() {
return <Button type="submit" size="sm" className="h-7" disabled={pending}>{pending ? '…' : 'Toevoegen'}</Button>
}
export function TaskList({ storyId, sprintId, productId: _productId, tasks, isDemo }: TaskListProps) {
export function TaskList({ storyId, storyCode, sprintId, productId: _productId, tasks, isDemo }: TaskListProps) {
const { taskOrder, initTasks, reorderTasks, rollbackTasks } = useSprintStore()
const [creating, setCreating] = useState(false)
const [activeDragId, setActiveDragId] = useState<string | null>(null)
@ -232,10 +238,11 @@ export function TaskList({ storyId, sprintId, productId: _productId, tasks, isDe
onDragEnd={handleDragEnd}
>
<SortableContext items={orderedTasks.map(t => t.id)} strategy={verticalListSortingStrategy}>
{orderedTasks.map(task => (
{orderedTasks.map((task, idx) => (
<SortableTaskRow
key={task.id}
task={task}
code={deriveTaskCode(storyCode, idx + 1)}
isDemo={isDemo}
onStatusToggle={() => handleStatusToggle(task)}
onDelete={() => handleDelete(task.id)}