Files
Wabenhain/src/app/rechnung/[invoiceNumber]/page.tsx
T
Nick SzaunigandClaude Opus 5.5 6950657c91 feat(ui): Dunkelmodus fuer Shop und Admin mit Umschalter
Rollen-Farben statt reiner Markenfarben, damit hell und dunkel aus einem
Satz Klassen entstehen:
- heading (Ueberschriften, Links, Rahmen): hell Waldgruen, dunkel Creme
- surface (abgesetzte Flaeche): hell Creme, dunkel angehobenes Dunkelgruen
- forest bleibt die dunkle Markenflaeche (Hero, Baender, Knoepfe,
  Admin-Leiste), cream der helle Text darauf
- honey-deep als fester Honigton fuer Flaechen; honey-dark (Text) wird
  im Dunkelmodus heller und bleibt lesbar
Codemod ueber src: text-/border-forest -> *-heading, bg-cream ->
bg-surface, bg-honey-dark -> bg-honey-deep. Im hellen Modus haben die
Rollen exakt die bisherigen Werte - das Erscheinungsbild bleibt gleich.

Umschalter (components/theme-toggle.tsx) Automatisch -> Hell -> Dunkel in
Kopfzeile, Mobilmenue und Admin-Leiste. Die Wahl liegt im Cookie "theme"
(nur light/dark, geprueft in lib/theme.ts); der Server setzt data-theme
schon beim Ausliefern, nichts blitzt hell auf. "Automatisch" folgt der
Systemeinstellung. Gewaehlter und automatischer Dunkelblock in
globals.css werden per Test auf Gleichheit geprueft.

Logo, Biene und Select-Pfeil nutzen die Rollenfarben. In der Kopfzeile
steht "Honig entdecken" zwischen 1280 und 1535 px nicht mehr doppelt
neben "Shop" (sonst stiess die Navigation an), der Slogan erscheint erst
ab 1680 px.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 06:12:44 +02:00

