208 lines
8.3 KiB
TypeScript
208 lines
8.3 KiB
TypeScript
import Link from "next/link";
|
|
import { Suspense } from "react";
|
|
import Shell from "./components/shell";
|
|
import LogoImg from "./components/logo-img";
|
|
import OverviewToolbar from "./components/overview-toolbar";
|
|
import { withLogoUrl } from "@/lib/logo";
|
|
import type { Voucher as DbVoucher } from "@prisma/client";
|
|
import { getDb } from "@/lib/db";
|
|
import { getViewer } from "@/lib/viewer";
|
|
import { scopeWhere } from "@/lib/access";
|
|
import { getVoucherStatus, type VoucherStatus } from "@/lib/voucher-status";
|
|
import { formatEuro, formatMindestbestellwert, formatWert } from "@/lib/vouchers";
|
|
import { filterVouchers, sortVouchers, groupVouchers, listCategories } from "@/lib/voucher-view";
|
|
|
|
const STATUS_STYLE: Record<VoucherStatus, string> = {
|
|
aktiv: "bg-brand-50 text-brand-800",
|
|
abgelaufen: "bg-danger/10 text-danger",
|
|
eingeloest: "bg-wash text-muted",
|
|
};
|
|
|
|
function statusLabel(s: VoucherStatus) {
|
|
if (s === "aktiv") return "Aktiv";
|
|
if (s === "abgelaufen") return "Abgelaufen";
|
|
return "Eingelöst";
|
|
}
|
|
|
|
type VoucherRow = DbVoucher & { logoUrl: string | null };
|
|
|
|
function StatusChip({ s }: { s: VoucherStatus }) {
|
|
return (
|
|
<span className={`shrink-0 rounded-full px-2.5 py-1 text-xs font-semibold ${STATUS_STYLE[s]}`}>
|
|
{statusLabel(s)}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function ListItem({ v }: { v: VoucherRow }) {
|
|
const s = getVoucherStatus(v);
|
|
return (
|
|
<li className="rounded-card border border-line bg-white p-5 transition-shadow hover:shadow-[0_8px_24px_rgba(32,55,39,0.08)]">
|
|
<div className="flex items-center gap-3">
|
|
<LogoImg logoUrl={v.logoUrl ?? null} name={v.anbieter} />
|
|
<div className="flex min-w-0 flex-1 items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<Link href={`/vouchers/${v.id}`} className="text-[15px] font-semibold tracking-tight hover:text-brand-700">
|
|
{v.name}
|
|
</Link>
|
|
<p className="mt-1 text-sm text-muted">
|
|
{v.anbieter} ·{" "}
|
|
{v.wertTyp === "prozent" ? formatWert(v) : `${formatEuro(v.restCents)} von ${formatEuro(v.wertCents)}`}
|
|
{v.ablaufdatum ? ` · bis ${v.ablaufdatum.toLocaleDateString("de-DE")}` : ""}
|
|
</p>
|
|
<p className="mt-0.5 text-[13px] text-muted">
|
|
{v.createdBy ? `Angelegt von ${v.createdBy}` : "Ersteller nicht erfasst"}
|
|
{v.mindestbestellwertCents != null ? ` · ${formatMindestbestellwert(v.mindestbestellwertCents)}` : ""}
|
|
</p>
|
|
</div>
|
|
<StatusChip s={s} />
|
|
</div>
|
|
</div>
|
|
</li>
|
|
);
|
|
}
|
|
|
|
function KachelItem({ v }: { v: VoucherRow }) {
|
|
const s = getVoucherStatus(v);
|
|
return (
|
|
<li className="flex flex-col rounded-card border border-line bg-white transition-shadow hover:shadow-[0_8px_24px_rgba(32,55,39,0.08)]">
|
|
<Link href={`/vouchers/${v.id}`} className="block flex-1 p-5 pb-3" aria-label={`Gutschein ${v.name} öffnen`}>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<LogoImg logoUrl={v.logoUrl ?? null} name={v.anbieter} size={48} />
|
|
<StatusChip s={s} />
|
|
</div>
|
|
<p className="mt-3 text-[15px] font-semibold tracking-tight">
|
|
{v.name}
|
|
</p>
|
|
<p className="mt-1 text-sm text-muted">{v.anbieter}</p>
|
|
<p className="mt-2 text-sm font-semibold">
|
|
{v.wertTyp === "prozent" ? formatWert(v) : `${formatEuro(v.restCents)} von ${formatEuro(v.wertCents)}`}
|
|
</p>
|
|
<p className="mt-1 text-[13px] text-muted">
|
|
{v.ablaufdatum ? `Bis ${v.ablaufdatum.toLocaleDateString("de-DE")}` : "Kein Ablaufdatum"}
|
|
{v.mindestbestellwertCents != null ? ` · ${formatMindestbestellwert(v.mindestbestellwertCents)}` : ""}
|
|
</p>
|
|
</Link>
|
|
</li>
|
|
);
|
|
}
|
|
|
|
function Skeleton() {
|
|
return (
|
|
<ul aria-label="lädt" className="grid gap-3">
|
|
{[0, 1, 2].map((i) => (
|
|
<li key={i} className="animate-pulse rounded-card border border-line bg-white p-5">
|
|
<div className="h-4 w-1/3 rounded bg-line" />
|
|
<div className="mt-2 h-3 w-1/4 rounded bg-wash" />
|
|
</li>
|
|
))}
|
|
</ul>
|
|
);
|
|
}
|
|
|
|
async function VoucherOverview({
|
|
params,
|
|
}: {
|
|
params: { filter: string; q: string; kat: string; typ: string; sort: string; ansicht: string; gruppe: string; bestand: string };
|
|
}) {
|
|
const viewer = await getViewer();
|
|
if (!viewer) return null;
|
|
const effectiveBestand = params.bestand === "private" || viewer.householdId === null ? "private" : "household";
|
|
const db = getDb();
|
|
const scoped = await db.voucher.findMany({ where: scopeWhere(viewer), orderBy: { createdAt: "desc" } });
|
|
const inBestand = scoped.filter((v) =>
|
|
effectiveBestand === "household" ? v.householdId === viewer.householdId : v.householdId === null
|
|
);
|
|
const kategorien = listCategories(inBestand);
|
|
const filtered = sortVouchers(
|
|
filterVouchers(inBestand, { q: params.q, status: params.filter, kategorie: params.kat, typ: params.typ }),
|
|
params.sort
|
|
);
|
|
const withLogos = await Promise.all(filtered.map(withLogoUrl));
|
|
const groups = groupVouchers(withLogos, params.gruppe);
|
|
const kachel = params.ansicht === "kachel";
|
|
if (withLogos.length === 0) {
|
|
return (
|
|
<div className="rounded-card border border-line bg-white p-10 text-center">
|
|
<p className="text-sm text-muted">Keine Gutscheine gefunden.</p>
|
|
<Link
|
|
href="/vouchers/new"
|
|
className="mt-4 inline-block rounded-field bg-brand-600 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-brand-700"
|
|
>
|
|
Gutschein anlegen
|
|
</Link>
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div className="grid gap-6">
|
|
{groups.map((g) => (
|
|
<section key={g.title || "alle"} aria-label={g.title || "Alle Gutscheine"}>
|
|
{g.title ? (
|
|
<details open>
|
|
<summary className="cursor-pointer text-[15px] font-semibold tracking-tight">
|
|
{g.title} <span className="font-normal text-muted">({g.items.length})</span>
|
|
</summary>
|
|
<ul className={kachel ? "mt-3 grid gap-3 sm:grid-cols-2" : "mt-3 grid gap-3"}>
|
|
{g.items.map((v) => (kachel ? <KachelItem key={v.id} v={v} /> : <ListItem key={v.id} v={v} />))}
|
|
</ul>
|
|
</details>
|
|
) : (
|
|
<ul className={kachel ? "grid gap-3 sm:grid-cols-2" : "grid gap-3"}>
|
|
{g.items.map((v) => (kachel ? <KachelItem key={v.id} v={v} /> : <ListItem key={v.id} v={v} />))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default async function Home({
|
|
searchParams,
|
|
}: {
|
|
searchParams: Promise<{ filter?: string; q?: string; kat?: string; typ?: string; sort?: string; ansicht?: string; gruppe?: string; bestand?: string }>;
|
|
}) {
|
|
const viewer = await getViewer();
|
|
if (!viewer) return null;
|
|
const sp = await searchParams;
|
|
const bestand = sp.bestand === "private" || viewer.householdId === null ? "private" : "household";
|
|
const current = {
|
|
filter: sp.filter ?? "",
|
|
q: sp.q ?? "",
|
|
kat: sp.kat ?? "",
|
|
typ: sp.typ ?? "",
|
|
sort: sp.sort ?? "neu",
|
|
ansicht: sp.ansicht ?? "liste",
|
|
gruppe: sp.gruppe ?? "keine",
|
|
bestand,
|
|
householdName: viewer.householdName,
|
|
};
|
|
const db = getDb();
|
|
const scoped = await db.voucher.findMany({ where: scopeWhere(viewer), select: { kategorie: true, householdId: true } });
|
|
const inBestand = scoped.filter((v) => (bestand === "household" ? v.householdId === viewer.householdId : v.householdId === null));
|
|
const kategorien = listCategories(inBestand);
|
|
return (
|
|
<Shell>
|
|
<div className="mb-5">
|
|
<h1 className="text-2xl font-semibold tracking-tight">Gutscheine</h1>
|
|
<p className="mt-1 text-sm text-muted">
|
|
{viewer.householdName ? `Gemeinsamer Bestand: ${viewer.householdName}.` : "Deine privaten Gutscheine."}
|
|
</p>
|
|
</div>
|
|
<OverviewToolbar current={current} kategorien={kategorien} />
|
|
<div className="mb-5">
|
|
<Link
|
|
href="/vouchers/new"
|
|
className="block h-12 rounded-field bg-brand-600 text-center text-sm font-semibold leading-[3rem] text-white transition-colors hover:bg-brand-700 sm:inline-block sm:h-auto sm:px-6 sm:py-2.5 sm:leading-normal"
|
|
>
|
|
Neuer Gutschein
|
|
</Link>
|
|
</div>
|
|
<Suspense fallback={<Skeleton />}>
|
|
<VoucherOverview params={current} />
|
|
</Suspense>
|
|
</Shell>
|
|
);
|
|
}
|