From f2b1d0293ae7a28434d1cda774000d3ddcca3495 Mon Sep 17 00:00:00 2001 From: opencode Date: Sun, 18 Oct 2026 10:33:05 +0200 Subject: [PATCH] feat: contacts and voucher assignment --- app/api/contacts/route.ts | 25 ++++++++++++ app/api/vouchers/[id]/route.ts | 2 +- app/api/vouchers/route.ts | 4 ++ app/contacts/page.tsx | 72 +++++++++++++++++++++++++++++++++ app/vouchers/[id]/page.tsx | 4 ++ app/vouchers/new/page.tsx | 29 +++++++++++-- lib/contacts.ts | 9 +++++ tests/contacts-validate.test.ts | 13 ++++++ 8 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 app/api/contacts/route.ts create mode 100644 app/contacts/page.tsx create mode 100644 lib/contacts.ts create mode 100644 tests/contacts-validate.test.ts diff --git a/app/api/contacts/route.ts b/app/api/contacts/route.ts new file mode 100644 index 0000000..ca9de66 --- /dev/null +++ b/app/api/contacts/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDb } from "@/lib/db"; +import { validateContact } from "@/lib/contacts"; + +export async function GET() { + const db = getDb(); + const all = await db.contact.findMany({ orderBy: { name: "asc" } }); + return NextResponse.json(all); +} + +export async function POST(req: NextRequest) { + const body = await req.json().catch(() => ({})); + const input = { + name: String(body.name ?? ""), + email: body.email ? String(body.email) : null, + telegramChatId: body.telegramChatId ? String(body.telegramChatId) : null, + }; + const errs = validateContact(input); + if (errs.length) return NextResponse.json({ errors: errs }, { status: 400 }); + const db = getDb(); + const created = await db.contact.create({ + data: { name: input.name.trim(), email: input.email?.trim() || null, telegramChatId: input.telegramChatId?.trim() || null }, + }); + return NextResponse.json(created, { status: 201 }); +} diff --git a/app/api/vouchers/[id]/route.ts b/app/api/vouchers/[id]/route.ts index d594a88..ee4ba02 100644 --- a/app/api/vouchers/[id]/route.ts +++ b/app/api/vouchers/[id]/route.ts @@ -6,7 +6,7 @@ export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: const numId = Number(id); if (!Number.isInteger(numId)) return NextResponse.json({ error: "not found" }, { status: 404 }); const db = getDb(); - const v = await db.voucher.findUnique({ where: { id: numId } }); + const v = await db.voucher.findUnique({ where: { id: numId }, include: { contacts: { include: { contact: true } } } }); if (!v) return NextResponse.json({ error: "not found" }, { status: 404 }); return NextResponse.json(v); } diff --git a/app/api/vouchers/route.ts b/app/api/vouchers/route.ts index 8456eab..f8e8319 100644 --- a/app/api/vouchers/route.ts +++ b/app/api/vouchers/route.ts @@ -33,6 +33,9 @@ export async function POST(req: NextRequest) { const errs = validateVoucher(input); if (errs.length) return NextResponse.json({ errors: errs }, { status: 400 }); const db = getDb(); + const contactIds: number[] = Array.isArray(body.contactIds) + ? body.contactIds.map(Number).filter((n: number) => Number.isInteger(n)) + : []; const created = await db.voucher.create({ data: { name: input.name.trim(), @@ -43,6 +46,7 @@ export async function POST(req: NextRequest) { codeText: input.codeText, kategorie: input.kategorie, notiz: input.notiz, + contacts: contactIds.length ? { create: contactIds.map((contactId) => ({ contactId })) } : undefined, }, }); return NextResponse.json(created, { status: 201 }); diff --git a/app/contacts/page.tsx b/app/contacts/page.tsx new file mode 100644 index 0000000..2bdefa3 --- /dev/null +++ b/app/contacts/page.tsx @@ -0,0 +1,72 @@ +"use client"; +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { validateContact } from "@/lib/contacts"; + +type Contact = { id: number; name: string; email: string | null; telegramChatId: string | null }; + +export default function ContactsPage() { + const [contacts, setContacts] = useState([]); + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [telegramChatId, setTelegramChatId] = useState(""); + const [errors, setErrors] = useState([]); + + async function load() { + const res = await fetch("/api/contacts"); + if (res.ok) setContacts(await res.json()); + } + useEffect(() => { load(); }, []); + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + const input = { name, email: email || null, telegramChatId: telegramChatId || null }; + const errs = validateContact(input); + setErrors(errs); + if (errs.length) return; + const res = await fetch("/api/contacts", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(input), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setErrors(data.errors ?? ["speichern"]); + return; + } + setName(""); setEmail(""); setTelegramChatId(""); setErrors([]); + load(); + } + + return ( +
+ ← Zurück +

Kontakte

+
+
+ + setName(e.target.value)} className="mt-1 w-full rounded border px-3 py-2" /> +
+
+ + setEmail(e.target.value)} className="mt-1 w-full rounded border px-3 py-2" /> +
+
+ + setTelegramChatId(e.target.value)} className="mt-1 w-full rounded border px-3 py-2" /> +
+ {errors.length > 0 &&

Ungültig: {errors.join(", ")}

} + +
+
    + {contacts.map((c) => ( +
  • + {c.name} + {c.email && · {c.email}} + {c.telegramChatId && · Telegram {c.telegramChatId}} +
  • + ))} +
+
+ ); +} diff --git a/app/vouchers/[id]/page.tsx b/app/vouchers/[id]/page.tsx index 8f54356..48dafbe 100644 --- a/app/vouchers/[id]/page.tsx +++ b/app/vouchers/[id]/page.tsx @@ -15,6 +15,7 @@ type Voucher = { kategorie: string | null; notiz: string | null; status: string; + contacts?: { contact: { id: number; name: string; email: string | null; telegramChatId: string | null } }[]; }; export default function VoucherDetailPage({ params }: { params: Promise<{ id: string }> }) { @@ -62,6 +63,9 @@ export default function VoucherDetailPage({ params }: { params: Promise<{ id: st {v.codeText &&
Code
{v.codeText}
} {v.kategorie &&
Kategorie
{v.kategorie}
} {v.notiz &&
Notiz
{v.notiz}
} + {v.contacts && v.contacts.length > 0 && ( +
Empfänger
{v.contacts.map((c) => c.contact.name).join(", ")}
+ )} {v.status !== "eingeloest" && (