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,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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user