146 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Metadata } from "next";
import { noticeUrl } from "@/lib/notice";
import { noIndexPrivate } from "@/lib/seo";
import { notFound, redirect } from "next/navigation";
import { Container } from "@/components/ui";
import { BackLink } from "@/components/back-link";
import { getCurrentCustomer } from "@/lib/auth";
import { getInvoiceForCustomer } from "@/lib/account-data";
import { formatPrice, formatDate } from "@/lib/format";
import { getCompanyInfo } from "@/lib/company-info";
import { invoiceVatBreakdown } from "@/lib/vat";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
// Rechnungen enthalten Name, Anschrift und Kaufhistorie. noIndexPrivate
// unterbindet zusaetzlich Cache-Kopie und Snippet (siehe lib/seo.ts).
export const metadata: Metadata = { title: "Rechnung", robots: noIndexPrivate };
export const dynamic = "force-dynamic";
export default async function InvoicePage(props: PageProps<"/rechnung/[invoiceNumber]">) {
const t = getDictionary(await getLocale());
const { invoiceNumber } = await props.params;
const customer = await getCurrentCustomer();
if (!customer) redirect(noticeUrl("invoice-login"));
const invoice = await getInvoiceForCustomer(invoiceNumber, customer.id);
if (!invoice) notFound();
const { order } = invoice;
const company = await getCompanyInfo();
// Aufstellung je Steuersatz — eine Bestellung kann Honig (ermaessigt) und
// eine Bienenwachskerze (voll) zugleich enthalten. Dieselbe Funktion nutzt
// das PDF, damit beide Darstellungen desselben Belegs uebereinstimmen.
const vatLines = invoiceVatBreakdown(order);
const isSmallBusiness = vatLines.length > 0 && vatLines.every((line) => line.ratePercent === 0);
return (
<div className="py-16 print:py-0">
<Container className="max-w-2xl">
<div className="print:hidden flex items-center justify-between">
<BackLink href="/konto/bestellungen" label={t.invoicePage.backToOrders} />
<a
href={`/rechnung/${invoice.invoiceNumber}/pdf`}
className="mb-6 text-sm text-heading underline"
>
{t.invoicePage.downloadPdf}
</a>
</div>
<div className="flex justify-between items-start">
<div>
<p className="text-2xl tracking-[0.2em] font-serif text-heading">{company.name.toUpperCase()}</p>
<p className="mt-2 text-sm text-ink/70">
{company.address.street}
<br />
{company.address.zip} {company.address.city}
</p>
</div>
<div className="text-right text-sm text-ink/70">
<p className="text-heading">{t.invoicePage.invoiceLabel(invoice.invoiceNumber)}</p>
<p>{t.invoicePage.orderLabel(order.orderNumber)}</p>
<p>{formatDate(invoice.issuedAt)}</p>
</div>
</div>
<div className="mt-10 text-sm text-ink/75">
<p>{order.firstName} {order.lastName}</p>
<p>{order.street}</p>
<p>{order.zip} {order.city}</p>
</div>
<table className="mt-10 w-full text-sm">
<thead className="text-left text-ink/65 border-b border-ink/10">
<tr>
<th className="py-2">{t.invoicePage.itemHeader}</th>
<th className="py-2 text-right">{t.invoicePage.quantityHeader}</th>
<th className="py-2 text-right">{t.invoicePage.priceHeader}</th>
<th className="py-2 text-right">{t.invoicePage.totalHeader}</th>
</tr>
</thead>
<tbody className="divide-y divide-ink/10">
{order.items.map((item) => (
<tr key={item.id}>
<td className="py-2">{item.productName} ({item.weightGrams} g)</td>
<td className="py-2 text-right">{item.quantity}</td>
<td className="py-2 text-right">{formatPrice(item.unitPriceCents)}</td>
<td className="py-2 text-right">{formatPrice(item.totalCents)}</td>
</tr>
))}
</tbody>
</table>
<div className="mt-6 flex justify-end">
<div className="w-56 space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-ink/70">{t.invoicePage.subtotalLabel}</span>
<span>{formatPrice(order.subtotalCents, order.currency)}</span>
</div>
<div className="flex justify-between">
<span className="text-ink/70">{t.invoicePage.shippingLabel}</span>
<span>
{order.shippingCents === 0 ? t.invoicePage.freeShipping : formatPrice(order.shippingCents, order.currency)}
</span>
</div>
{order.discountCents > 0 ? (
<div className="flex justify-between">
<span className="text-ink/70">
{t.invoicePage.discountLabel}{order.discountCode ? ` (${order.discountCode.code})` : ""}
</span>
<span>−{formatPrice(order.discountCents, order.currency)}</span>
</div>
) : null}
{/* Steuerausweis mit demselben an der Rechnung festgeschriebenen
Satz wie im PDF — beide Darstellungen desselben Belegs muessen
uebereinstimmen. */}
{vatLines
.filter((line) => line.ratePercent > 0)
.map((line) => (
<div key={line.ratePercent}>
<div className="flex justify-between">
<span className="text-ink/70">{t.invoicePage.netLabel}</span>
<span>{formatPrice(line.netCents, order.currency)}</span>
</div>
<div className="flex justify-between">
<span className="text-ink/70">{t.invoicePage.vatLabel(line.ratePercent)}</span>
<span>{formatPrice(line.vatCents, order.currency)}</span>
</div>
</div>
))}
<div className="flex justify-between text-heading pt-2 border-t border-ink/10">
<span>{t.invoicePage.totalLabel}</span>
<span>{formatPrice(invoice.totalCents, order.currency)}</span>
</div>
{/* Pflichtangabe bei Kleinunternehmerregelung — steht anstelle der
Steuerzeilen, nicht zusaetzlich. */}
{isSmallBusiness ? (
<p className="pt-2 text-xs text-ink/65">{t.invoicePage.smallBusinessNote}</p>
) : null}
</div>
</div>
<p className="mt-16 text-xs text-ink/65">{t.invoicePage.legalNote}</p>
</Container>
</div>
);
}