feat: voucher crud and list
This commit is contained in:
1 parent
73295653ee
commit
660d6cff51
7 files changed
+358
No files matched your search
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<ul aria-label="lädt">
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<li key={i} className="animate-pulse rounded border p-4">
|
||||||
|
<div className="h-4 w-1/3 bg-gray-200" />
|
||||||
|
<div className="mt-2 h-3 w-1/4 bg-gray-100" />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="rounded border p-8 text-center">
|
||||||
|
<p>Keine Gutscheine gefunden.</p>
|
||||||
|
<Link href="/vouchers/new" className="mt-4 inline-block rounded bg-black px-4 py-2 text-white">
|
||||||
|
Gutschein anlegen
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{filtered.map((v) => (
|
||||||
|
<li key={v.id} className="rounded border p-4">
|
||||||
|
<Link href={`/vouchers/${v.id}`} className="font-medium underline">
|
||||||
|
{v.name}
|
||||||
|
</Link>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{v.anbieter} · {formatEuro(v.restCents)} / {formatEuro(v.wertCents)} · {getVoucherStatus(v)}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function Home({ searchParams }: { searchParams: Promise<{ filter?: string }> }) {
|
||||||
|
const { filter = "" } = await searchParams;
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-2xl space-y-4 p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-bold">Gutscheine</h1>
|
||||||
|
<Link href="/vouchers/new" className="rounded bg-black px-4 py-2 text-white">
|
||||||
|
Neu
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<nav className="flex gap-2" aria-label="Filter">
|
||||||
|
{FILTERS.map((f) => (
|
||||||
|
<Link
|
||||||
|
key={f.key}
|
||||||
|
href={f.key ? `/?filter=${f.key}` : "/"}
|
||||||
|
aria-current={filter === f.key ? "page" : undefined}
|
||||||
|
className={`rounded border px-3 py-1 text-sm ${filter === f.key ? "bg-black text-white" : ""}`}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<Suspense fallback={<Skeleton />}>
|
||||||
|
<VoucherList filter={filter} />
|
||||||
|
</Suspense>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Voucher | null>(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 <main className="mx-auto max-w-xl animate-pulse space-y-2 p-6"><div className="h-6 w-1/2 bg-gray-200" /><div className="h-4 w-1/3 bg-gray-100" /></main>;
|
||||||
|
if (error || !v)
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-xl space-y-4 p-6">
|
||||||
|
<p>{error}</p>
|
||||||
|
<Link href="/" className="underline">Zurück zur Liste</Link>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-xl space-y-3 p-6">
|
||||||
|
<Link href="/" className="text-sm underline">← Zurück</Link>
|
||||||
|
<h1 className="text-xl font-bold">{v.name}</h1>
|
||||||
|
<dl className="space-y-1 text-sm">
|
||||||
|
<div><dt className="font-medium">Anbieter</dt><dd>{v.anbieter}</dd></div>
|
||||||
|
<div><dt className="font-medium">Wert</dt><dd>{formatEuro(v.wertCents)}</dd></div>
|
||||||
|
<div><dt className="font-medium">Rest</dt><dd>{formatEuro(v.restCents)}</dd></div>
|
||||||
|
<div><dt className="font-medium">Status</dt><dd>{v.status}</dd></div>
|
||||||
|
{v.ablaufdatum && <div><dt className="font-medium">Ablaufdatum</dt><dd>{new Date(v.ablaufdatum).toLocaleDateString("de-DE")}</dd></div>}
|
||||||
|
{v.codeText && <div><dt className="font-medium">Code</dt><dd>{v.codeText}</dd></div>}
|
||||||
|
{v.kategorie && <div><dt className="font-medium">Kategorie</dt><dd>{v.kategorie}</dd></div>}
|
||||||
|
{v.notiz && <div><dt className="font-medium">Notiz</dt><dd>{v.notiz}</dd></div>}
|
||||||
|
</dl>
|
||||||
|
{v.status !== "eingeloest" && (
|
||||||
|
<button onClick={redeem} className="rounded bg-black px-4 py-2 text-white">
|
||||||
|
Einlösen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 + <ContactSelect />
|
||||||
|
// 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<Record<string, string>>({});
|
||||||
|
const [errors, setErrors] = useState<string[]>([]);
|
||||||
|
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 (
|
||||||
|
<main className="mx-auto max-w-xl space-y-4 p-6">
|
||||||
|
<h1 className="text-xl font-bold">Gutschein anlegen</h1>
|
||||||
|
<form onSubmit={onSubmit} className="space-y-3" noValidate>
|
||||||
|
{FIELDS.map((f) => (
|
||||||
|
<div key={f.name}>
|
||||||
|
<label htmlFor={f.name} className="block text-sm font-medium">
|
||||||
|
{f.label}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={f.name}
|
||||||
|
name={f.name}
|
||||||
|
type={f.type}
|
||||||
|
value={values[f.name] ?? ""}
|
||||||
|
onChange={(e) => set(f.name, e.target.value)}
|
||||||
|
className="mt-1 w-full rounded border px-3 py-2"
|
||||||
|
/>
|
||||||
|
{errors.includes(f.name) && <p className="mt-1 text-sm text-red-600">Ungültig: {f.name}</p>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{/* Task 5 hook: <section id="kontakte">…ContactSelect…</section> */}
|
||||||
|
{serverError && <p className="text-sm text-red-600">{serverError}</p>}
|
||||||
|
<button type="submit" className="rounded bg-black px-4 py-2 text-white">
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" });
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user