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 (
+
+ {[0, 1, 2].map((i) => (
+ -
+
+
+
+ ))}
+
+ );
+}
+
+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
+
+
+ );
+}
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");
+ });
+});