diff --git a/actions/pbis.ts b/actions/pbis.ts new file mode 100644 index 0000000..dee93cb --- /dev/null +++ b/actions/pbis.ts @@ -0,0 +1,112 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { cookies } from 'next/headers' +import { getIronSession } from 'iron-session' +import { z } from 'zod' +import { prisma } from '@/lib/prisma' +import { SessionData, sessionOptions } from '@/lib/session' + +async function getSession() { + return getIronSession(await cookies(), sessionOptions) +} + +async function verifyProductOwnership(productId: string, userId: string) { + return prisma.product.findFirst({ where: { id: productId, user_id: userId } }) +} + +const createPbiSchema = z.object({ + productId: z.string(), + title: z.string().min(1, 'Titel is verplicht').max(200), + priority: z.coerce.number().int().min(1).max(4), +}) + +const updatePbiSchema = z.object({ + id: z.string(), + title: z.string().min(1, 'Titel is verplicht').max(200), + description: z.string().max(2000).optional(), + priority: z.coerce.number().int().min(1).max(4), +}) + +export async function createPbiAction(_prevState: unknown, formData: FormData) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const parsed = createPbiSchema.safeParse({ + productId: formData.get('productId'), + title: formData.get('title'), + priority: formData.get('priority'), + }) + if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } + + const product = await verifyProductOwnership(parsed.data.productId, session.userId) + if (!product) return { error: 'Product niet gevonden' } + + const last = await prisma.pbi.findFirst({ + where: { product_id: parsed.data.productId, priority: parsed.data.priority }, + orderBy: { sort_order: 'desc' }, + }) + const sort_order = (last?.sort_order ?? 0) + 1.0 + + const pbi = await prisma.pbi.create({ + data: { + product_id: parsed.data.productId, + title: parsed.data.title, + priority: parsed.data.priority, + sort_order, + }, + }) + + revalidatePath(`/products/${parsed.data.productId}`) + return { success: true, pbi } +} + +export async function updatePbiAction(_prevState: unknown, formData: FormData) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const parsed = updatePbiSchema.safeParse({ + id: formData.get('id'), + title: formData.get('title'), + description: formData.get('description') || undefined, + priority: formData.get('priority'), + }) + if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } + + const pbi = await prisma.pbi.findFirst({ + where: { id: parsed.data.id }, + include: { product: true }, + }) + if (!pbi || pbi.product.user_id !== session.userId) return { error: 'PBI niet gevonden' } + + await prisma.pbi.update({ + where: { id: parsed.data.id }, + data: { + title: parsed.data.title, + description: parsed.data.description ?? null, + priority: parsed.data.priority, + }, + }) + + revalidatePath(`/products/${pbi.product_id}`) + return { success: true } +} + +export async function deletePbiAction(id: string) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const pbi = await prisma.pbi.findFirst({ + where: { id }, + include: { product: true }, + }) + if (!pbi || pbi.product.user_id !== session.userId) return { error: 'PBI niet gevonden' } + + await prisma.pbi.delete({ where: { id } }) + + revalidatePath(`/products/${pbi.product_id}`) + return { success: true } +} diff --git a/actions/products.ts b/actions/products.ts new file mode 100644 index 0000000..1424b74 --- /dev/null +++ b/actions/products.ts @@ -0,0 +1,139 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { redirect } from 'next/navigation' +import { cookies } from 'next/headers' +import { getIronSession } from 'iron-session' +import { z } from 'zod' +import { prisma } from '@/lib/prisma' +import { SessionData, sessionOptions } from '@/lib/session' + +const productSchema = z.object({ + name: z.string().min(1, 'Naam is verplicht').max(100, 'Naam mag maximaal 100 tekens bevatten'), + description: z.string().max(1000, 'Beschrijving mag maximaal 1000 tekens bevatten').optional(), + repo_url: z + .string() + .url('Voer een geldige URL in (inclusief https://)') + .optional() + .or(z.literal('')), + definition_of_done: z + .string() + .min(1, 'Definition of Done is verplicht') + .max(500, 'Definition of Done mag maximaal 500 tekens bevatten'), +}) + +async function getSession() { + return getIronSession(await cookies(), sessionOptions) +} + +export async function createProductAction(_prevState: unknown, formData: FormData) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const parsed = productSchema.safeParse({ + name: formData.get('name'), + description: formData.get('description') || undefined, + repo_url: formData.get('repo_url') || undefined, + definition_of_done: formData.get('definition_of_done'), + }) + + if (!parsed.success) { + return { error: parsed.error.flatten().fieldErrors } + } + + const existing = await prisma.product.findFirst({ + where: { user_id: session.userId, name: parsed.data.name }, + }) + if (existing) return { error: { name: ['Een product met deze naam bestaat al'] } } + + const product = await prisma.product.create({ + data: { + user_id: session.userId, + name: parsed.data.name, + description: parsed.data.description ?? null, + repo_url: parsed.data.repo_url || null, + definition_of_done: parsed.data.definition_of_done, + }, + }) + + redirect(`/products/${product.id}`) +} + +export async function updateProductAction(_prevState: unknown, formData: FormData) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const id = formData.get('id') as string + if (!id) return { error: 'Product niet gevonden' } + + const parsed = productSchema.safeParse({ + name: formData.get('name'), + description: formData.get('description') || undefined, + repo_url: formData.get('repo_url') || undefined, + definition_of_done: formData.get('definition_of_done'), + }) + + if (!parsed.success) { + return { error: parsed.error.flatten().fieldErrors } + } + + // Verify ownership + const product = await prisma.product.findFirst({ + where: { id, user_id: session.userId }, + }) + if (!product) return { error: 'Product niet gevonden' } + + // Check unique name (excluding self) + const duplicate = await prisma.product.findFirst({ + where: { user_id: session.userId, name: parsed.data.name, NOT: { id } }, + }) + if (duplicate) return { error: { name: ['Een product met deze naam bestaat al'] } } + + await prisma.product.update({ + where: { id }, + data: { + name: parsed.data.name, + description: parsed.data.description ?? null, + repo_url: parsed.data.repo_url || null, + definition_of_done: parsed.data.definition_of_done, + }, + }) + + revalidatePath(`/products/${id}`) + revalidatePath('/dashboard') + return { success: true } +} + +export async function archiveProductAction(id: string) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const product = await prisma.product.findFirst({ + where: { id, user_id: session.userId }, + }) + if (!product) return { error: 'Product niet gevonden' } + + await prisma.product.update({ where: { id }, data: { archived: true } }) + + revalidatePath('/dashboard') + redirect('/dashboard') +} + +export async function restoreProductAction(id: string) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const product = await prisma.product.findFirst({ + where: { id, user_id: session.userId }, + }) + if (!product) return { error: 'Product niet gevonden' } + + await prisma.product.update({ where: { id }, data: { archived: false } }) + + revalidatePath('/dashboard') + return { success: true } +} diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx index 8893480..e4f675c 100644 --- a/app/(app)/dashboard/page.tsx +++ b/app/(app)/dashboard/page.tsx @@ -6,24 +6,47 @@ import Link from 'next/link' import { Button } from '@/components/ui/button' import { ProductList } from '@/components/dashboard/product-list' -export default async function DashboardPage() { +interface Props { + searchParams: Promise<{ archived?: string }> +} + +export default async function DashboardPage({ searchParams }: Props) { const session = await getIronSession(await cookies(), sessionOptions) + const { archived } = await searchParams + const showArchived = archived === '1' const products = await prisma.product.findMany({ - where: { user_id: session.userId, archived: false }, + where: { user_id: session.userId, archived: showArchived }, orderBy: { created_at: 'desc' }, }) return (
-

Mijn Producten

- {!session.isDemo && ( - +
+

+ {showArchived ? 'Gearchiveerde producten' : 'Mijn Producten'} +

+ {showArchived ? ( + + ← Actief + + ) : ( + + Toon gearchiveerd + + )} +
+ {!session.isDemo && !showArchived && ( + )}
- +
) } diff --git a/app/(app)/products/[id]/page.tsx b/app/(app)/products/[id]/page.tsx new file mode 100644 index 0000000..3975f1f --- /dev/null +++ b/app/(app)/products/[id]/page.tsx @@ -0,0 +1,80 @@ +import { notFound } from 'next/navigation' +import { cookies } from 'next/headers' +import { getIronSession } from 'iron-session' +import { SessionData, sessionOptions } from '@/lib/session' +import { prisma } from '@/lib/prisma' +import { SplitPane } from '@/components/split-pane/split-pane' +import { PbiList } from '@/components/backlog/pbi-list' +import { StoryPanel } from '@/components/backlog/story-panel' + +interface Props { + params: Promise<{ id: string }> +} + +export default async function ProductBacklogPage({ params }: Props) { + const { id } = await params + const session = await getIronSession(await cookies(), sessionOptions) + + const product = await prisma.product.findFirst({ + where: { id, user_id: session.userId }, + }) + if (!product) notFound() + + const pbis = await prisma.pbi.findMany({ + where: { product_id: id }, + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], + }) + + const stories = await prisma.story.findMany({ + where: { product_id: id }, + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], + select: { id: true, title: true, status: true, pbi_id: true }, + }) + + // Group stories by PBI id + const storiesByPbi: Record = {} + for (const story of stories) { + if (!storiesByPbi[story.pbi_id]) storiesByPbi[story.pbi_id] = [] + storiesByPbi[story.pbi_id].push(story) + } + + return ( +
+ {/* Product header */} +
+
+

