Files

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>
);
}