feat: Todo altijd gekoppeld aan product backlog
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b541379964
commit
cb7eb36fbb
9 changed files with 128 additions and 46 deletions
|
|
@ -14,11 +14,20 @@ async function getSession() {
|
||||||
export async function createTodoAction(_prevState: unknown, formData: FormData) {
|
export async function createTodoAction(_prevState: unknown, formData: FormData) {
|
||||||
const session = await getSession()
|
const session = await getSession()
|
||||||
if (!session.userId) return { error: 'Niet ingelogd' }
|
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||||
|
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
|
||||||
|
|
||||||
const title = (formData.get('title') as string)?.trim()
|
const title = (formData.get('title') as string)?.trim()
|
||||||
if (!title) return { error: 'Titel is verplicht' }
|
const productId = (formData.get('productId') as string)?.trim()
|
||||||
|
|
||||||
await prisma.todo.create({ data: { user_id: session.userId, title } })
|
if (!title) return { error: 'Titel is verplicht' }
|
||||||
|
if (!productId) return { error: 'Product is verplicht' }
|
||||||
|
|
||||||
|
const product = await prisma.product.findFirst({
|
||||||
|
where: { id: productId, user_id: session.userId, archived: false },
|
||||||
|
})
|
||||||
|
if (!product) return { error: 'Product niet gevonden' }
|
||||||
|
|
||||||
|
await prisma.todo.create({ data: { user_id: session.userId, product_id: productId, title } })
|
||||||
revalidatePath('/todos')
|
revalidatePath('/todos')
|
||||||
return { success: true }
|
return { success: true }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ export default async function TodosPage() {
|
||||||
const todos = await prisma.todo.findMany({
|
const todos = await prisma.todo.findMany({
|
||||||
where: { user_id: session.userId, archived: false },
|
where: { user_id: session.userId, archived: false },
|
||||||
orderBy: { created_at: 'asc' },
|
orderBy: { created_at: 'asc' },
|
||||||
|
include: { product: { select: { name: true } } },
|
||||||
})
|
})
|
||||||
|
|
||||||
const products = await prisma.product.findMany({
|
const products = await prisma.product.findMany({
|
||||||
|
|
@ -24,7 +25,14 @@ export default async function TodosPage() {
|
||||||
<div className="p-6 max-w-2xl mx-auto w-full">
|
<div className="p-6 max-w-2xl mx-auto w-full">
|
||||||
<h1 className="text-xl font-medium text-foreground mb-6">Todo's</h1>
|
<h1 className="text-xl font-medium text-foreground mb-6">Todo's</h1>
|
||||||
<TodoList
|
<TodoList
|
||||||
todos={todos.map(t => ({ id: t.id, title: t.title, done: t.done, created_at: t.created_at.toISOString() }))}
|
todos={todos.map(t => ({
|
||||||
|
id: t.id,
|
||||||
|
title: t.title,
|
||||||
|
done: t.done,
|
||||||
|
created_at: t.created_at.toISOString(),
|
||||||
|
product_id: t.product_id ?? null,
|
||||||
|
product_name: t.product?.name ?? null,
|
||||||
|
}))}
|
||||||
products={products.map(p => ({
|
products={products.map(p => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { z } from 'zod'
|
||||||
|
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
title: z.string().min(1, 'Titel is verplicht').max(500),
|
title: z.string().min(1, 'Titel is verplicht').max(500),
|
||||||
|
product_id: z.string().min(1, 'Product is verplicht'),
|
||||||
})
|
})
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
|
|
@ -21,9 +22,17 @@ export async function POST(request: Request) {
|
||||||
return Response.json({ error: parsed.error.flatten() }, { status: 400 })
|
return Response.json({ error: parsed.error.flatten() }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const product = await prisma.product.findFirst({
|
||||||
|
where: { id: parsed.data.product_id, user_id: auth.userId, archived: false },
|
||||||
|
})
|
||||||
|
if (!product) {
|
||||||
|
return Response.json({ error: 'Product niet gevonden' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
const todo = await prisma.todo.create({
|
const todo = await prisma.todo.create({
|
||||||
data: {
|
data: {
|
||||||
user_id: auth.userId,
|
user_id: auth.userId,
|
||||||
|
product_id: parsed.data.product_id,
|
||||||
title: parsed.data.title,
|
title: parsed.data.title,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ interface Todo {
|
||||||
title: string
|
title: string
|
||||||
done: boolean
|
done: boolean
|
||||||
created_at: string
|
created_at: string
|
||||||
|
product_id: string | null
|
||||||
|
product_name: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Pbi {
|
interface Pbi {
|
||||||
|
|
@ -38,7 +40,7 @@ interface TodoListProps {
|
||||||
isDemo: boolean
|
isDemo: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function QuickInput({ isDemo }: { isDemo: boolean }) {
|
function QuickInput({ products, isDemo }: { products: Product[]; isDemo: boolean }) {
|
||||||
const [, formAction] = useActionState(createTodoAction, undefined)
|
const [, formAction] = useActionState(createTodoAction, undefined)
|
||||||
const ref = useRef<HTMLFormElement>(null)
|
const ref = useRef<HTMLFormElement>(null)
|
||||||
|
|
||||||
|
|
@ -49,15 +51,27 @@ function QuickInput({ isDemo }: { isDemo: boolean }) {
|
||||||
onSubmit={() => setTimeout(() => ref.current?.reset(), 0)}
|
onSubmit={() => setTimeout(() => ref.current?.reset(), 0)}
|
||||||
className="flex gap-2 mb-6"
|
className="flex gap-2 mb-6"
|
||||||
>
|
>
|
||||||
|
<select
|
||||||
|
name="productId"
|
||||||
|
required
|
||||||
|
disabled={isDemo || products.length === 0}
|
||||||
|
className="border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background shrink-0"
|
||||||
|
>
|
||||||
|
{products.length === 0 ? (
|
||||||
|
<option value="">Geen producten</option>
|
||||||
|
) : (
|
||||||
|
products.map(p => <option key={p.id} value={p.id}>{p.name}</option>)
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
<Input
|
<Input
|
||||||
name="title"
|
name="title"
|
||||||
placeholder={isDemo ? 'Alleen-lezen in demo' : 'Nieuwe todo… (Enter om op te slaan)'}
|
placeholder={isDemo ? 'Alleen-lezen in demo' : 'Nieuwe todo… (Enter om op te slaan)'}
|
||||||
disabled={isDemo}
|
disabled={isDemo || products.length === 0}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
<DemoTooltip show={isDemo}>
|
<DemoTooltip show={isDemo}>
|
||||||
<QuickSubmitButton isDemo={isDemo} />
|
<QuickSubmitButton isDemo={isDemo || products.length === 0} />
|
||||||
</DemoTooltip>
|
</DemoTooltip>
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
|
|
@ -79,7 +93,10 @@ function PromotePbiDialog({
|
||||||
onClose,
|
onClose,
|
||||||
}: { todo: Todo; products: Product[]; onClose: () => void }) {
|
}: { todo: Todo; products: Product[]; onClose: () => void }) {
|
||||||
const handleKey = useCallback((e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }, [onClose])
|
const handleKey = useCallback((e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }, [onClose])
|
||||||
useEffect(() => { document.addEventListener('keydown', handleKey); return () => document.removeEventListener('keydown', handleKey) }, [handleKey])
|
useEffect(() => {
|
||||||
|
document.addEventListener('keydown', handleKey)
|
||||||
|
return () => document.removeEventListener('keydown', handleKey)
|
||||||
|
}, [handleKey])
|
||||||
|
|
||||||
const [state, formAction] = useActionState(
|
const [state, formAction] = useActionState(
|
||||||
async (_prev: unknown, fd: FormData) => {
|
async (_prev: unknown, fd: FormData) => {
|
||||||
|
|
@ -106,7 +123,12 @@ function PromotePbiDialog({
|
||||||
{products.length === 0 ? (
|
{products.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">Maak eerst een product aan.</p>
|
<p className="text-sm text-muted-foreground">Maak eerst een product aan.</p>
|
||||||
) : (
|
) : (
|
||||||
<select name="productId" required className="w-full border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background">
|
<select
|
||||||
|
name="productId"
|
||||||
|
required
|
||||||
|
defaultValue={todo.product_id ?? products[0]?.id}
|
||||||
|
className="w-full border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background"
|
||||||
|
>
|
||||||
{products.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
{products.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
|
|
@ -115,7 +137,7 @@ function PromotePbiDialog({
|
||||||
<label className="text-sm font-medium">Prioriteit</label>
|
<label className="text-sm font-medium">Prioriteit</label>
|
||||||
<select name="priority" required className="w-full border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background">
|
<select name="priority" required className="w-full border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background">
|
||||||
<option value="1">Kritiek</option>
|
<option value="1">Kritiek</option>
|
||||||
<option value="2" selected>Hoog</option>
|
<option value="2">Hoog</option>
|
||||||
<option value="3">Gemiddeld</option>
|
<option value="3">Gemiddeld</option>
|
||||||
<option value="4">Laag</option>
|
<option value="4">Laag</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -138,9 +160,12 @@ function PromoteStoryDialog({
|
||||||
onClose,
|
onClose,
|
||||||
}: { todo: Todo; products: Product[]; onClose: () => void }) {
|
}: { todo: Todo; products: Product[]; onClose: () => void }) {
|
||||||
const handleKey = useCallback((e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }, [onClose])
|
const handleKey = useCallback((e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }, [onClose])
|
||||||
useEffect(() => { document.addEventListener('keydown', handleKey); return () => document.removeEventListener('keydown', handleKey) }, [handleKey])
|
useEffect(() => {
|
||||||
|
document.addEventListener('keydown', handleKey)
|
||||||
|
return () => document.removeEventListener('keydown', handleKey)
|
||||||
|
}, [handleKey])
|
||||||
|
|
||||||
const [selectedProductId, setSelectedProductId] = useState(products[0]?.id ?? '')
|
const [selectedProductId, setSelectedProductId] = useState(todo.product_id ?? products[0]?.id ?? '')
|
||||||
const selectedProduct = products.find(p => p.id === selectedProductId)
|
const selectedProduct = products.find(p => p.id === selectedProductId)
|
||||||
|
|
||||||
const [state, formAction] = useActionState(
|
const [state, formAction] = useActionState(
|
||||||
|
|
@ -192,7 +217,7 @@ function PromoteStoryDialog({
|
||||||
<label className="text-sm font-medium">Prioriteit</label>
|
<label className="text-sm font-medium">Prioriteit</label>
|
||||||
<select name="priority" required className="w-full border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background">
|
<select name="priority" required className="w-full border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background">
|
||||||
<option value="1">Kritiek</option>
|
<option value="1">Kritiek</option>
|
||||||
<option value="2" selected>Hoog</option>
|
<option value="2">Hoog</option>
|
||||||
<option value="3">Gemiddeld</option>
|
<option value="3">Gemiddeld</option>
|
||||||
<option value="4">Laag</option>
|
<option value="4">Laag</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -233,13 +258,17 @@ export function TodoList({ todos, products, isDemo }: TodoListProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<QuickInput isDemo={isDemo} />
|
<QuickInput products={products} isDemo={isDemo} />
|
||||||
|
|
||||||
{todos.length === 0 ? (
|
{products.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">Maak eerst een product aan om todo's toe te voegen.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{todos.length === 0 && products.length > 0 ? (
|
||||||
<div className="bg-surface-container-low border border-border rounded-xl p-12 text-center">
|
<div className="bg-surface-container-low border border-border rounded-xl p-12 text-center">
|
||||||
<p className="text-muted-foreground text-sm">Geen todo's. Voeg er een toe hierboven.</p>
|
<p className="text-muted-foreground text-sm">Geen todo's. Voeg er een toe hierboven.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : todos.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="bg-surface-container-low border border-border rounded-xl divide-y divide-border">
|
<div className="bg-surface-container-low border border-border rounded-xl divide-y divide-border">
|
||||||
{open.map(todo => (
|
{open.map(todo => (
|
||||||
|
|
@ -252,6 +281,11 @@ export function TodoList({ todos, products, isDemo }: TodoListProps) {
|
||||||
className="w-4 h-4 rounded accent-primary cursor-pointer"
|
className="w-4 h-4 rounded accent-primary cursor-pointer"
|
||||||
/>
|
/>
|
||||||
<span className="flex-1 text-sm">{todo.title}</span>
|
<span className="flex-1 text-sm">{todo.title}</span>
|
||||||
|
{todo.product_name && (
|
||||||
|
<span className="text-xs text-muted-foreground bg-surface-container px-1.5 py-0.5 rounded shrink-0">
|
||||||
|
{todo.product_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{!isDemo && (
|
{!isDemo && (
|
||||||
<div className="opacity-0 group-hover:opacity-100 flex gap-2 shrink-0">
|
<div className="opacity-0 group-hover:opacity-100 flex gap-2 shrink-0">
|
||||||
<button onClick={() => setPromotePbi(todo)} className="text-xs text-muted-foreground hover:text-foreground">→ PBI</button>
|
<button onClick={() => setPromotePbi(todo)} className="text-xs text-muted-foreground hover:text-foreground">→ PBI</button>
|
||||||
|
|
@ -270,6 +304,11 @@ export function TodoList({ todos, products, isDemo }: TodoListProps) {
|
||||||
className="w-4 h-4 rounded accent-primary cursor-pointer"
|
className="w-4 h-4 rounded accent-primary cursor-pointer"
|
||||||
/>
|
/>
|
||||||
<span className="flex-1 text-sm line-through text-muted-foreground">{todo.title}</span>
|
<span className="flex-1 text-sm line-through text-muted-foreground">{todo.title}</span>
|
||||||
|
{todo.product_name && (
|
||||||
|
<span className="text-xs text-muted-foreground bg-surface-container px-1.5 py-0.5 rounded shrink-0">
|
||||||
|
{todo.product_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -282,7 +321,7 @@ export function TodoList({ todos, products, isDemo }: TodoListProps) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
{promotePbi && (
|
{promotePbi && (
|
||||||
<PromotePbiDialog todo={promotePbi} products={products} onClose={() => setPromotePbi(null)} />
|
<PromotePbiDialog todo={promotePbi} products={products} onClose={() => setPromotePbi(null)} />
|
||||||
|
|
|
||||||
|
|
@ -208,13 +208,14 @@ Scrum4Me is een desktop-first Next.js 15 webapplicatie die server-side wordt ger
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| id | String (cuid) | PK | |
|
| id | String (cuid) | PK | |
|
||||||
| user_id | String | FK → users, not null | |
|
| user_id | String | FK → users, not null | |
|
||||||
|
| product_id | String | FK → products, nullable | Verplicht in UI en API; nullable voor backward compatibility |
|
||||||
| title | String | not null | |
|
| title | String | not null | |
|
||||||
| done | Boolean | default false | |
|
| done | Boolean | default false | |
|
||||||
| archived | Boolean | default false | |
|
| archived | Boolean | default false | |
|
||||||
| created_at | DateTime | default now() | |
|
| created_at | DateTime | default now() | |
|
||||||
| updated_at | DateTime | auto-update | |
|
| updated_at | DateTime | auto-update | |
|
||||||
|
|
||||||
**Indexes:** `(user_id, done, archived)` — standaard weergave filtert op actieve todo's
|
**Indexes:** `(user_id, done, archived)` — standaard weergave filtert op actieve todo's; `(user_id, product_id)` — filteren per product
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -405,6 +406,8 @@ model Todo {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||||
user_id String
|
user_id String
|
||||||
|
product Product? @relation(fields: [product_id], references: [id], onDelete: SetNull)
|
||||||
|
product_id String?
|
||||||
title String
|
title String
|
||||||
done Boolean @default(false)
|
done Boolean @default(false)
|
||||||
archived Boolean @default(false)
|
archived Boolean @default(false)
|
||||||
|
|
@ -412,6 +415,7 @@ model Todo {
|
||||||
updated_at DateTime @updatedAt
|
updated_at DateTime @updatedAt
|
||||||
|
|
||||||
@@index([user_id, done, archived])
|
@@index([user_id, done, archived])
|
||||||
|
@@index([user_id, product_id])
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -233,13 +233,13 @@ De MVP is klaar wanneer Lars — de primaire persona — de volledige cyclus kan
|
||||||
- Route Handler; body: `{ type, content, status?, commit_hash?, commit_message? }`; Zod-validatie per type; schrijf naar `story_logs`; retourneer `{ id, created_at }`
|
- Route Handler; body: `{ type, content, status?, commit_hash?, commit_message? }`; Zod-validatie per type; schrijf naar `story_logs`; retourneer `{ id, created_at }`
|
||||||
- Done when: drie typen werken (IMPLEMENTATION_PLAN, TEST_RESULT, COMMIT); log-entry zichtbaar in story-detail UI na aanmaken via API; ontbrekend verplicht veld geeft 400
|
- Done when: drie typen werken (IMPLEMENTATION_PLAN, TEST_RESULT, COMMIT); log-entry zichtbaar in story-detail UI na aanmaken via API; ontbrekend verplicht veld geeft 400
|
||||||
|
|
||||||
- [ ] **ST-408** `PATCH /api/tasks/:id` — taakstatus bijwerken
|
- [ ] **ST-408** `PATCH /api/tasks/:id` — taakstatus en implementatieplan bijwerken
|
||||||
- Route Handler; body: `{ status: "TO_DO" | "IN_PROGRESS" | "DONE" }`; valideer dat taak aan requester's product behoort; update status; retourneer `{ id, status }`
|
- Route Handler; body: `{ status?: "TO_DO" | "IN_PROGRESS" | "DONE", implementation_plan?: string }`; minimaal één veld verplicht; valideer dat taak aan requester's product behoort; retourneer `{ id, status, implementation_plan }`
|
||||||
- Done when: status update via API zichtbaar in Sprint Planning UI; ongeldige status geeft 400; andere gebruikers taak geeft 403
|
- Done when: status update via API zichtbaar in Sprint Planning UI; implementation_plan opgeslagen en opvraagbaar; lege body geeft 400; andere gebruikers taak geeft 403
|
||||||
|
|
||||||
- [ ] **ST-409** `POST /api/todos` — todo aanmaken
|
- [ ] **ST-409** `POST /api/todos` — todo aanmaken
|
||||||
- Route Handler; body: `{ title: string }`; schrijf naar `todos` voor de geverifieerde gebruiker; retourneer `{ id, title, created_at }`
|
- Route Handler; body: `{ title: string, product_id: string }`; valideer dat product bij de geverifieerde gebruiker hoort; schrijf naar `todos`; retourneer `{ id, title, created_at }`
|
||||||
- Done when: todo aangemaakt via API verschijnt in todo-lijst UI; lege titel geeft 400
|
- Done when: todo aangemaakt via API met product_id verschijnt in todo-lijst UI gekoppeld aan het juiste product; lege titel of ontbrekend product_id geeft 400; onbekend product geeft 404
|
||||||
|
|
||||||
- [ ] **ST-410** Story-activiteitenlog UI
|
- [ ] **ST-410** Story-activiteitenlog UI
|
||||||
- Activiteitenlog sectie in story-detail slide-over; haal `story_logs` op via Server Component; render chronologisch; visuele stijl per type (IMPLEMENTATION_PLAN = blauw, TEST_RESULT passed = groen, failed = rood, COMMIT = paars); commit-hash klikbaar als `repo_url` ingesteld; lege staat
|
- Activiteitenlog sectie in story-detail slide-over; haal `story_logs` op via Server Component; render chronologisch; visuele stijl per type (IMPLEMENTATION_PLAN = blauw, TEST_RESULT passed = groen, failed = rood, COMMIT = paars); commit-hash klikbaar als `repo_url` ingesteld; lege staat
|
||||||
|
|
@ -250,8 +250,8 @@ De MVP is klaar wanneer Lars — de primaire persona — de volledige cyclus kan
|
||||||
### M5: Todo-lijst
|
### M5: Todo-lijst
|
||||||
|
|
||||||
- [ ] **ST-501** Todo-lijst pagina
|
- [ ] **ST-501** Todo-lijst pagina
|
||||||
- `/todos` pagina; haal actieve (niet-gearchiveerde) todos op; snel-invoerveld bovenaan (Enter om op te slaan); `createTodo` Server Action; lege staat met instructie
|
- `/todos` pagina; haal actieve (niet-gearchiveerde) todos op inclusief productnaam; snel-invoerveld bovenaan met product-dropdown (verplicht) en titel (Enter om op te slaan); `createTodo` Server Action; lege staat met instructie; productnaam-badge per todo-item
|
||||||
- Done when: todo aanmaken via Enter persisteert en verschijnt in lijst; lege staat zichtbaar bij geen todos
|
- Done when: todo aanmaken via Enter persisteert en verschijnt in lijst met productnaam; aanmaken zonder product geblokkeerd; lege staat zichtbaar bij geen todos
|
||||||
|
|
||||||
- [ ] **ST-502** Todo afvinken
|
- [ ] **ST-502** Todo afvinken
|
||||||
- Checkbox per todo; `toggleTodo` Server Action; afgevinkte todos visueel doorgestreept; afgevinkte todos blijven zichtbaar onderaan de lijst
|
- Checkbox per todo; `toggleTodo` Server Action; afgevinkte todos visueel doorgestreept; afgevinkte todos blijven zichtbaar onderaan de lijst
|
||||||
|
|
|
||||||
|
|
@ -220,16 +220,16 @@ Elke story heeft een activiteitenlog die alle door Claude Code vastgelegde stapp
|
||||||
**Persona:** Lars (snelle vastlegging), Dina (losse klantnotities)
|
**Persona:** Lars (snelle vastlegging), Dina (losse klantnotities)
|
||||||
|
|
||||||
**Omschrijving:**
|
**Omschrijving:**
|
||||||
Een snelle todo-lijst voor losse taken die (nog) niet aan een product of Sprint gekoppeld zijn. Todo-items kunnen worden afgevinkt en gepromoveerd naar een PBI of story in een bestaand product.
|
Een snelle todo-lijst voor taken die aan een specifiek product zijn gekoppeld. Todo-items kunnen worden afgevinkt en gepromoveerd naar een PBI of story in dat product. Zowel de UI als de REST API vereisen een `product_id` bij aanmaken — zodat Claude Code altijd werkt binnen de context van de actieve product backlog.
|
||||||
|
|
||||||
**Acceptatiecriteria:**
|
**Acceptatiecriteria:**
|
||||||
- [ ] Todo aanmaken via snel-invoerveld (Enter om op te slaan); alleen titel verplicht
|
- [ ] Todo aanmaken via snel-invoerveld (Enter om op te slaan); product (dropdown) en titel verplicht
|
||||||
- [ ] Todo aanmaken ook mogelijk via REST API: `POST /api/todos` (body: `{ "title": string }`) — zodat Claude Code losse bevindingen kan vastleggen zonder de planningsflow te onderbreken
|
- [ ] Todo aanmaken ook mogelijk via REST API: `POST /api/todos` (body: `{ "title": string, "product_id": string }`) — zodat Claude Code bevindingen kan vastleggen binnen de actieve product backlog
|
||||||
- [ ] Todo-lijst is zichtbaar als apart scherm of persistent zijpaneel
|
- [ ] Todo-lijst is zichtbaar als apart scherm of persistent zijpaneel
|
||||||
- [ ] Todo afvinken markeert het als afgerond (visueel doorgestreept)
|
- [ ] Todo afvinken markeert het als afgerond (visueel doorgestreept)
|
||||||
- [ ] Afgevinkte todo's blijven zichtbaar; kunnen worden gearchiveerd via "Archiveer afgeronde items"
|
- [ ] Afgevinkte todo's blijven zichtbaar; kunnen worden gearchiveerd via "Archiveer afgeronde items"
|
||||||
- [ ] Todo promoveren naar PBI: dialoog vraagt om product en prioriteit; todo verdwijnt na promotie
|
- [ ] Todo promoveren naar PBI: dialoog pre-selecteert het gekoppelde product (bewerkbaar), vraagt prioriteit; todo verdwijnt na promotie
|
||||||
- [ ] Todo promoveren naar story: dialoog vraagt om product, PBI en prioriteit; todo verdwijnt na promotie
|
- [ ] Todo promoveren naar story: dialoog pre-selecteert het gekoppelde product (bewerkbaar), vraagt PBI en prioriteit; todo verdwijnt na promotie
|
||||||
- [ ] Titel van het todo-item is vooringevuld in de promotiedialoog (bewerkbaar)
|
- [ ] Titel van het todo-item is vooringevuld in de promotiedialoog (bewerkbaar)
|
||||||
- [ ] Promotie is niet ongedaan te maken; dialoog waarschuwt hiervoor
|
- [ ] Promotie is niet ongedaan te maken; dialoog waarschuwt hiervoor
|
||||||
|
|
||||||
|
|
@ -238,7 +238,7 @@ Een snelle todo-lijst voor losse taken die (nog) niet aan een product of Sprint
|
||||||
- Promoveren naar story zonder PBI's in het product → dialoog toont melding "Maak eerst een PBI aan"
|
- Promoveren naar story zonder PBI's in het product → dialoog toont melding "Maak eerst een PBI aan"
|
||||||
|
|
||||||
**Data:**
|
**Data:**
|
||||||
- Opgeslagen: `todos` (id, user_id, title, done, archived, created_at, updated_at)
|
- Opgeslagen: `todos` (id, user_id, product_id, title, done, archived, created_at, updated_at)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -316,8 +316,8 @@ Een REST API waarmee Claude Code stories en taken kan ophalen, de taakvolgorde k
|
||||||
- [ ] `GET /api/sprints/:id/tasks?limit=10` — eerste N taken in huidige volgorde
|
- [ ] `GET /api/sprints/:id/tasks?limit=10` — eerste N taken in huidige volgorde
|
||||||
- [ ] `PATCH /api/stories/:id/tasks/reorder` — accepteert geordende lijst van taak-id's
|
- [ ] `PATCH /api/stories/:id/tasks/reorder` — accepteert geordende lijst van taak-id's
|
||||||
- [ ] `POST /api/stories/:id/log` — vastleggen van implementatieplan, testresultaat of commit
|
- [ ] `POST /api/stories/:id/log` — vastleggen van implementatieplan, testresultaat of commit
|
||||||
- [ ] `PATCH /api/tasks/:id` — status bijwerken (TO_DO → IN_PROGRESS → DONE)
|
- [ ] `PATCH /api/tasks/:id` — status bijwerken (TO_DO → IN_PROGRESS → DONE) en/of `implementation_plan` opslaan
|
||||||
- [ ] `POST /api/todos` — todo aanmaken vanuit Claude Code (body: `{ "title": string }`)
|
- [ ] `POST /api/todos` — todo aanmaken vanuit Claude Code (body: `{ "title": string, "product_id": string }`)
|
||||||
|
|
||||||
**Authenticatie:**
|
**Authenticatie:**
|
||||||
- [ ] Alle endpoints vereisen `Authorization: Bearer <token>` header
|
- [ ] Alle endpoints vereisen `Authorization: Bearer <token>` header
|
||||||
|
|
@ -404,8 +404,8 @@ De app is deployable op Vercel + Neon PostgreSQL en lokaal draaibaar met een Neo
|
||||||
| `stories` | id, pbi_id, product_id, title, description, acceptance_criteria, priority, sort_order, status, sprint_id? | Status: OPEN / IN_SPRINT / DONE |
|
| `stories` | id, pbi_id, product_id, title, description, acceptance_criteria, priority, sort_order, status, sprint_id? | Status: OPEN / IN_SPRINT / DONE |
|
||||||
| `story_logs` | id, story_id, type, content, status?, commit_hash?, commit_message?, created_at | Aangemaakt via API; read-only in UI |
|
| `story_logs` | id, story_id, type, content, status?, commit_hash?, commit_message?, created_at | Aangemaakt via API; read-only in UI |
|
||||||
| `sprints` | id, product_id, sprint_goal, status (ACTIVE / COMPLETED), created_at, completed_at? | Max. 1 actieve Sprint per product |
|
| `sprints` | id, product_id, sprint_goal, status (ACTIVE / COMPLETED), created_at, completed_at? | Max. 1 actieve Sprint per product |
|
||||||
| `tasks` | id, story_id, sprint_id, title, description, priority, sort_order, status | Status: TO_DO / IN_PROGRESS / DONE |
|
| `tasks` | id, story_id, sprint_id, title, description, implementation_plan?, priority, sort_order, status | Status: TO_DO / IN_PROGRESS / DONE; implementation_plan door MCP |
|
||||||
| `todos` | id, user_id, title, done, archived, created_at | Ontkoppeld van producthiërarchie |
|
| `todos` | id, user_id, product_id, title, done, archived, created_at | Gekoppeld aan product backlog; verplicht in UI en API |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "todos" ADD COLUMN "product_id" TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "todos_user_id_product_id_idx" ON "todos"("user_id", "product_id");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "todos" ADD CONSTRAINT "todos_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
@ -92,6 +92,7 @@ model Product {
|
||||||
pbis Pbi[]
|
pbis Pbi[]
|
||||||
sprints Sprint[]
|
sprints Sprint[]
|
||||||
stories Story[]
|
stories Story[]
|
||||||
|
todos Todo[]
|
||||||
|
|
||||||
@@unique([user_id, name])
|
@@unique([user_id, name])
|
||||||
@@index([user_id, archived])
|
@@index([user_id, archived])
|
||||||
|
|
@ -170,18 +171,19 @@ model Sprint {
|
||||||
}
|
}
|
||||||
|
|
||||||
model Task {
|
model Task {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
story Story @relation(fields: [story_id], references: [id], onDelete: Cascade)
|
story Story @relation(fields: [story_id], references: [id], onDelete: Cascade)
|
||||||
story_id String
|
story_id String
|
||||||
sprint Sprint? @relation(fields: [sprint_id], references: [id])
|
sprint Sprint? @relation(fields: [sprint_id], references: [id])
|
||||||
sprint_id String?
|
sprint_id String?
|
||||||
title String
|
title String
|
||||||
description String?
|
description String?
|
||||||
priority Int
|
implementation_plan String?
|
||||||
sort_order Float
|
priority Int
|
||||||
status TaskStatus @default(TO_DO)
|
sort_order Float
|
||||||
created_at DateTime @default(now())
|
status TaskStatus @default(TO_DO)
|
||||||
updated_at DateTime @updatedAt
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
|
||||||
@@index([story_id, priority, sort_order])
|
@@index([story_id, priority, sort_order])
|
||||||
@@index([sprint_id, status])
|
@@index([sprint_id, status])
|
||||||
|
|
@ -192,6 +194,8 @@ model Todo {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||||
user_id String
|
user_id String
|
||||||
|
product Product? @relation(fields: [product_id], references: [id], onDelete: SetNull)
|
||||||
|
product_id String?
|
||||||
title String
|
title String
|
||||||
done Boolean @default(false)
|
done Boolean @default(false)
|
||||||
archived Boolean @default(false)
|
archived Boolean @default(false)
|
||||||
|
|
@ -199,5 +203,6 @@ model Todo {
|
||||||
updated_at DateTime @updatedAt
|
updated_at DateTime @updatedAt
|
||||||
|
|
||||||
@@index([user_id, done, archived])
|
@@index([user_id, done, archived])
|
||||||
|
@@index([user_id, product_id])
|
||||||
@@map("todos")
|
@@map("todos")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue