From 660d6cff511d3dbf59cbe4072e973b2280bccd2a Mon Sep 17 00:00:00 2001 From: opencode Date: Sun, 18 Oct 2026 10:30:55 +0200 Subject: [PATCH] feat: voucher crud and list --- app/api/vouchers/[id]/route.ts | 21 ++++++++ app/api/vouchers/route.ts | 57 ++++++++++++++++++++ app/page.tsx | 95 +++++++++++++++++++++++++++++++++ app/vouchers/[id]/page.tsx | 72 +++++++++++++++++++++++++ app/vouchers/new/page.tsx | 89 ++++++++++++++++++++++++++++++ lib/vouchers.ts | 12 +++++ tests/vouchers-validate.test.ts | 12 +++++ 7 files changed, 358 insertions(+) create mode 100644 app/api/vouchers/[id]/route.ts create mode 100644 app/api/vouchers/route.ts create mode 100644 app/page.tsx create mode 100644 app/vouchers/[id]/page.tsx create mode 100644 app/vouchers/new/page.tsx create mode 100644 lib/vouchers.ts create mode 100644 tests/vouchers-validate.test.ts diff --git a/app/api/vouchers/[id]/route.ts b/app/api/vouchers/[id]/route.ts new file mode 100644 index 0000000..095a708 --- /dev/null +++ b/app/api/vouchers/[id]/route.ts @@ -0,0 +1,21 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDb } from "@/lib/db"; + +export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const db = getDb(); + const v = await db.voucher.findUnique({ where: { id: Number(id) } }); + if (!v) return NextResponse.json({ error: "not found" }, { status: 404 }); + return NextResponse.json(v); +} + +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const body = await req.json().catch(() => ({})); + if (body.action !== "redeem") return NextResponse.json({ error: "unknown action" }, { status: 400 }); + const db = getDb(); + const v = await db.voucher.findUnique({ where: { id: Number(id) } }); + if (!v) return NextResponse.json({ error: "not found" }, { status: 404 }); + const updated = await db.voucher.update({ where: { id: v.id }, data: { status: "eingeloest", restCents: 0 } }); + return NextResponse.json(updated); +} diff --git a/app/api/vouchers/route.ts b/app/api/vouchers/route.ts new file mode 100644 index 0000000..a05b438 --- /dev/null +++ b/app/api/vouchers/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDb } from "@/lib/db"; +import { getVoucherStatus } from "@/lib/voucher-status"; +import { validateVoucher } from "@/lib/vouchers"; + +export async function GET(req: NextRequest) { + const filter = req.nextUrl.searchParams.get("filter"); + const db = getDb(); + const all = await db.voucher.findMany({ orderBy: { createdAt: "desc" } }); + const now = Date.now(); + const in14d = now + 14 * 24 * 3600 * 1000; + const filtered = all.filter((v) => { + const s = getVoucherStatus(v); + if (filter === "eingeloest") return s === "eingeloest"; + if (filter === "abgelaufen") return s === "abgelaufen"; + if (filter === "bald") + return ( + s === "aktiv" && + v.ablaufdatum && + new Date(v.ablaufdatum).getTime() >= now && + new Date(v.ablaufdatum).getTime() <= in14d + ); + if (filter === "aktiv") return s === "aktiv"; + return true; + }); + return NextResponse.json(filtered); +} + +export async function POST(req: NextRequest) { + const body = await req.json(); + const input = { + name: String(body.name ?? ""), + anbieter: String(body.anbieter ?? ""), + wertCents: Number(body.wertCents), + restCents: body.restCents === undefined || body.restCents === null || body.restCents === "" ? Number(body.wertCents) : Number(body.restCents), + ablaufdatum: body.ablaufdatum ? String(body.ablaufdatum) : null, + codeText: body.codeText ? String(body.codeText) : null, + kategorie: body.kategorie ? String(body.kategorie) : null, + notiz: body.notiz ? String(body.notiz) : null, + }; + const errs = validateVoucher(input); + if (errs.length) return NextResponse.json({ errors: errs }, { status: 400 }); + const db = getDb(); + const created = await db.voucher.create({ + data: { + name: input.name.trim(), + anbieter: input.anbieter.trim(), + wertCents: input.wertCents, + restCents: input.restCents, + ablaufdatum: input.ablaufdatum ? new Date(input.ablaufdatum) : null, + codeText: input.codeText, + kategorie: input.kategorie, + notiz: input.notiz, + }, + }); + return NextResponse.json(created, { status: 201 }); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..8359656 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,95 @@ +import Link from "next/link"; +import { Suspense } from "react"; +import { getDb } from "@/lib/db"; +import { getVoucherStatus } from "@/lib/voucher-status"; +import { formatEuro } from "@/lib/vouchers"; + +const FILTERS = [ + { key: "", label: "Alle" }, + { key: "aktiv", label: "Aktiv" }, + { key: "bald", label: "Bald ablaufend" }, + { key: "abgelaufen", label: "Abgelaufen" }, + { key: "eingeloest", label: "Eingelöst" }, +] as const; + +function Skeleton() { + return ( + + ); +} + +async function VoucherList({ filter }: { filter: string }) { + const db = getDb(); + const all = await db.voucher.findMany({ orderBy: { createdAt: "desc" } }); + const now = Date.now(); + const in14d = now + 14 * 24 * 3600 * 1000; + const filtered = all.filter((v) => { + const s = getVoucherStatus(v); + if (filter === "eingeloest") return s === "eingeloest"; + if (filter === "abgelaufen") return s === "abgelaufen"; + if (filter === "bald") + return s === "aktiv" && v.ablaufdatum && +new Date(v.ablaufdatum) >= now && +new Date(v.ablaufdatum) <= in14d; + if (filter === "aktiv") return s === "aktiv"; + return true; + }); + if (filtered.length === 0) { + return ( +
+

Keine Gutscheine gefunden.

+ + Gutschein anlegen + +
+ ); + } + return ( + + ); +} + +export default async function Home({ searchParams }: { searchParams: Promise<{ filter?: string }> }) { + const { filter = "" } = await searchParams; + return ( +
+
+

Gutscheine

+ + Neu + +
+ + }> + + +
+ ); +} diff --git a/app/vouchers/[id]/page.tsx b/app/vouchers/[id]/page.tsx new file mode 100644 index 0000000..6bf2bfa --- /dev/null +++ b/app/vouchers/[id]/page.tsx @@ -0,0 +1,72 @@ +"use client"; +import { use, useEffect, useState } from "react"; +import Link from "next/link"; +import { formatEuro } from "@/lib/vouchers"; + +type Voucher = { + id: number; + name: string; + anbieter: string; + wertCents: number; + restCents: number; + ablaufdatum: string | null; + codeText: string | null; + kategorie: string | null; + notiz: string | null; + status: string; +}; + +export default function VoucherDetailPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = use(params); + const [v, setV] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + useEffect(() => { + fetch(`/api/vouchers/${id}`) + .then((r) => (r.ok ? r.json() : Promise.reject(new Error("not found")))) + .then((d) => setV(d)) + .catch(() => setError("Gutschein nicht gefunden")) + .finally(() => setLoading(false)); + }, [id]); + + async function redeem() { + const res = await fetch(`/api/vouchers/${id}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: "redeem" }), + }); + if (res.ok) setV(await res.json()); + } + + if (loading) return
; + if (error || !v) + return ( +
+

{error}

+ Zurück zur Liste +
+ ); + + return ( +
+ ← Zurück +

{v.name}

+
+
Anbieter
{v.anbieter}
+
Wert
{formatEuro(v.wertCents)}
+
Rest
{formatEuro(v.restCents)}
+
Status
{v.status}
+ {v.ablaufdatum &&
Ablaufdatum
{new Date(v.ablaufdatum).toLocaleDateString("de-DE")}
} + {v.codeText &&
Code
{v.codeText}
} + {v.kategorie &&
Kategorie
{v.kategorie}
} + {v.notiz &&
Notiz
{v.notiz}
} +
+ {v.status !== "eingeloest" && ( + + )} +
+ ); +} diff --git a/app/vouchers/new/page.tsx b/app/vouchers/new/page.tsx new file mode 100644 index 0000000..1e22ef9 --- /dev/null +++ b/app/vouchers/new/page.tsx @@ -0,0 +1,89 @@ +"use client"; +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { validateVoucher } from "@/lib/vouchers"; + +// Erweiterbar für Task 5 (Kontakte): zusätzliches Feld contactIds + +// ohne bestehende Felder anzufassen — Felder als Konfiguration belassen. +const FIELDS = [ + { name: "name", label: "Name", type: "text" }, + { name: "anbieter", label: "Anbieter", type: "text" }, + { name: "wertCents", label: "Wert (Cent)", type: "number" }, + { name: "restCents", label: "Rest (Cent, leer = Wert)", type: "number" }, + { name: "ablaufdatum", label: "Ablaufdatum", type: "date" }, + { name: "codeText", label: "Code", type: "text" }, + { name: "kategorie", label: "Kategorie", type: "text" }, + { name: "notiz", label: "Notiz", type: "text" }, +] as const; + +export default function NewVoucherPage() { + const router = useRouter(); + const [values, setValues] = useState>({}); + const [errors, setErrors] = useState([]); + const [serverError, setServerError] = useState(""); + + function set(name: string, value: string) { + setValues((v) => ({ ...v, [name]: value })); + } + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + const wertCents = Number(values.wertCents); + const restRaw = values.restCents; + const input = { + name: values.name ?? "", + anbieter: values.anbieter ?? "", + wertCents, + restCents: restRaw === undefined || restRaw === "" ? wertCents : Number(restRaw), + ablaufdatum: values.ablaufdatum || null, + codeText: values.codeText || null, + kategorie: values.kategorie || null, + notiz: values.notiz || null, + }; + const errs = validateVoucher(input); + setErrors(errs); + if (errs.length) return; + const res = await fetch("/api/vouchers", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(input), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setErrors(data.errors ?? []); + setServerError(data.errors ? "" : "Speichern fehlgeschlagen"); + return; + } + const created = await res.json(); + router.push(`/vouchers/${created.id}`); + } + + return ( +
+

Gutschein anlegen

+
+ {FIELDS.map((f) => ( +
+ + set(f.name, e.target.value)} + className="mt-1 w-full rounded border px-3 py-2" + /> + {errors.includes(f.name) &&

Ungültig: {f.name}

} +
+ ))} + {/* Task 5 hook:
…ContactSelect…
*/} + {serverError &&

{serverError}

} + +
+
+ ); +} diff --git a/lib/vouchers.ts b/lib/vouchers.ts new file mode 100644 index 0000000..cff3ad9 --- /dev/null +++ b/lib/vouchers.ts @@ -0,0 +1,12 @@ +export type VoucherInput = { name: string; anbieter: string; wertCents: number; restCents: number; ablaufdatum: string | null; codeText?: string | null; kategorie?: string | null; notiz?: string | null; }; +export function validateVoucher(v: VoucherInput): string[] { + const errs: string[] = []; + if (!v.name?.trim()) errs.push("name"); + if (!v.anbieter?.trim()) errs.push("anbieter"); + if (!Number.isInteger(v.wertCents) || v.wertCents <= 0) errs.push("wertCents"); + if (!Number.isInteger(v.restCents) || v.restCents < 0 || v.restCents > v.wertCents) errs.push("restCents"); + return errs; +} +export function formatEuro(cents: number): string { + return (cents / 100).toLocaleString("de-DE", { style: "currency", currency: "EUR" }); +} diff --git a/tests/vouchers-validate.test.ts b/tests/vouchers-validate.test.ts new file mode 100644 index 0000000..14299db --- /dev/null +++ b/tests/vouchers-validate.test.ts @@ -0,0 +1,12 @@ +import { describe, it, expect } from "vitest"; +import { validateVoucher, formatEuro } from "../lib/vouchers"; +describe("validateVoucher", () => { + it("lehnt negativen Rest und Rest über Wert ab", () => { + const base = { name: "X", anbieter: "Y", wertCents: 5000, restCents: 6000, ablaufdatum: null }; + expect(validateVoucher(base)).toContain("restCents"); + expect(validateVoucher({ ...base, restCents: -1 })).toContain("restCents"); + }); + it("formatEuro zeigt 25,50 €", () => { + expect(formatEuro(2550)).toContain("25,50"); + }); +});