{product.name}

+ {product.description && ( +

{product.description}

+ )} +
+ + Instellingen + +
+ + {/* Split pane */} +
+ ({ id: p.id, title: p.title, priority: p.priority }))} + isDemo={session.isDemo ?? false} + /> + } + right={ + + } + /> +
+
+ ) +} diff --git a/app/(app)/products/[id]/settings/page.tsx b/app/(app)/products/[id]/settings/page.tsx new file mode 100644 index 0000000..5095bef --- /dev/null +++ b/app/(app)/products/[id]/settings/page.tsx @@ -0,0 +1,62 @@ +import { notFound, redirect } from 'next/navigation' +import { cookies } from 'next/headers' +import { getIronSession } from 'iron-session' +import { SessionData, sessionOptions } from '@/lib/session' +import { prisma } from '@/lib/prisma' +import { ProductForm } from '@/components/products/product-form' +import { ArchiveProductButton } from '@/components/products/archive-product-button' +import { updateProductAction } from '@/actions/products' +import Link from 'next/link' + +interface Props { + params: Promise<{ id: string }> +} + +export default async function ProductSettingsPage({ params }: Props) { + const { id } = await params + const session = await getIronSession(await cookies(), sessionOptions) + + if (session.isDemo) redirect(`/products/${id}`) + + const product = await prisma.product.findFirst({ + where: { id, user_id: session.userId }, + }) + if (!product) notFound() + + return ( +
+
+ + ← {product.name} + + / +

Instellingen

+
+ + + +
+

Gevaarlijke zone

+
+
+

Product archiveren

+

+ Het product wordt verborgen uit het dashboard. Je kunt het later herstellen. +

+
+ +
+
+
+ ) +} diff --git a/app/(app)/products/new/page.tsx b/app/(app)/products/new/page.tsx new file mode 100644 index 0000000..ba768a7 --- /dev/null +++ b/app/(app)/products/new/page.tsx @@ -0,0 +1,18 @@ +import { cookies } from 'next/headers' +import { getIronSession } from 'iron-session' +import { redirect } from 'next/navigation' +import { SessionData, sessionOptions } from '@/lib/session' +import { ProductForm } from '@/components/products/product-form' +import { createProductAction } from '@/actions/products' + +export default async function NewProductPage() { + const session = await getIronSession(await cookies(), sessionOptions) + if (session.isDemo) redirect('/dashboard') + + return ( +
+

Nieuw product

+ +
+ ) +} diff --git a/app/apple-icon.png b/app/apple-icon.png new file mode 100644 index 0000000..628e713 Binary files /dev/null and b/app/apple-icon.png differ diff --git a/app/favicon.ico b/app/favicon.ico index 718d6fe..ab88226 100644 Binary files a/app/favicon.ico and b/app/favicon.ico differ diff --git a/app/icon.png b/app/icon.png new file mode 100644 index 0000000..79fd6ec Binary files /dev/null and b/app/icon.png differ diff --git a/app/layout.tsx b/app/layout.tsx index 976eb90..fabf779 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -13,8 +13,21 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: { + default: "Scrum4Me", + template: "%s — Scrum4Me", + }, + description: "Lichtgewicht Scrum-planner voor solo developers en kleine teams", + icons: { + icon: [ + { url: "/favicon.ico", sizes: "48x48" }, + { url: "/icon.png", sizes: "192x192", type: "image/png" }, + ], + apple: [ + { url: "/apple-icon.png", sizes: "180x180", type: "image/png" }, + ], + }, + manifest: "/manifest.json", }; export default function RootLayout({ @@ -24,7 +37,7 @@ export default function RootLayout({ }>) { return ( {children} diff --git a/components/backlog/pbi-list.tsx b/components/backlog/pbi-list.tsx new file mode 100644 index 0000000..011058e --- /dev/null +++ b/components/backlog/pbi-list.tsx @@ -0,0 +1,237 @@ +'use client' + +import { useState, useTransition } from 'react' +import { useActionState } from 'react' +import { useFormStatus } from 'react-dom' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Badge } from '@/components/ui/badge' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { PanelNavBar } from '@/components/shared/panel-nav-bar' +import { useSelectionStore } from '@/stores/selection-store' +import { createPbiAction, deletePbiAction } from '@/actions/pbis' +import { cn } from '@/lib/utils' + +const PRIORITY_LABELS: Record = { + 1: 'Kritiek', + 2: 'Hoog', + 3: 'Gemiddeld', + 4: 'Laag', +} + +const PRIORITY_COLORS: Record = { + 1: 'bg-priority-critical/15 text-priority-critical border-priority-critical/30', + 2: 'bg-priority-high/15 text-priority-high border-priority-high/30', + 3: 'bg-priority-medium/15 text-priority-medium border-priority-medium/30', + 4: 'bg-priority-low/15 text-priority-low border-priority-low/30', +} + +interface Pbi { + id: string + title: string + priority: number +} + +interface PbiListProps { + productId: string + pbis: Pbi[] + isDemo: boolean +} + +function CreatePbiForm({ productId, priority, onDone }: { productId: string; priority: number; onDone: () => void }) { + const [state, formAction] = useActionState( + async (_prev: unknown, fd: FormData) => { + const result = await createPbiAction(_prev, fd) + if (result?.success) onDone() + return result + }, + undefined + ) + const error = state?.error + + return ( +
+ + + + + + {typeof error === 'string' && ( +

{error}

+ )} + + ) +} + +function CreateSubmitButton() { + const { pending } = useFormStatus() + return ( + + ) +} + +export function PbiList({ productId, pbis, isDemo }: PbiListProps) { + const { selectedPbiId, selectPbi } = useSelectionStore() + const [filterPriority, setFilterPriority] = useState(null) + const [creatingForPriority, setCreatingForPriority] = useState(null) + const [, startTransition] = useTransition() + + const filtered = filterPriority ? pbis.filter(p => p.priority === filterPriority) : pbis + + const grouped = [1, 2, 3, 4].reduce>((acc, p) => { + acc[p] = filtered.filter(pbi => pbi.priority === p) + return acc + }, {} as Record) + + const visiblePriorities = [1, 2, 3, 4].filter( + p => grouped[p].length > 0 || creatingForPriority === p + ) + + function handleDelete(id: string) { + startTransition(async () => { + await deletePbiAction(id) + if (selectedPbiId === id) selectPbi(null) + }) + } + + return ( +
+ + {filterPriority !== null && ( + + )} + + {!isDemo && ( + + )} + + } + /> + +
+ {pbis.length === 0 && creatingForPriority === null ? ( +
+

Nog geen PBI's aangemaakt.

+ {!isDemo && ( + + )} +
+ ) : ( +
+ {visiblePriorities.map(priority => ( +
+ {/* Priority group header */} +
+ + {PRIORITY_LABELS[priority]} + +
+ {!isDemo && ( + + )} +
+ + {/* PBI items */} + {grouped[priority].map(pbi => ( +
selectPbi(pbi.id)} + className={cn( + 'group flex items-center justify-between px-4 py-2 cursor-pointer transition-colors hover:bg-surface-container', + selectedPbiId === pbi.id && 'bg-primary-container text-primary-container-foreground' + )} + > + {pbi.title} + {!isDemo && ( + + )} +
+ ))} + + {/* Inline create form for this priority */} + {creatingForPriority === priority && ( + setCreatingForPriority(null)} + /> + )} +
+ ))} + + {/* If creating for a priority that has no items yet and isn't in visiblePriorities */} + {creatingForPriority !== null && !visiblePriorities.includes(creatingForPriority) && ( +
+
+ + {PRIORITY_LABELS[creatingForPriority]} + +
+
+ setCreatingForPriority(null)} + /> +
+ )} +
+ )} +
+
+ ) +} diff --git a/components/backlog/story-panel.tsx b/components/backlog/story-panel.tsx new file mode 100644 index 0000000..4adb86f --- /dev/null +++ b/components/backlog/story-panel.tsx @@ -0,0 +1,56 @@ +'use client' + +import { useSelectionStore } from '@/stores/selection-store' +import { PanelNavBar } from '@/components/shared/panel-nav-bar' + +interface Story { + id: string + title: string + status: string +} + +interface StoryPanelProps { + storiesByPbi: Record + isDemo: boolean +} + +export function StoryPanel({ storiesByPbi, isDemo }: StoryPanelProps) { + const { selectedPbiId } = useSelectionStore() + + const stories = selectedPbiId ? (storiesByPbi[selectedPbiId] ?? []) : null + + return ( +
+ + Story + ) : undefined + } + /> +
+ {stories === null ? ( +

+ Selecteer een PBI om de stories te bekijken. +

+ ) : stories.length === 0 ? ( +

+ Nog geen stories voor dit PBI. +

+ ) : ( +
+ {stories.map(story => ( +
+ {story.title} +
+ ))} +
+ )} +
+
+ ) +} diff --git a/components/dashboard/product-list.tsx b/components/dashboard/product-list.tsx index 3099d5b..3571dc2 100644 --- a/components/dashboard/product-list.tsx +++ b/components/dashboard/product-list.tsx @@ -2,7 +2,9 @@ import Link from 'next/link' import { useRouter } from 'next/navigation' +import { useTransition } from 'react' import { Button } from '@/components/ui/button' +import { restoreProductAction } from '@/actions/products' interface Product { id: string @@ -14,17 +16,30 @@ interface Product { interface ProductListProps { products: Product[] isDemo: boolean + showArchived?: boolean } -export function ProductList({ products, isDemo }: ProductListProps) { +export function ProductList({ products, isDemo, showArchived = false }: ProductListProps) { const router = useRouter() + const [, startTransition] = useTransition() + + function handleRestore(id: string) { + startTransition(async () => { + await restoreProductAction(id) + router.refresh() + }) + } if (products.length === 0) { return (
-

Je hebt nog geen producten aangemaakt.

- {!isDemo && ( - )} @@ -37,8 +52,10 @@ export function ProductList({ products, isDemo }: ProductListProps) { {products.map(product => (
router.push(`/products/${product.id}`)} - className="group cursor-pointer bg-surface-container-low border border-border rounded-xl p-4 hover:border-primary transition-colors" + onClick={() => !showArchived && router.push(`/products/${product.id}`)} + className={`group bg-surface-container-low border border-border rounded-xl p-4 transition-colors ${ + showArchived ? 'opacity-60' : 'cursor-pointer hover:border-primary' + }`} >
@@ -51,17 +68,27 @@ export function ProductList({ products, isDemo }: ProductListProps) {

)}
- {product.repo_url && ( - e.stopPropagation()} - className="text-xs text-muted-foreground hover:text-primary shrink-0 underline" - > - Repo - - )} +
+ {product.repo_url && ( + e.stopPropagation()} + className="text-xs text-muted-foreground hover:text-primary underline" + > + Repo + + )} + {showArchived && !isDemo && ( + + )} +
))} diff --git a/components/products/archive-product-button.tsx b/components/products/archive-product-button.tsx new file mode 100644 index 0000000..a083e77 --- /dev/null +++ b/components/products/archive-product-button.tsx @@ -0,0 +1,54 @@ +'use client' + +import { useState, useTransition } from 'react' +import { Button } from '@/components/ui/button' +import { archiveProductAction } from '@/actions/products' + +interface ArchiveProductButtonProps { + productId: string +} + +export function ArchiveProductButton({ productId }: ArchiveProductButtonProps) { + const [confirming, setConfirming] = useState(false) + const [isPending, startTransition] = useTransition() + + function handleArchive() { + startTransition(async () => { + await archiveProductAction(productId) + }) + } + + if (confirming) { + return ( +
+ + +
+ ) + } + + return ( + + ) +} diff --git a/components/products/product-form.tsx b/components/products/product-form.tsx new file mode 100644 index 0000000..f8910df --- /dev/null +++ b/components/products/product-form.tsx @@ -0,0 +1,135 @@ +'use client' + +import { useActionState } from 'react' +import { useFormStatus } from 'react-dom' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' + +type FieldErrors = Record +type ActionResult = { error?: string | FieldErrors; success?: boolean } | undefined + +function SubmitButton({ label }: { label: string }) { + const { pending } = useFormStatus() + return ( + + ) +} + +function getFieldError(error: string | FieldErrors | undefined, field: string): string | undefined { + if (!error || typeof error === 'string') return undefined + return (error as FieldErrors)[field]?.[0] +} + +function getGlobalError(error: string | FieldErrors | undefined): string | undefined { + if (typeof error === 'string') return error + return undefined +} + +interface ProductFormProps { + action: (_prevState: unknown, formData: FormData) => Promise + submitLabel: string + defaultValues?: { + id?: string + name?: string + description?: string + repo_url?: string + definition_of_done?: string + } +} + +export function ProductForm({ action, submitLabel, defaultValues }: ProductFormProps) { + const [state, formAction] = useActionState(action, undefined) + + const fieldError = (field: string) => getFieldError(state?.error, field) + const globalError = getGlobalError(state?.error) + + return ( +
+ {defaultValues?.id && ( + + )} + +
+ + + {fieldError('name') && ( +

{fieldError('name')}

+ )} +
+ +
+ +