feat: gemeinsames App-Design mit Seitenleiste, globalem Theme und neuem Login
This commit is contained in:
1 parent
be6aac86ce
commit
fa1e8c2c33
15 files changed
+1248
-189
No files matched your search
+56
-22
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import { use, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Shell from "../../components/shell";
|
||||
import { formatEuro } from "@/lib/vouchers";
|
||||
import { getVoucherStatus } from "@/lib/voucher-status";
|
||||
|
||||
@@ -18,6 +19,15 @@ type Voucher = {
|
||||
contacts?: { contact: { id: number; name: string; email: string | null; telegramChatId: string | null } }[];
|
||||
};
|
||||
|
||||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[120px_1fr] gap-3 py-2.5">
|
||||
<dt className="text-sm text-muted">{label}</dt>
|
||||
<dd className="text-sm font-medium">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VoucherDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params);
|
||||
const [v, setV] = useState<Voucher | null>(null);
|
||||
@@ -41,37 +51,61 @@ export default function VoucherDetailPage({ params }: { params: Promise<{ id: st
|
||||
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 (loading)
|
||||
return (
|
||||
<Shell>
|
||||
<div className="animate-pulse rounded-card border border-line bg-white p-6">
|
||||
<div className="h-6 w-1/2 rounded bg-line" />
|
||||
<div className="mt-2 h-4 w-1/3 rounded bg-wash" />
|
||||
</div>
|
||||
</Shell>
|
||||
);
|
||||
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>
|
||||
<Shell>
|
||||
<div className="rounded-card border border-line bg-white p-8 text-center">
|
||||
<p className="text-sm text-muted">{error}</p>
|
||||
<Link href="/" className="mt-4 inline-block text-sm font-semibold text-brand-700 underline">
|
||||
Zurück zur Liste
|
||||
</Link>
|
||||
</div>
|
||||
</Shell>
|
||||
);
|
||||
|
||||
const status = getVoucherStatus({
|
||||
status: v.status,
|
||||
ablaufdatum: v.ablaufdatum ? new Date(v.ablaufdatum) : null,
|
||||
});
|
||||
|
||||
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>{getVoucherStatus({ status: v.status, ablaufdatum: v.ablaufdatum ? new Date(v.ablaufdatum) : null })}</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>}
|
||||
{v.contacts && v.contacts.length > 0 && (
|
||||
<div><dt className="font-medium">Empfänger</dt><dd>{v.contacts.map((c) => c.contact.name).join(", ")}</dd></div>
|
||||
<Shell>
|
||||
<Link href="/" className="text-sm font-medium text-muted hover:text-ink">
|
||||
← Zurück
|
||||
</Link>
|
||||
<h1 className="mt-2 text-2xl font-semibold tracking-tight">{v.name}</h1>
|
||||
<section aria-label="Details" className="mt-4 divide-y divide-line rounded-card border border-line bg-white px-5 py-2">
|
||||
<Row label="Anbieter">{v.anbieter}</Row>
|
||||
<Row label="Wert">{formatEuro(v.wertCents)}</Row>
|
||||
<Row label="Restguthaben">{formatEuro(v.restCents)}</Row>
|
||||
<Row label="Status">{status === "aktiv" ? "Aktiv" : status === "abgelaufen" ? "Abgelaufen" : "Eingelöst"}</Row>
|
||||
{v.ablaufdatum && (
|
||||
<Row label="Ablaufdatum">{new Date(v.ablaufdatum).toLocaleDateString("de-DE")}</Row>
|
||||
)}
|
||||
</dl>
|
||||
{v.codeText && <Row label="Code">{v.codeText}</Row>}
|
||||
{v.kategorie && <Row label="Kategorie">{v.kategorie}</Row>}
|
||||
{v.notiz && <Row label="Notiz">{v.notiz}</Row>}
|
||||
{v.contacts && v.contacts.length > 0 && (
|
||||
<Row label="Empfänger">{v.contacts.map((c) => c.contact.name).join(", ")}</Row>
|
||||
)}
|
||||
</section>
|
||||
{v.status !== "eingeloest" && (
|
||||
<button onClick={redeem} className="rounded bg-black px-4 py-2 text-white">
|
||||
<button
|
||||
onClick={redeem}
|
||||
className="mt-4 h-12 w-full rounded-field bg-brand-600 text-sm font-semibold text-white transition-colors hover:bg-brand-700 active:scale-[0.99] sm:w-auto sm:px-8"
|
||||
>
|
||||
Einlösen
|
||||
</button>
|
||||
)}
|
||||
</main>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
+85
-48
@@ -1,21 +1,26 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import Shell from "../../components/shell";
|
||||
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" },
|
||||
{ name: "name", label: "Name", type: "text", placeholder: "z. B. Therme Erding" },
|
||||
{ name: "anbieter", label: "Anbieter", type: "text", placeholder: "z. B. Therme Erding" },
|
||||
{ name: "wertCents", label: "Wert (Cent)", type: "number", placeholder: "5000" },
|
||||
{ name: "restCents", label: "Rest (Cent, leer = Wert)", type: "number", placeholder: "" },
|
||||
{ name: "ablaufdatum", label: "Ablaufdatum", type: "date", placeholder: "" },
|
||||
{ name: "codeText", label: "Code", type: "text", placeholder: "Code oder Nummer" },
|
||||
{ name: "kategorie", label: "Kategorie", type: "text", placeholder: "z. B. Erlebnis" },
|
||||
{ name: "notiz", label: "Notiz", type: "text", placeholder: "Optionale Notiz" },
|
||||
] as const;
|
||||
|
||||
const inputClass =
|
||||
"mt-1.5 h-11 w-full rounded-field border border-line bg-white px-3.5 text-sm text-ink placeholder:text-muted/60";
|
||||
|
||||
export default function NewVoucherPage() {
|
||||
const router = useRouter();
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
@@ -69,27 +74,37 @@ export default function NewVoucherPage() {
|
||||
}
|
||||
|
||||
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>
|
||||
))}
|
||||
<div>
|
||||
<label htmlFor="reminderDays" className="block text-sm font-medium">
|
||||
<Shell>
|
||||
<Link href="/" className="text-sm font-medium text-muted hover:text-ink">
|
||||
← Zurück
|
||||
</Link>
|
||||
<h1 className="mt-2 text-2xl font-semibold tracking-tight">Gutschein anlegen</h1>
|
||||
<p className="mt-1 text-sm text-muted">Alle Angaben lassen sich später noch ändern.</p>
|
||||
<form onSubmit={onSubmit} className="mt-5 rounded-card border border-line bg-white p-5 sm:p-6" noValidate>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{FIELDS.map((f) => (
|
||||
<div key={f.name} className={f.name === "notiz" ? "sm:col-span-2" : undefined}>
|
||||
<label htmlFor={f.name} className="block text-[13px] font-semibold">
|
||||
{f.label}
|
||||
</label>
|
||||
<input
|
||||
id={f.name}
|
||||
name={f.name}
|
||||
type={f.type}
|
||||
value={values[f.name] ?? ""}
|
||||
placeholder={f.placeholder || undefined}
|
||||
onChange={(e) => set(f.name, e.target.value)}
|
||||
aria-invalid={errors.includes(f.name)}
|
||||
className={`${inputClass} ${errors.includes(f.name) ? "border-danger" : ""}`}
|
||||
/>
|
||||
{errors.includes(f.name) && (
|
||||
<p className="mt-1 text-[13px] font-medium text-danger">Bitte prüfe dieses Feld.</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<label htmlFor="reminderDays" className="block text-[13px] font-semibold">
|
||||
Erinnerung (Tage vorher, Kommaliste)
|
||||
</label>
|
||||
<input
|
||||
@@ -99,30 +114,52 @@ export default function NewVoucherPage() {
|
||||
value={reminderDays}
|
||||
onChange={(e) => setReminderDays(e.target.value)}
|
||||
placeholder="30,6,1"
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
aria-invalid={errors.includes("reminderDays") || errors.includes("reminder")}
|
||||
className={`${inputClass} ${errors.includes("reminderDays") || errors.includes("reminder") ? "border-danger" : ""}`}
|
||||
/>
|
||||
{errors.includes("reminderDays") && <p className="mt-1 text-sm text-red-600">Ungültig: reminderDays</p>}
|
||||
{(errors.includes("reminderDays") || errors.includes("reminder")) && (
|
||||
<p className="mt-1 text-[13px] font-medium text-danger">Mindestens ein gültiger Tag nötig.</p>
|
||||
)}
|
||||
</div>
|
||||
<section id="kontakte">
|
||||
<h2 className="text-sm font-medium">Empfänger</h2>
|
||||
{contacts.map((c) => (
|
||||
<label key={c.id} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={contactIds.includes(c.id)}
|
||||
onChange={(e) =>
|
||||
setContactIds((ids) => (e.target.checked ? [...ids, c.id] : ids.filter((x) => x !== c.id)))
|
||||
}
|
||||
/>
|
||||
{c.name}
|
||||
</label>
|
||||
))}
|
||||
<section id="kontakte" aria-label="Empfänger" className="mt-5 rounded-field bg-wash p-4">
|
||||
<h2 className="text-[13px] font-semibold">Empfänger</h2>
|
||||
<p className="mt-0.5 text-[13px] text-muted">Wer soll an diesen Gutschein erinnert werden?</p>
|
||||
{contacts.length === 0 ? (
|
||||
<p className="mt-2 text-[13px] text-muted">
|
||||
Noch keine Kontakte. Lege sie unter <Link href="/contacts" className="font-semibold text-brand-700 underline">Kontakte</Link> an.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-2 grid gap-1.5">
|
||||
{contacts.map((c) => (
|
||||
<li key={c.id}>
|
||||
<label className="flex cursor-pointer items-center gap-2.5 rounded-lg px-1 py-1 text-sm hover:bg-white">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={contactIds.includes(c.id)}
|
||||
onChange={(e) =>
|
||||
setContactIds((ids) => (e.target.checked ? [...ids, c.id] : ids.filter((x) => x !== c.id)))
|
||||
}
|
||||
className="h-4 w-4 accent-brand-700"
|
||||
/>
|
||||
{c.name}
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
{serverError && <p className="text-sm text-red-600">{serverError}</p>}
|
||||
<button type="submit" className="rounded bg-black px-4 py-2 text-white">
|
||||
{serverError && (
|
||||
<p role="alert" className="mt-4 rounded-field bg-danger/10 px-3.5 py-2.5 text-sm font-medium text-danger">
|
||||
{serverError}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
className="mt-5 h-12 w-full rounded-field bg-brand-600 text-sm font-semibold text-white transition-colors hover:bg-brand-700 active:scale-[0.99] sm:w-auto sm:px-8"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user