Compare commits

..
8 Commits
Author SHA1 Message Date
Nick SzaunigandClaude Opus 5.5 ae2726b0ee refactor(admin): ApiCore-Synchronisierung nur noch einmal auf dem Dashboard
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m25s
Derselbe "Alles synchronisieren"-Knopf stand auf /admin/produkte und
/admin/ernten und sah dadurch wie zwei verschiedene Abgleiche aus. Er
sitzt jetzt einmal auf dem Dashboard, das Ergebnis des Laufs erscheint
dort oben. Die Action springt standardmaessig nach /admin zurueck; die
alten Ziele bleiben in der Whitelist erlaubt.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:34:18 +02:00
Nick SzaunigandClaude Opus 5.5 11eccec01b feat(ui): Polish-Durchgang Admin und Mobil, Erstattung nur mit Rueckfrage
Sicherheit: "Jetzt per Stripe erstatten" loeste die Rueckerstattung bisher
mit einem einzigen Klick aus. Erstatten und Ablehnen einer Retoure laufen
jetzt ueber ConfirmForm; die Rueckfrage nennt Bestellnummer und Betrag.

Admin: Produkte, Bestellungen und Retouren nutzen dieselben Bausteine wie
die uebrigen Listen (Seitenkopf, Suche mit Zusatzfiltern und Archiv-
Parameter, Status-Badges, Leerzustaende). Produkttabelle ohne seitliches
Scrollen bei 1440 px: Bearbeiten ueber den Produktnamen, Charge ohne
Umbruch. Geschenk-Markierung als SVG statt Emoji. Letzte rohe Knoepfe
(Bestelldetail, ApiCore, Inhalte) auf Button umgestellt.

Mobil (390 px): Ueberlauf auf /konto (Datenexport) und /kontakt
(E-Mail-Adresse) behoben. Tippziele unter 24 px vergroessert
(Zurueck-Link, "Zum Honig", Links auf der Produktseite). Wunschliste mit
SVG-Stern statt Unicode-Zeichen.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:24:55 +02:00
Nick SzaunigandClaude Opus 5.5 b7a75dddca feat(admin): Formulare fuer Gutscheine, Versand, Benutzer, Ernten und Chargen vereinheitlicht
Alle Anlege- und Bearbeiten-Formulare nutzen jetzt die Bausteine aus
components/admin-form.tsx wie das Produktformular: Rahmen mit fester
Speicherleiste, Abschnitte mit Serif-Ueberschrift (Gutschein: Code und
Rabatt / Bedingungen / Zeitraum und Status; Ernte: Angaben / Bilder /
Sichtbarkeit; Charge: Abfuellung und Bestand / Bilder), Schalter statt
Checkbox, buendige Eingabefelder in Rastern.

Benutzer bearbeiten: Sicherheitsbereich mit Status-Badges, Passwort-Reset
als Sekundaerknopf, Sperren/Loeschen in eigenem abgesetzten Bereich.
AdminField/AdminSelect koennen jetzt min und einen Hinweistext.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 21:14:10 +02:00
Nick SzaunigandClaude Opus 5.5 a09057d960 feat(admin): Listen mit Status-Badges, Leerzustaenden und klaren Aktionen
Neue gemeinsame Bausteine in components/admin-list.tsx: Seitenkopf mit
Hauptaktion, Suchleiste, Status-Badge (gleiche Form wie der Bestellstatus,
wartende Eintraege mit pulsierendem Punkt), Leerzustand mit Wabe und
naechstem Schritt sowie Zeilenaktionen mit einheitlichem Unterstrich-
Hover (Loeschen rot, Sperren/Zurueckziehen gedaempft).

Umgestellt: Bewertungen (SVG-Sterne statt Unicode), Benutzer, Gutscheine
(neu: Status "Abgelaufen"), Versand, Ernten, Chargen (Anlegeformulare auf
admin-form), Firmenanfragen (Status als Badge, beschriftete Auswahl) und
Audit-Log. Zahlen und Daten in Tabellen mit tabular-nums.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 20:51:50 +02:00
Nick SzaunigandClaude Opus 5.5 95b4b5953d fix(admin): Kategorie und Slug beim Produkt-Anlegen serverseitig pruefen
Die Kategorie kam bisher nur per Typ-Cast aus dem Formular; ein
manipulierter Wert endete als unbehandelte Prisma-Ausnahme. Ein eigener
Slug wurde ungeprueft zur oeffentlichen Adresse, inklusive Schraegstrichen
und Sonderzeichen. Neues adminProductIdentitySchema: Kategorie per Enum,
Slug auf a-z, 0-9 und Bindestrich (Umlaute umschrieben, max. 80 Zeichen),
leere Adresse wird mit Meldung abgelehnt.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 20:43:54 +02:00
Nick SzaunigandClaude Opus 5.5 c125e91941 feat(admin): Produkt anlegen gegliedert wie Produkt bearbeiten
Gemeinsame Formularbausteine in components/admin-form.tsx (Feld,
Auswahl, Abschnitt, Schalter, Formularrahmen mit fester Speicherleiste).
Das Anlegen-Formular nutzt dieselben Abschnitte wie das Bearbeiten und
blendet Ernte/Charge sowie Geschmack per :has() aus, sobald keine
Honig-Kategorie gewaehlt ist. Eingabefelder in Rastern stehen buendig,
auch wenn Beschriftungen mobil umbrechen.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 20:41:59 +02:00
Nick SzaunigandClaude Opus 5.5 76ebe7d042 feat(admin): Bestelldetail mit Zeitleiste, Produktformular gegliedert
- Bestelldetail: Status-Badge neben der Nummer, Waben-Zeitleiste
  (dieselbe wie im Kundenkonto) zeigt auf einen Blick den Stand
- Produkt bearbeiten: Abschnitte (Text und Bilder, Geschmack, Preis und
  Bestand, Sichtbarkeit), "Im Shop sichtbar" als Schalter (gleiches Feld
  isActive), Speicherleiste bleibt beim Scrollen unten erreichbar
- Kartentitel im gesamten Admin als lesbare Serifentitel statt winziger
  Versal-Etiketten (zentral per .admin-content, farbige Titel bleiben)

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 19:48:29 +02:00
Nick SzaunigandClaude Opus 5.5 c95c713717 feat(admin): eigener Arbeitsbereich mit Dashboard, Zaehlern und Schnellsuche
- Admin ohne Shop-Kopf-/Fusszeile (HideOnAdmin); kein doppeltes <main> mehr
- Seitenleiste gruppiert (Verkauf, Sortiment, Kundschaft, Seite), aktive
  Seite als wandernde Flaeche mit Wabe, Zaehler fuer Wartendes: bezahlte
  offene Bestellungen, neue Retouren, unfreigegebene Bewertungen, neue
  Firmenanfragen (COUNT-Abfragen erst nach Admin-/2FA-Pruefung)
- Schnellsuche (Strg/Cmd+K): Seiten und haeufige Aktionen per Tastatur,
  tolerante Wortsuche, Ziele nur aus fester Liste
- Dashboard: Begruessung nach Tageszeit (Berliner Zeit), eine Leitzahl
  "Umsatz heute" mit Vergleich zu gestern und Mini-Verlauf, "Zu erledigen"
  mit direkten Links bzw. "Alles erledigt"-Siegel, ruhigere Kennzahl-Kacheln
- Diagramme nach dataviz-Skill: per Validator gepruefter Diagrammton
  #a0722f (>=3:1; #b88a44 erreichte nur 2,93:1), gerundete Balkenkappen,
  2px Luecke, Tooltip je Balken (auch per Tastatur), Meter mit Spur im
  selben Ton, Rang der Top-Produkte als Wabe
- Einheitliche Admin-Details zentral: groessere Ueberschriften,
  Tabellenkopf als Etikett, feste Ziffernbreite, eigene Auswahlpfeile

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-25 13:45:24 +02:00
46 changed files with 2083 additions and 1226 deletions

No files matched your search

+4 -2
View File
@@ -370,11 +370,13 @@ async function syncBatches(sources: ApiCoreBottlingBatchWithHarvestId[]): Promis
* Formularfeld und waere sonst eine offene Weiterleitung — ein praeparierter * Formularfeld und waere sonst eine offene Weiterleitung — ein praeparierter
* Wert koennte Admins auf eine fremde Seite schicken. * Wert koennte Admins auf eine fremde Seite schicken.
*/ */
const RETURN_TARGETS = ["/admin/ernten", "/admin/produkte", "/admin/chargen"] as const; // "/admin" ist der Normalfall (der eine Sync-Knopf sitzt auf dem Dashboard);
// die uebrigen bleiben erlaubt, damit alte Lesezeichen/Formulare nicht ins Leere laufen.
const RETURN_TARGETS = ["/admin", "/admin/ernten", "/admin/produkte", "/admin/chargen"] as const;
function returnTargetFrom(formData: FormData): string { function returnTargetFrom(formData: FormData): string {
const requested = formData.get("returnTo"); const requested = formData.get("returnTo");
return RETURN_TARGETS.find((target) => target === requested) ?? "/admin/ernten"; return RETURN_TARGETS.find((target) => target === requested) ?? "/admin";
} }
/** /**
+10 -3
View File
@@ -4,7 +4,7 @@ import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log"; import { logAdminAction } from "@/lib/audit-log";
import { notifyStockAvailable } from "@/lib/stock-notifications"; import { notifyStockAvailable } from "@/lib/stock-notifications";
import { adminProductNumbersSchema } from "@/lib/validation"; import { adminProductIdentitySchema, adminProductNumbersSchema } from "@/lib/validation";
import { isValidUploadPath } from "@/lib/uploads"; import { isValidUploadPath } from "@/lib/uploads";
import { requireAdmin, fail, str, strOrNull, strArray } from "./_shared"; import { requireAdmin, fail, str, strOrNull, strArray } from "./_shared";
import { succeed } from "@/lib/flash"; import { succeed } from "@/lib/flash";
@@ -29,7 +29,15 @@ async function assertPricedIfActive(redirectPath: string, isActive: boolean, pri
export async function createProductAction(formData: FormData): Promise<void> { export async function createProductAction(formData: FormData): Promise<void> {
const admin = await requireAdmin(); const admin = await requireAdmin();
const slug = str(formData, "slug") || str(formData, "name").toLowerCase().replace(/[^a-z0-9]+/g, "-"); const parsedIdentity = adminProductIdentitySchema.safeParse({
name: str(formData, "name"),
slug: str(formData, "slug"),
category: str(formData, "category") || "HONIG",
});
if (!parsedIdentity.success) {
return fail("/admin/produkte/neu", parsedIdentity.error.issues[0]?.message ?? "Ungültige Eingabe.");
}
const { slug, category } = parsedIdentity.data;
const parsedNumbers = adminProductNumbersSchema.safeParse({ const parsedNumbers = adminProductNumbersSchema.safeParse({
intensityMild: str(formData, "intensityMild"), intensityMild: str(formData, "intensityMild"),
@@ -49,7 +57,6 @@ export async function createProductAction(formData: FormData): Promise<void> {
const isActive = formData.get("isActive") === "on"; const isActive = formData.get("isActive") === "on";
await assertPricedIfActive("/admin/produkte/neu", isActive, numbers.priceCents); await assertPricedIfActive("/admin/produkte/neu", isActive, numbers.priceCents);
const category = (str(formData, "category") || "HONIG") as "HONIG" | "KERZEN" | "PROPOLIS";
// Nur Honig ist einer Ernte/Charge zugeordnet — Kerzen, Propolis & Co. sind // Nur Honig ist einer Ernte/Charge zugeordnet — Kerzen, Propolis & Co. sind
// davon unabhaengig. Eingaben aus dem Formular werden fuer diese Kategorien // davon unabhaengig. Eingaben aus dem Formular werden fuer diese Kategorien
// ignoriert statt sich auf eine korrekt ausgefuellte Auswahl zu verlassen. // ignoriert statt sich auf eine korrekt ausgefuellte Auswahl zu verlassen.
+37 -26
View File
@@ -1,12 +1,25 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { insensitiveContains } from "@/lib/prisma-search"; import { insensitiveContains } from "@/lib/prisma-search";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { formatDate } from "@/lib/format"; import { formatDate } from "@/lib/format";
import {
AdminBadge,
AdminEmptyState,
AdminPageHeader,
AdminSearchForm,
type AdminBadgeTone,
} from "@/components/admin-list";
import { updateBusinessInquiryStatusAction } from "../actions"; import { updateBusinessInquiryStatusAction } from "../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
const STATUS_TONES: Record<string, AdminBadgeTone> = {
NEU: "pending",
IN_BEARBEITUNG: "pending",
ABGESCHLOSSEN: "positive",
ABGELEHNT: "neutral",
};
export default async function AdminInquiriesPage(props: PageProps<"/admin/anfragen">) { export default async function AdminInquiriesPage(props: PageProps<"/admin/anfragen">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const statusLabels: Record<string, string> = { const statusLabels: Record<string, string> = {
@@ -35,46 +48,40 @@ export default async function AdminInquiriesPage(props: PageProps<"/admin/anfrag
return ( return (
<div> <div>
<BackLink href="/admin" label={t.adminInquiriesPage.backToDashboard} /> <BackLink href="/admin" label={t.adminInquiriesPage.backToDashboard} />
<h1 className="text-2xl text-forest mb-8">{t.adminInquiriesPage.title}</h1> <AdminPageHeader title={t.adminInquiriesPage.title} />
<form method="GET" className="flex flex-wrap items-end gap-3 mb-6"> <AdminSearchForm
<label className="text-sm"> query={query}
<span className="block text-ink/65 text-xs mb-1">{t.adminInquiriesPage.searchLabel}</span> label={t.adminInquiriesPage.searchLabel}
<input
type="text"
name="q"
defaultValue={query}
placeholder={t.adminInquiriesPage.searchPlaceholder} placeholder={t.adminInquiriesPage.searchPlaceholder}
className="border border-ink/20 bg-background px-3 py-2 text-sm w-64" submitLabel={t.adminInquiriesPage.filterButton}
resetHref="/admin/anfragen"
resetLabel={t.adminInquiriesPage.resetFilter}
/> />
</label>
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm">
{t.adminInquiriesPage.filterButton}
</button>
{query ? (
<Link href="/admin/anfragen" className="text-sm text-ink/65 underline">
{t.adminInquiriesPage.resetFilter}
</Link>
) : null}
</form>
<div className="space-y-4"> <div className="space-y-4">
{inquiries.map((inquiry) => ( {inquiries.map((inquiry) => (
<div key={inquiry.id} className="bg-background border border-ink/10 p-6"> <div key={inquiry.id} className="bg-background border border-ink/10 p-6">
<div className="flex flex-wrap items-start justify-between gap-4"> <div className="flex flex-wrap items-start justify-between gap-4">
<div> <div>
<div className="flex flex-wrap items-center gap-3">
<p className="text-forest">{inquiry.companyName}</p> <p className="text-forest">{inquiry.companyName}</p>
<AdminBadge tone={STATUS_TONES[inquiry.status] ?? "neutral"} pulse={inquiry.status === "NEU"}>
{statusLabels[inquiry.status] ?? inquiry.status}
</AdminBadge>
</div>
<p className="text-sm text-ink/70"> <p className="text-sm text-ink/70">
{inquiry.contactPerson} · {inquiry.email} {inquiry.contactPerson} · {inquiry.email}
{inquiry.phone ? ` · ${inquiry.phone}` : ""} {inquiry.phone ? ` · ${inquiry.phone}` : ""}
</p> </p>
<p className="text-xs text-ink/65 mt-1">{formatDate(inquiry.createdAt)}</p> <p className="text-xs text-ink/65 mt-1 tabular-nums">{formatDate(inquiry.createdAt)}</p>
</div> </div>
<form action={updateBusinessInquiryStatusAction} className="flex items-center gap-2"> <form action={updateBusinessInquiryStatusAction} className="flex items-center gap-2">
<input type="hidden" name="id" value={inquiry.id} /> <input type="hidden" name="id" value={inquiry.id} />
<select <select
name="status" name="status"
defaultValue={inquiry.status} defaultValue={inquiry.status}
className="border border-ink/20 bg-background px-2 py-1 text-xs" aria-label={t.adminInquiriesPage.statusSelectLabel}
className="border border-ink/20 bg-background px-2 py-1.5 text-xs"
> >
{Object.entries(statusLabels).map(([value, label]) => ( {Object.entries(statusLabels).map(([value, label]) => (
<option key={value} value={value}> <option key={value} value={value}>
@@ -82,7 +89,10 @@ export default async function AdminInquiriesPage(props: PageProps<"/admin/anfrag
</option> </option>
))} ))}
</select> </select>
<button type="submit" className="text-xs text-forest underline"> <button
type="submit"
className="border border-forest px-3 py-1.5 text-xs text-forest transition-colors duration-300 hover:bg-forest hover:text-cream"
>
{t.adminInquiriesPage.save} {t.adminInquiriesPage.save}
</button> </button>
</form> </form>
@@ -107,9 +117,10 @@ export default async function AdminInquiriesPage(props: PageProps<"/admin/anfrag
</div> </div>
))} ))}
{inquiries.length === 0 ? ( {inquiries.length === 0 ? (
<p className="text-ink/65"> <AdminEmptyState
{query ? t.adminInquiriesPage.noResultsFiltered : t.adminInquiriesPage.noInquiriesYet} bordered
</p> message={query ? t.adminInquiriesPage.noResultsFiltered : t.adminInquiriesPage.noInquiriesYet}
/>
) : null} ) : null}
</div> </div>
</div> </div>
+4 -4
View File
@@ -1,6 +1,7 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { formatDateTime } from "@/lib/format"; import { formatDateTime } from "@/lib/format";
import { AdminEmptyState, AdminPageHeader } from "@/components/admin-list";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
@@ -16,8 +17,7 @@ export default async function AdminAuditLogPage() {
return ( return (
<div> <div>
<BackLink href="/admin" label={t.adminAuditLogPage.backToDashboard} /> <BackLink href="/admin" label={t.adminAuditLogPage.backToDashboard} />
<h1 className="text-2xl text-forest mb-2">{t.adminAuditLogPage.title}</h1> <AdminPageHeader title={t.adminAuditLogPage.title} intro={t.adminAuditLogPage.intro(RECENT_LIMIT)} />
<p className="text-sm text-ink/65 mb-8">{t.adminAuditLogPage.intro(RECENT_LIMIT)}</p>
<div className="overflow-x-auto bg-background border border-ink/10"> <div className="overflow-x-auto bg-background border border-ink/10">
<table className="w-full text-sm"> <table className="w-full text-sm">
@@ -33,7 +33,7 @@ export default async function AdminAuditLogPage() {
<tbody className="divide-y divide-ink/10"> <tbody className="divide-y divide-ink/10">
{entries.map((entry) => ( {entries.map((entry) => (
<tr key={entry.id} className="transition-colors hover:bg-ink/[0.03]"> <tr key={entry.id} className="transition-colors hover:bg-ink/[0.03]">
<td className="p-4 whitespace-nowrap text-ink/70">{formatDateTime(entry.createdAt)}</td> <td className="p-4 whitespace-nowrap tabular-nums text-ink/70">{formatDateTime(entry.createdAt)}</td>
<td className="p-4 whitespace-nowrap">{entry.adminEmail}</td> <td className="p-4 whitespace-nowrap">{entry.adminEmail}</td>
<td className="p-4 whitespace-nowrap font-mono text-xs text-forest">{entry.action}</td> <td className="p-4 whitespace-nowrap font-mono text-xs text-forest">{entry.action}</td>
<td className="p-4 whitespace-nowrap text-ink/70"> <td className="p-4 whitespace-nowrap text-ink/70">
@@ -45,7 +45,7 @@ export default async function AdminAuditLogPage() {
))} ))}
</tbody> </tbody>
</table> </table>
{entries.length === 0 ? <p className="p-6 text-ink/65">{t.adminAuditLogPage.noEntriesYet}</p> : null} {entries.length === 0 ? <AdminEmptyState message={t.adminAuditLogPage.noEntriesYet} /> : null}
</div> </div>
</div> </div>
); );
+56 -84
View File
@@ -2,8 +2,11 @@ import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { ConfirmForm } from "@/components/confirm-form"; import { ConfirmForm } from "@/components/confirm-form";
import { Button } from "@/components/ui";
import { getCurrentAdmin } from "@/lib/auth"; import { getCurrentAdmin } from "@/lib/auth";
import { formatDate } from "@/lib/format"; import { formatDate } from "@/lib/format";
import { AdminField, AdminFieldset, AdminFormPanel, AdminSelect } from "@/components/admin-form";
import { AdminActionButton, AdminBadge } from "@/components/admin-list";
import { import {
updateCustomerAction, updateCustomerAction,
blockCustomerAction, blockCustomerAction,
@@ -16,6 +19,7 @@ import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminBenutzerEditPage(props: PageProps<"/admin/benutzer/[id]">) { export default async function AdminBenutzerEditPage(props: PageProps<"/admin/benutzer/[id]">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const tu = t.adminUserEditPage;
const { id } = await props.params; const { id } = await props.params;
const [admin, customer] = await Promise.all([ const [admin, customer] = await Promise.all([
@@ -28,60 +32,56 @@ export default async function AdminBenutzerEditPage(props: PageProps<"/admin/ben
const isTemporarilyLocked = Boolean(customer.lockedUntil && customer.lockedUntil > new Date()); const isTemporarilyLocked = Boolean(customer.lockedUntil && customer.lockedUntil > new Date());
return ( return (
<div className="max-w-xl"> <div className="max-w-2xl">
<BackLink href="/admin/benutzer" label={t.adminUserEditPage.backToUsers} /> <BackLink href="/admin/benutzer" label={tu.backToUsers} />
<h1 className="text-2xl text-forest mb-2"> <h1 className="text-2xl text-forest mb-2">
{customer.firstName} {customer.lastName} {customer.firstName} {customer.lastName}
</h1> </h1>
<p className="text-sm text-ink/65 mb-8">{t.adminUserEditPage.createdOn(formatDate(customer.createdAt))}</p> <p className="text-sm text-ink/65 mb-8">{tu.createdOn(formatDate(customer.createdAt))}</p>
<AdminFormPanel action={updateCustomerAction} actions={<Button type="submit">{tu.save}</Button>}>
<form action={updateCustomerAction} className="space-y-4 bg-background border border-ink/10 p-6">
<input type="hidden" name="id" value={customer.id} /> <input type="hidden" name="id" value={customer.id} />
<div className="grid grid-cols-2 gap-4"> <AdminFieldset legend={tu.sectionProfile} last>
<Field name="firstName" label={t.adminUserEditPage.firstNameLabel} defaultValue={customer.firstName} required /> <div className="grid grid-cols-2 items-end gap-4">
<Field name="lastName" label={t.adminUserEditPage.lastNameLabel} defaultValue={customer.lastName} required /> <AdminField name="firstName" label={tu.firstNameLabel} defaultValue={customer.firstName} required />
<AdminField name="lastName" label={tu.lastNameLabel} defaultValue={customer.lastName} required />
</div> </div>
<Field name="email" label={t.adminUserEditPage.emailLabel} type="email" defaultValue={customer.email} required /> <AdminField name="email" label={tu.emailLabel} type="email" defaultValue={customer.email} required />
<Field name="phone" label={t.adminUserEditPage.phoneLabel} defaultValue={customer.phone ?? ""} /> <AdminField name="phone" label={tu.phoneLabel} type="tel" defaultValue={customer.phone ?? ""} />
<label className="block text-sm"> <AdminSelect
<span className="text-ink/70">{t.adminUserEditPage.roleLabel}</span>
<select
name="role" name="role"
label={tu.roleLabel}
defaultValue={customer.role} defaultValue={customer.role}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" hint={isSelf ? tu.cannotChangeOwnRole : undefined}
> >
<option value="KUNDE">{t.adminUserEditPage.roleCustomer}</option> <option value="KUNDE">{tu.roleCustomer}</option>
<option value="ADMIN">{t.adminUserEditPage.roleAdmin}</option> <option value="ADMIN">{tu.roleAdmin}</option>
</select> </AdminSelect>
{isSelf ? ( </AdminFieldset>
<span className="mt-1 block text-xs text-ink/65">{t.adminUserEditPage.cannotChangeOwnRole}</span> </AdminFormPanel>
) : null}
</label>
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm"> <section className="mt-8 space-y-6 border border-ink/10 bg-background p-6 sm:p-8">
{t.adminUserEditPage.save} <h2 className="font-serif text-xl text-forest">{tu.securityHeading}</h2>
</button> <dl className="divide-y divide-ink/10 text-sm">
</form> <div className="flex items-center justify-between gap-4 py-3">
<dt className="text-ink/70">{tu.twoFactorLabel}</dt>
<div className="mt-6 bg-background border border-ink/10 p-6 space-y-4">
<h2 className="text-sm text-ink/70">{t.adminUserEditPage.securityHeading}</h2>
<dl className="text-sm space-y-1">
<div className="flex justify-between">
<dt className="text-ink/65">{t.adminUserEditPage.twoFactorLabel}</dt>
<dd>{customer.twoFactorEnabled ? t.adminUserEditPage.twoFactorActive : t.adminUserEditPage.twoFactorInactive}</dd>
</div>
<div className="flex justify-between">
<dt className="text-ink/65">{t.adminUserEditPage.statusLabel}</dt>
<dd> <dd>
{customer.blockedAt ? ( {customer.twoFactorEnabled ? (
<span className="text-red-700"> <AdminBadge tone="positive">{tu.twoFactorActive}</AdminBadge>
{t.adminUserEditPage.blockedSince(formatDate(customer.blockedAt))}
</span>
) : isTemporarilyLocked ? (
<span className="text-honey-dark">{t.adminUserEditPage.temporarilyLockedFailedAttempts}</span>
) : ( ) : (
<span className="text-forest">{t.adminUserEditPage.statusActive}</span> <AdminBadge tone="neutral">{tu.twoFactorInactive}</AdminBadge>
)}
</dd>
</div>
<div className="flex items-center justify-between gap-4 py-3">
<dt className="text-ink/70">{tu.statusLabel}</dt>
<dd className="text-right">
{customer.blockedAt ? (
<AdminBadge tone="danger">{tu.blockedSince(formatDate(customer.blockedAt))}</AdminBadge>
) : isTemporarilyLocked ? (
<AdminBadge tone="pending">{tu.temporarilyLockedFailedAttempts}</AdminBadge>
) : (
<AdminBadge tone="positive">{tu.statusActive}</AdminBadge>
)} )}
</dd> </dd>
</div> </div>
@@ -89,67 +89,39 @@ export default async function AdminBenutzerEditPage(props: PageProps<"/admin/ben
<form action={sendCustomerPasswordResetAction}> <form action={sendCustomerPasswordResetAction}>
<input type="hidden" name="id" value={customer.id} /> <input type="hidden" name="id" value={customer.id} />
<button type="submit" className="bg-forest text-cream px-5 py-2 text-sm"> <Button type="submit" variant="secondary" className="px-5 py-2.5">
{t.adminUserEditPage.sendPasswordReset} {tu.sendPasswordReset}
</button> </Button>
</form> </form>
{/* Sperren/Loeschen des eigenen Kontos ist serverseitig ohnehin
verboten — hier gar nicht erst angeboten. */}
{!isSelf ? ( {!isSelf ? (
<div className="flex items-center gap-4 pt-2 border-t border-ink/10"> <div className="border-t border-ink/10 pt-6">
<h3 className="mb-3 text-xs tracking-wide-caps text-ink/65">{tu.sectionDanger}</h3>
<div className="flex flex-wrap items-center gap-6 text-sm">
{customer.blockedAt ? ( {customer.blockedAt ? (
<form action={unblockCustomerAction}> <form action={unblockCustomerAction}>
<input type="hidden" name="id" value={customer.id} /> <input type="hidden" name="id" value={customer.id} />
<button type="submit" className="text-forest underline text-sm"> <AdminActionButton>{tu.unblock}</AdminActionButton>
{t.adminUserEditPage.unblock}
</button>
</form> </form>
) : ( ) : (
<form action={blockCustomerAction}> <form action={blockCustomerAction}>
<input type="hidden" name="id" value={customer.id} /> <input type="hidden" name="id" value={customer.id} />
<button type="submit" className="text-ink/70 underline text-sm"> <AdminActionButton tone="muted">{tu.block}</AdminActionButton>
{t.adminUserEditPage.block}
</button>
</form> </form>
)} )}
<ConfirmForm <ConfirmForm
action={deleteCustomerAction} action={deleteCustomerAction}
confirmMessage={t.adminUserEditPage.deleteConfirm(`${customer.firstName} ${customer.lastName}`)} confirmMessage={tu.deleteConfirm(`${customer.firstName} ${customer.lastName}`)}
> >
<input type="hidden" name="id" value={customer.id} /> <input type="hidden" name="id" value={customer.id} />
<button type="submit" className="text-red-700 underline text-sm"> <AdminActionButton tone="danger">{tu.deleteAccount}</AdminActionButton>
{t.adminUserEditPage.deleteAccount}
</button>
</ConfirmForm> </ConfirmForm>
</div> </div>
</div>
) : null} ) : null}
</div> </section>
</div> </div>
); );
} }
function Field({
name,
label,
type = "text",
defaultValue,
required = false,
}: {
name: string;
label: string;
type?: string;
defaultValue?: string;
required?: boolean;
}) {
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<input
name={name}
type={type}
defaultValue={defaultValue}
required={required}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
);
}
+18 -49
View File
@@ -1,64 +1,33 @@
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { Button } from "@/components/ui";
import { AdminField, AdminFormPanel, AdminSelect } from "@/components/admin-form";
import { createCustomerAction } from "../../actions"; import { createCustomerAction } from "../../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminBenutzerNewPage() { export default async function AdminBenutzerNewPage() {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const tu = t.adminUserNewPage;
return ( return (
<div className="max-w-xl"> <div className="max-w-xl">
<BackLink href="/admin/benutzer" label={t.adminUserNewPage.backToUsers} /> <BackLink href="/admin/benutzer" label={tu.backToUsers} />
<h1 className="text-2xl text-forest mb-2">{t.adminUserNewPage.title}</h1> <h1 className="text-2xl text-forest mb-2">{tu.title}</h1>
<p className="text-sm text-ink/70 mb-8">{t.adminUserNewPage.intro}</p> <p className="text-sm text-ink/70 mb-8">{tu.intro}</p>
<AdminFormPanel action={createCustomerAction} actions={<Button type="submit">{tu.createAndSend}</Button>}>
<form action={createCustomerAction} className="space-y-4 bg-background border border-ink/10 p-6"> <div className="space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 items-end gap-4">
<Field name="firstName" label={t.adminUserNewPage.firstNameLabel} required /> <AdminField name="firstName" label={tu.firstNameLabel} required />
<Field name="lastName" label={t.adminUserNewPage.lastNameLabel} required /> <AdminField name="lastName" label={tu.lastNameLabel} required />
</div> </div>
<Field name="email" label={t.adminUserNewPage.emailLabel} type="email" required /> <AdminField name="email" label={tu.emailLabel} type="email" required />
<label className="block text-sm"> <AdminSelect name="role" label={tu.roleLabel} defaultValue="KUNDE">
<span className="text-ink/70">{t.adminUserNewPage.roleLabel}</span> <option value="KUNDE">{tu.roleCustomer}</option>
<select <option value="ADMIN">{tu.roleAdmin}</option>
name="role" </AdminSelect>
defaultValue="KUNDE" </div>
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" </AdminFormPanel>
>
<option value="KUNDE">{t.adminUserNewPage.roleCustomer}</option>
<option value="ADMIN">{t.adminUserNewPage.roleAdmin}</option>
</select>
</label>
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
{t.adminUserNewPage.createAndSend}
</button>
</form>
</div> </div>
); );
} }
function Field({
name,
label,
type = "text",
required = false,
}: {
name: string;
label: string;
type?: string;
required?: boolean;
}) {
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<input
name={name}
type={type}
required={required}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
);
}
+48 -51
View File
@@ -1,10 +1,18 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { insensitiveContains } from "@/lib/prisma-search"; import { insensitiveContains } from "@/lib/prisma-search";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { ConfirmForm } from "@/components/confirm-form"; import { ConfirmForm } from "@/components/confirm-form";
import { getCurrentAdmin } from "@/lib/auth"; import { getCurrentAdmin } from "@/lib/auth";
import { formatDate } from "@/lib/format"; import { formatDate } from "@/lib/format";
import {
AdminActionButton,
AdminActionLink,
AdminBadge,
AdminEmptyState,
AdminPageHeader,
AdminRowActions,
AdminSearchForm,
} from "@/components/admin-list";
import { blockCustomerAction, unblockCustomerAction, deleteCustomerAction } from "../actions"; import { blockCustomerAction, unblockCustomerAction, deleteCustomerAction } from "../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
@@ -32,37 +40,21 @@ export default async function AdminBenutzerPage(props: PageProps<"/admin/benutze
}), }),
]); ]);
const newUser = { href: "/admin/benutzer/neu", label: t.adminUsersPage.newUser };
return ( return (
<div> <div>
<BackLink href="/admin" label={t.adminUsersPage.backToDashboard} /> <BackLink href="/admin" label={t.adminUsersPage.backToDashboard} />
<div className="flex items-center justify-between mb-8"> <AdminPageHeader title={t.adminUsersPage.title} action={newUser} />
<h1 className="text-2xl text-forest">{t.adminUsersPage.title}</h1>
<Link href="/admin/benutzer/neu" className="bg-forest text-cream px-5 py-2 text-sm">
{t.adminUsersPage.newUser}
</Link>
</div>
<AdminSearchForm
<form method="GET" className="flex flex-wrap items-end gap-3 mb-6"> query={query}
<label className="text-sm"> label={t.adminUsersPage.searchLabel}
<span className="block text-ink/65 text-xs mb-1">{t.adminUsersPage.searchLabel}</span>
<input
type="text"
name="q"
defaultValue={query}
placeholder={t.adminUsersPage.searchPlaceholder} placeholder={t.adminUsersPage.searchPlaceholder}
className="border border-ink/20 bg-background px-3 py-2 text-sm w-64" submitLabel={t.adminUsersPage.filterButton}
resetHref="/admin/benutzer"
resetLabel={t.adminUsersPage.resetFilter}
/> />
</label>
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm">
{t.adminUsersPage.filterButton}
</button>
{query ? (
<Link href="/admin/benutzer" className="text-sm text-ink/65 underline">
{t.adminUsersPage.resetFilter}
</Link>
) : null}
</form>
<div className="overflow-x-auto bg-background border border-ink/10"> <div className="overflow-x-auto bg-background border border-ink/10">
<table className="w-full text-sm"> <table className="w-full text-sm">
@@ -89,37 +81,43 @@ export default async function AdminBenutzerPage(props: PageProps<"/admin/benutze
{isSelf ? <span className="text-ink/65">{t.adminUsersPage.youSuffix}</span> : null} {isSelf ? <span className="text-ink/65">{t.adminUsersPage.youSuffix}</span> : null}
</td> </td>
<td className="p-4">{customer.email}</td> <td className="p-4">{customer.email}</td>
<td className="p-4">{roleLabels[customer.role] ?? customer.role}</td>
<td className="p-4"> <td className="p-4">
{customer.blockedAt ? ( {customer.role === "ADMIN" ? (
<span className="text-red-700">{t.adminUsersPage.statusBlocked}</span> <AdminBadge tone="neutral">{roleLabels.ADMIN}</AdminBadge>
) : isTemporarilyLocked ? (
<span className="text-honey-dark">{t.adminUsersPage.statusTemporarilyLocked}</span>
) : ( ) : (
<span className="text-forest">{t.adminUsersPage.statusActive}</span> (roleLabels[customer.role] ?? customer.role)
)} )}
</td> </td>
<td className="p-4">{customer.twoFactorEnabled ? t.adminUsersPage.statusActive : "—"}</td>
<td className="p-4">{formatDate(customer.createdAt)}</td>
<td className="p-4"> <td className="p-4">
<div className="flex items-center gap-3 whitespace-nowrap"> {customer.blockedAt ? (
<Link href={`/admin/benutzer/${customer.id}`} className="text-forest underline"> <AdminBadge tone="danger">{t.adminUsersPage.statusBlocked}</AdminBadge>
{t.adminUsersPage.edit} ) : isTemporarilyLocked ? (
</Link> <AdminBadge tone="pending">{t.adminUsersPage.statusTemporarilyLocked}</AdminBadge>
) : (
<AdminBadge tone="positive">{t.adminUsersPage.statusActive}</AdminBadge>
)}
</td>
<td className="p-4">
{customer.twoFactorEnabled ? (
<AdminBadge tone="positive">{t.adminUsersPage.statusActive}</AdminBadge>
) : (
<span className="text-ink/55">—</span>
)}
</td>
<td className="p-4 whitespace-nowrap tabular-nums">{formatDate(customer.createdAt)}</td>
<td className="p-4">
<AdminRowActions>
<AdminActionLink href={`/admin/benutzer/${customer.id}`}>{t.adminUsersPage.edit}</AdminActionLink>
{!isSelf ? ( {!isSelf ? (
customer.blockedAt ? ( customer.blockedAt ? (
<form action={unblockCustomerAction}> <form action={unblockCustomerAction}>
<input type="hidden" name="id" value={customer.id} /> <input type="hidden" name="id" value={customer.id} />
<button type="submit" className="text-forest underline"> <AdminActionButton>{t.adminUsersPage.unblock}</AdminActionButton>
{t.adminUsersPage.unblock}
</button>
</form> </form>
) : ( ) : (
<form action={blockCustomerAction}> <form action={blockCustomerAction}>
<input type="hidden" name="id" value={customer.id} /> <input type="hidden" name="id" value={customer.id} />
<button type="submit" className="text-ink/70 underline"> <AdminActionButton tone="muted">{t.adminUsersPage.block}</AdminActionButton>
{t.adminUsersPage.block}
</button>
</form> </form>
) )
) : null} ) : null}
@@ -129,12 +127,10 @@ export default async function AdminBenutzerPage(props: PageProps<"/admin/benutze
confirmMessage={t.adminUsersPage.deleteConfirm(`${customer.firstName} ${customer.lastName}`)} confirmMessage={t.adminUsersPage.deleteConfirm(`${customer.firstName} ${customer.lastName}`)}
> >
<input type="hidden" name="id" value={customer.id} /> <input type="hidden" name="id" value={customer.id} />
<button type="submit" className="text-red-700 underline"> <AdminActionButton tone="danger">{t.adminUsersPage.delete}</AdminActionButton>
{t.adminUsersPage.delete}
</button>
</ConfirmForm> </ConfirmForm>
) : null} ) : null}
</div> </AdminRowActions>
</td> </td>
</tr> </tr>
); );
@@ -142,9 +138,10 @@ export default async function AdminBenutzerPage(props: PageProps<"/admin/benutze
</tbody> </tbody>
</table> </table>
{customers.length === 0 ? ( {customers.length === 0 ? (
<p className="p-6 text-ink/65"> <AdminEmptyState
{query ? t.adminUsersPage.noResultsFiltered : t.adminUsersPage.noUsersYet} message={query ? t.adminUsersPage.noResultsFiltered : t.adminUsersPage.noUsersYet}
</p> action={query ? undefined : newUser}
/>
) : null} ) : null}
</div> </div>
</div> </div>
+30 -6
View File
@@ -14,6 +14,8 @@ import { checkOrderDeletable } from "@/lib/order-delete";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
import { isDhlConfigured } from "@/lib/dhl"; import { isDhlConfigured } from "@/lib/dhl";
import { OrderStatusBadge, OrderStatusTimeline } from "@/components/account-ui";
import { Button } from "@/components/ui";
export default async function AdminOrderDetailPage(props: PageProps<"/admin/bestellungen/[id]">) { export default async function AdminOrderDetailPage(props: PageProps<"/admin/bestellungen/[id]">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
@@ -43,13 +45,16 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
}).deletable; }).deletable;
return ( return (
<div className="max-w-3xl"> <div className="max-w-4xl">
<BackLink href="/admin/bestellungen" label={t.adminOrderDetailPage.backToOrders} /> <BackLink href="/admin/bestellungen" label={t.adminOrderDetailPage.backToOrders} />
<div className="flex flex-wrap items-start justify-between gap-4 mb-8"> <div className="flex flex-wrap items-start justify-between gap-4 mb-8">
<div> <div>
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl text-forest">{order.orderNumber}</h1> <h1 className="text-2xl text-forest">{order.orderNumber}</h1>
<OrderStatusBadge status={order.status} label={statusLabels[order.status] ?? order.status} />
</div>
<p className="text-sm text-ink/65 mt-1">{formatDate(order.createdAt)}</p> <p className="text-sm text-ink/65 mt-1">{formatDate(order.createdAt)}</p>
</div> </div>
<form action={updateOrderStatusAction} className="flex items-center gap-2"> <form action={updateOrderStatusAction} className="flex items-center gap-2">
@@ -65,12 +70,31 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
</option> </option>
))} ))}
</select> </select>
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm"> <Button type="submit" className="px-5 py-2.5">
{t.adminOrderDetailPage.saveStatus} {t.adminOrderDetailPage.saveStatus}
</button> </Button>
</form> </form>
</div> </div>
{/* Wo steht die Bestellung? Dieselbe Waben-Zeitleiste wie im Kundenkonto. */}
<div className="mb-8 border border-ink/10 bg-background px-6 py-5">
<OrderStatusTimeline
status={order.status}
fulfillmentMethod={order.fulfillmentMethod}
labels={{
ordered: t.orderStatusTimeline.ordered,
paid: t.orderStatusTimeline.paid,
preparing: t.orderStatusTimeline.preparing,
readyToShip: t.orderStatusTimeline.readyToShip,
shipped: t.orderStatusTimeline.shipped,
readyForPickup: t.orderStatusTimeline.readyForPickup,
pickedUp: t.orderStatusTimeline.pickedUp,
cancelled: t.orderStatusTimeline.cancelled,
ariaLabel: t.orderStatusTimeline.ariaLabel,
}}
/>
</div>
{order.giftMessage ? ( {order.giftMessage ? (
<div className="bg-honey/10 border border-honey-dark/30 p-6 mb-8"> <div className="bg-honey/10 border border-honey-dark/30 p-6 mb-8">
<h2 className="text-xs tracking-wide-caps text-honey-dark mb-3">{t.adminOrderDetailPage.giftHeading}</h2> <h2 className="text-xs tracking-wide-caps text-honey-dark mb-3">{t.adminOrderDetailPage.giftHeading}</h2>
@@ -169,9 +193,9 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
className="mt-1 w-56 border border-ink/20 bg-background px-3 py-2 text-sm" className="mt-1 w-56 border border-ink/20 bg-background px-3 py-2 text-sm"
/> />
</label> </label>
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm"> <Button type="submit" className="px-5 py-2.5">
{t.adminOrderDetailPage.saveTracking} {t.adminOrderDetailPage.saveTracking}
</button> </Button>
</form> </form>
</div> </div>
) : null} ) : null}
@@ -258,7 +282,7 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
<span>{order.invoice.invoiceNumber}</span> <span>{order.invoice.invoiceNumber}</span>
<a <a
href={`/rechnung/${order.invoice.invoiceNumber}/pdf`} href={`/rechnung/${order.invoice.invoiceNumber}/pdf`}
className="text-forest underline text-sm" className="link-draw text-sm text-forest"
> >
{t.adminOrderDetailPage.downloadPdf} {t.adminOrderDetailPage.downloadPdf}
</a> </a>
+38 -37
View File
@@ -11,6 +11,7 @@ import { getDictionary } from "@/i18n/get-dictionary";
import { isArchiveView } from "@/lib/archive"; import { isArchiveView } from "@/lib/archive";
import { ensureAutoArchived } from "@/lib/archive-runner"; import { ensureAutoArchived } from "@/lib/archive-runner";
import { AdminArchiveTabs, AdminArchiveToggle } from "@/components/admin-archive"; import { AdminArchiveTabs, AdminArchiveToggle } from "@/components/admin-archive";
import { AdminEmptyState, AdminPageHeader, AdminSearchForm } from "@/components/admin-list";
export default async function AdminOrdersPage(props: PageProps<"/admin/bestellungen">) { export default async function AdminOrdersPage(props: PageProps<"/admin/bestellungen">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
@@ -49,7 +50,7 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
return ( return (
<div> <div>
<BackLink href="/admin" label={t.adminOrdersPage.backToDashboard} /> <BackLink href="/admin" label={t.adminOrdersPage.backToDashboard} />
<h1 className="text-2xl text-forest mb-8">{t.adminOrdersPage.title}</h1> <AdminPageHeader title={t.adminOrdersPage.title} />
{/* Die DHL-Aktionen leiten bei einer nicht mehr auffindbaren Bestellung {/* Die DHL-Aktionen leiten bei einer nicht mehr auffindbaren Bestellung
hierher zurueck (admin/actions/dhl.ts). Ohne diese Ausgabe sah das hierher zurueck (admin/actions/dhl.ts). Ohne diese Ausgabe sah das
aus wie ein folgenloser Klick. */} aus wie ein folgenloser Klick. */}
@@ -67,18 +68,16 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
/> />
{isArchive ? <p className="mb-6 text-xs text-ink/60 max-w-2xl">{t.adminArchive.autoRuleOrders}</p> : null} {isArchive ? <p className="mb-6 text-xs text-ink/60 max-w-2xl">{t.adminArchive.autoRuleOrders}</p> : null}
<form method="GET" className="flex flex-wrap items-end gap-3 mb-6"> <AdminSearchForm
{isArchive ? <input type="hidden" name="ansicht" value="archiv" /> : null} query={query}
<label className="text-sm"> label={t.adminOrdersPage.searchLabel}
<span className="block text-ink/65 text-xs mb-1">{t.adminOrdersPage.searchLabel}</span>
<input
type="text"
name="q"
defaultValue={query}
placeholder={t.adminOrdersPage.searchPlaceholder} placeholder={t.adminOrdersPage.searchPlaceholder}
className="border border-ink/20 bg-background px-3 py-2 text-sm w-64" submitLabel={t.adminOrdersPage.filterButton}
/> resetHref={isArchive ? "/admin/bestellungen?ansicht=archiv" : "/admin/bestellungen"}
</label> resetLabel={t.adminOrdersPage.resetFilter}
hiddenFields={isArchive ? { ansicht: "archiv" } : {}}
isFiltered={Boolean(query || statusFilter)}
>
<label className="text-sm"> <label className="text-sm">
<span className="block text-ink/65 text-xs mb-1">{t.adminOrdersPage.statusLabel}</span> <span className="block text-ink/65 text-xs mb-1">{t.adminOrdersPage.statusLabel}</span>
<select <select
@@ -94,18 +93,7 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
))} ))}
</select> </select>
</label> </label>
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm"> </AdminSearchForm>
{t.adminOrdersPage.filterButton}
</button>
{query || statusFilter ? (
<Link
href={isArchive ? "/admin/bestellungen?ansicht=archiv" : "/admin/bestellungen"}
className="text-sm text-ink/65 underline"
>
{t.adminOrdersPage.resetFilter}
</Link>
) : null}
</form>
<div className="overflow-x-auto bg-background border border-ink/10"> <div className="overflow-x-auto bg-background border border-ink/10">
<table className="w-full text-sm"> <table className="w-full text-sm">
@@ -124,13 +112,20 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
<tbody className="divide-y divide-ink/10"> <tbody className="divide-y divide-ink/10">
{orders.map((order) => ( {orders.map((order) => (
<tr key={order.id} className="transition-colors hover:bg-ink/[0.03]"> <tr key={order.id} className="transition-colors hover:bg-ink/[0.03]">
<td className="p-4 text-forest"> <td className="p-4 text-forest whitespace-nowrap">
<Link href={`/admin/bestellungen/${order.id}`} className="underline"> <Link href={`/admin/bestellungen/${order.id}`} className="link-draw">
{order.orderNumber} {order.orderNumber}
</Link> </Link>
{order.giftMessage ? ( {order.giftMessage ? (
<span className="ml-1.5" title={t.adminOrdersPage.giftOrderTitle}> <span
🎁 className="ml-1.5 inline-flex align-[-2px] text-honey-dark"
title={t.adminOrdersPage.giftOrderTitle}
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" aria-hidden="true">
<rect x="3.5" y="8.5" width="17" height="4" />
<path d="M5 12.5v8h14v-8M12 8.5v12M12 8.5c-1.5-3.5-5-4-5-1.5 0 1.5 2.5 1.5 5 1.5zm0 0c1.5-3.5 5-4 5-1.5 0 1.5-2.5 1.5-5 1.5z" />
</svg>
<span className="sr-only">{t.adminOrdersPage.giftOrderTitle}</span>
</span> </span>
) : null} ) : null}
</td> </td>
@@ -139,9 +134,9 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
<br /> <br />
<span className="text-ink/65 text-xs">{order.email}</span> <span className="text-ink/65 text-xs">{order.email}</span>
</td> </td>
<td className="p-4">{formatDate(order.createdAt)}</td> <td className="p-4 whitespace-nowrap tabular-nums">{formatDate(order.createdAt)}</td>
<td className="p-4">{order.items.length}</td> <td className="p-4 tabular-nums">{order.items.length}</td>
<td className="p-4">{formatPrice(order.totalCents, order.currency)}</td> <td className="p-4 whitespace-nowrap tabular-nums">{formatPrice(order.totalCents, order.currency)}</td>
<td className="p-4"> <td className="p-4">
<OrderStatusBadge status={order.status} label={statusLabels[order.status] ?? order.status} /> <OrderStatusBadge status={order.status} label={statusLabels[order.status] ?? order.status} />
<form action={updateOrderStatusAction} className="mt-2 flex items-center gap-2"> <form action={updateOrderStatusAction} className="mt-2 flex items-center gap-2">
@@ -149,7 +144,8 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
<select <select
name="status" name="status"
defaultValue={order.status} defaultValue={order.status}
className="border border-ink/20 bg-background px-2 py-1 text-xs" aria-label={t.adminOrdersPage.statusLabel}
className="border border-ink/20 bg-background px-2 py-1.5 text-xs"
> >
{Object.entries(statusLabels).map(([value, label]) => ( {Object.entries(statusLabels).map(([value, label]) => (
<option key={value} value={value}> <option key={value} value={value}>
@@ -157,7 +153,10 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
</option> </option>
))} ))}
</select> </select>
<button type="submit" className="text-xs text-forest underline"> <button
type="submit"
className="border border-forest px-3 py-1.5 text-xs text-forest transition-colors duration-300 hover:bg-forest hover:text-cream"
>
{t.adminOrdersPage.save} {t.adminOrdersPage.save}
</button> </button>
</form> </form>
@@ -184,13 +183,15 @@ export default async function AdminOrdersPage(props: PageProps<"/admin/bestellun
</tbody> </tbody>
</table> </table>
{orders.length === 0 ? ( {orders.length === 0 ? (
<p className="p-6 text-ink/65"> <AdminEmptyState
{query || statusFilter message={
query || statusFilter
? t.adminOrdersPage.noResultsFiltered ? t.adminOrdersPage.noResultsFiltered
: isArchive : isArchive
? t.adminArchive.emptyArchive ? t.adminArchive.emptyArchive
: t.adminOrdersPage.noOrdersYet} : t.adminOrdersPage.noOrdersYet
</p> }
/>
) : null} ) : null}
</div> </div>
</div> </div>
+31 -36
View File
@@ -3,6 +3,15 @@ import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { ConfirmForm } from "@/components/confirm-form"; import { ConfirmForm } from "@/components/confirm-form";
import { formatDate } from "@/lib/format"; import { formatDate } from "@/lib/format";
import { StarRating } from "@/components/star-rating";
import {
AdminActionButton,
AdminBadge,
AdminEmptyState,
AdminPageHeader,
AdminRowActions,
AdminSearchForm,
} from "@/components/admin-list";
import { insensitiveContains } from "@/lib/prisma-search"; import { insensitiveContains } from "@/lib/prisma-search";
import { approveReviewAction, deleteReviewAction } from "../actions"; import { approveReviewAction, deleteReviewAction } from "../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
@@ -34,28 +43,16 @@ export default async function AdminBewertungenPage(props: PageProps<"/admin/bewe
return ( return (
<div> <div>
<BackLink href="/admin" label={t.adminReviewsPage.backToDashboard} /> <BackLink href="/admin" label={t.adminReviewsPage.backToDashboard} />
<h1 className="text-2xl text-forest mb-8">{t.adminReviewsPage.title}</h1> <AdminPageHeader title={t.adminReviewsPage.title} />
<form method="GET" className="flex flex-wrap items-end gap-3 mb-6"> <AdminSearchForm
<label className="text-sm"> query={query}
<span className="block text-ink/65 text-xs mb-1">{t.adminReviewsPage.searchLabel}</span> label={t.adminReviewsPage.searchLabel}
<input
type="text"
name="q"
defaultValue={query}
placeholder={t.adminReviewsPage.searchPlaceholder} placeholder={t.adminReviewsPage.searchPlaceholder}
className="border border-ink/20 bg-background px-3 py-2 text-sm w-64" submitLabel={t.adminReviewsPage.filterButton}
resetHref="/admin/bewertungen"
resetLabel={t.adminReviewsPage.resetFilter}
/> />
</label>
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm">
{t.adminReviewsPage.filterButton}
</button>
{query ? (
<Link href="/admin/bewertungen" className="text-sm text-ink/65 underline">
{t.adminReviewsPage.resetFilter}
</Link>
) : null}
</form>
<div className="overflow-x-auto bg-background border border-ink/10"> <div className="overflow-x-auto bg-background border border-ink/10">
<table className="w-full text-sm"> <table className="w-full text-sm">
@@ -74,34 +71,34 @@ export default async function AdminBewertungenPage(props: PageProps<"/admin/bewe
{reviews.map((review) => ( {reviews.map((review) => (
<tr key={review.id} className="transition-colors hover:bg-ink/[0.03]"> <tr key={review.id} className="transition-colors hover:bg-ink/[0.03]">
<td className="p-4"> <td className="p-4">
<Link href={`/honig/${review.product.slug}`} className="text-forest underline"> <Link href={`/honig/${review.product.slug}`} className="link-draw text-forest">
{review.product.name} {review.product.name}
</Link> </Link>
</td> </td>
<td className="p-4"> <td className="p-4">
{review.customer.firstName} {review.customer.lastName} {review.customer.firstName} {review.customer.lastName}
</td> </td>
<td className="p-4 text-honey-dark whitespace-nowrap"> <td className="p-4 whitespace-nowrap">
{"★".repeat(review.rating)} <StarRating value={review.rating} />
{"☆".repeat(5 - review.rating)} <span className="sr-only">{review.rating} / 5</span>
</td> </td>
<td className="p-4 max-w-xs">{review.comment ?? "—"}</td> <td className="p-4 max-w-xs">{review.comment ?? "—"}</td>
<td className="p-4 whitespace-nowrap">{formatDate(review.createdAt)}</td> <td className="p-4 whitespace-nowrap">{formatDate(review.createdAt)}</td>
<td className="p-4"> <td className="p-4">
{review.isApproved ? ( {review.isApproved ? (
<span className="text-forest">{t.adminReviewsPage.statusPublished}</span> <AdminBadge tone="positive">{t.adminReviewsPage.statusPublished}</AdminBadge>
) : ( ) : (
<span className="text-honey-dark">{t.adminReviewsPage.statusPending}</span> <AdminBadge tone="pending" pulse>
{t.adminReviewsPage.statusPending}
</AdminBadge>
)} )}
</td> </td>
<td className="p-4"> <td className="p-4">
<div className="flex items-center gap-3 whitespace-nowrap"> <AdminRowActions>
{!review.isApproved ? ( {!review.isApproved ? (
<form action={approveReviewAction}> <form action={approveReviewAction}>
<input type="hidden" name="id" value={review.id} /> <input type="hidden" name="id" value={review.id} />
<button type="submit" className="text-forest underline"> <AdminActionButton>{t.adminReviewsPage.approve}</AdminActionButton>
{t.adminReviewsPage.approve}
</button>
</form> </form>
) : null} ) : null}
<ConfirmForm <ConfirmForm
@@ -109,20 +106,18 @@ export default async function AdminBewertungenPage(props: PageProps<"/admin/bewe
confirmMessage={t.adminReviewsPage.deleteConfirm} confirmMessage={t.adminReviewsPage.deleteConfirm}
> >
<input type="hidden" name="id" value={review.id} /> <input type="hidden" name="id" value={review.id} />
<button type="submit" className="text-red-700 underline"> <AdminActionButton tone="danger">{t.adminReviewsPage.delete}</AdminActionButton>
{t.adminReviewsPage.delete}
</button>
</ConfirmForm> </ConfirmForm>
</div> </AdminRowActions>
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
{reviews.length === 0 ? ( {reviews.length === 0 ? (
<p className="p-6 text-ink/65"> <AdminEmptyState
{query ? t.adminReviewsPage.noResultsFiltered : t.adminReviewsPage.noReviewsYet} message={query ? t.adminReviewsPage.noResultsFiltered : t.adminReviewsPage.noReviewsYet}
</p> />
) : null} ) : null}
</div> </div>
</div> </div>
+30 -47
View File
@@ -2,96 +2,79 @@ import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { asStringArray } from "@/lib/json-array"; import { asStringArray } from "@/lib/json-array";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { Button } from "@/components/ui";
import { AdminImageField } from "@/components/admin-image-field"; import { AdminImageField } from "@/components/admin-image-field";
import { AdminField, AdminFieldset, AdminFormPanel } from "@/components/admin-form";
import { updateBatchAction } from "../../actions"; import { updateBatchAction } from "../../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminBatchEditPage(props: PageProps<"/admin/chargen/[id]">) { export default async function AdminBatchEditPage(props: PageProps<"/admin/chargen/[id]">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const tb = t.adminBatchEditPage;
const { id } = await props.params; const { id } = await props.params;
const rawBatch = await prisma.batch.findUnique({ where: { id }, include: { harvest: true } }); const rawBatch = await prisma.batch.findUnique({ where: { id }, include: { harvest: true } });
if (!rawBatch) notFound(); if (!rawBatch) notFound();
const batch = { ...rawBatch, images: asStringArray(rawBatch.images) }; const batch = { ...rawBatch, images: asStringArray(rawBatch.images) };
return ( return (
<div className="max-w-xl"> <div className="max-w-2xl">
<BackLink href="/admin/chargen" label={t.adminBatchEditPage.backToBatches} /> <BackLink href="/admin/chargen" label={tb.backToBatches} />
<h1 className="text-2xl text-forest mb-2">{t.adminBatchEditPage.title}</h1> <h1 className="text-2xl text-forest mb-2">{tb.title}</h1>
<p className="text-sm text-ink/65 mb-8"> <p className="text-sm text-ink/65 mb-8">
{batch.code} · {batch.harvest.type} {batch.harvest.year} {batch.code} · {batch.harvest.type} {batch.harvest.year}
</p> </p>
<form action={updateBatchAction} className="space-y-4 bg-background border border-ink/10 p-6">
<AdminFormPanel action={updateBatchAction} actions={<Button type="submit">{tb.save}</Button>}>
<input type="hidden" name="id" value={batch.id} /> <input type="hidden" name="id" value={batch.id} />
<div className="grid grid-cols-2 gap-4"> <AdminFieldset legend={tb.sectionDetails}>
<label className="block text-sm"> <div className="grid grid-cols-2 items-end gap-4">
<span className="text-ink/70">{t.adminBatchEditPage.fillDateLabel}</span> <AdminField
<input
name="fillDate" name="fillDate"
label={tb.fillDateLabel}
type="date" type="date"
defaultValue={batch.fillDate.toISOString().slice(0, 10)} defaultValue={batch.fillDate.toISOString().slice(0, 10)}
required required
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/> />
</label> <AdminField
<label className="block text-sm">
<span className="text-ink/70">{t.adminBatchEditPage.bestBeforeDateLabel}</span>
<input
name="bestBeforeDate" name="bestBeforeDate"
label={tb.bestBeforeDateLabel}
type="date" type="date"
defaultValue={batch.bestBeforeDate.toISOString().slice(0, 10)} defaultValue={batch.bestBeforeDate.toISOString().slice(0, 10)}
required required
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/> />
</label>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 items-end gap-4">
<label className="block text-sm"> <AdminField
<span className="text-ink/70">{t.adminBatchEditPage.glassCountLabel}</span>
<input
name="glassCount" name="glassCount"
label={tb.glassCountLabel}
type="number" type="number"
defaultValue={batch.glassCount} min={0}
defaultValue={String(batch.glassCount)}
required required
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/> />
</label> <AdminField
<label className="block text-sm">
<span className="text-ink/70">{t.adminBatchEditPage.remainingGlassesLabel}</span>
<input
name="remainingGlasses" name="remainingGlasses"
label={tb.remainingGlassesLabel}
type="number" type="number"
defaultValue={batch.remainingGlasses} min={0}
defaultValue={String(batch.remainingGlasses)}
required required
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/> />
</label>
</div> </div>
</AdminFieldset>
{/* Zwei einzeln setzbare Slots statt einer Liste: die Position {/* Zwei einzeln setzbare Slots statt einer Liste: die Position
entscheidet, welche Kachel auf /charge/[code] gefuellt wird. entscheidet, welche Kachel auf /charge/[code] gefuellt wird.
Beide tragen denselben Feldnamen "images" und werden in dieser Beide tragen denselben Feldnamen "images" und werden in dieser
Reihenfolge uebermittelt (siehe imageSlots() in Reihenfolge uebermittelt (siehe imageSlots() in
admin/actions/harvests-batches.ts). Ein leerer Slot faellt auf admin/actions/harvests-batches.ts). Ein leerer Slot faellt auf
das entsprechende Erntebild zurueck. */} das entsprechende Erntebild zurueck. */}
<fieldset className="border border-ink/10 p-4"> <AdminFieldset legend={tb.imagesLabel} last>
<legend className="px-1 text-sm text-ink/70">{t.adminBatchEditPage.imagesLabel}</legend> <AdminImageField name="images" label={tb.image1Label} initialValue={batch.images[0] ?? ""} />
<div className="space-y-4"> <AdminImageField name="images" label={tb.image2Label} initialValue={batch.images[1] ?? ""} />
<AdminImageField </AdminFieldset>
name="images" </AdminFormPanel>
label={t.adminBatchEditPage.image1Label}
initialValue={batch.images[0] ?? ""}
/>
<AdminImageField
name="images"
label={t.adminBatchEditPage.image2Label}
initialValue={batch.images[1] ?? ""}
/>
</div>
</fieldset>
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
{t.adminBatchEditPage.save}
</button>
</form>
</div> </div>
); );
} }
+45 -50
View File
@@ -1,8 +1,17 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { ConfirmForm } from "@/components/confirm-form"; import { ConfirmForm } from "@/components/confirm-form";
import { formatDate } from "@/lib/format"; import { formatDate } from "@/lib/format";
import { Button } from "@/components/ui";
import { AdminField, AdminSelect } from "@/components/admin-form";
import {
AdminActionButton,
AdminActionLink,
AdminActionNote,
AdminEmptyState,
AdminPageHeader,
AdminRowActions,
} from "@/components/admin-list";
import { createBatchAction, deleteBatchAction } from "../actions"; import { createBatchAction, deleteBatchAction } from "../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
@@ -20,7 +29,7 @@ export default async function AdminBatchesPage() {
return ( return (
<div> <div>
<BackLink href="/admin" label={t.adminBatchesPage.backToDashboard} /> <BackLink href="/admin" label={t.adminBatchesPage.backToDashboard} />
<h1 className="text-2xl text-forest mb-8">{t.adminBatchesPage.title}</h1> <AdminPageHeader title={t.adminBatchesPage.title} />
<div className="overflow-x-auto bg-background border border-ink/10"> <div className="overflow-x-auto bg-background border border-ink/10">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="text-left text-ink/65 border-b border-ink/10"> <thead className="text-left text-ink/65 border-b border-ink/10">
@@ -41,84 +50,70 @@ export default async function AdminBatchesPage() {
<td className="p-4"> <td className="p-4">
{batch.harvest.type} {batch.harvest.year} {batch.harvest.type} {batch.harvest.year}
</td> </td>
<td className="p-4">{formatDate(batch.fillDate)}</td> <td className="p-4 whitespace-nowrap tabular-nums">{formatDate(batch.fillDate)}</td>
<td className="p-4">{formatDate(batch.bestBeforeDate)}</td> <td className="p-4 whitespace-nowrap tabular-nums">{formatDate(batch.bestBeforeDate)}</td>
<td className="p-4">{batch.glassCount}</td> <td className="p-4 tabular-nums">{batch.glassCount}</td>
<td className="p-4">{batch.remainingGlasses}</td> <td className={`p-4 tabular-nums ${batch.remainingGlasses === 0 ? "text-ink/55" : ""}`}>
<td className="p-4 whitespace-nowrap"> {batch.remainingGlasses}
<Link href={`/admin/chargen/${batch.id}`} className="text-forest underline mr-4"> </td>
{t.adminBatchesPage.edit} <td className="p-4">
</Link> <AdminRowActions>
<AdminActionLink href={`/admin/chargen/${batch.id}`}>{t.adminBatchesPage.edit}</AdminActionLink>
{batch._count.products === 0 && batch._count.orderItems === 0 ? ( {batch._count.products === 0 && batch._count.orderItems === 0 ? (
<ConfirmForm <ConfirmForm
action={deleteBatchAction} action={deleteBatchAction}
confirmMessage={t.adminBatchesPage.deleteConfirm(batch.code)} confirmMessage={t.adminBatchesPage.deleteConfirm(batch.code)}
className="inline"
> >
<input type="hidden" name="id" value={batch.id} /> <input type="hidden" name="id" value={batch.id} />
<button type="submit" className="text-red-700 underline"> <AdminActionButton tone="danger">{t.adminBatchesPage.delete}</AdminActionButton>
{t.adminBatchesPage.delete}
</button>
</ConfirmForm> </ConfirmForm>
) : ( ) : (
// Statt den Knopf wortlos wegzulassen: der Grund steht da. // Statt den Knopf wortlos wegzulassen: der Grund steht da.
<span className="text-xs text-ink/50">{t.adminBatchesPage.notDeletable}</span> <AdminActionNote>{t.adminBatchesPage.notDeletable}</AdminActionNote>
)} )}
</AdminRowActions>
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
{batches.length === 0 ? <p className="p-6 text-ink/65">{t.adminBatchesPage.noBatchesYet}</p> : null} {batches.length === 0 ? <AdminEmptyState message={t.adminBatchesPage.noBatchesYet} /> : null}
</div> </div>
<div className="mt-10 bg-background border border-ink/10 p-6 max-w-xl"> <div className="mt-10 bg-background border border-ink/10 p-6 max-w-xl">
<h2 className="text-lg text-forest mb-4">{t.adminBatchesPage.newBatchHeading}</h2> <h2 className="font-serif text-xl text-forest mb-4">{t.adminBatchesPage.newBatchHeading}</h2>
<form action={createBatchAction} className="space-y-4"> <form action={createBatchAction} className="space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 items-end gap-4">
<label className="block text-sm"> <AdminField
<span className="text-ink/70">{t.adminBatchesPage.codeLabel}</span>
<input
name="code" name="code"
required label={t.adminBatchesPage.codeLabel}
placeholder={t.adminBatchesPage.codePlaceholder} placeholder={t.adminBatchesPage.codePlaceholder}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" required
/> />
</label> <AdminSelect name="harvestId" label={t.adminBatchesPage.harvestLabel}>
<label className="block text-sm">
<span className="text-ink/70">{t.adminBatchesPage.harvestLabel}</span>
<select name="harvestId" required className="mt-1 w-full border border-ink/20 bg-background px-3 py-2">
{harvests.map((h) => ( {harvests.map((h) => (
<option key={h.id} value={h.id}> <option key={h.id} value={h.id}>
{h.type} {h.year} {h.type} {h.year}
</option> </option>
))} ))}
</select> </AdminSelect>
</label>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 items-end gap-4">
<label className="block text-sm"> <AdminField name="fillDate" label={t.adminBatchesPage.fillDateLabel} type="date" required />
<span className="text-ink/70">{t.adminBatchesPage.fillDateLabel}</span> <AdminField name="bestBeforeDate" label={t.adminBatchesPage.bestBeforeDateLabel} type="date" required />
<input name="fillDate" type="date" required className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" />
</label>
<label className="block text-sm">
<span className="text-ink/70">{t.adminBatchesPage.bestBeforeDateLabel}</span>
<input name="bestBeforeDate" type="date" required className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" />
</label>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 items-end gap-4">
<label className="block text-sm"> <AdminField name="glassCount" label={t.adminBatchesPage.glassCountLabel} type="number" required />
<span className="text-ink/70">{t.adminBatchesPage.glassCountLabel}</span> <AdminField
<input name="glassCount" type="number" required className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" /> name="remainingGlasses"
</label> label={t.adminBatchesPage.remainingGlassesLabel}
<label className="block text-sm"> type="number"
<span className="text-ink/70">{t.adminBatchesPage.remainingGlassesLabel}</span> required
<input name="remainingGlasses" type="number" required className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" /> />
</label> </div>
<div className="flex justify-end border-t border-ink/10 pt-4">
<Button type="submit">{t.adminBatchesPage.create}</Button>
</div> </div>
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
{t.adminBatchesPage.create}
</button>
</form> </form>
</div> </div>
</div> </div>
+43 -72
View File
@@ -2,13 +2,23 @@ import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { asStringArray } from "@/lib/json-array"; import { asStringArray } from "@/lib/json-array";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { Button } from "@/components/ui";
import { AdminImageField } from "@/components/admin-image-field"; import { AdminImageField } from "@/components/admin-image-field";
import {
AdminField,
AdminFieldset,
AdminFormPanel,
AdminHint,
AdminSwitch,
AdminTextarea,
} from "@/components/admin-form";
import { updateHarvestAction, toggleHarvestPublishedAction } from "../../actions"; import { updateHarvestAction, toggleHarvestPublishedAction } from "../../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminHarvestEditPage(props: PageProps<"/admin/ernten/[id]">) { export default async function AdminHarvestEditPage(props: PageProps<"/admin/ernten/[id]">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const th = t.adminHarvestEditPage;
const { id } = await props.params; const { id } = await props.params;
const rawHarvest = await prisma.harvest.findUnique({ where: { id } }); const rawHarvest = await prisma.harvest.findUnique({ where: { id } });
if (!rawHarvest) notFound(); if (!rawHarvest) notFound();
@@ -17,102 +27,63 @@ export default async function AdminHarvestEditPage(props: PageProps<"/admin/ernt
const harvestDateValue = harvest.harvestDate.toISOString().slice(0, 10); const harvestDateValue = harvest.harvestDate.toISOString().slice(0, 10);
return ( return (
<div className="max-w-xl"> <div className="max-w-2xl">
<BackLink href="/admin/ernten" label={t.adminHarvestEditPage.backToHarvests} /> <BackLink href="/admin/ernten" label={th.backToHarvests} />
<h1 className="text-2xl text-forest mb-8">{t.adminHarvestEditPage.title}</h1> <h1 className="text-2xl text-forest mb-8">{th.title}</h1>
{harvest.isPublished ? null : ( {harvest.isPublished ? null : (
<div className="mb-6 bg-honey/10 border border-honey/30 px-4 py-3 flex flex-col sm:flex-row sm:items-center gap-3 justify-between"> <div className="mb-6 flex flex-col justify-between gap-3 border border-honey/40 bg-honey/5 px-4 py-3 sm:flex-row sm:items-center">
<p className="text-sm text-honey-dark">{t.adminHarvestEditPage.draftNotice}</p> <p className="text-sm text-honey-dark">{th.draftNotice}</p>
<form action={toggleHarvestPublishedAction}> <form action={toggleHarvestPublishedAction}>
<input type="hidden" name="id" value={harvest.id} /> <input type="hidden" name="id" value={harvest.id} />
<input type="hidden" name="isPublished" value="on" /> <input type="hidden" name="isPublished" value="on" />
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm whitespace-nowrap"> <Button type="submit" className="whitespace-nowrap px-5 py-2.5">
{t.adminHarvestEditPage.publishNow} {th.publishNow}
</button> </Button>
</form> </form>
</div> </div>
)} )}
<form action={updateHarvestAction} className="space-y-4 bg-background border border-ink/10 p-6"> <AdminFormPanel action={updateHarvestAction} actions={<Button type="submit">{th.save}</Button>}>
<input type="hidden" name="id" value={harvest.id} /> <input type="hidden" name="id" value={harvest.id} />
<div className="grid grid-cols-2 gap-4"> <AdminFieldset legend={th.sectionDetails}>
<label className="block text-sm"> <div className="grid grid-cols-2 items-end gap-4">
<span className="text-ink/70">{t.adminHarvestEditPage.typeLabel}</span> <AdminField name="type" label={th.typeLabel} defaultValue={harvest.type} required />
<input <AdminField name="year" label={th.yearLabel} type="number" defaultValue={String(harvest.year)} required />
name="type"
defaultValue={harvest.type}
required
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
<label className="block text-sm">
<span className="text-ink/70">{t.adminHarvestEditPage.yearLabel}</span>
<input
name="year"
type="number"
defaultValue={harvest.year}
required
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
</div> </div>
<label className="block text-sm"> <div className="grid items-end gap-4 sm:grid-cols-2">
<span className="text-ink/70">{t.adminHarvestEditPage.harvestDateLabel}</span> <AdminField
<input
name="harvestDate" name="harvestDate"
label={th.harvestDateLabel}
type="date" type="date"
defaultValue={harvestDateValue} defaultValue={harvestDateValue}
required required
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/> />
</label> <AdminField name="location" label={th.locationLabel} defaultValue={harvest.location} />
<label className="block text-sm"> </div>
<span className="text-ink/70">{t.adminHarvestEditPage.locationLabel}</span> <AdminTextarea
<input
name="location"
defaultValue={harvest.location}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
<label className="block text-sm">
<span className="text-ink/70">{t.adminHarvestEditPage.descriptionLabel}</span>
<textarea
name="description" name="description"
label={th.descriptionLabel}
defaultValue={harvest.description} defaultValue={harvest.description}
required
rows={4} rows={4}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" required
/> />
</label> </AdminFieldset>
{/* Siehe Kommentar im Chargen-Formular: zwei benannte Slots statt {/* Siehe Kommentar im Chargen-Formular: zwei benannte Slots statt
einer Liste, damit sich das zweite Bild auch ohne erstes setzen einer Liste, damit sich das zweite Bild auch ohne erstes setzen
laesst. Slot 1 erscheint zusaetzlich auf /ernten und der laesst. Slot 1 erscheint zusaetzlich auf /ernten und der
Ernten-Detailseite. */} Ernten-Detailseite. */}
<fieldset className="border border-ink/10 p-4"> <AdminFieldset legend={th.imagesLabel}>
<legend className="px-1 text-sm text-ink/70">{t.adminHarvestEditPage.imagesLabel}</legend> <AdminImageField name="images" label={th.image1Label} initialValue={harvest.images[0] ?? ""} />
<div className="space-y-4"> <AdminImageField name="images" label={th.image2Label} initialValue={harvest.images[1] ?? ""} />
<AdminImageField </AdminFieldset>
name="images"
label={t.adminHarvestEditPage.image1Label} <AdminFieldset legend={th.sectionVisibility} last>
initialValue={harvest.images[0] ?? ""} <AdminSwitch name="isPublished" label={th.isPublishedLabel} defaultChecked={harvest.isPublished} />
/> <AdminHint>{th.isPublishedHint}</AdminHint>
<AdminImageField </AdminFieldset>
name="images" </AdminFormPanel>
label={t.adminHarvestEditPage.image2Label}
initialValue={harvest.images[1] ?? ""}
/>
</div>
</fieldset>
<label className="flex items-center gap-2 text-sm text-ink/70">
<input type="checkbox" name="isPublished" defaultChecked={harvest.isPublished} />
{t.adminHarvestEditPage.isPublishedLabel}
</label>
<p className="text-xs text-ink/70">{t.adminHarvestEditPage.isPublishedHint}</p>
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
{t.adminHarvestEditPage.save}
</button>
</form>
</div> </div>
); );
} }
+44 -83
View File
@@ -1,16 +1,24 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { ConfirmForm } from "@/components/confirm-form"; import { ConfirmForm } from "@/components/confirm-form";
import { formatDate } from "@/lib/format"; import { formatDate } from "@/lib/format";
import { Button } from "@/components/ui";
import { AdminField, AdminSwitch, AdminTextarea } from "@/components/admin-form";
import {
AdminActionButton,
AdminActionLink,
AdminActionNote,
AdminBadge,
AdminEmptyState,
AdminPageHeader,
AdminRowActions,
} from "@/components/admin-list";
import { createHarvestAction, toggleHarvestPublishedAction, deleteHarvestAction } from "../actions"; import { createHarvestAction, toggleHarvestPublishedAction, deleteHarvestAction } from "../actions";
import { ApiCoreSyncPanel, ApiCoreSyncResult } from "@/components/apicore-sync-panel";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten">) { export default async function AdminHarvestsPage() {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const searchParams = await props.searchParams;
const harvests = await prisma.harvest.findMany({ const harvests = await prisma.harvest.findMany({
orderBy: [{ year: "desc" }, { harvestDate: "desc" }], orderBy: [{ year: "desc" }, { harvestDate: "desc" }],
include: { _count: { select: { batches: true, products: true } } }, include: { _count: { select: { batches: true, products: true } } },
@@ -19,9 +27,8 @@ export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten"
return ( return (
<div> <div>
<BackLink href="/admin" label={t.adminHarvestsPage.backToDashboard} /> <BackLink href="/admin" label={t.adminHarvestsPage.backToDashboard} />
<h1 className="text-2xl text-forest mb-8">{t.adminHarvestsPage.title}</h1> <AdminPageHeader title={t.adminHarvestsPage.title} />
<ApiCoreSyncResult searchParams={searchParams} />
<div className="overflow-x-auto bg-background border border-ink/10"> <div className="overflow-x-auto bg-background border border-ink/10">
<table className="w-full text-sm"> <table className="w-full text-sm">
@@ -41,115 +48,69 @@ export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten"
{harvests.map((harvest) => ( {harvests.map((harvest) => (
<tr key={harvest.id} className="transition-colors hover:bg-ink/[0.03]"> <tr key={harvest.id} className="transition-colors hover:bg-ink/[0.03]">
<td className="p-4 text-forest">{harvest.type}</td> <td className="p-4 text-forest">{harvest.type}</td>
<td className="p-4">{harvest.year}</td> <td className="p-4 tabular-nums">{harvest.year}</td>
<td className="p-4">{formatDate(harvest.harvestDate)}</td> <td className="p-4 whitespace-nowrap tabular-nums">{formatDate(harvest.harvestDate)}</td>
<td className="p-4">{harvest.location}</td> <td className="p-4">{harvest.location}</td>
<td className="p-4">{harvest._count.batches}</td> <td className="p-4 tabular-nums">{harvest._count.batches}</td>
<td className="p-4">{harvest._count.products}</td> <td className="p-4 tabular-nums">{harvest._count.products}</td>
<td className="p-4"> <td className="p-4">
<span {harvest.isPublished ? (
className={ <AdminBadge tone="positive">{t.adminHarvestsPage.statusPublished}</AdminBadge>
harvest.isPublished ) : (
? "text-forest bg-forest/10 px-2 py-1 text-xs" <AdminBadge tone="pending">{t.adminHarvestsPage.statusDraft}</AdminBadge>
: "text-honey-dark bg-honey/10 px-2 py-1 text-xs" )}
}
>
{harvest.isPublished ? t.adminHarvestsPage.statusPublished : t.adminHarvestsPage.statusDraft}
</span>
</td> </td>
<td className="p-4 whitespace-nowrap"> <td className="p-4">
<Link href={`/admin/ernten/${harvest.id}`} className="text-forest underline mr-4"> <AdminRowActions>
{t.adminHarvestsPage.edit} <AdminActionLink href={`/admin/ernten/${harvest.id}`}>{t.adminHarvestsPage.edit}</AdminActionLink>
</Link> <form action={toggleHarvestPublishedAction}>
<form action={toggleHarvestPublishedAction} className="inline">
<input type="hidden" name="id" value={harvest.id} /> <input type="hidden" name="id" value={harvest.id} />
{harvest.isPublished ? null : <input type="hidden" name="isPublished" value="on" />} {harvest.isPublished ? null : <input type="hidden" name="isPublished" value="on" />}
<button type="submit" className="text-forest underline mr-4"> <AdminActionButton tone={harvest.isPublished ? "muted" : "default"}>
{harvest.isPublished ? t.adminHarvestsPage.unpublish : t.adminHarvestsPage.publish} {harvest.isPublished ? t.adminHarvestsPage.unpublish : t.adminHarvestsPage.publish}
</button> </AdminActionButton>
</form> </form>
{harvest._count.batches === 0 && harvest._count.products === 0 ? ( {harvest._count.batches === 0 && harvest._count.products === 0 ? (
<ConfirmForm <ConfirmForm
action={deleteHarvestAction} action={deleteHarvestAction}
confirmMessage={t.adminHarvestsPage.deleteConfirm(`${harvest.type} ${harvest.year}`)} confirmMessage={t.adminHarvestsPage.deleteConfirm(`${harvest.type} ${harvest.year}`)}
className="inline"
> >
<input type="hidden" name="id" value={harvest.id} /> <input type="hidden" name="id" value={harvest.id} />
<button type="submit" className="text-red-700 underline"> <AdminActionButton tone="danger">{t.adminHarvestsPage.delete}</AdminActionButton>
{t.adminHarvestsPage.delete}
</button>
</ConfirmForm> </ConfirmForm>
) : ( ) : (
// Statt den Knopf wortlos wegzulassen: der Grund steht da. // Statt den Knopf wortlos wegzulassen: der Grund steht da.
<span className="text-xs text-ink/50">{t.adminHarvestsPage.notDeletable}</span> <AdminActionNote>{t.adminHarvestsPage.notDeletable}</AdminActionNote>
)} )}
</AdminRowActions>
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
{harvests.length === 0 ? <p className="p-6 text-ink/65">{t.adminHarvestsPage.noHarvestsYet}</p> : null} {harvests.length === 0 ? <AdminEmptyState message={t.adminHarvestsPage.noHarvestsYet} /> : null}
</div> </div>
<div className="mt-10 grid md:grid-cols-2 gap-6"> <div className="mt-10 max-w-xl">
<div className="bg-background border border-ink/10 p-6"> <div className="bg-background border border-ink/10 p-6">
<h2 className="text-lg text-forest mb-4">{t.adminHarvestsPage.newHarvestHeading}</h2> <h2 className="font-serif text-xl text-forest mb-4">{t.adminHarvestsPage.newHarvestHeading}</h2>
<form action={createHarvestAction} className="space-y-4"> <form action={createHarvestAction} className="space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 items-end gap-4">
<Field name="type" label={t.adminHarvestsPage.typeLabel} required /> <AdminField name="type" label={t.adminHarvestsPage.typeLabel} required />
<Field name="year" label={t.adminHarvestsPage.yearLabel} type="number" required /> <AdminField name="year" label={t.adminHarvestsPage.yearLabel} type="number" required />
</div>
<AdminField name="harvestDate" label={t.adminHarvestsPage.harvestDateLabel} type="date" required />
<AdminField name="location" label={t.adminHarvestsPage.locationLabel} defaultValue="Altenmünster" />
<AdminTextarea name="description" label={t.adminHarvestsPage.descriptionLabel} required />
<AdminSwitch name="isPublished" label={t.adminHarvestsPage.isPublishedLabel} defaultChecked />
<div className="flex justify-end border-t border-ink/10 pt-4">
<Button type="submit">{t.adminHarvestsPage.create}</Button>
</div> </div>
<Field name="harvestDate" label={t.adminHarvestsPage.harvestDateLabel} type="date" required />
<Field name="location" label={t.adminHarvestsPage.locationLabel} defaultValue="Altenmünster" />
<TextArea name="description" label={t.adminHarvestsPage.descriptionLabel} required />
<label className="flex items-center gap-2 text-sm text-ink/70">
<input type="checkbox" name="isPublished" defaultChecked />
{t.adminHarvestsPage.isPublishedLabel}
</label>
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
{t.adminHarvestsPage.create}
</button>
</form> </form>
</div> </div>
<ApiCoreSyncPanel returnTo="/admin/ernten" />
</div> </div>
</div> </div>
); );
} }
function Field({
name,
label,
type = "text",
defaultValue,
required = false,
}: {
name: string;
label: string;
type?: string;
defaultValue?: string;
required?: boolean;
}) {
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<input
name={name}
type={type}
defaultValue={defaultValue}
required={required}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
);
}
function TextArea({ name, label, required = false }: { name: string; label: string; required?: boolean }) {
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<textarea name={name} required={required} rows={3} className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" />
</label>
);
}
+53 -74
View File
@@ -1,6 +1,14 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { Button } from "@/components/ui";
import {
AdminField,
AdminFieldset,
AdminFormPanel,
AdminSelect,
AdminSwitch,
} from "@/components/admin-form";
import { updateDiscountCodeAction } from "../../actions"; import { updateDiscountCodeAction } from "../../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
@@ -12,108 +20,79 @@ function toDateInputValue(date: Date | null): string {
export default async function AdminGutscheinEditPage(props: PageProps<"/admin/gutscheine/[id]">) { export default async function AdminGutscheinEditPage(props: PageProps<"/admin/gutscheine/[id]">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const tv = t.adminVoucherEditPage;
const { id } = await props.params; const { id } = await props.params;
const code = await prisma.discountCode.findUnique({ where: { id } }); const code = await prisma.discountCode.findUnique({ where: { id } });
if (!code) notFound(); if (!code) notFound();
return ( return (
<div className="max-w-md"> <div className="max-w-2xl">
<BackLink href="/admin/gutscheine" label={t.adminVoucherEditPage.backToVouchers} /> <BackLink href="/admin/gutscheine" label={tv.backToVouchers} />
<h1 className="text-2xl text-forest mb-8">{t.adminVoucherEditPage.title}</h1> <h1 className="text-2xl text-forest mb-2">{tv.title}</h1>
<p className="text-sm text-ink/65 mb-8">{tv.redeemedSoFar(code.usedCount, code.maxUses)}</p>
<AdminFormPanel action={updateDiscountCodeAction} actions={<Button type="submit">{tv.save}</Button>}>
<form action={updateDiscountCodeAction} className="space-y-4 bg-background border border-ink/10 p-6">
<input type="hidden" name="id" value={code.id} /> <input type="hidden" name="id" value={code.id} />
<Field name="code" label={t.adminVoucherEditPage.codeLabel} defaultValue={code.code} required /> <AdminFieldset legend={tv.sectionDiscount}>
<AdminField name="code" label={tv.codeLabel} defaultValue={code.code} required />
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 items-end gap-4">
<label className="block text-sm"> <AdminSelect name="type" label={tv.typeLabel} defaultValue={code.type}>
<span className="text-ink/70">{t.adminVoucherEditPage.typeLabel}</span> <option value="PERCENT">{tv.typePercent}</option>
<select <option value="FIXED">{tv.typeFixed}</option>
name="type" </AdminSelect>
defaultValue={code.type} <AdminField
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" name="value"
> label={tv.valueLabel}
<option value="PERCENT">{t.adminVoucherEditPage.typePercent}</option>
<option value="FIXED">{t.adminVoucherEditPage.typeFixed}</option>
</select>
</label>
<Field name="value" label={t.adminVoucherEditPage.valueLabel} type="number" defaultValue={String(code.value)} required />
</div>
<Field
name="minOrderCents"
label={t.adminVoucherEditPage.minOrderLabel}
type="number" type="number"
min={0}
defaultValue={String(code.value)}
required
/>
</div>
</AdminFieldset>
<AdminFieldset legend={tv.sectionConditions}>
<AdminField
name="minOrderCents"
label={tv.minOrderLabel}
type="number"
min={0}
defaultValue={String(code.minOrderCents)} defaultValue={String(code.minOrderCents)}
/> />
<Field <AdminField
name="minQuantity" name="minQuantity"
label={t.adminVoucherEditPage.minQuantityLabel} label={tv.minQuantityLabel}
type="number" type="number"
min={0}
defaultValue={code.minQuantity !== null ? String(code.minQuantity) : ""} defaultValue={code.minQuantity !== null ? String(code.minQuantity) : ""}
/> />
<Field <AdminField
name="maxUses" name="maxUses"
label={t.adminVoucherEditPage.maxUsesLabel} label={tv.maxUsesLabel}
type="number" type="number"
min={0}
defaultValue={code.maxUses !== null ? String(code.maxUses) : ""} defaultValue={code.maxUses !== null ? String(code.maxUses) : ""}
/> />
</AdminFieldset>
<div className="grid grid-cols-2 gap-4"> <AdminFieldset legend={tv.sectionValidity} last>
<Field <div className="grid grid-cols-2 items-end gap-4">
<AdminField
name="validFrom" name="validFrom"
label={t.adminVoucherEditPage.validFromLabel} label={tv.validFromLabel}
type="date" type="date"
defaultValue={toDateInputValue(code.validFrom)} defaultValue={toDateInputValue(code.validFrom)}
/> />
<Field <AdminField
name="validUntil" name="validUntil"
label={t.adminVoucherEditPage.validUntilLabel} label={tv.validUntilLabel}
type="date" type="date"
defaultValue={toDateInputValue(code.validUntil)} defaultValue={toDateInputValue(code.validUntil)}
/> />
</div> </div>
<AdminSwitch name="isActive" label={tv.activeLabel} defaultChecked={code.isActive} />
<label className="flex items-center gap-2 text-sm text-ink/70"> </AdminFieldset>
<input type="checkbox" name="isActive" defaultChecked={code.isActive} /> </AdminFormPanel>
{t.adminVoucherEditPage.activeLabel}
</label>
<p className="text-xs text-ink/65">{t.adminVoucherEditPage.redeemedSoFar(code.usedCount, code.maxUses)}</p>
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
{t.adminVoucherEditPage.save}
</button>
</form>
</div> </div>
); );
} }
function Field({
name,
label,
type = "text",
defaultValue,
required = false,
}: {
name: string;
label: string;
type?: string;
defaultValue?: string;
required?: boolean;
}) {
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<input
name={name}
type={type}
defaultValue={defaultValue}
required={required}
min={0}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
);
}
+34 -60
View File
@@ -1,77 +1,51 @@
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { Button } from "@/components/ui";
import {
AdminField,
AdminFieldset,
AdminFormPanel,
AdminSelect,
AdminSwitch,
} from "@/components/admin-form";
import { createDiscountCodeAction } from "../../actions"; import { createDiscountCodeAction } from "../../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminGutscheinNewPage() { export default async function AdminGutscheinNewPage() {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const tv = t.adminVoucherNewPage;
return ( return (
<div className="max-w-md"> <div className="max-w-2xl">
<BackLink href="/admin/gutscheine" label={t.adminVoucherNewPage.backToVouchers} /> <BackLink href="/admin/gutscheine" label={tv.backToVouchers} />
<h1 className="text-2xl text-forest mb-8">{t.adminVoucherNewPage.title}</h1> <h1 className="text-2xl text-forest mb-8">{tv.title}</h1>
<AdminFormPanel action={createDiscountCodeAction} actions={<Button type="submit">{tv.create}</Button>}>
<form action={createDiscountCodeAction} className="space-y-4 bg-background border border-ink/10 p-6"> <AdminFieldset legend={tv.sectionDiscount}>
<Field name="code" label={t.adminVoucherNewPage.codeLabel} placeholder={t.adminVoucherNewPage.codePlaceholder} required /> <AdminField name="code" label={tv.codeLabel} placeholder={tv.codePlaceholder} required />
<div className="grid grid-cols-2 items-end gap-4">
<div className="grid grid-cols-2 gap-4"> <AdminSelect name="type" label={tv.typeLabel} defaultValue="PERCENT">
<label className="block text-sm"> <option value="PERCENT">{tv.typePercent}</option>
<span className="text-ink/70">{t.adminVoucherNewPage.typeLabel}</span> <option value="FIXED">{tv.typeFixed}</option>
<select name="type" className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"> </AdminSelect>
<option value="PERCENT">{t.adminVoucherNewPage.typePercent}</option> <AdminField name="value" label={tv.valueLabel} type="number" min={0} placeholder="10" required />
<option value="FIXED">{t.adminVoucherNewPage.typeFixed}</option>
</select>
</label>
<Field name="value" label={t.adminVoucherNewPage.valueLabel} type="number" placeholder="10" required />
</div> </div>
</AdminFieldset>
<Field name="minOrderCents" label={t.adminVoucherNewPage.minOrderLabel} type="number" placeholder="0" /> <AdminFieldset legend={tv.sectionConditions}>
<Field name="minQuantity" label={t.adminVoucherNewPage.minQuantityLabel} type="number" placeholder="3" /> <AdminField name="minOrderCents" label={tv.minOrderLabel} type="number" min={0} placeholder="0" />
<Field name="maxUses" label={t.adminVoucherNewPage.maxUsesLabel} type="number" /> <AdminField name="minQuantity" label={tv.minQuantityLabel} type="number" min={0} placeholder="3" />
<AdminField name="maxUses" label={tv.maxUsesLabel} type="number" min={0} />
</AdminFieldset>
<div className="grid grid-cols-2 gap-4"> <AdminFieldset legend={tv.sectionValidity} last>
<Field name="validFrom" label={t.adminVoucherNewPage.validFromLabel} type="date" /> <div className="grid grid-cols-2 items-end gap-4">
<Field name="validUntil" label={t.adminVoucherNewPage.validUntilLabel} type="date" /> <AdminField name="validFrom" label={tv.validFromLabel} type="date" />
<AdminField name="validUntil" label={tv.validUntilLabel} type="date" />
</div> </div>
<AdminSwitch name="isActive" label={tv.activeLabel} defaultChecked />
<label className="flex items-center gap-2 text-sm text-ink/70"> </AdminFieldset>
<input type="checkbox" name="isActive" defaultChecked /> </AdminFormPanel>
{t.adminVoucherNewPage.activeLabel}
</label>
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
{t.adminVoucherNewPage.create}
</button>
</form>
</div> </div>
); );
} }
function Field({
name,
label,
type = "text",
placeholder,
required = false,
}: {
name: string;
label: string;
type?: string;
placeholder?: string;
required?: boolean;
}) {
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<input
name={name}
type={type}
placeholder={placeholder}
required={required}
min={0}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
);
}
+22 -21
View File
@@ -1,8 +1,15 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { ConfirmForm } from "@/components/confirm-form"; import { ConfirmForm } from "@/components/confirm-form";
import { formatPrice, formatDate } from "@/lib/format"; import { formatPrice, formatDate } from "@/lib/format";
import {
AdminActionButton,
AdminActionLink,
AdminBadge,
AdminEmptyState,
AdminPageHeader,
AdminRowActions,
} from "@/components/admin-list";
import { deleteDiscountCodeAction } from "../actions"; import { deleteDiscountCodeAction } from "../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
@@ -10,17 +17,13 @@ import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminGutscheinePage() { export default async function AdminGutscheinePage() {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const codes = await prisma.discountCode.findMany({ orderBy: { createdAt: "desc" } }); const codes = await prisma.discountCode.findMany({ orderBy: { createdAt: "desc" } });
const now = new Date();
const newVoucher = { href: "/admin/gutscheine/neu", label: t.adminVouchersPage.newVoucher };
return ( return (
<div> <div>
<BackLink href="/admin" label={t.adminVouchersPage.backToDashboard} /> <BackLink href="/admin" label={t.adminVouchersPage.backToDashboard} />
<div className="flex items-center justify-between mb-8"> <AdminPageHeader title={t.adminVouchersPage.title} action={newVoucher} />
<h1 className="text-2xl text-forest">{t.adminVouchersPage.title}</h1>
<Link href="/admin/gutscheine/neu" className="bg-forest text-cream px-5 py-2 text-sm">
{t.adminVouchersPage.newVoucher}
</Link>
</div>
<div className="overflow-x-auto bg-background border border-ink/10"> <div className="overflow-x-auto bg-background border border-ink/10">
<table className="w-full text-sm"> <table className="w-full text-sm">
@@ -45,7 +48,7 @@ export default async function AdminGutscheinePage() {
</td> </td>
<td className="p-4">{code.minOrderCents > 0 ? formatPrice(code.minOrderCents) : "—"}</td> <td className="p-4">{code.minOrderCents > 0 ? formatPrice(code.minOrderCents) : "—"}</td>
<td className="p-4">{code.minQuantity !== null ? code.minQuantity : "—"}</td> <td className="p-4">{code.minQuantity !== null ? code.minQuantity : "—"}</td>
<td className="p-4"> <td className="p-4 tabular-nums">
{code.usedCount} {code.usedCount}
{code.maxUses !== null ? ` / ${code.maxUses}` : ""} {code.maxUses !== null ? ` / ${code.maxUses}` : ""}
</td> </td>
@@ -54,33 +57,31 @@ export default async function AdminGutscheinePage() {
{code.validUntil ? formatDate(code.validUntil) : t.adminVouchersPage.unlimited} {code.validUntil ? formatDate(code.validUntil) : t.adminVouchersPage.unlimited}
</td> </td>
<td className="p-4"> <td className="p-4">
{code.isActive ? ( {!code.isActive ? (
<span className="text-forest">{t.adminVouchersPage.statusActive}</span> <AdminBadge tone="neutral">{t.adminVouchersPage.statusInactive}</AdminBadge>
) : code.validUntil && code.validUntil < now ? (
<AdminBadge tone="pending">{t.adminVouchersPage.statusExpired}</AdminBadge>
) : ( ) : (
<span className="text-ink/65">{t.adminVouchersPage.statusInactive}</span> <AdminBadge tone="positive">{t.adminVouchersPage.statusActive}</AdminBadge>
)} )}
</td> </td>
<td className="p-4"> <td className="p-4">
<div className="flex items-center gap-3 whitespace-nowrap"> <AdminRowActions>
<Link href={`/admin/gutscheine/${code.id}`} className="text-forest underline"> <AdminActionLink href={`/admin/gutscheine/${code.id}`}>{t.adminVouchersPage.edit}</AdminActionLink>
{t.adminVouchersPage.edit}
</Link>
<ConfirmForm <ConfirmForm
action={deleteDiscountCodeAction} action={deleteDiscountCodeAction}
confirmMessage={t.adminVouchersPage.deleteConfirm(code.code)} confirmMessage={t.adminVouchersPage.deleteConfirm(code.code)}
> >
<input type="hidden" name="id" value={code.id} /> <input type="hidden" name="id" value={code.id} />
<button type="submit" className="text-red-700 underline"> <AdminActionButton tone="danger">{t.adminVouchersPage.delete}</AdminActionButton>
{t.adminVouchersPage.delete}
</button>
</ConfirmForm> </ConfirmForm>
</div> </AdminRowActions>
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
{codes.length === 0 ? <p className="p-6 text-ink/65">{t.adminVouchersPage.noVouchersYet}</p> : null} {codes.length === 0 ? <AdminEmptyState message={t.adminVouchersPage.noVouchersYet} action={newVoucher} /> : null}
</div> </div>
</div> </div>
); );
+3 -2
View File
@@ -8,6 +8,7 @@ import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
import type { Dictionary } from "@/i18n/dictionary-type"; import type { Dictionary } from "@/i18n/dictionary-type";
import type { Locale } from "@/i18n/config"; import type { Locale } from "@/i18n/config";
import { Button } from "@/components/ui";
const ROWS_BY_TYPE: Record<ContentFieldDef["type"], number> = { const ROWS_BY_TYPE: Record<ContentFieldDef["type"], number> = {
text: 1, text: 1,
@@ -190,9 +191,9 @@ function GroupForm({
<FieldEditor key={def.key} def={def} row={current.get(def.key)} locale={locale} t={t} /> <FieldEditor key={def.key} def={def} row={current.get(def.key)} locale={locale} t={t} />
))} ))}
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm"> <Button type="submit" className="px-5 py-2.5">
{t.adminContentPage.save} {t.adminContentPage.save}
</button> </Button>
</form> </form>
); );
} }
+20
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { noticeUrl } from "@/lib/notice"; import { noticeUrl } from "@/lib/notice";
import { getCurrentAdmin } from "@/lib/auth"; import { getCurrentAdmin } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { logoutAction } from "@/app/konto/actions"; import { logoutAction } from "@/app/konto/actions";
import { AdminShell } from "@/components/admin-shell"; import { AdminShell } from "@/components/admin-shell";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
@@ -42,9 +43,28 @@ export default async function AdminLayout({ children }: LayoutProps<"/admin">) {
redirect(noticeUrl("admin-2fa")); redirect(noticeUrl("admin-2fa"));
} }
// Was gerade auf Bearbeitung wartet — erscheint als Zaehler in der
// Seitenleiste. Nur echte Arbeit: bezahlte, noch nicht versendete oder
// abgeholte Bestellungen (keine offenen Zahlungen), neue Retouren,
// unfreigegebene Bewertungen, neue Firmenanfragen. Reine COUNT-Abfragen.
const [openOrders, openReturns, pendingReviews, newInquiries] = await Promise.all([
prisma.order.count({
where: { status: { in: ["BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "ABHOLBEREIT"] }, archivedAt: null },
}),
prisma.returnRequest.count({ where: { status: "ANGEFRAGT" } }),
prisma.review.count({ where: { isApproved: false } }),
prisma.businessInquiry.count({ where: { status: "NEU" } }),
]);
return ( return (
<AdminShell <AdminShell
adminName={`${admin.firstName} ${admin.lastName}`} adminName={`${admin.firstName} ${admin.lastName}`}
badges={{
"/admin/bestellungen": openOrders,
"/admin/retouren": openReturns,
"/admin/bewertungen": pendingReviews,
"/admin/anfragen": newInquiries,
}}
logoutAction={logoutAction} logoutAction={logoutAction}
> >
{children} {children}
+194 -39
View File
@@ -1,10 +1,13 @@
import Link from "next/link"; import Link from "next/link";
import { ApiCoreSyncPanel, ApiCoreSyncResult } from "@/components/apicore-sync-panel";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { formatPrice } from "@/lib/format"; import { formatPrice } from "@/lib/format";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
import { checkUploadFiles } from "@/lib/upload-health"; import { checkUploadFiles } from "@/lib/upload-health";
import { checkUploadVolume } from "@/lib/uploads"; import { checkUploadVolume } from "@/lib/uploads";
import { getCurrentAdmin } from "@/lib/auth";
import { HexMark } from "@/components/honeycomb";
const DASHBOARD_ICON_PROPS = { const DASHBOARD_ICON_PROPS = {
width: 20, width: 20,
@@ -98,8 +101,10 @@ function daysAgo(n: number): Date {
return d; return d;
} }
export default async function AdminDashboardPage() { export default async function AdminDashboardPage(props: PageProps<"/admin">) {
const t = getDictionary(await getLocale()); const searchParams = await props.searchParams;
const locale = await getLocale();
const t = getDictionary(locale);
const [ const [
revenueToday, revenueToday,
revenueMonth, revenueMonth,
@@ -252,6 +257,39 @@ export default async function AdminDashboardPage() {
const revenueTrend = [...revenueByDay.entries()]; const revenueTrend = [...revenueByDay.entries()];
const maxDailyRevenue = Math.max(1, ...revenueTrend.map(([, v]) => v)); const maxDailyRevenue = Math.max(1, ...revenueTrend.map(([, v]) => v));
const [admin, waitingOrders, waitingReturns, waitingReviews, waitingInquiries] = await Promise.all([
getCurrentAdmin(),
prisma.order.count({
where: { status: { in: ["BEZAHLT", "IN_VORBEREITUNG", "VERSANDBEREIT", "ABHOLBEREIT"] }, archivedAt: null },
}),
prisma.returnRequest.count({ where: { status: "ANGEFRAGT" } }),
prisma.review.count({ where: { isApproved: false } }),
prisma.businessInquiry.count({ where: { status: "NEU" } }),
]);
const toDo = [
{ count: waitingOrders, href: "/admin/bestellungen", label: t.adminDashboardPage.toDoOrders(waitingOrders) },
{ count: waitingReturns, href: "/admin/retouren", label: t.adminDashboardPage.toDoReturns(waitingReturns) },
{ count: waitingReviews, href: "/admin/bewertungen", label: t.adminDashboardPage.toDoReviews(waitingReviews) },
{ count: waitingInquiries, href: "/admin/anfragen", label: t.adminDashboardPage.toDoInquiries(waitingInquiries) },
].filter((item) => item.count > 0);
// Begruessung nach Tageszeit in Berliner Zeit (der Server laeuft in UTC).
const berlinHour = Number(
new Intl.DateTimeFormat("de-DE", { hour: "numeric", hour12: false, timeZone: "Europe/Berlin" }).format(new Date())
);
const dayPart = berlinHour < 11 ? "morning" : berlinHour < 18 ? "day" : "evening";
const todayLine = new Intl.DateTimeFormat(locale === "en" ? "en-GB" : "de-DE", {
weekday: "long",
day: "numeric",
month: "long",
timeZone: "Europe/Berlin",
}).format(new Date());
// Vergleich mit gestern fuer die Leitzahl (aus dem ohnehin geladenen Verlauf).
const todayCents = revenueTrend[revenueTrend.length - 1]?.[1] ?? 0;
const yesterdayCents = revenueTrend[revenueTrend.length - 2]?.[1] ?? 0;
const deltaCents = todayCents - yesterdayCents;
// Bewusst NACH den Kennzahlen abgefragt und nicht in deren Promise.all: // Bewusst NACH den Kennzahlen abgefragt und nicht in deren Promise.all:
// schlaegt der Dateiabgleich fehl (etwa weil das Upload-Verzeichnis gar // schlaegt der Dateiabgleich fehl (etwa weil das Upload-Verzeichnis gar
// nicht lesbar ist), soll das Dashboard trotzdem seine Zahlen zeigen. // nicht lesbar ist), soll das Dashboard trotzdem seine Zahlen zeigen.
@@ -262,7 +300,16 @@ export default async function AdminDashboardPage() {
return ( return (
<div> <div>
<h1 className="text-2xl text-forest mb-8">{t.adminDashboardPage.title}</h1> {/* Begruessung statt nackter Ueberschrift: der Tag beginnt hier. */}
<div className="mb-10">
<p className="text-sm text-ink/60">{todayLine}</p>
<h1 className="mt-1 text-4xl md:text-5xl leading-tight text-forest">
{t.adminDashboardPage.greeting(dayPart, admin?.firstName ?? "")}
</h1>
</div>
{/* Ergebnis des ApiCore-Laufs (nur direkt nach dem Klick sichtbar). */}
<ApiCoreSyncResult searchParams={searchParams} />
{/* Fehlen Dateien, zu denen die Datenbank noch Pfade haelt, ist das {/* Fehlen Dateien, zu denen die Datenbank noch Pfade haelt, ist das
fast immer ein nicht persistentes Upload-Verzeichnis (in Kubernetes fast immer ein nicht persistentes Upload-Verzeichnis (in Kubernetes
@@ -301,44 +348,131 @@ export default async function AdminDashboardPage() {
) : null} ) : null}
</div> </div>
) : null} ) : null}
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid gap-6 lg:grid-cols-5">
{stats.map((stat) => { {/* Die eine Leitzahl der Ansicht: Umsatz heute (Grundschrift,
proportionale Ziffern, siehe dataviz-Skill). */}
<section className="relative overflow-hidden bg-forest p-8 text-cream lg:col-span-3">
{/* Mini-Verlauf der letzten Tage: zeigt ohne Achsen, ob es
aufwaerts geht — die Details stehen im Diagramm weiter unten. */}
<svg
aria-hidden="true"
viewBox={`0 0 ${Math.max(1, revenueTrend.length - 1) * 10} 40`}
preserveAspectRatio="none"
className="absolute right-8 top-10 hidden h-20 w-2/5 sm:block"
>
<polyline
fill="none"
stroke="var(--color-honey-light)"
strokeOpacity="0.6"
strokeWidth="2"
vectorEffect="non-scaling-stroke"
strokeLinejoin="round"
strokeLinecap="round"
points={revenueTrend
.map(([, cents], i) => `${i * 10},${38 - (cents / maxDailyRevenue) * 34}`)
.join(" ")}
/>
<circle
cx={(revenueTrend.length - 1) * 10}
cy={38 - ((revenueTrend[revenueTrend.length - 1]?.[1] ?? 0) / maxDailyRevenue) * 34}
r="3"
fill="var(--color-honey-light)"
vectorEffect="non-scaling-stroke"
/>
</svg>
<p className="text-sm text-cream/70">{t.adminDashboardPage.todayRevenueLabel}</p>
<p className="mt-2 text-6xl font-semibold tracking-tight">{formatPrice(revenueToday._sum.totalCents ?? 0)}</p>
{todayCents !== 0 || yesterdayCents !== 0 ? (
<p className={`mt-3 text-sm ${deltaCents >= 0 ? "text-honey-light" : "text-cream/70"}`}>
{t.adminDashboardPage.vsYesterday(formatPrice(Math.abs(deltaCents)), deltaCents >= 0)}
</p>
) : null}
<p className="mt-8 border-t border-cream/15 pt-4 text-sm text-cream/70">
{t.adminDashboardPage.monthRevenueLabel}{" "}
<span className="ml-2 text-lg font-semibold text-cream">{formatPrice(revenueMonth._sum.totalCents ?? 0)}</span>
</p>
</section>
{/* Was jetzt ansteht — direkt anklickbar. Wartet nichts, gibt es eine
kleine Belohnung statt einer leeren Liste. */}
<section className="border border-ink/10 bg-background p-6 lg:col-span-2">
<h2 className="text-lg text-forest">{t.adminDashboardPage.toDoHeading}</h2>
{toDo.length > 0 ? (
<ul className="mt-4 divide-y divide-ink/10">
{toDo.map((item) => (
<li key={item.href}>
<Link
href={item.href}
className="group flex items-center gap-3 py-3 text-sm text-ink/80 transition-colors hover:text-forest"
>
<span className="flex h-7 min-w-7 items-center justify-center rounded-full bg-honey-dark px-2 text-xs font-semibold text-cream tabular-nums">
{item.count}
</span>
<span className="flex-1">{item.label}</span>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="h-4 w-4 text-ink/40 transition-[color,transform] duration-300 group-hover:translate-x-1 group-hover:text-forest"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
>
<path d="M4 12h15M13 6l6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</Link>
</li>
))}
</ul>
) : (
<div className="mt-6 flex items-start gap-4">
<svg viewBox="0 0 24 26" aria-hidden="true" className="motion-stamp h-10 w-10 flex-shrink-0 animate-[wh-stamp_0.7s_var(--ease-arrive)_both]">
<polygon points="12,1 22.4,7 22.4,19 12,25 1.6,19 1.6,7" fill="var(--color-honey)" />
<path d="M7.5 13.2l3 3 6-6.4" stroke="var(--color-cream)" strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<div>
<p className="font-serif text-xl text-forest">{t.adminDashboardPage.allDoneTitle}</p>
<p className="mt-1 text-sm text-ink/65">{t.adminDashboardPage.allDoneText}</p>
</div>
</div>
)}
</section>
</div>
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
{stats
.filter((stat) => stat.key !== "revenueToday" && stat.key !== "revenueMonth")
.map((stat) => {
const Icon = statIcons[stat.key]; const Icon = statIcons[stat.key];
const primary = stat.key === "revenueToday";
return ( return (
<div <div
key={stat.key} key={stat.key}
className={`border p-6 flex items-start gap-4 ${ className="group border border-ink/10 bg-background p-5 transition-[border-color,transform] duration-300 hover:-translate-y-0.5 hover:border-honey/50"
primary ? "border-honey/40 bg-honey/5" : "bg-background border-ink/10"
}`}
>
<span
className={`flex-shrink-0 flex items-center justify-center w-10 h-10 border ${
primary ? "border-honey/40 text-honey-dark" : "border-ink/15 text-forest"
}`}
> >
<span className="flex h-9 w-9 items-center justify-center border border-ink/10 text-forest transition-colors group-hover:border-honey/50 group-hover:text-honey-dark">
<Icon /> <Icon />
</span> </span>
<div> <p className="mt-4 text-xs text-ink/60">{statLabels[stat.key]}</p>
<p className="text-xs text-ink/65 tracking-wide-caps">{statLabels[stat.key]}</p> <p className="mt-1 text-2xl font-semibold text-forest">{stat.value}</p>
<p className="mt-1 text-2xl text-forest">{stat.value}</p>
</div>
</div> </div>
); );
})} })}
</div> </div>
<div className="mt-10 grid lg:grid-cols-2 gap-6"> <div className="mt-10 grid lg:grid-cols-2 gap-6">
<div className="bg-background border border-ink/10 p-6"> <div className="bg-background border border-ink/10 p-6 transition-colors hover:border-ink/20">
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-4">{t.adminDashboardPage.topProductsHeading}</h2> <h2 className="text-lg text-forest mb-4">{t.adminDashboardPage.topProductsHeading}</h2>
{topProducts.length === 0 ? ( {topProducts.length === 0 ? (
<p className="text-sm text-ink/65">{t.adminDashboardPage.noSalesYet}</p> <p className="text-sm text-ink/65">{t.adminDashboardPage.noSalesYet}</p>
) : ( ) : (
<ul className="space-y-3 text-sm"> <ul className="space-y-3 text-sm">
{topProducts.map((product, i) => ( {topProducts.map((product, i) => (
<li key={product.name} className="flex items-center justify-between gap-4"> <li key={product.name} className="flex items-center justify-between gap-4">
<span className="text-ink/70"> <span className="flex items-center gap-3 text-ink/80">
{i + 1}. {product.name}{" "} <span className="relative flex h-6 w-6 flex-shrink-0 items-center justify-center text-[10px] font-semibold text-cream">
<HexMark className={`absolute inset-0 h-6 w-6 ${i === 0 ? "text-[var(--color-chart-honey)]" : "text-forest/70"}`} />
<span className="relative">{i + 1}</span>
</span>
{product.name}{" "}
<span className="text-ink/65">{t.adminDashboardPage.unitsSuffix(product.quantity)}</span> <span className="text-ink/65">{t.adminDashboardPage.unitsSuffix(product.quantity)}</span>
</span> </span>
<span className="text-forest whitespace-nowrap">{formatPrice(product.revenueCents)}</span> <span className="text-forest whitespace-nowrap">{formatPrice(product.revenueCents)}</span>
@@ -348,8 +482,8 @@ export default async function AdminDashboardPage() {
)} )}
</div> </div>
<div className="bg-background border border-ink/10 p-6"> <div className="bg-background border border-ink/10 p-6 transition-colors hover:border-ink/20">
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-4"> <h2 className="text-lg text-forest mb-4">
{t.adminDashboardPage.revenueByCategoryHeading} {t.adminDashboardPage.revenueByCategoryHeading}
</h2> </h2>
{revenueByCategory.length === 0 ? ( {revenueByCategory.length === 0 ? (
@@ -364,9 +498,10 @@ export default async function AdminDashboardPage() {
</span> </span>
<span className="text-forest">{formatPrice(totals.revenueCents)}</span> <span className="text-forest">{formatPrice(totals.revenueCents)}</span>
</div> </div>
<div className="h-1.5 bg-ink/10"> {/* Meter: Spur ist eine hellere Stufe desselben Tons. */}
<div className="h-2 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--color-chart-honey)_15%,transparent)]">
<div <div
className="h-full bg-honey" className="motion-grow h-full origin-left rounded-full bg-[var(--color-chart-honey)] animate-[wh-grow_0.8s_var(--ease-arrive)_both]"
style={{ width: `${(totals.revenueCents / maxCategoryRevenue) * 100}%` }} style={{ width: `${(totals.revenueCents / maxCategoryRevenue) * 100}%` }}
/> />
</div> </div>
@@ -378,23 +513,38 @@ export default async function AdminDashboardPage() {
</div> </div>
<div className="mt-6 grid lg:grid-cols-2 gap-6"> <div className="mt-6 grid lg:grid-cols-2 gap-6">
<div className="bg-background border border-ink/10 p-6"> <div className="bg-background border border-ink/10 p-6 transition-colors hover:border-ink/20">
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-4"> <h2 className="text-lg text-forest mb-4">
{t.adminDashboardPage.revenueTrendHeading(REVENUE_TREND_DAYS)} {t.adminDashboardPage.revenueTrendHeading(REVENUE_TREND_DAYS)}
</h2> </h2>
<div className="flex items-end gap-1 h-24"> {/* Balken nach dataviz-Skill: duenn, 4px gerundete Kappe an der
{revenueTrend.map(([day, revenueCents]) => ( Grundlinie verankert, 2px Luecke, gepruefter Honigton
(#a0722f, >=3:1 gegen die Flaeche), Tooltip je Balken mit
grosszuegiger Trefferflaeche. */}
<div className="relative flex h-32 items-end gap-[2px] border-b border-ink/15">
{revenueTrend.map(([day, revenueCents], i) => {
const isToday = i === revenueTrend.length - 1;
const label = new Intl.DateTimeFormat(locale === "en" ? "en-GB" : "de-DE", {
day: "numeric",
month: "short",
}).format(new Date(day));
return (
<div key={day} className="group relative flex h-full flex-1 flex-col justify-end" tabIndex={0}>
<div <div
key={day} className={`w-full rounded-t-[4px] bg-[var(--color-chart-honey)] transition-[opacity,filter] duration-200 group-hover:brightness-110 ${
className="flex-1 h-full flex flex-col justify-end" isToday ? "" : "opacity-70 group-hover:opacity-100"
title={`${day}: ${formatPrice(revenueCents)}`} }`}
>
<div
className="bg-honey w-full"
style={{ height: `${Math.max(2, (revenueCents / maxDailyRevenue) * 100)}%` }} style={{ height: `${Math.max(2, (revenueCents / maxDailyRevenue) * 100)}%` }}
/> />
<span
role="tooltip"
className="pointer-events-none absolute bottom-full left-1/2 z-10 mb-2 -translate-x-1/2 whitespace-nowrap bg-forest px-2 py-1 text-[11px] text-cream opacity-0 shadow transition-opacity duration-150 group-hover:opacity-100 group-focus:opacity-100"
>
{t.adminDashboardPage.chartTooltip(label, formatPrice(revenueCents))}
</span>
</div> </div>
))} );
})}
</div> </div>
<div className="mt-2 flex justify-between text-[10px] text-ink/65"> <div className="mt-2 flex justify-between text-[10px] text-ink/65">
<span>{t.adminDashboardPage.daysAgoLabel(REVENUE_TREND_DAYS)}</span> <span>{t.adminDashboardPage.daysAgoLabel(REVENUE_TREND_DAYS)}</span>
@@ -402,8 +552,8 @@ export default async function AdminDashboardPage() {
</div> </div>
</div> </div>
<div className="bg-background border border-ink/10 p-6"> <div className="bg-background border border-ink/10 p-6 transition-colors hover:border-ink/20">
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-4">{t.adminDashboardPage.topVouchersHeading}</h2> <h2 className="text-lg text-forest mb-4">{t.adminDashboardPage.topVouchersHeading}</h2>
{topDiscountCodes.length === 0 ? ( {topDiscountCodes.length === 0 ? (
<p className="text-sm text-ink/65">{t.adminDashboardPage.noVouchersYet}</p> <p className="text-sm text-ink/65">{t.adminDashboardPage.noVouchersYet}</p>
) : ( ) : (
@@ -431,6 +581,11 @@ export default async function AdminDashboardPage() {
{t.adminDashboardPage.viewAllOrders} {t.adminDashboardPage.viewAllOrders}
</Link> </Link>
</p> </p>
{/* Der eine Sync-Knopf fuer Ernten, Chargen und Produkte. */}
<div className="mt-10 max-w-3xl">
<ApiCoreSyncPanel returnTo="/admin" />
</div>
</div> </div>
); );
} }
+50 -97
View File
@@ -1,14 +1,24 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { Button } from "@/components/ui";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { asStringArray } from "@/lib/json-array"; import { asStringArray } from "@/lib/json-array";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { AdminImageListField } from "@/components/admin-image-list-field"; import { AdminImageListField } from "@/components/admin-image-list-field";
import {
AdminField,
AdminFieldset,
AdminFormPanel,
AdminHint,
AdminSwitch,
AdminTextarea,
} from "@/components/admin-form";
import { updateProductAction } from "../../actions"; import { updateProductAction } from "../../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminProductEditPage(props: PageProps<"/admin/produkte/[id]">) { export default async function AdminProductEditPage(props: PageProps<"/admin/produkte/[id]">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const te = t.adminProductEditPage;
const { id } = await props.params; const { id } = await props.params;
const rawProduct = await prisma.product.findUnique({ const rawProduct = await prisma.product.findUnique({
where: { id }, where: { id },
@@ -22,156 +32,99 @@ export default async function AdminProductEditPage(props: PageProps<"/admin/prod
}; };
return ( return (
<div className="max-w-xl"> <div className="max-w-2xl">
<BackLink href="/admin/produkte" label={t.adminProductEditPage.backToProducts} /> <BackLink href="/admin/produkte" label={te.backToProducts} />
<h1 className="text-2xl text-forest mb-2">{t.adminProductEditPage.title}</h1> <h1 className="text-2xl text-forest mb-2">{te.title}</h1>
<p className="text-sm text-ink/65 mb-8"> <p className="text-sm text-ink/65 mb-8">
{t.common.categories[product.category]} {t.common.categories[product.category]}
{product.harvest && product.batch {product.harvest && product.batch
? t.adminProductEditPage.harvestBatchSuffix( ? te.harvestBatchSuffix(product.harvest.type, product.harvest.year, product.batch.code)
product.harvest.type,
product.harvest.year,
product.batch.code
)
: null} : null}
</p> </p>
<form action={updateProductAction} className="space-y-4 bg-background border border-ink/10 p-6"> <AdminFormPanel action={updateProductAction} actions={<Button type="submit">{te.save}</Button>}>
<input type="hidden" name="id" value={product.id} /> <input type="hidden" name="id" value={product.id} />
<Field name="name" label={t.adminProductEditPage.nameLabel} defaultValue={product.name} required /> <AdminFieldset legend={te.sectionText}>
<AdminField name="name" label={te.nameLabel} defaultValue={product.name} required />
<label className="block text-sm"> <AdminTextarea name="description" label={te.descriptionLabel} defaultValue={product.description} required />
<span className="text-ink/70">{t.adminProductEditPage.descriptionLabel}</span> <AdminField name="tasteTags" label={te.tasteTagsLabel} defaultValue={product.tasteTags.join(", ")} />
<textarea <AdminImageListField name="images" label={te.imagesLabel} initialValues={product.images} />
name="description" </AdminFieldset>
defaultValue={product.description}
required
rows={3}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
<Field
name="tasteTags"
label={t.adminProductEditPage.tasteTagsLabel}
defaultValue={product.tasteTags.join(", ")}
/>
<AdminImageListField
name="images"
label={t.adminProductEditPage.imagesLabel}
initialValues={product.images}
/>
{product.category === "HONIG" ? ( {product.category === "HONIG" ? (
<> <AdminFieldset legend={te.sectionTaste}>
<Field <AdminField name="consistency" label={te.consistencyLabel} defaultValue={product.consistency} />
name="consistency" <div className="grid items-end gap-4 sm:grid-cols-3">
label={t.adminProductEditPage.consistencyLabel} <AdminField
defaultValue={product.consistency}
/>
<div className="grid grid-cols-3 gap-4">
<Field
name="intensityMild" name="intensityMild"
label={t.adminProductEditPage.intensityMildLabel} label={te.intensityMildLabel}
type="number" type="number"
defaultValue={String(product.intensityMild)} defaultValue={String(product.intensityMild)}
/> />
<Field <AdminField
name="intensityFloral" name="intensityFloral"
label={t.adminProductEditPage.intensityFloralLabel} label={te.intensityFloralLabel}
type="number" type="number"
defaultValue={String(product.intensityFloral)} defaultValue={String(product.intensityFloral)}
/> />
<Field <AdminField
name="intensityKraeftig" name="intensityKraeftig"
label={t.adminProductEditPage.intensityKraeftigLabel} label={te.intensityKraeftigLabel}
type="number" type="number"
defaultValue={String(product.intensityKraeftig)} defaultValue={String(product.intensityKraeftig)}
/> />
</div> </div>
</> </AdminFieldset>
) : null} ) : null}
<div className="grid grid-cols-2 gap-4"> <AdminFieldset legend={te.sectionPriceStock}>
<Field <div className="grid grid-cols-2 items-end gap-4">
<AdminField
name="weightGrams" name="weightGrams"
label={t.adminProductEditPage.weightLabel} label={te.weightLabel}
type="number" type="number"
defaultValue={String(product.weightGrams)} defaultValue={String(product.weightGrams)}
required required
/> />
<Field <AdminField
name="quantity" name="quantity"
label={t.adminProductEditPage.stockLabel} label={te.stockLabel}
type="number" type="number"
defaultValue={String(product.inventory?.quantity ?? 0)} defaultValue={String(product.inventory?.quantity ?? 0)}
required required
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 items-end gap-4">
<Field <AdminField
name="priceCents" name="priceCents"
label={t.adminProductEditPage.priceLabel} label={te.priceLabel}
type="number" type="number"
defaultValue={String(product.priceCents)} defaultValue={String(product.priceCents)}
required required
/> />
<Field <AdminField
name="pricePerKgCents" name="pricePerKgCents"
label={t.adminProductEditPage.pricePerKgLabel} label={te.pricePerKgLabel}
type="number" type="number"
defaultValue={String(product.pricePerKgCents)} defaultValue={String(product.pricePerKgCents)}
required required
/> />
</div> </div>
{/* Optional: nur fuer Artikel, die vom Satz ihrer Kategorie abweichen. {/* Optional: nur fuer Artikel, die vom Satz ihrer Kategorie abweichen.
Leer heisst "Satz der Kategorie" — nicht 0, denn 0 waere Leer heisst "Satz der Kategorie" — nicht 0, denn 0 waere
steuerfrei nach § 19 UStG (siehe lib/validation.ts). */} steuerfrei nach § 19 UStG (siehe lib/validation.ts). */}
<Field <AdminField
name="vatRatePercent" name="vatRatePercent"
label={t.adminProductEditPage.vatRateLabel} label={te.vatRateLabel}
type="number" type="number"
defaultValue={product.vatRatePercent == null ? "" : String(product.vatRatePercent)} defaultValue={product.vatRatePercent == null ? "" : String(product.vatRatePercent)}
/> />
<p className="text-xs text-ink/65">{t.adminProductEditPage.vatRateHint}</p> <AdminHint>{te.vatRateHint}</AdminHint>
</AdminFieldset>
<label className="flex items-center gap-2 text-sm text-ink/70"> <AdminFieldset legend={te.sectionVisibility} last>
<input type="checkbox" name="isActive" defaultChecked={product.isActive} /> <AdminSwitch name="isActive" label={te.visibleInShop} defaultChecked={product.isActive} />
{t.adminProductEditPage.visibleInShop} </AdminFieldset>
</label> </AdminFormPanel>
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
{t.adminProductEditPage.save}
</button>
</form>
</div> </div>
); );
} }
function Field({
name,
label,
type = "text",
defaultValue,
required = false,
}: {
name: string;
label: string;
type?: string;
defaultValue?: string;
required?: boolean;
}) {
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<input
name={name}
type={type}
defaultValue={defaultValue}
required={required}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
);
}
+70 -121
View File
@@ -1,170 +1,119 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { Button } from "@/components/ui";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { AdminImageListField } from "@/components/admin-image-list-field"; import { AdminImageListField } from "@/components/admin-image-list-field";
import {
AdminField,
AdminFieldset,
AdminFormPanel,
AdminHint,
AdminSelect,
AdminSwitch,
AdminTextarea,
} from "@/components/admin-form";
import { createProductAction } from "../../actions"; import { createProductAction } from "../../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminProductNewPage() { export default async function AdminProductNewPage() {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const tn = t.adminProductNewPage;
const [harvests, batches] = await Promise.all([ const [harvests, batches] = await Promise.all([
prisma.harvest.findMany({ orderBy: [{ year: "desc" }] }), prisma.harvest.findMany({ orderBy: [{ year: "desc" }] }),
prisma.batch.findMany({ orderBy: { fillDate: "desc" }, include: { harvest: true } }), prisma.batch.findMany({ orderBy: { fillDate: "desc" }, include: { harvest: true } }),
]); ]);
return ( return (
<div className="max-w-xl"> <div className="max-w-2xl">
<BackLink href="/admin/produkte" label={t.adminProductNewPage.backToProducts} /> <BackLink href="/admin/produkte" label={tn.backToProducts} />
<h1 className="text-2xl text-forest mb-8">{t.adminProductNewPage.title}</h1> <h1 className="text-2xl text-forest mb-8">{tn.title}</h1>
<form action={createProductAction} className="space-y-4 bg-background border border-ink/10 p-6"> <AdminFormPanel
<Field name="name" label={t.adminProductNewPage.nameLabel} required /> action={createProductAction}
<Field name="slug" label={t.adminProductNewPage.slugLabel} /> className="group/product"
actions={<Button type="submit">{tn.create}</Button>}
>
<AdminFieldset legend={tn.sectionText}>
<AdminField name="name" label={tn.nameLabel} required />
<AdminField name="slug" label={tn.slugLabel} />
<AdminTextarea name="description" label={tn.descriptionLabel} required />
<AdminField name="tasteTags" label={tn.tasteTagsLabel} placeholder={tn.tasteTagsPlaceholder} />
<AdminImageListField name="images" label={tn.imagesLabel} initialValues={[]} />
</AdminFieldset>
<label className="block text-sm"> <AdminFieldset legend={tn.sectionCategory}>
<span className="text-ink/70">{t.adminProductNewPage.categoryLabel}</span> <AdminSelect name="category" label={tn.categoryLabel} defaultValue="HONIG">
<select name="category" className="mt-1 w-full border border-ink/20 bg-background px-3 py-2">
<option value="HONIG">{t.common.categories.HONIG}</option> <option value="HONIG">{t.common.categories.HONIG}</option>
<option value="KERZEN">{t.common.categories.KERZEN}</option> <option value="KERZEN">{t.common.categories.KERZEN}</option>
<option value="PROPOLIS">{t.common.categories.PROPOLIS}</option> <option value="PROPOLIS">{t.common.categories.PROPOLIS}</option>
</select> </AdminSelect>
</label> {/* Ernte/Charge gibt es nur bei Honig. Ausgeblendet wird ueber :has()
ohne Client-JS; ohne :has()-Unterstuetzung bleibt alles sichtbar
<p className="text-xs text-ink/65">{t.adminProductNewPage.categoryHint}</p> und der Server ignoriert die Auswahl ohnehin (products.ts). */}
<div className="grid grid-cols-2 gap-4"> <div className="grid gap-4 sm:grid-cols-2 group-has-[option[value=KERZEN]:checked]/product:hidden group-has-[option[value=PROPOLIS]:checked]/product:hidden">
<label className="block text-sm"> <AdminSelect name="harvestId" label={tn.harvestLabel} defaultValue="">
<span className="text-ink/70">{t.adminProductNewPage.harvestLabel}</span> <option value="">{tn.noneOption}</option>
<select name="harvestId" className="mt-1 w-full border border-ink/20 bg-background px-3 py-2">
<option value="">{t.adminProductNewPage.noneOption}</option>
{harvests.map((h) => ( {harvests.map((h) => (
<option key={h.id} value={h.id}> <option key={h.id} value={h.id}>
{h.type} {h.year} {h.type} {h.year}
</option> </option>
))} ))}
</select> </AdminSelect>
</label> <AdminSelect name="batchId" label={tn.batchLabel} defaultValue="">
<label className="block text-sm"> <option value="">{tn.noneOption}</option>
<span className="text-ink/70">{t.adminProductNewPage.batchLabel}</span>
<select name="batchId" className="mt-1 w-full border border-ink/20 bg-background px-3 py-2">
<option value="">{t.adminProductNewPage.noneOption}</option>
{batches.map((b) => ( {batches.map((b) => (
<option key={b.id} value={b.id}> <option key={b.id} value={b.id}>
{t.adminProductNewPage.batchOption(b.code, b.harvest.type, b.harvest.year)} {tn.batchOption(b.code, b.harvest.type, b.harvest.year)}
</option> </option>
))} ))}
</select> </AdminSelect>
</label>
</div> </div>
<AdminHint>{tn.categoryHint}</AdminHint>
</AdminFieldset>
<label className="block text-sm"> <AdminFieldset
<span className="text-ink/70">{t.adminProductNewPage.descriptionLabel}</span> legend={tn.sectionTaste}
<textarea name="description" required rows={3} className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" /> className="group-has-[option[value=KERZEN]:checked]/product:hidden group-has-[option[value=PROPOLIS]:checked]/product:hidden"
</label> >
<AdminField name="consistency" label={tn.consistencyLabel} placeholder={tn.consistencyPlaceholder} />
<Field <div className="grid items-end gap-4 sm:grid-cols-3">
name="tasteTags" <AdminField name="intensityMild" label={tn.intensityMildLabel} type="number" defaultValue="3" />
label={t.adminProductNewPage.tasteTagsLabel} <AdminField name="intensityFloral" label={tn.intensityFloralLabel} type="number" defaultValue="3" />
placeholder={t.adminProductNewPage.tasteTagsPlaceholder} <AdminField name="intensityKraeftig" label={tn.intensityKraeftigLabel} type="number" defaultValue="3" />
/>
<AdminImageListField
name="images"
label={t.adminProductNewPage.imagesLabel}
initialValues={[]}
/>
<Field
name="consistency"
label={t.adminProductNewPage.consistencyLabel}
placeholder={t.adminProductNewPage.consistencyPlaceholder}
/>
<div className="grid grid-cols-3 gap-4">
<Field
name="intensityMild"
label={t.adminProductNewPage.intensityMildLabel}
type="number"
defaultValue="3"
/>
<Field
name="intensityFloral"
label={t.adminProductNewPage.intensityFloralLabel}
type="number"
defaultValue="3"
/>
<Field
name="intensityKraeftig"
label={t.adminProductNewPage.intensityKraeftigLabel}
type="number"
defaultValue="3"
/>
</div> </div>
</AdminFieldset>
<div className="grid grid-cols-2 gap-4"> <AdminFieldset legend={tn.sectionPriceStock}>
<Field name="weightGrams" label={t.adminProductNewPage.weightLabel} type="number" required defaultValue="500" /> <div className="grid grid-cols-2 items-end gap-4">
<Field name="quantity" label={t.adminProductNewPage.stockLabel} type="number" required /> <AdminField name="weightGrams" label={tn.weightLabel} type="number" required defaultValue="500" />
<AdminField name="quantity" label={tn.stockLabel} type="number" required />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 items-end gap-4">
<Field <AdminField
name="priceCents" name="priceCents"
label={t.adminProductNewPage.priceLabel} label={tn.priceLabel}
type="number" type="number"
required required
placeholder={t.adminProductNewPage.pricePlaceholder} placeholder={tn.pricePlaceholder}
/> />
<Field <AdminField
name="pricePerKgCents" name="pricePerKgCents"
label={t.adminProductNewPage.pricePerKgLabel} label={tn.pricePerKgLabel}
type="number" type="number"
required required
placeholder={t.adminProductNewPage.pricePerKgPlaceholder} placeholder={tn.pricePerKgPlaceholder}
/> />
</div> </div>
{/* Optional: nur fuer Artikel, die vom Satz ihrer Kategorie abweichen. {/* Optional: nur fuer Artikel, die vom Satz ihrer Kategorie abweichen.
Leer heisst "Satz der Kategorie" — nicht 0, denn 0 waere Leer heisst "Satz der Kategorie" — nicht 0, denn 0 waere
steuerfrei nach § 19 UStG (siehe lib/validation.ts). */} steuerfrei nach § 19 UStG (siehe lib/validation.ts). */}
<Field name="vatRatePercent" label={t.adminProductNewPage.vatRateLabel} type="number" /> <AdminField name="vatRatePercent" label={tn.vatRateLabel} type="number" />
<p className="text-xs text-ink/65">{t.adminProductNewPage.vatRateHint}</p> <AdminHint>{tn.vatRateHint}</AdminHint>
</AdminFieldset>
<label className="flex items-center gap-2 text-sm text-ink/70"> <AdminFieldset legend={tn.sectionVisibility} last>
<input type="checkbox" name="isActive" defaultChecked /> <AdminSwitch name="isActive" label={tn.visibleInShop} defaultChecked />
{t.adminProductNewPage.visibleInShop} </AdminFieldset>
</label> </AdminFormPanel>
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
{t.adminProductNewPage.create}
</button>
</form>
</div> </div>
); );
} }
function Field({
name,
label,
type = "text",
defaultValue,
placeholder,
required = false,
}: {
name: string;
label: string;
type?: string;
defaultValue?: string;
placeholder?: string;
required?: boolean;
}) {
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<input
name={name}
type={type}
defaultValue={defaultValue}
placeholder={placeholder}
required={required}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
);
}
+50 -45
View File
@@ -1,15 +1,22 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { formatPrice } from "@/lib/format"; import { formatPrice } from "@/lib/format";
import { ConfirmForm } from "@/components/confirm-form"; import { ConfirmForm } from "@/components/confirm-form";
import { deleteProductAction } from "../actions"; import { deleteProductAction } from "../actions";
import { ApiCoreSyncPanel, ApiCoreSyncResult } from "@/components/apicore-sync-panel";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
import { isArchiveView } from "@/lib/archive"; import { isArchiveView } from "@/lib/archive";
import { ensureAutoArchived } from "@/lib/archive-runner"; import { ensureAutoArchived } from "@/lib/archive-runner";
import { AdminArchiveTabs, AdminArchiveToggle } from "@/components/admin-archive"; import { AdminArchiveTabs, AdminArchiveToggle } from "@/components/admin-archive";
import {
AdminActionButton,
AdminActionLink,
AdminActionNote,
AdminBadge,
AdminEmptyState,
AdminPageHeader,
AdminRowActions,
} from "@/components/admin-list";
export default async function AdminProductsPage(props: PageProps<"/admin/produkte">) { export default async function AdminProductsPage(props: PageProps<"/admin/produkte">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
@@ -34,17 +41,13 @@ export default async function AdminProductsPage(props: PageProps<"/admin/produkt
prisma.product.count({ where: { archivedAt: { not: null } } }), prisma.product.count({ where: { archivedAt: { not: null } } }),
]); ]);
const newProduct = { href: "/admin/produkte/neu", label: t.adminProductsPage.newProduct };
return ( return (
<div> <div>
<BackLink href="/admin" label={t.adminProductsPage.backToDashboard} /> <BackLink href="/admin" label={t.adminProductsPage.backToDashboard} />
<div className="flex items-center justify-between mb-8"> <AdminPageHeader title={t.adminProductsPage.title} action={newProduct} />
<h1 className="text-2xl text-forest">{t.adminProductsPage.title}</h1>
<Link href="/admin/produkte/neu" className="bg-forest text-cream px-5 py-2 text-sm">
{t.adminProductsPage.newProduct}
</Link>
</div>
<ApiCoreSyncResult searchParams={searchParams} />
<AdminArchiveTabs <AdminArchiveTabs
basePath="/admin/produkte" basePath="/admin/produkte"
@@ -63,44 +66,51 @@ export default async function AdminProductsPage(props: PageProps<"/admin/produkt
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="text-left text-ink/65 border-b border-ink/10"> <thead className="text-left text-ink/65 border-b border-ink/10">
<tr> <tr>
<th className="p-4">{t.adminProductsPage.colProduct}</th> <th className="px-3 py-4">{t.adminProductsPage.colProduct}</th>
<th className="p-4">{t.adminProductsPage.colCategory}</th> <th className="px-3 py-4">{t.adminProductsPage.colCategory}</th>
<th className="p-4">{t.adminProductsPage.colHarvest}</th> <th className="px-3 py-4">{t.adminProductsPage.colHarvest}</th>
<th className="p-4">{t.adminProductsPage.colBatch}</th> <th className="px-3 py-4">{t.adminProductsPage.colBatch}</th>
<th className="p-4">{t.adminProductsPage.colWeight}</th> <th className="px-3 py-4">{t.adminProductsPage.colWeight}</th>
<th className="p-4">{t.adminProductsPage.colPrice}</th> <th className="px-3 py-4">{t.adminProductsPage.colPrice}</th>
<th className="p-4">{t.adminProductsPage.colStock}</th> <th className="px-3 py-4">{t.adminProductsPage.colStock}</th>
<th className="p-4">{t.adminProductsPage.colStatus}</th> <th className="px-3 py-4">{t.adminProductsPage.colStatus}</th>
<th className="p-4" /> <th className="px-3 py-4" />
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-ink/10"> <tbody className="divide-y divide-ink/10">
{products.map((product) => ( {products.map((product) => (
<tr key={product.id} className="transition-colors hover:bg-ink/[0.03]"> <tr key={product.id} className="transition-colors hover:bg-ink/[0.03]">
<td className="p-4 text-forest">{product.name}</td> <td className="px-3 py-4">
<td className="p-4">{t.common.categories[product.category]}</td> <AdminActionLink href={`/admin/produkte/${product.id}`}>{product.name}</AdminActionLink>
<td className="p-4"> </td>
<td className="px-3 py-4">{t.common.categories[product.category]}</td>
<td className="px-3 py-4">
{product.harvest ? `${product.harvest.type} ${product.harvest.year}` : "—"} {product.harvest ? `${product.harvest.type} ${product.harvest.year}` : "—"}
</td> </td>
<td className="p-4">{product.batch?.code ?? "—"}</td> <td className="px-3 py-4 whitespace-nowrap">{product.batch?.code ?? "—"}</td>
<td className="p-4">{product.weightGrams} g</td> <td className="px-3 py-4 whitespace-nowrap tabular-nums">{product.weightGrams} g</td>
<td className="p-4">{formatPrice(product.priceCents, product.currency)}</td> <td className="px-3 py-4 whitespace-nowrap tabular-nums">{formatPrice(product.priceCents, product.currency)}</td>
<td className="p-4">{product.inventory?.quantity ?? 0}</td> <td className={`px-3 py-4 tabular-nums ${(product.inventory?.quantity ?? 0) === 0 ? "text-ink/55" : ""}`}>
<td className="p-4"> {product.inventory?.quantity ?? 0}
{product.isActive ? t.adminProductsPage.statusActive : t.adminProductsPage.statusInactive} </td>
<td className="px-3 py-4">
{product.isActive ? (
<AdminBadge tone="positive">{t.adminProductsPage.statusActive}</AdminBadge>
) : (
<AdminBadge tone="neutral">{t.adminProductsPage.statusInactive}</AdminBadge>
)}
{/* Ein Produkt ohne Preis laesst sich nicht aktiv schalten {/* Ein Produkt ohne Preis laesst sich nicht aktiv schalten
(admin/actions/products.ts). Aus ApiCore importierte (admin/actions/products.ts). Aus ApiCore importierte
Entwuerfe sehen sonst wie ein normales inaktives Produkt Entwuerfe sehen sonst wie ein normales inaktives Produkt
aus, und es bliebe unklar, WARUM das Aktivieren scheitert. */} aus, und es bliebe unklar, WARUM das Aktivieren scheitert. */}
{product.priceCents <= 0 ? ( {product.priceCents <= 0 ? (
<span className="block text-xs text-honey-dark">{t.adminProductsPage.draftHint}</span> <span className="mt-1.5 block text-xs text-honey-dark">{t.adminProductsPage.draftHint}</span>
) : null} ) : null}
</td> </td>
<td className="p-4 whitespace-nowrap"> <td className="px-3 py-4">
<Link href={`/admin/produkte/${product.id}`} className="text-forest underline mr-4"> {/* Bearbeiten ueber den Produktnamen links — hier nur die
{t.adminProductsPage.edit} selteneren Aktionen, damit die Tabelle nicht seitlich scrollt. */}
</Link> <AdminRowActions>
<span className="mr-4">
<AdminArchiveToggle <AdminArchiveToggle
kind="products" kind="products"
id={product.id} id={product.id}
@@ -113,7 +123,6 @@ export default async function AdminProductsPage(props: PageProps<"/admin/produkt
manualHint: t.adminArchive.manualHint, manualHint: t.adminArchive.manualHint,
}} }}
/> />
</span>
{/* Loeschen nur, solange nie bestellt: OrderItem verweist mit {/* Loeschen nur, solange nie bestellt: OrderItem verweist mit
onDelete: Restrict auf das Produkt, damit gestellte onDelete: Restrict auf das Produkt, damit gestellte
Rechnungen ihren Bezug behalten (siehe Rechnungen ihren Bezug behalten (siehe
@@ -123,31 +132,27 @@ export default async function AdminProductsPage(props: PageProps<"/admin/produkt
<ConfirmForm <ConfirmForm
action={deleteProductAction} action={deleteProductAction}
confirmMessage={t.adminProductsPage.deleteConfirm(product.name)} confirmMessage={t.adminProductsPage.deleteConfirm(product.name)}
className="inline"
> >
<input type="hidden" name="id" value={product.id} /> <input type="hidden" name="id" value={product.id} />
<button type="submit" className="text-red-700 underline"> <AdminActionButton tone="danger">{t.adminProductsPage.delete}</AdminActionButton>
{t.adminProductsPage.delete}
</button>
</ConfirmForm> </ConfirmForm>
) : ( ) : (
<span className="text-xs text-ink/50">{t.adminProductsPage.notDeletable}</span> <AdminActionNote>{t.adminProductsPage.notDeletable}</AdminActionNote>
)} )}
</AdminRowActions>
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
{products.length === 0 ? ( {products.length === 0 ? (
<p className="p-6 text-ink/65"> <AdminEmptyState
{isArchive ? t.adminArchive.emptyArchive : t.adminProductsPage.noProductsYet} message={isArchive ? t.adminArchive.emptyArchive : t.adminProductsPage.noProductsYet}
</p> action={isArchive ? undefined : newProduct}
/>
) : null} ) : null}
</div> </div>
<div className="mt-10 max-w-3xl">
<ApiCoreSyncPanel returnTo="/admin/produkte" />
</div>
</div> </div>
); );
} }
+64 -45
View File
@@ -1,4 +1,3 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { formatDate, formatPrice } from "@/lib/format"; import { formatDate, formatPrice } from "@/lib/format";
@@ -9,6 +8,23 @@ import { getDictionary } from "@/i18n/get-dictionary";
import { isArchiveView } from "@/lib/archive"; import { isArchiveView } from "@/lib/archive";
import { ensureAutoArchived } from "@/lib/archive-runner"; import { ensureAutoArchived } from "@/lib/archive-runner";
import { AdminArchiveTabs, AdminArchiveToggle } from "@/components/admin-archive"; import { AdminArchiveTabs, AdminArchiveToggle } from "@/components/admin-archive";
import { ConfirmForm } from "@/components/confirm-form";
import { Button } from "@/components/ui";
import {
AdminActionButton,
AdminBadge,
AdminEmptyState,
AdminPageHeader,
AdminSearchForm,
type AdminBadgeTone,
} from "@/components/admin-list";
const STATUS_TONES: Record<string, AdminBadgeTone> = {
ANGEFRAGT: "pending",
ANGENOMMEN: "pending",
ABGELEHNT: "neutral",
ERSTATTET: "positive",
};
export default async function AdminRetourenPage(props: PageProps<"/admin/retouren">) { export default async function AdminRetourenPage(props: PageProps<"/admin/retouren">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
@@ -53,7 +69,7 @@ export default async function AdminRetourenPage(props: PageProps<"/admin/retoure
return ( return (
<div> <div>
<BackLink href="/admin" label={t.adminReturnsPage.backToDashboard} /> <BackLink href="/admin" label={t.adminReturnsPage.backToDashboard} />
<h1 className="text-2xl text-forest mb-8">{t.adminReturnsPage.title}</h1> <AdminPageHeader title={t.adminReturnsPage.title} />
<AdminArchiveTabs <AdminArchiveTabs
basePath="/admin/retouren" basePath="/admin/retouren"
@@ -68,30 +84,15 @@ export default async function AdminRetourenPage(props: PageProps<"/admin/retoure
/> />
{isArchive ? <p className="mb-6 text-xs text-ink/60 max-w-2xl">{t.adminArchive.autoRuleReturns}</p> : null} {isArchive ? <p className="mb-6 text-xs text-ink/60 max-w-2xl">{t.adminArchive.autoRuleReturns}</p> : null}
<form method="GET" className="flex flex-wrap items-end gap-3 mb-6"> <AdminSearchForm
{isArchive ? <input type="hidden" name="ansicht" value="archiv" /> : null} query={query}
<label className="text-sm"> label={t.adminReturnsPage.searchLabel}
<span className="block text-ink/65 text-xs mb-1">{t.adminReturnsPage.searchLabel}</span>
<input
type="text"
name="q"
defaultValue={query}
placeholder={t.adminReturnsPage.searchPlaceholder} placeholder={t.adminReturnsPage.searchPlaceholder}
className="border border-ink/20 bg-background px-3 py-2 text-sm w-64" submitLabel={t.adminReturnsPage.filterButton}
resetHref={isArchive ? "/admin/retouren?ansicht=archiv" : "/admin/retouren"}
resetLabel={t.adminReturnsPage.resetFilter}
hiddenFields={isArchive ? { ansicht: "archiv" } : {}}
/> />
</label>
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm">
{t.adminReturnsPage.filterButton}
</button>
{query ? (
<Link
href={isArchive ? "/admin/retouren?ansicht=archiv" : "/admin/retouren"}
className="text-sm text-ink/65 underline"
>
{t.adminReturnsPage.resetFilter}
</Link>
) : null}
</form>
<div className="space-y-4"> <div className="space-y-4">
{returnRequests.map((request) => ( {returnRequests.map((request) => (
@@ -102,11 +103,16 @@ export default async function AdminRetourenPage(props: PageProps<"/admin/retoure
<p className="text-sm text-ink/70"> <p className="text-sm text-ink/70">
{request.order.firstName} {request.order.lastName} · {request.order.email} {request.order.firstName} {request.order.lastName} · {request.order.email}
</p> </p>
<p className="text-xs text-ink/65 mt-1">{formatDate(request.createdAt)}</p> <p className="text-xs text-ink/65 mt-1 tabular-nums">{formatDate(request.createdAt)}</p>
</div> </div>
<div className="text-right"> <div className="flex flex-col items-end gap-2">
<p className="text-sm">{statusLabels[request.status] ?? request.status}</p> <AdminBadge
<div className="mt-2"> tone={STATUS_TONES[request.status] ?? "neutral"}
pulse={request.status === "ANGEFRAGT"}
>
{statusLabels[request.status] ?? request.status}
</AdminBadge>
<div>
<AdminArchiveToggle <AdminArchiveToggle
kind="returns" kind="returns"
id={request.id} id={request.id}
@@ -130,46 +136,59 @@ export default async function AdminRetourenPage(props: PageProps<"/admin/retoure
</li> </li>
))} ))}
</ul> </ul>
<p className="mt-2 text-forest">{formatPrice(request.order.totalCents, request.order.currency)}</p> <p className="mt-2 text-forest tabular-nums">{formatPrice(request.order.totalCents, request.order.currency)}</p>
<p className="mt-4 text-sm text-ink/70 border-t border-ink/10 pt-4">{request.reason}</p> <p className="mt-4 text-sm text-ink/70 border-t border-ink/10 pt-4">{request.reason}</p>
{request.status === "ANGEFRAGT" ? ( {request.status === "ANGEFRAGT" ? (
<div className="mt-4 flex gap-4"> <div className="mt-4 flex flex-wrap items-center gap-6 text-sm">
<form action={updateReturnRequestStatusAction}> <form action={updateReturnRequestStatusAction}>
<input type="hidden" name="id" value={request.id} /> <input type="hidden" name="id" value={request.id} />
<input type="hidden" name="status" value="ANGENOMMEN" /> <input type="hidden" name="status" value="ANGENOMMEN" />
<button type="submit" className="text-xs text-forest underline"> <Button type="submit" variant="secondary" className="px-5 py-2.5">
{t.adminReturnsPage.accept} {t.adminReturnsPage.accept}
</button> </Button>
</form> </form>
<form action={updateReturnRequestStatusAction}> <ConfirmForm
action={updateReturnRequestStatusAction}
confirmMessage={t.adminReturnsPage.rejectConfirm(request.order.orderNumber)}
>
<input type="hidden" name="id" value={request.id} /> <input type="hidden" name="id" value={request.id} />
<input type="hidden" name="status" value="ABGELEHNT" /> <input type="hidden" name="status" value="ABGELEHNT" />
<button type="submit" className="text-xs text-red-700 underline"> <AdminActionButton tone="danger">{t.adminReturnsPage.reject}</AdminActionButton>
{t.adminReturnsPage.reject} </ConfirmForm>
</button>
</form>
</div> </div>
) : null} ) : null}
{request.status === "ANGENOMMEN" ? ( {request.status === "ANGENOMMEN" ? (
<form action={refundReturnRequestAction} className="mt-4"> // Erstattung bewegt echtes Geld und laesst sich nicht zuruecknehmen —
// deshalb mit Rueckfrage samt Betrag statt Ein-Klick-Link.
<ConfirmForm
action={refundReturnRequestAction}
confirmMessage={t.adminReturnsPage.refundConfirm(
request.order.orderNumber,
formatPrice(request.order.totalCents, request.order.currency)
)}
className="mt-4"
>
<input type="hidden" name="id" value={request.id} /> <input type="hidden" name="id" value={request.id} />
<button type="submit" className="text-xs text-forest underline"> <Button type="submit" className="px-5 py-2.5">
{t.adminReturnsPage.refundNow} {t.adminReturnsPage.refundNow}
</button> </Button>
</form> </ConfirmForm>
) : null} ) : null}
</div> </div>
))} ))}
{returnRequests.length === 0 ? ( {returnRequests.length === 0 ? (
<p className="text-ink/65"> <AdminEmptyState
{query bordered
message={
query
? t.adminReturnsPage.noResultsFiltered ? t.adminReturnsPage.noResultsFiltered
: isArchive : isArchive
? t.adminArchive.emptyArchive ? t.adminArchive.emptyArchive
: t.adminReturnsPage.noRequestsYet} : t.adminReturnsPage.noRequestsYet
</p> }
/>
) : null} ) : null}
</div> </div>
</div> </div>
+20 -44
View File
@@ -1,78 +1,54 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { Button } from "@/components/ui";
import { AdminField, AdminFormPanel } from "@/components/admin-form";
import { updateShippingTierAction } from "../../actions"; import { updateShippingTierAction } from "../../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminVersandEditPage(props: PageProps<"/admin/versand/[id]">) { export default async function AdminVersandEditPage(props: PageProps<"/admin/versand/[id]">) {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const ts = t.adminShippingEditPage;
const { id } = await props.params; const { id } = await props.params;
const tier = await prisma.shippingTier.findUnique({ where: { id } }); const tier = await prisma.shippingTier.findUnique({ where: { id } });
if (!tier) notFound(); if (!tier) notFound();
return ( return (
<div className="max-w-md"> <div className="max-w-xl">
<BackLink href="/admin/versand" label={t.adminShippingEditPage.backToShipping} /> <BackLink href="/admin/versand" label={ts.backToShipping} />
<h1 className="text-2xl text-forest mb-8">{t.adminShippingEditPage.title}</h1> <h1 className="text-2xl text-forest mb-8">{ts.title}</h1>
<AdminFormPanel action={updateShippingTierAction} actions={<Button type="submit">{ts.save}</Button>}>
<form action={updateShippingTierAction} className="space-y-4 bg-background border border-ink/10 p-6">
<input type="hidden" name="id" value={tier.id} /> <input type="hidden" name="id" value={tier.id} />
<div className="grid grid-cols-2 gap-4"> <div className="space-y-4">
<Field <div className="grid grid-cols-2 items-end gap-4">
<AdminField
name="minQuantity" name="minQuantity"
label={t.adminShippingEditPage.minQuantityLabel} label={ts.minQuantityLabel}
type="number" type="number"
min={0}
defaultValue={String(tier.minQuantity)} defaultValue={String(tier.minQuantity)}
required required
/> />
<Field <AdminField
name="maxQuantity" name="maxQuantity"
label={t.adminShippingEditPage.maxQuantityLabel} label={ts.maxQuantityLabel}
type="number" type="number"
min={0}
defaultValue={tier.maxQuantity !== null ? String(tier.maxQuantity) : ""} defaultValue={tier.maxQuantity !== null ? String(tier.maxQuantity) : ""}
/> />
</div> </div>
<Field <AdminField
name="priceCents" name="priceCents"
label={t.adminShippingEditPage.priceLabel} label={ts.priceLabel}
type="number" type="number"
min={0}
defaultValue={String(tier.priceCents)} defaultValue={String(tier.priceCents)}
required required
/> />
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm"> </div>
{t.adminShippingEditPage.save} </AdminFormPanel>
</button>
</form>
</div> </div>
); );
} }
function Field({
name,
label,
type = "text",
defaultValue,
required = false,
}: {
name: string;
label: string;
type?: string;
defaultValue?: string;
required?: boolean;
}) {
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<input
name={name}
type={type}
defaultValue={defaultValue}
required={required}
min={0}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
);
}
+14 -41
View File
@@ -1,55 +1,28 @@
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { Button } from "@/components/ui";
import { AdminField, AdminFormPanel } from "@/components/admin-form";
import { createShippingTierAction } from "../../actions"; import { createShippingTierAction } from "../../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminVersandNewPage() { export default async function AdminVersandNewPage() {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const ts = t.adminShippingNewPage;
return ( return (
<div className="max-w-md"> <div className="max-w-xl">
<BackLink href="/admin/versand" label={t.adminShippingNewPage.backToShipping} /> <BackLink href="/admin/versand" label={ts.backToShipping} />
<h1 className="text-2xl text-forest mb-8">{t.adminShippingNewPage.title}</h1> <h1 className="text-2xl text-forest mb-8">{ts.title}</h1>
<AdminFormPanel action={createShippingTierAction} actions={<Button type="submit">{ts.create}</Button>}>
<form action={createShippingTierAction} className="space-y-4 bg-background border border-ink/10 p-6"> <div className="space-y-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 items-end gap-4">
<Field name="minQuantity" label={t.adminShippingNewPage.minQuantityLabel} type="number" required placeholder="0" /> <AdminField name="minQuantity" label={ts.minQuantityLabel} type="number" min={0} required placeholder="0" />
<Field name="maxQuantity" label={t.adminShippingNewPage.maxQuantityLabel} type="number" placeholder="3" /> <AdminField name="maxQuantity" label={ts.maxQuantityLabel} type="number" min={0} placeholder="3" />
</div> </div>
<Field name="priceCents" label={t.adminShippingNewPage.priceLabel} type="number" required placeholder="700" /> <AdminField name="priceCents" label={ts.priceLabel} type="number" min={0} required placeholder="700" />
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm"> </div>
{t.adminShippingNewPage.create} </AdminFormPanel>
</button>
</form>
</div> </div>
); );
} }
function Field({
name,
label,
type = "text",
placeholder,
required = false,
}: {
name: string;
label: string;
type?: string;
placeholder?: string;
required?: boolean;
}) {
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<input
name={name}
type={type}
placeholder={placeholder}
required={required}
min={0}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
);
}
+15 -20
View File
@@ -1,8 +1,14 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link"; import { BackLink } from "@/components/back-link";
import { ConfirmForm } from "@/components/confirm-form"; import { ConfirmForm } from "@/components/confirm-form";
import { formatPrice } from "@/lib/format"; import { formatPrice } from "@/lib/format";
import {
AdminActionButton,
AdminActionLink,
AdminEmptyState,
AdminPageHeader,
AdminRowActions,
} from "@/components/admin-list";
import { deleteShippingTierAction } from "../actions"; import { deleteShippingTierAction } from "../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
@@ -10,19 +16,12 @@ import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminVersandPage() { export default async function AdminVersandPage() {
const t = getDictionary(await getLocale()); const t = getDictionary(await getLocale());
const tiers = await prisma.shippingTier.findMany({ orderBy: { minQuantity: "asc" } }); const tiers = await prisma.shippingTier.findMany({ orderBy: { minQuantity: "asc" } });
const newTier = { href: "/admin/versand/neu", label: t.adminShippingPage.newTier };
return ( return (
<div> <div>
<BackLink href="/admin" label={t.adminShippingPage.backToDashboard} /> <BackLink href="/admin" label={t.adminShippingPage.backToDashboard} />
<div className="flex items-center justify-between mb-4"> <AdminPageHeader title={t.adminShippingPage.title} intro={t.adminShippingPage.intro} action={newTier} />
<h1 className="text-2xl text-forest">{t.adminShippingPage.title}</h1>
<Link href="/admin/versand/neu" className="bg-forest text-cream px-5 py-2 text-sm">
{t.adminShippingPage.newTier}
</Link>
</div>
<p className="text-sm text-ink/70 mb-8 max-w-2xl">{t.adminShippingPage.intro}</p>
<div className="overflow-x-auto bg-background border border-ink/10"> <div className="overflow-x-auto bg-background border border-ink/10">
<table className="w-full text-sm"> <table className="w-full text-sm">
@@ -41,28 +40,24 @@ export default async function AdminVersandPage() {
? t.adminShippingPage.quantityFrom(tier.minQuantity) ? t.adminShippingPage.quantityFrom(tier.minQuantity)
: t.adminShippingPage.quantityRange(tier.minQuantity, tier.maxQuantity)} : t.adminShippingPage.quantityRange(tier.minQuantity, tier.maxQuantity)}
</td> </td>
<td className="p-4">{formatPrice(tier.priceCents)}</td> <td className="p-4 tabular-nums">{formatPrice(tier.priceCents)}</td>
<td className="p-4"> <td className="p-4">
<div className="flex items-center gap-3 whitespace-nowrap"> <AdminRowActions>
<Link href={`/admin/versand/${tier.id}`} className="text-forest underline"> <AdminActionLink href={`/admin/versand/${tier.id}`}>{t.adminShippingPage.edit}</AdminActionLink>
{t.adminShippingPage.edit}
</Link>
<ConfirmForm <ConfirmForm
action={deleteShippingTierAction} action={deleteShippingTierAction}
confirmMessage={t.adminShippingPage.deleteConfirm} confirmMessage={t.adminShippingPage.deleteConfirm}
> >
<input type="hidden" name="id" value={tier.id} /> <input type="hidden" name="id" value={tier.id} />
<button type="submit" className="text-red-700 underline"> <AdminActionButton tone="danger">{t.adminShippingPage.delete}</AdminActionButton>
{t.adminShippingPage.delete}
</button>
</ConfirmForm> </ConfirmForm>
</div> </AdminRowActions>
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
{tiers.length === 0 ? <p className="p-6 text-ink/65">{t.adminShippingPage.noTiersYet}</p> : null} {tiers.length === 0 ? <AdminEmptyState message={t.adminShippingPage.noTiersYet} action={newTier} /> : null}
</div> </div>
</div> </div>
); );
+68
View File
@@ -19,6 +19,9 @@
--color-background: #faf8f3; --color-background: #faf8f3;
--color-ink: #242424; --color-ink: #242424;
--color-sage: #788774; --color-sage: #788774;
/* Diagrammton, per dataviz-Validator geprueft: Helligkeitsband, Chroma
und >=3:1 gegen die helle Flaeche (#b88a44 erreichte nur 2,93:1). */
--color-chart-honey: #a0722f;
} }
@theme inline { @theme inline {
@@ -301,3 +304,68 @@ textarea::placeholder {
animation: none !important; animation: none !important;
} }
} }
/* ---------------------------------------------------------------------------
Admin-Arbeitsbereich (components/admin-shell.tsx): einheitliche Details
fuer alle Admin-Seiten zentral statt in jeder der rund 20 Seiten einzeln.
Bewusst ausserhalb der Tailwind-Ebenen, damit es die kleinen Standard-
Groessen der Seiten ueberstimmt.
------------------------------------------------------------------------- */
.admin-content h1 {
font-size: clamp(2rem, 2.6vw, 2.75rem);
line-height: 1.1;
letter-spacing: -0.01em;
}
.admin-content table {
font-variant-numeric: tabular-nums;
}
/* Tabellenkopf als ruhiges Etikett, bleibt beim Scrollen langer Listen oben. */
.admin-content thead th {
position: sticky;
top: 0;
z-index: 1;
background: color-mix(in oklab, var(--color-cream) 70%, var(--color-background));
font-size: 0.68rem;
font-weight: 500;
letter-spacing: 0.12em;
text-transform: uppercase;
color: color-mix(in oklab, var(--color-ink) 60%, transparent);
}
.admin-content tbody tr {
transition: background-color 150ms var(--ease-arrive);
}
/* Auswahlfelder mit eigenem Pfeil statt des grauen Browser-Standards. */
.admin-content select {
appearance: none;
padding-right: 2rem;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2324352b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='m6 9 6 6 6-6'/></svg>");
background-repeat: no-repeat;
background-position: right 0.6rem center;
background-size: 0.8rem;
cursor: pointer;
}
.admin-content select:hover,
.admin-content input:not([type="checkbox"]):not([type="radio"]):hover,
.admin-content textarea:hover {
border-color: color-mix(in oklab, var(--color-ink) 40%, transparent);
}
/* Kartentitel im Admin: bisher winzige, blasse Versal-Etiketten
(text-xs tracking-wide-caps) als Ueberschrift jeder Karte. Als ruhige
Serifentitel lassen sich Abschnitte beim Ueberfliegen finden. Farbige
Titel (z.B. rote Gefahrenzone, Honig-Geschenkhinweis) behalten ihre Farbe. */
.admin-content h2.tracking-wide-caps {
font-family: var(--font-serif);
font-size: 1.25rem;
line-height: 1.3;
letter-spacing: 0;
text-transform: none;
}
.admin-content h2.tracking-wide-caps.text-ink\/65 {
color: var(--color-forest);
}
+19 -5
View File
@@ -292,7 +292,7 @@ export default async function ProductDetailPage(
</p> </p>
<Link <Link
href="/versand-zahlung" href="/versand-zahlung"
className="mt-2 inline-block text-xs text-ink/65 underline decoration-ink/30 hover:text-forest hover:decoration-forest" className="mt-1 inline-block py-1 text-xs text-ink/65 underline decoration-ink/30 hover:text-forest hover:decoration-forest"
> >
{t.productPage.shippingHint( {t.productPage.shippingHint(
baseShippingCents === 0 ? t.cartPage.free : formatPrice(baseShippingCents, "EUR", locale) baseShippingCents === 0 ? t.cartPage.free : formatPrice(baseShippingCents, "EUR", locale)
@@ -367,7 +367,21 @@ export default async function ProductDetailPage(
<form action={toggleWishlistAction} className="mt-3"> <form action={toggleWishlistAction} className="mt-3">
<input type="hidden" name="productId" value={product.id} /> <input type="hidden" name="productId" value={product.id} />
<input type="hidden" name="redirectPath" value={`/honig/${product.slug}`} /> <input type="hidden" name="redirectPath" value={`/honig/${product.slug}`} />
<button type="submit" className="text-sm text-ink/70 hover:text-forest underline"> <button
type="submit"
className="inline-flex items-center gap-2 py-1.5 text-sm text-ink/70 underline hover:text-forest"
>
<svg
viewBox="0 0 20 20"
className={`h-4 w-4 ${isWishlisted ? "text-honey" : "text-honey-dark"}`}
fill={isWishlisted ? "currentColor" : "none"}
stroke="currentColor"
strokeWidth="1.3"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M10 1.5l2.47 5.27 5.66.6-4.24 3.9 1.15 5.6L10 14.9l-5.04 2.97 1.15-5.6-4.24-3.9 5.66-.6z" />
</svg>
{isWishlisted ? t.productPage.removeFromWishlist : t.productPage.addToWishlist} {isWishlisted ? t.productPage.removeFromWishlist : t.productPage.addToWishlist}
</button> </button>
</form> </form>
@@ -413,11 +427,11 @@ export default async function ProductDetailPage(
{isHoney ? ( {isHoney ? (
<div className="mt-6 flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm"> <div className="mt-6 flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm">
<span className="text-ink/65">{t.productPage.learnMoreLabel}</span> <span className="text-ink/65">{t.productPage.learnMoreLabel}</span>
<Link href="/wissen/warum-kristallisiert-honig" className="text-forest underline"> <Link href="/wissen/warum-kristallisiert-honig" className="inline-block py-0.5 text-forest underline">
{t.wissenArticles["warum-kristallisiert-honig"].title} {t.wissenArticles["warum-kristallisiert-honig"].title}
</Link> </Link>
{harvestWissenSlug ? ( {harvestWissenSlug ? (
<Link href={`/wissen/${harvestWissenSlug}`} className="text-forest underline"> <Link href={`/wissen/${harvestWissenSlug}`} className="inline-block py-0.5 text-forest underline">
{t.wissenArticles[harvestWissenSlug].title} {t.wissenArticles[harvestWissenSlug].title}
</Link> </Link>
) : null} ) : null}
@@ -427,7 +441,7 @@ export default async function ProductDetailPage(
{isHoney && product.batch ? ( {isHoney && product.batch ? (
<Link <Link
href={`/charge/${product.batch.code}`} href={`/charge/${product.batch.code}`}
className="mt-3 inline-block text-sm text-forest underline" className="mt-2.5 inline-block py-0.5 text-sm text-forest underline"
> >
{t.productPage.viewBatchOrigin} {t.productPage.viewBatchOrigin}
</Link> </Link>
+2 -2
View File
@@ -28,13 +28,13 @@ export default async function KontaktPage() {
<div className="mt-12 grid gap-6 md:grid-cols-5"> <div className="mt-12 grid gap-6 md:grid-cols-5">
{/* Kontaktkarte auf Wabenraster: die E-Mail ist der Hauptweg und {/* Kontaktkarte auf Wabenraster: die E-Mail ist der Hauptweg und
steht deshalb gross in Serifenschrift. */} steht deshalb gross in Serifenschrift. */}
<ScrollReveal delay={0.1} className="md:col-span-3"> <ScrollReveal delay={0.1} className="min-w-0 md:col-span-3">
<div className="relative h-full overflow-hidden bg-forest px-8 py-10 text-cream md:px-10"> <div className="relative h-full overflow-hidden bg-forest px-8 py-10 text-cream md:px-10">
<HoneycombPattern className="text-honey-light/[0.1]" size={24} fade="right" /> <HoneycombPattern className="text-honey-light/[0.1]" size={24} fade="right" />
<div className="relative"> <div className="relative">
<a <a
href={`mailto:${company.email}`} href={`mailto:${company.email}`}
className="group inline-flex items-center gap-3 font-serif text-3xl md:text-4xl text-cream" className="group inline-flex items-center gap-3 font-serif text-2xl sm:text-3xl md:text-4xl text-cream"
> >
<span className="link-draw pb-1">{company.email}</span> <span className="link-draw pb-1">{company.email}</span>
<svg <svg
+1 -1
View File
@@ -209,7 +209,7 @@ export default async function KontoPage() {
</form> </form>
<h2 className="text-2xl text-forest mb-4">{t.account.privacy.heading}</h2> <h2 className="text-2xl text-forest mb-4">{t.account.privacy.heading}</h2>
<div className="border border-ink/10 p-6 flex items-center justify-between gap-4"> <div className="border border-ink/10 p-6 flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
<div> <div>
<h3 className="text-lg text-forest">{t.account.privacy.downloadDataHeading}</h3> <h3 className="text-lg text-forest">{t.account.privacy.downloadDataHeading}</h3>
<p className="mt-1 text-sm text-ink/70">{t.account.privacy.downloadDataDescription}</p> <p className="mt-1 text-sm text-ink/70">{t.account.privacy.downloadDataDescription}</p>
+5
View File
@@ -5,6 +5,7 @@ import { Header, CartDrawer } from "@/components/site-chrome";
import { Footer } from "@/components/site-footer"; import { Footer } from "@/components/site-footer";
import { CookieConsentBanner } from "@/components/cookie-consent"; import { CookieConsentBanner } from "@/components/cookie-consent";
import { FlashToast } from "@/components/flash-toast"; import { FlashToast } from "@/components/flash-toast";
import { HideOnAdmin } from "@/components/hide-on-admin";
import { SubmitFeedback } from "@/components/submit-feedback"; import { SubmitFeedback } from "@/components/submit-feedback";
import { readFlash } from "@/lib/flash"; import { readFlash } from "@/lib/flash";
import { getSiteUrl } from "@/lib/url"; import { getSiteUrl } from "@/lib/url";
@@ -129,11 +130,15 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
> >
{dict.nav.skipToContent} {dict.nav.skipToContent}
</a> </a>
<HideOnAdmin>
<Header companyName={company.name} /> <Header companyName={company.name} />
</HideOnAdmin>
<main id="main-content" className="flex-1"> <main id="main-content" className="flex-1">
{children} {children}
</main> </main>
<HideOnAdmin>
<Footer companyInfo={company} socialLinks={socialLinks} /> <Footer companyInfo={company} socialLinks={socialLinks} />
</HideOnAdmin>
<CartDrawer /> <CartDrawer />
<CookieConsentBanner /> <CookieConsentBanner />
<FlashToast flash={flash} /> <FlashToast flash={flash} />
+1 -1
View File
@@ -72,7 +72,7 @@ export function AdminArchiveToggle({
{archivedManually ? ` · ${labels.manualHint}` : null} {archivedManually ? ` · ${labels.manualHint}` : null}
</span> </span>
) : null} ) : null}
<button type="submit" className="text-xs text-ink/65 underline hover:text-forest"> <button type="submit" className="link-draw cursor-pointer text-ink/70 hover:text-forest">
{archivedAt ? labels.restore : labels.archive} {archivedAt ? labels.restore : labels.archive}
</button> </button>
</form> </form>
+172
View File
@@ -0,0 +1,172 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { AnimatePresence, motion } from "motion/react";
import { HexMark } from "@/components/honeycomb";
export interface PaletteEntry {
href: string;
label: string;
section: "pages" | "actions";
}
/**
* Schnellsuche fuer den Admin-Bereich (Strg/Cmd + K): jede Seite und die
* haeufigsten Aktionen sind zwei Tastendruecke entfernt, statt durch die
* Seitenleiste zu scrollen. Pfeiltasten waehlen, Enter springt, Esc schliesst.
*
* Sucht tolerant: jedes getippte Wort muss irgendwo im Eintrag vorkommen
* ("neu gut" findet "Neuen Gutschein anlegen"). Ziele sind ausschliesslich
* die fest hinterlegten Eintraege, nie die Eingabe selbst.
*/
export function AdminCommandPalette({
entries,
labels,
open,
onOpenChange,
}: {
entries: PaletteEntry[];
labels: { placeholder: string; noResults: string; pages: string; actions: string; dialog: string };
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const router = useRouter();
const [query, setQuery] = useState("");
const [active, setActive] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
// Globale Tastenkombination — auch wenn der Fokus gerade woanders liegt.
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
onOpenChange(!open);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onOpenChange]);
const results = useMemo(() => {
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
if (words.length === 0) return entries;
return entries.filter((entry) => words.every((word) => entry.label.toLowerCase().includes(word)));
}, [entries, query]);
function close() {
onOpenChange(false);
setQuery("");
setActive(0);
}
function go(entry: PaletteEntry | undefined) {
if (!entry) return;
close();
router.push(entry.href);
}
function onKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key === "Escape") {
close();
} else if (event.key === "ArrowDown") {
event.preventDefault();
setActive((i) => Math.min(results.length - 1, i + 1));
} else if (event.key === "ArrowUp") {
event.preventDefault();
setActive((i) => Math.max(0, i - 1));
} else if (event.key === "Enter") {
event.preventDefault();
go(results[active]);
}
}
return (
<AnimatePresence>
{open ? (
<motion.div
className="fixed inset-0 z-[65] flex items-start justify-center bg-forest/40 px-4 pt-[15vh] backdrop-blur-[2px]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onMouseDown={(event) => {
if (event.target === event.currentTarget) close();
}}
>
<motion.div
role="dialog"
aria-modal="true"
aria-label={labels.dialog}
initial={{ opacity: 0, y: -12, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -8, scale: 0.98, transition: { duration: 0.12 } }}
transition={{ duration: 0.22, ease: [0.16, 1, 0.3, 1] }}
onAnimationComplete={() => inputRef.current?.focus()}
className="w-full max-w-lg overflow-hidden border border-ink/10 bg-background shadow-[0_40px_80px_-30px_rgba(36,53,43,0.6)]"
>
<div className="flex items-center gap-3 border-b border-ink/10 px-5">
<svg viewBox="0 0 24 24" className="h-4 w-4 flex-shrink-0 text-ink/45" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
<circle cx="11" cy="11" r="6.5" />
<path d="m16 16 4.5 4.5" strokeLinecap="round" />
</svg>
<input
ref={inputRef}
autoFocus
value={query}
onChange={(event) => {
setQuery(event.target.value);
setActive(0);
}}
onKeyDown={onKeyDown}
placeholder={labels.placeholder}
role="combobox"
aria-expanded="true"
aria-controls="admin-palette-results"
aria-activedescendant={results[active] ? `palette-${active}` : undefined}
className="h-14 w-full bg-transparent text-base focus:outline-none focus-visible:outline-none focus:shadow-none"
/>
<kbd className="flex-shrink-0 border border-ink/15 px-1.5 py-0.5 text-[10px] text-ink/50">Esc</kbd>
</div>
<ul id="admin-palette-results" role="listbox" className="max-h-80 overflow-y-auto py-2">
{results.length === 0 ? (
<li className="px-5 py-6 text-center text-sm text-ink/60">{labels.noResults}</li>
) : null}
{results.map((entry, i) => {
const showSection = i === 0 || results[i - 1].section !== entry.section;
return (
<li key={entry.href + entry.label} role="presentation">
{showSection ? (
<p className="px-5 pb-1 pt-3 text-[10px] tracking-wide-caps text-ink/45">
{entry.section === "pages" ? labels.pages : labels.actions}
</p>
) : null}
<button
id={`palette-${i}`}
type="button"
role="option"
aria-selected={i === active}
onMouseEnter={() => setActive(i)}
onClick={() => go(entry)}
className={`flex w-full items-center gap-3 px-5 py-2.5 text-left text-sm transition-colors ${
i === active ? "bg-honey/10 text-forest" : "text-ink/75"
}`}
>
<HexMark
filled={i === active}
className={`h-2.5 w-2.5 flex-shrink-0 ${i === active ? "text-honey-dark" : "text-ink/25"}`}
/>
<span className="flex-1">{entry.label}</span>
{i === active ? (
<kbd className="border border-ink/15 px-1.5 py-0.5 text-[10px] text-ink/50">Enter</kbd>
) : null}
</button>
</li>
);
})}
</ul>
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
);
}
+158
View File
@@ -0,0 +1,158 @@
import type { ReactNode } from "react";
// Gemeinsame Bausteine der Admin-Formulare (Produkt anlegen/bearbeiten,
// spaeter Gutscheine, Versand, Benutzer). Reine Server-Komponenten — sie
// rendern nur Markup, damit jedes Formular gleich gegliedert aussieht.
const inputClass = "mt-1 w-full border border-ink/20 bg-background px-3 py-2";
export function AdminField({
name,
label,
type = "text",
defaultValue,
placeholder,
required = false,
min,
hint,
}: {
name: string;
label: string;
type?: string;
defaultValue?: string;
placeholder?: string;
required?: boolean;
min?: number;
/** Kurzer Zusatz unter dem Feld, statt ihn in die Beschriftung zu packen. */
hint?: string;
}) {
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<input
name={name}
type={type}
defaultValue={defaultValue}
placeholder={placeholder}
required={required}
min={min}
className={inputClass}
/>
{hint ? <span className="mt-1 block text-xs text-ink/65">{hint}</span> : null}
</label>
);
}
export function AdminTextarea({
name,
label,
defaultValue,
required = false,
rows = 3,
}: {
name: string;
label: string;
defaultValue?: string;
required?: boolean;
rows?: number;
}) {
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<textarea name={name} defaultValue={defaultValue} required={required} rows={rows} className={inputClass} />
</label>
);
}
export function AdminSelect({
name,
label,
defaultValue,
hint,
children,
}: {
name: string;
label: string;
defaultValue?: string;
hint?: string;
children: ReactNode;
}) {
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<select name={name} defaultValue={defaultValue} className={inputClass}>
{children}
</select>
{hint ? <span className="mt-1 block text-xs text-ink/65">{hint}</span> : null}
</label>
);
}
export function AdminFieldset({
legend,
last = false,
className = "",
children,
}: {
legend: string;
/** Letzter Abschnitt vor der Speicherleiste: ohne Trennlinie. */
last?: boolean;
className?: string;
children: ReactNode;
}) {
return (
<fieldset className={`space-y-4 ${last ? "" : "border-b border-ink/10 pb-8"} ${className}`}>
<legend className="mb-4 font-serif text-xl text-forest">{legend}</legend>
{children}
</fieldset>
);
}
export function AdminHint({ children }: { children: ReactNode }) {
return <p className="text-xs text-ink/65">{children}</p>;
}
/** Schalter statt kleiner Checkbox — dasselbe Formularfeld, nur als Umschalter dargestellt. */
export function AdminSwitch({
name,
label,
defaultChecked,
}: {
name: string;
label: string;
defaultChecked?: boolean;
}) {
return (
<label className="inline-flex cursor-pointer items-center gap-3 text-sm text-ink/80">
<input type="checkbox" name={name} defaultChecked={defaultChecked} className="peer sr-only" />
<span
aria-hidden="true"
className="relative h-6 w-11 flex-shrink-0 rounded-full bg-ink/20 transition-colors duration-300 peer-checked:bg-forest peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-honey-dark after:absolute after:left-0.5 after:top-0.5 after:h-5 after:w-5 after:rounded-full after:bg-cream after:shadow after:transition-transform after:duration-300 after:content-[''] peer-checked:after:translate-x-5"
/>
{label}
</label>
);
}
/** Formularrahmen samt Speicherleiste, die beim Scrollen am unteren Rand
bleibt — der Knopf ist bei langen Formularen immer erreichbar. */
export function AdminFormPanel({
action,
actions,
className = "",
children,
}: {
action: (formData: FormData) => void | Promise<void>;
actions: ReactNode;
className?: string;
children: ReactNode;
}) {
return (
<form action={action} className={`space-y-8 border border-ink/10 bg-background p-6 sm:p-8 ${className}`}>
{children}
<div className="sticky bottom-0 -mx-6 -mb-6 flex items-center justify-end gap-4 border-t border-ink/10 bg-background/95 px-6 py-4 backdrop-blur sm:-mx-8 sm:-mb-8 sm:px-8">
{actions}
</div>
</form>
);
}
+188
View File
@@ -0,0 +1,188 @@
import Link from "next/link";
import type { ReactNode } from "react";
import { LinkButton } from "@/components/ui";
import { HexMark } from "@/components/honeycomb";
// Gemeinsame Bausteine der Admin-Listen (Kopfzeile, Suche, Status-Badge,
// Leerzustand, Zeilenaktionen). Operate-Modus: gleiche Stelle, gleiche Form
// auf jeder Liste, damit man beim Wechseln nicht neu suchen muss.
export function AdminPageHeader({
title,
intro,
action,
}: {
title: string;
intro?: ReactNode;
/** Hauptaktion der Seite, z.B. "Neuer Gutschein". */
action?: { href: string; label: string };
}) {
return (
<div className="mb-8">
<div className="flex flex-wrap items-center justify-between gap-4">
<h1 className="text-2xl text-forest">{title}</h1>
{action ? (
<LinkButton href={action.href} className="px-5 py-2.5">
{action.label}
</LinkButton>
) : null}
</div>
{intro ? <p className="mt-2 max-w-2xl text-sm text-ink/70">{intro}</p> : null}
</div>
);
}
export function AdminSearchForm({
query,
label,
placeholder,
submitLabel,
resetHref,
resetLabel,
hiddenFields = {},
children,
isFiltered,
}: {
query: string;
label: string;
placeholder: string;
submitLabel: string;
resetHref: string;
resetLabel: string;
/** Mitzusendende Parameter, z.B. die Archiv-Ansicht. */
hiddenFields?: Record<string, string>;
/** Weitere Filterfelder (z.B. Status-Auswahl) zwischen Suche und Knopf. */
children?: ReactNode;
/** Ob "Filter zuruecksetzen" erscheint; Standard: sobald gesucht wird. */
isFiltered?: boolean;
}) {
return (
<form method="GET" role="search" className="mb-6 flex flex-wrap items-end gap-3">
{Object.entries(hiddenFields).map(([name, value]) => (
<input key={name} type="hidden" name={name} value={value} />
))}
<label className="text-sm">
<span className="mb-1 block text-xs text-ink/65">{label}</span>
<input
type="search"
name="q"
defaultValue={query}
placeholder={placeholder}
className="w-64 max-w-full border border-ink/20 bg-background px-3 py-2 text-sm"
/>
</label>
{children}
<button
type="submit"
className="border border-forest px-4 py-2 text-sm text-forest transition-colors duration-300 hover:bg-forest hover:text-cream"
>
{submitLabel}
</button>
{(isFiltered ?? query !== "") ? (
<Link href={resetHref} className="link-draw py-2 text-sm text-ink/65">
{resetLabel}
</Link>
) : null}
</form>
);
}
export type AdminBadgeTone = "positive" | "pending" | "neutral" | "danger";
const BADGE_TONES: Record<AdminBadgeTone, string> = {
positive: "border-forest/40 bg-forest/5 text-forest",
pending: "border-honey/40 bg-honey/5 text-honey-dark",
neutral: "border-ink/20 text-ink/70",
danger: "border-red-200 bg-red-50 text-red-700",
};
/** Status auf einen Blick — gleiche Form wie der Bestellstatus (OrderStatusBadge). */
export function AdminBadge({
tone,
pulse = false,
children,
}: {
tone: AdminBadgeTone;
/** Wartet auf eine Handlung (z.B. Freigabe): Punkt pulsiert sanft. */
pulse?: boolean;
children: ReactNode;
}) {
return (
<span
className={`inline-flex items-center gap-2 whitespace-nowrap border px-2.5 py-1 text-xs tracking-wide-caps ${BADGE_TONES[tone]}`}
>
<span
aria-hidden="true"
className={`h-1.5 w-1.5 rounded-full bg-current ${pulse ? "motion-pulse animate-[wh-pulse_2s_ease-out_infinite]" : ""}`}
/>
{children}
</span>
);
}
/** Leerzustand statt einer blassen Textzeile: sagt, was fehlt, und bietet den naechsten Schritt an. */
export function AdminEmptyState({
message,
action,
bordered = false,
}: {
message: string;
action?: { href: string; label: string };
/** Eigener Rahmen, wenn der Leerzustand nicht in einer Tabellenkarte steht. */
bordered?: boolean;
}) {
return (
<div
className={`flex flex-col items-center gap-4 px-6 py-12 text-center ${bordered ? "border border-ink/10 bg-background" : ""}`}
>
<span className="relative inline-flex text-honey/60">
<HexMark className="h-10 w-10" filled={false} />
<HexMark className="absolute left-1/2 top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 text-honey" />
</span>
<p className="max-w-sm text-sm text-ink/70">{message}</p>
{action ? (
<LinkButton href={action.href} variant="secondary" className="px-5 py-2.5">
{action.label}
</LinkButton>
) : null}
</div>
);
}
export function AdminRowActions({ children }: { children: ReactNode }) {
return <div className="flex items-center justify-end gap-4 whitespace-nowrap">{children}</div>;
}
export function AdminActionLink({ href, children }: { href: string; children: ReactNode }) {
return (
<Link href={href} className="link-draw text-forest">
{children}
</Link>
);
}
const ACTION_TONES = {
default: "text-forest",
muted: "text-ink/70",
danger: "text-red-700",
} as const;
/** Absende-Knopf einer Zeilenaktion — sieht aus wie ein Link, ist aber ein Formular-Submit. */
export function AdminActionButton({
tone = "default",
children,
}: {
tone?: keyof typeof ACTION_TONES;
children: ReactNode;
}) {
return (
<button type="submit" className={`link-draw cursor-pointer ${ACTION_TONES[tone]}`}>
{children}
</button>
);
}
/** Grund, warum eine Aktion fehlt — statt den Knopf wortlos wegzulassen. */
export function AdminActionNote({ children }: { children: ReactNode }) {
return <span className="text-xs text-ink/55">{children}</span>;
}
+177 -45
View File
@@ -7,27 +7,74 @@ import { usePathname } from "next/navigation";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import { Logomark } from "@/components/logomark"; import { Logomark } from "@/components/logomark";
import { HexMark, HoneycombPattern } from "@/components/honeycomb"; import { HexMark, HoneycombPattern } from "@/components/honeycomb";
import { AdminCommandPalette, type PaletteEntry } from "@/components/admin-command-palette";
import { useI18n } from "@/i18n/i18n-provider"; import { useI18n } from "@/i18n/i18n-provider";
import type { Dictionary } from "@/i18n/dictionary-type"; import type { Dictionary } from "@/i18n/dictionary-type";
function getAdminNav(t: Dictionary) { /** Was an einer Seite gerade auf Bearbeitung wartet (Zaehler in der Seitenleiste). */
export type AdminBadges = Partial<Record<string, number>>;
interface NavItem {
href: string;
label: string;
}
/**
* Gruppiert statt einer langen Liste: 13 Eintraege lesen sich in vier
* kurzen Bloecken schneller, und die taegliche Arbeit (Verkauf) steht oben.
*/
function getAdminNavGroups(t: Dictionary): { label: string | null; items: NavItem[] }[] {
return [ return [
{ href: "/admin", label: t.adminShell.navDashboard }, { label: null, items: [{ href: "/admin", label: t.adminShell.navDashboard }] },
{
label: t.adminShell.groupSales,
items: [
{ href: "/admin/bestellungen", label: t.adminShell.navOrders }, { href: "/admin/bestellungen", label: t.adminShell.navOrders },
{ href: "/admin/retouren", label: t.adminShell.navReturns }, { href: "/admin/retouren", label: t.adminShell.navReturns },
{ href: "/admin/gutscheine", label: t.adminShell.navVouchers },
{ href: "/admin/versand", label: t.adminShell.navShipping },
],
},
{
label: t.adminShell.groupRange,
items: [
{ href: "/admin/produkte", label: t.adminShell.navProducts }, { href: "/admin/produkte", label: t.adminShell.navProducts },
{ href: "/admin/ernten", label: t.adminShell.navHarvests }, { href: "/admin/ernten", label: t.adminShell.navHarvests },
{ href: "/admin/chargen", label: t.adminShell.navBatches }, { href: "/admin/chargen", label: t.adminShell.navBatches },
{ href: "/admin/anfragen", label: t.adminShell.navBusinessInquiries },
{ href: "/admin/benutzer", label: t.adminShell.navUsers },
{ href: "/admin/versand", label: t.adminShell.navShipping },
{ href: "/admin/gutscheine", label: t.adminShell.navVouchers },
{ href: "/admin/bewertungen", label: t.adminShell.navReviews }, { href: "/admin/bewertungen", label: t.adminShell.navReviews },
],
},
{
label: t.adminShell.groupCustomers,
items: [
{ href: "/admin/benutzer", label: t.adminShell.navUsers },
{ href: "/admin/anfragen", label: t.adminShell.navBusinessInquiries },
],
},
{
label: t.adminShell.groupSite,
items: [
{ href: "/admin/inhalte", label: t.adminShell.navContent }, { href: "/admin/inhalte", label: t.adminShell.navContent },
{ href: "/admin/audit-log", label: t.adminShell.navAuditLog }, { href: "/admin/audit-log", label: t.adminShell.navAuditLog },
],
},
]; ];
} }
function getPaletteEntries(t: Dictionary): PaletteEntry[] {
const pages = getAdminNavGroups(t).flatMap((group) =>
group.items.map((item) => ({ ...item, section: "pages" as const }))
);
const actions: PaletteEntry[] = [
{ href: "/admin/produkte/neu", label: t.adminShell.actionNewProduct, section: "actions" },
{ href: "/admin/gutscheine/neu", label: t.adminShell.actionNewVoucher, section: "actions" },
{ href: "/admin/benutzer/neu", label: t.adminShell.actionNewUser, section: "actions" },
{ href: "/admin/versand/neu", label: t.adminShell.actionNewShippingTier, section: "actions" },
{ href: "/shop", label: t.adminShell.actionViewShop, section: "actions" },
];
return [...pages, ...actions];
}
function isActiveHref(pathname: string, href: string): boolean { function isActiveHref(pathname: string, href: string): boolean {
if (href === "/admin") return pathname === "/admin"; if (href === "/admin") return pathname === "/admin";
return pathname === href || pathname.startsWith(`${href}/`); return pathname === href || pathname.startsWith(`${href}/`);
@@ -35,80 +82,128 @@ function isActiveHref(pathname: string, href: string): boolean {
function NavLinks({ function NavLinks({
pathname, pathname,
adminNav, groups,
badges,
onNavigate, onNavigate,
waitingLabel,
}: { }: {
pathname: string; pathname: string;
adminNav: ReturnType<typeof getAdminNav>; groups: ReturnType<typeof getAdminNavGroups>;
badges: AdminBadges;
onNavigate?: () => void; onNavigate?: () => void;
waitingLabel: (count: number) => string;
}) { }) {
return ( return (
<> <>
{adminNav.map((item) => { {groups.map((group, groupIndex) => (
<div key={group.label ?? groupIndex} className={groupIndex > 0 ? "mt-5" : ""}>
{group.label ? (
<p className="px-5 pb-1.5 text-[10px] tracking-wide-caps text-cream/40">{group.label}</p>
) : null}
{group.items.map((item) => {
const active = isActiveHref(pathname, item.href); const active = isActiveHref(pathname, item.href);
const count = badges[item.href] ?? 0;
return ( return (
<Link <Link
key={item.href} key={item.href}
href={item.href} href={item.href}
onClick={onNavigate} onClick={onNavigate}
aria-current={active ? "page" : undefined} aria-current={active ? "page" : undefined}
className={`group flex items-center gap-3 px-5 py-2.5 text-sm transition-colors ${ className={`group relative flex items-center gap-3 px-5 py-2 text-sm transition-colors ${
active ? "bg-cream/10 text-cream" : "text-cream/70 hover:text-cream hover:bg-cream/5" active ? "text-cream" : "text-cream/70 hover:text-cream"
}`} }`}
> >
{/* Wabe statt farbigem Seitenbalken markiert die aktive Seite. */} {/* Aktive Seite als weich einblendende Flaeche, die beim
Wechsel zur neuen Seite hinueberwandert. */}
{active ? (
<motion.span
layoutId="admin-nav-active"
className="absolute inset-y-0.5 left-2 right-2 -z-10 bg-cream/10"
transition={{ type: "spring", stiffness: 420, damping: 36 }}
/>
) : null}
<HexMark <HexMark
filled={active} filled={active}
className={`h-2.5 w-2.5 flex-shrink-0 transition-[color,transform] duration-300 ${ className={`h-2.5 w-2.5 flex-shrink-0 transition-[color,transform] duration-300 ${
active ? "text-honey-light" : "text-cream/25 group-hover:text-cream/60 group-hover:rotate-90" active ? "text-honey-light" : "text-cream/25 group-hover:text-cream/60 group-hover:rotate-90"
}`} }`}
/> />
{item.label} <span className="flex-1">{item.label}</span>
{count > 0 ? (
<span
className="min-w-5 rounded-full bg-honey-dark px-1.5 text-center text-[11px] leading-5 text-cream tabular-nums"
title={waitingLabel(count)}
>
<span aria-hidden="true">{count}</span>
<span className="sr-only">{waitingLabel(count)}</span>
</span>
) : null}
</Link> </Link>
); );
})} })}
</div>
))}
</> </>
); );
} }
function Wordmark() { function Wordmark() {
return ( return (
<span className="flex items-center gap-2"> <Link href="/admin" className="flex items-center gap-2">
<Logomark className="h-7 w-7 flex-shrink-0" variant="inverted" /> <Logomark className="h-7 w-7 flex-shrink-0" variant="inverted" />
<span className="tracking-[0.15em] text-xs whitespace-nowrap">WABENHAIN ADMIN</span> <span className="tracking-[0.15em] text-xs whitespace-nowrap">WABENHAIN ADMIN</span>
</span> </Link>
);
}
function PaletteTrigger({ onClick, label, shortcut }: { onClick: () => void; label: string; shortcut: string }) {
return (
<button
type="button"
onClick={onClick}
className="mx-3 flex w-[calc(100%-1.5rem)] items-center gap-2 border border-cream/15 px-3 py-2 text-left text-xs text-cream/60 transition-colors hover:border-cream/35 hover:text-cream"
>
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
<circle cx="11" cy="11" r="6.5" />
<path d="m16 16 4.5 4.5" strokeLinecap="round" />
</svg>
<span className="flex-1">{label}</span>
<kbd className="border border-cream/20 px-1 text-[10px] text-cream/50">{shortcut}</kbd>
</button>
); );
} }
interface AdminShellProps { interface AdminShellProps {
adminName: string; adminName: string;
badges?: AdminBadges;
logoutAction: () => void | Promise<void>; logoutAction: () => void | Promise<void>;
children: ReactNode; children: ReactNode;
} }
/** /**
* Sidebar-Layout fuer den Admin-Bereich statt der frueheren einzeiligen, * Eigener Arbeitsbereich fuer die Imkerei: gruppierte Seitenleiste mit
* horizontal scrollenden Navigation (11 Punkte passten auf typischen * Zaehlern fuer alles, was gerade wartet, und einer Schnellsuche (Strg/Cmd+K).
* Bildschirmbreiten nicht nebeneinander). Ab lg persistente Sidebar links, * Ab lg persistente Seitenleiste links, darunter ein Vollbild-Menue nach
* darunter ein Vollbild-Overlay-Menue nach demselben Muster wie die mobile * demselben Muster wie die mobile Hauptnavigation in site-chrome.tsx. Die
* Hauptnavigation in site-chrome.tsx. * Shop-Kopf- und -Fusszeile blendet das Root-Layout hier aus
* (components/hide-on-admin.tsx).
*/ */
export function AdminShell({ adminName, logoutAction, children }: AdminShellProps) { export function AdminShell({ adminName, badges = {}, logoutAction, children }: AdminShellProps) {
const { t } = useI18n(); const { t } = useI18n();
const adminNav = getAdminNav(t); const groups = getAdminNavGroups(t);
const pathname = usePathname(); const pathname = usePathname();
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
const [paletteOpen, setPaletteOpen] = useState(false);
return ( const paletteLabels = {
<div className="min-h-screen bg-cream/40 lg:flex"> placeholder: t.adminShell.palettePlaceholder,
<aside className="relative isolate hidden overflow-hidden lg:flex lg:flex-col lg:w-60 lg:shrink-0 lg:sticky lg:top-0 lg:h-screen bg-forest text-cream"> noResults: t.adminShell.paletteNoResults,
<HoneycombPattern className="-z-10 text-honey-light/[0.06]" size={18} fade="bottom" /> pages: t.adminShell.paletteSectionPages,
<div className="flex items-center h-16 px-5 border-b border-cream/10"> actions: t.adminShell.paletteSectionActions,
<Wordmark /> dialog: t.adminShell.paletteOpen,
</div> };
<nav aria-label={t.adminShell.navAriaLabel} className="flex-1 overflow-y-auto py-4 flex flex-col gap-0.5">
<NavLinks pathname={pathname} adminNav={adminNav} /> const footer = (
</nav>
<div className="px-5 py-4 border-t border-cream/10"> <div className="px-5 py-4 border-t border-cream/10">
<p className="text-xs text-cream/60 truncate">{t.adminShell.loggedInAs(adminName)}</p> <p className="text-xs text-cream/60 truncate">{t.adminShell.loggedInAs(adminName)}</p>
<form action={logoutAction} className="mt-2"> <form action={logoutAction} className="mt-2">
@@ -117,11 +212,42 @@ export function AdminShell({ adminName, logoutAction, children }: AdminShellProp
</button> </button>
</form> </form>
</div> </div>
);
return (
<div className="min-h-screen bg-cream/40 lg:flex">
<aside className="relative isolate hidden overflow-hidden lg:flex lg:flex-col lg:w-64 lg:shrink-0 lg:sticky lg:top-0 lg:h-screen bg-forest text-cream">
<HoneycombPattern className="-z-10 text-honey-light/[0.06]" size={18} fade="bottom" />
<div className="flex items-center h-16 px-5 border-b border-cream/10">
<Wordmark />
</div>
<div className="pt-4">
<PaletteTrigger
onClick={() => setPaletteOpen(true)}
label={t.adminShell.paletteOpen}
shortcut={t.adminShell.paletteShortcut}
/>
</div>
<nav aria-label={t.adminShell.navAriaLabel} className="relative isolate flex-1 overflow-y-auto py-4">
<NavLinks pathname={pathname} groups={groups} badges={badges} waitingLabel={t.adminShell.badgeWaiting} />
</nav>
{footer}
</aside> </aside>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<header className="lg:hidden sticky top-0 z-30 bg-forest text-cream flex items-center justify-between h-16 px-5"> <header className="lg:hidden sticky top-0 z-30 bg-forest text-cream flex items-center justify-between h-16 px-5">
<Wordmark /> <Wordmark />
<div className="flex items-center gap-1">
<button
aria-label={t.adminShell.paletteOpen}
className="w-10 h-10 flex items-center justify-center"
onClick={() => setPaletteOpen(true)}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="11" cy="11" r="6.5" />
<path d="m16 16 4.5 4.5" />
</svg>
</button>
<button <button
aria-label={t.adminShell.openMenu} aria-label={t.adminShell.openMenu}
className="w-10 h-10 flex items-center justify-center flex-shrink-0" className="w-10 h-10 flex items-center justify-center flex-shrink-0"
@@ -131,11 +257,11 @@ export function AdminShell({ adminName, logoutAction, children }: AdminShellProp
<path d="M3 6h18M3 12h18M3 18h18" /> <path d="M3 6h18M3 12h18M3 18h18" />
</svg> </svg>
</button> </button>
</div>
</header> </header>
<main className="max-w-6xl mx-auto px-6 py-10 overflow-x-hidden"> {/* Kein zweites <main>: das Root-Layout stellt bereits eines. */}
{children} <div className="admin-content max-w-6xl mx-auto px-6 py-10 overflow-x-hidden">{children}</div>
</main>
</div> </div>
<AnimatePresence> <AnimatePresence>
@@ -159,20 +285,26 @@ export function AdminShell({ adminName, logoutAction, children }: AdminShellProp
</svg> </svg>
</button> </button>
</div> </div>
<nav aria-label={t.adminShell.mobileNavAriaLabel} className="flex-1 overflow-y-auto py-4"> <nav aria-label={t.adminShell.mobileNavAriaLabel} className="relative isolate flex-1 overflow-y-auto py-4">
<NavLinks pathname={pathname} adminNav={adminNav} onNavigate={() => setMobileOpen(false)} /> <NavLinks
pathname={pathname}
groups={groups}
badges={badges}
waitingLabel={t.adminShell.badgeWaiting}
onNavigate={() => setMobileOpen(false)}
/>
</nav> </nav>
<div className="px-5 py-4 border-t border-cream/10 flex-shrink-0"> {footer}
<p className="text-xs text-cream/60">{t.adminShell.loggedInAs(adminName)}</p>
<form action={logoutAction} className="mt-2">
<button type="submit" className="text-xs text-cream/80 underline hover:text-cream">
{t.adminShell.logout}
</button>
</form>
</div>
</motion.div> </motion.div>
) : null} ) : null}
</AnimatePresence> </AnimatePresence>
<AdminCommandPalette
entries={getPaletteEntries(t)}
labels={paletteLabels}
open={paletteOpen}
onOpenChange={setPaletteOpen}
/>
</div> </div>
); );
} }
+7 -6
View File
@@ -1,14 +1,15 @@
import { syncApiCoreAction } from "@/app/admin/actions"; import { syncApiCoreAction } from "@/app/admin/actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
import { Button } from "@/components/ui";
/** /**
* Der eine Sync-Knopf und die Anzeige seines Ergebnisses. * Der eine Sync-Knopf und die Anzeige seines Ergebnisses.
* *
* Gemeinsam genutzt von /admin/ernten und /admin/produkte: es ist derselbe * Sitzt genau einmal: auf dem Admin-Dashboard. Ernten, Chargen und Produkte
* Vorgang, egal von welcher Seite er ausgeloest wird — Ernten, Chargen und * laufen ohnehin immer zusammen durch (siehe syncApiCoreAction) — derselbe
* Produkte laufen ohnehin immer zusammen. Zwei getrennte Bloecke in zwei * Knopf zusaetzlich auf /admin/ernten und /admin/produkte sah aus wie zwei
* Seiten waeren dieselbe Anzeige zweimal gepflegt. * verschiedene Synchronisierungen.
*/ */
/** Rohwerte aus der Adresszeile — Next liefert Query-Parameter so aus. */ /** Rohwerte aus der Adresszeile — Next liefert Query-Parameter so aus. */
@@ -95,9 +96,9 @@ export async function ApiCoreSyncPanel({ returnTo }: { returnTo: string }) {
<p className="text-sm text-ink/65 mb-4">{t.intro}</p> <p className="text-sm text-ink/65 mb-4">{t.intro}</p>
<form action={syncApiCoreAction}> <form action={syncApiCoreAction}>
<input type="hidden" name="returnTo" value={returnTo} /> <input type="hidden" name="returnTo" value={returnTo} />
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm"> <Button type="submit" className="px-5 py-2.5">
{t.button} {t.button}
</button> </Button>
</form> </form>
</div> </div>
); );
+1 -1
View File
@@ -9,7 +9,7 @@ export function BackLink({ href, label }: { href: string; label: string }) {
return ( return (
<Link <Link
href={href} href={href}
className="inline-flex items-center gap-1.5 text-sm text-ink/70 hover:text-forest mb-6" className="-mt-1.5 mb-4.5 inline-flex items-center gap-1.5 py-1.5 text-sm text-ink/70 hover:text-forest"
> >
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75">
<path d="M15 6l-6 6 6 6" /> <path d="M15 6l-6 6 6 6" />
+16
View File
@@ -0,0 +1,16 @@
"use client";
import type { ReactNode } from "react";
import { usePathname } from "next/navigation";
/**
* Blendet Shop-Kopf- und -Fusszeile im Admin-Bereich aus: dort arbeitet die
* Imkerei in einem eigenen Arbeitsbereich mit Seitenleiste
* (components/admin-shell.tsx), statt zwischen Shop-Navigation und
* Admin-Menue zu stehen.
*/
export function HideOnAdmin({ children }: { children: ReactNode }) {
const pathname = usePathname();
if (pathname === "/admin" || pathname.startsWith("/admin/")) return null;
return <>{children}</>;
}
+1 -1
View File
@@ -331,7 +331,7 @@ export function ProductCard({
) : null} ) : null}
<Link <Link
href={`/honig/${product.slug}`} href={`/honig/${product.slug}`}
className="group/more mt-4 inline-flex items-center gap-2 self-start text-sm tracking-wide-caps text-forest" className="group/more mt-3 inline-flex items-center gap-2 self-start py-1 text-sm tracking-wide-caps text-forest"
> >
<span className="link-draw pb-0.5"> <span className="link-draw pb-0.5">
{product.category === "HONIG" ? t.product.toHoney : t.product.toProduct} {product.category === "HONIG" ? t.product.toHoney : t.product.toProduct}
+55 -2
View File
@@ -718,12 +718,16 @@ export const de: Dictionary = {
unlimited: "unbegrenzt", unlimited: "unbegrenzt",
statusActive: "Aktiv", statusActive: "Aktiv",
statusInactive: "Inaktiv", statusInactive: "Inaktiv",
statusExpired: "Abgelaufen",
edit: "Bearbeiten", edit: "Bearbeiten",
deleteConfirm: (code) => `Gutschein "${code}" wirklich löschen?`, deleteConfirm: (code) => `Gutschein "${code}" wirklich löschen?`,
delete: "Löschen", delete: "Löschen",
noVouchersYet: "Noch keine Gutscheine angelegt.", noVouchersYet: "Noch keine Gutscheine angelegt.",
}, },
adminVoucherEditPage: { adminVoucherEditPage: {
sectionDiscount: "Code und Rabatt",
sectionConditions: "Bedingungen",
sectionValidity: "Zeitraum und Status",
backToVouchers: "Zurück zu Gutscheinen", backToVouchers: "Zurück zu Gutscheinen",
title: "Gutschein bearbeiten", title: "Gutschein bearbeiten",
codeLabel: "Code", codeLabel: "Code",
@@ -741,6 +745,9 @@ export const de: Dictionary = {
save: "Speichern", save: "Speichern",
}, },
adminVoucherNewPage: { adminVoucherNewPage: {
sectionDiscount: "Code und Rabatt",
sectionConditions: "Bedingungen",
sectionValidity: "Zeitraum und Status",
backToVouchers: "Zurück zu Gutscheinen", backToVouchers: "Zurück zu Gutscheinen",
title: "Neuer Gutschein", title: "Neuer Gutschein",
codeLabel: "Code", codeLabel: "Code",
@@ -817,6 +824,8 @@ export const de: Dictionary = {
noUsersYet: "Noch keine Benutzer angelegt.", noUsersYet: "Noch keine Benutzer angelegt.",
}, },
adminUserEditPage: { adminUserEditPage: {
sectionProfile: "Stammdaten",
sectionDanger: "Konto sperren oder löschen",
backToUsers: "Zurück zu Benutzern", backToUsers: "Zurück zu Benutzern",
createdOn: (date) => `Konto erstellt am ${date}`, createdOn: (date) => `Konto erstellt am ${date}`,
firstNameLabel: "Vorname", firstNameLabel: "Vorname",
@@ -880,6 +889,7 @@ export const de: Dictionary = {
create: "Anlegen", create: "Anlegen",
}, },
adminBatchEditPage: { adminBatchEditPage: {
sectionDetails: "Abfüllung und Bestand",
backToBatches: "Zurück zu Chargen", backToBatches: "Zurück zu Chargen",
title: "Charge bearbeiten", title: "Charge bearbeiten",
fillDateLabel: "Abfülldatum", fillDateLabel: "Abfülldatum",
@@ -987,6 +997,8 @@ export const de: Dictionary = {
create: "Anlegen", create: "Anlegen",
}, },
adminHarvestEditPage: { adminHarvestEditPage: {
sectionDetails: "Angaben",
sectionVisibility: "Sichtbarkeit",
backToHarvests: "Zurück zu Ernten", backToHarvests: "Zurück zu Ernten",
title: "Ernte bearbeiten", title: "Ernte bearbeiten",
typeLabel: "Typ", typeLabel: "Typ",
@@ -1063,6 +1075,10 @@ export const de: Dictionary = {
"Leer lassen: es gilt der Satz der Kategorie (Honig 7 %, Kerzen 19 %). Nur ausfüllen, wenn dieses Produkt davon abweicht. 0 bedeutet steuerfrei nach § 19 UStG.", "Leer lassen: es gilt der Satz der Kategorie (Honig 7 %, Kerzen 19 %). Nur ausfüllen, wenn dieses Produkt davon abweicht. 0 bedeutet steuerfrei nach § 19 UStG.",
imagesLabel: "Produktbilder", imagesLabel: "Produktbilder",
visibleInShop: "Im Shop sichtbar", visibleInShop: "Im Shop sichtbar",
sectionText: "Text und Bilder",
sectionTaste: "Geschmack",
sectionPriceStock: "Preis und Bestand",
sectionVisibility: "Sichtbarkeit",
save: "Speichern", save: "Speichern",
}, },
adminProductNewPage: { adminProductNewPage: {
@@ -1096,9 +1112,16 @@ export const de: Dictionary = {
pricePerKgPlaceholder: "1980", pricePerKgPlaceholder: "1980",
imagesLabel: "Produktbilder", imagesLabel: "Produktbilder",
visibleInShop: "Im Shop sichtbar", visibleInShop: "Im Shop sichtbar",
sectionText: "Text und Bilder",
sectionCategory: "Kategorie und Herkunft",
sectionTaste: "Geschmack",
sectionPriceStock: "Preis und Bestand",
sectionVisibility: "Sichtbarkeit",
create: "Anlegen", create: "Anlegen",
}, },
adminReturnsPage: { adminReturnsPage: {
refundConfirm: (orderNumber, amount) => `Bestellung ${orderNumber} jetzt mit ${amount} per Stripe erstatten? Das lässt sich nicht rückgängig machen.`,
rejectConfirm: (orderNumber) => `Retoure zu Bestellung ${orderNumber} wirklich ablehnen?`,
backToDashboard: "Zurück zum Dashboard", backToDashboard: "Zurück zum Dashboard",
title: "Retouren", title: "Retouren",
searchLabel: "Suche", searchLabel: "Suche",
@@ -1127,6 +1150,7 @@ export const de: Dictionary = {
statusInProgress: "In Bearbeitung", statusInProgress: "In Bearbeitung",
statusCompleted: "Abgeschlossen", statusCompleted: "Abgeschlossen",
statusRejected: "Abgelehnt", statusRejected: "Abgelehnt",
statusSelectLabel: "Status der Anfrage",
save: "Speichern", save: "Speichern",
packageLabel: "Paket", packageLabel: "Paket",
quantityLabel: "Menge", quantityLabel: "Menge",
@@ -1234,6 +1258,19 @@ export const de: Dictionary = {
noVouchersYet: "Noch keine Gutscheine eingelöst.", noVouchersYet: "Noch keine Gutscheine eingelöst.",
usedCountLabel: (count, maxUses) => `${count}× genutzt${maxUses ? ` / ${maxUses}` : ""}`, usedCountLabel: (count, maxUses) => `${count}× genutzt${maxUses ? ` / ${maxUses}` : ""}`,
viewAllOrders: "Alle Bestellungen ansehen →", viewAllOrders: "Alle Bestellungen ansehen →",
greeting: (part, name) =>
`${part === "morning" ? "Guten Morgen" : part === "day" ? "Hallo" : "Guten Abend"}, ${name}.`,
todayRevenueLabel: "Umsatz heute",
monthRevenueLabel: "Umsatz diesen Monat",
vsYesterday: (delta, up) => `${up ? "↑" : "↓"} ${delta} gegenüber gestern`,
toDoHeading: "Zu erledigen",
toDoOrders: (count) => (count === 1 ? "1 Bestellung wartet auf Versand" : `${count} Bestellungen warten auf Versand`),
toDoReturns: (count) => (count === 1 ? "1 neue Retoure" : `${count} neue Retouren`),
toDoReviews: (count) => (count === 1 ? "1 Bewertung wartet auf Freigabe" : `${count} Bewertungen warten auf Freigabe`),
toDoInquiries: (count) => (count === 1 ? "1 neue Firmenanfrage" : `${count} neue Firmenanfragen`),
allDoneTitle: "Alles erledigt.",
allDoneText: "Gerade wartet nichts auf dich. Zeit für die Bienen.",
chartTooltip: (day, value) => `${day}: ${value}`,
}, },
adminContentPage: { adminContentPage: {
backToDashboard: "Zurück zum Dashboard", backToDashboard: "Zurück zum Dashboard",
@@ -1308,6 +1345,22 @@ export const de: Dictionary = {
closeMenu: "Menü schließen", closeMenu: "Menü schließen",
loggedInAs: (name) => `Angemeldet als ${name}`, loggedInAs: (name) => `Angemeldet als ${name}`,
logout: "Abmelden", logout: "Abmelden",
groupSales: "Verkauf",
groupRange: "Sortiment",
groupCustomers: "Kundschaft",
groupSite: "Seite",
paletteOpen: "Schnellsuche",
paletteShortcut: "Strg K",
palettePlaceholder: "Wohin? Seite oder Aktion suchen …",
paletteNoResults: "Nichts gefunden.",
paletteSectionPages: "Seiten",
paletteSectionActions: "Aktionen",
actionNewProduct: "Neues Produkt anlegen",
actionNewVoucher: "Neuen Gutschein anlegen",
actionNewUser: "Neues Konto anlegen",
actionNewShippingTier: "Neue Versandstufe anlegen",
actionViewShop: "Shop ansehen",
badgeWaiting: (count) => `${count} warten`,
twoFactorWarning: twoFactorWarning:
"Dieses Admin-Konto hat noch keine Zwei-Faktor-Authentifizierung aktiviert, empfohlen für Konten mit Zugriff auf Bestellungen, Rückerstattungen und Kundendaten.", "Dieses Admin-Konto hat noch keine Zwei-Faktor-Authentifizierung aktiviert, empfohlen für Konten mit Zugriff auf Bestellungen, Rückerstattungen und Kundendaten.",
twoFactorSetupLink: "Jetzt einrichten →", twoFactorSetupLink: "Jetzt einrichten →",
@@ -1537,8 +1590,8 @@ export const de: Dictionary = {
notifyMeLabel: "Benachrichtige mich, wenn es wieder verfügbar ist", notifyMeLabel: "Benachrichtige mich, wenn es wieder verfügbar ist",
emailPlaceholder: "E-Mail-Adresse", emailPlaceholder: "E-Mail-Adresse",
notifySubmit: "Anmelden", notifySubmit: "Anmelden",
removeFromWishlist: "★ Von der Wunschliste entfernen", removeFromWishlist: "Von der Wunschliste entfernen",
addToWishlist: "☆ Zur Wunschliste hinzufügen", addToWishlist: "Zur Wunschliste hinzufügen",
loginToSave: "um dieses Produkt zu merken.", loginToSave: "um dieses Produkt zu merken.",
loginLinkLabel: "Melde dich an", loginLinkLabel: "Melde dich an",
checklist: { checklist: {
+55 -2
View File
@@ -706,12 +706,16 @@ export const en: Dictionary = {
unlimited: "unlimited", unlimited: "unlimited",
statusActive: "Active", statusActive: "Active",
statusInactive: "Inactive", statusInactive: "Inactive",
statusExpired: "Expired",
edit: "Edit", edit: "Edit",
deleteConfirm: (code) => `Really delete voucher "${code}"?`, deleteConfirm: (code) => `Really delete voucher "${code}"?`,
delete: "Delete", delete: "Delete",
noVouchersYet: "No vouchers created yet.", noVouchersYet: "No vouchers created yet.",
}, },
adminVoucherEditPage: { adminVoucherEditPage: {
sectionDiscount: "Code and discount",
sectionConditions: "Conditions",
sectionValidity: "Period and status",
backToVouchers: "Back to vouchers", backToVouchers: "Back to vouchers",
title: "Edit voucher", title: "Edit voucher",
codeLabel: "Code", codeLabel: "Code",
@@ -729,6 +733,9 @@ export const en: Dictionary = {
save: "Save", save: "Save",
}, },
adminVoucherNewPage: { adminVoucherNewPage: {
sectionDiscount: "Code and discount",
sectionConditions: "Conditions",
sectionValidity: "Period and status",
backToVouchers: "Back to vouchers", backToVouchers: "Back to vouchers",
title: "New voucher", title: "New voucher",
codeLabel: "Code", codeLabel: "Code",
@@ -805,6 +812,8 @@ export const en: Dictionary = {
noUsersYet: "No users created yet.", noUsersYet: "No users created yet.",
}, },
adminUserEditPage: { adminUserEditPage: {
sectionProfile: "Profile",
sectionDanger: "Block or delete account",
backToUsers: "Back to users", backToUsers: "Back to users",
createdOn: (date) => `Account created on ${date}`, createdOn: (date) => `Account created on ${date}`,
firstNameLabel: "First name", firstNameLabel: "First name",
@@ -868,6 +877,7 @@ export const en: Dictionary = {
create: "Create", create: "Create",
}, },
adminBatchEditPage: { adminBatchEditPage: {
sectionDetails: "Filling and stock",
backToBatches: "Back to batches", backToBatches: "Back to batches",
title: "Edit batch", title: "Edit batch",
fillDateLabel: "Fill date", fillDateLabel: "Fill date",
@@ -974,6 +984,8 @@ export const en: Dictionary = {
create: "Create", create: "Create",
}, },
adminHarvestEditPage: { adminHarvestEditPage: {
sectionDetails: "Details",
sectionVisibility: "Visibility",
backToHarvests: "Back to harvests", backToHarvests: "Back to harvests",
title: "Edit harvest", title: "Edit harvest",
typeLabel: "Type", typeLabel: "Type",
@@ -1049,6 +1061,10 @@ export const en: Dictionary = {
"Leave empty to use the category rate (honey 7%, candles 19%). Fill in only if this product differs. 0 means exempt under § 19 German VAT Act.", "Leave empty to use the category rate (honey 7%, candles 19%). Fill in only if this product differs. 0 means exempt under § 19 German VAT Act.",
imagesLabel: "Product images", imagesLabel: "Product images",
visibleInShop: "Visible in shop", visibleInShop: "Visible in shop",
sectionText: "Text and images",
sectionTaste: "Taste",
sectionPriceStock: "Price and stock",
sectionVisibility: "Visibility",
save: "Save", save: "Save",
}, },
adminProductNewPage: { adminProductNewPage: {
@@ -1082,9 +1098,16 @@ export const en: Dictionary = {
pricePerKgPlaceholder: "1980", pricePerKgPlaceholder: "1980",
imagesLabel: "Product images", imagesLabel: "Product images",
visibleInShop: "Visible in shop", visibleInShop: "Visible in shop",
sectionText: "Text and images",
sectionCategory: "Category and origin",
sectionTaste: "Taste",
sectionPriceStock: "Price and stock",
sectionVisibility: "Visibility",
create: "Create", create: "Create",
}, },
adminReturnsPage: { adminReturnsPage: {
refundConfirm: (orderNumber, amount) => `Refund ${amount} for order ${orderNumber} via Stripe now? This cannot be undone.`,
rejectConfirm: (orderNumber) => `Really reject the return for order ${orderNumber}?`,
backToDashboard: "Back to dashboard", backToDashboard: "Back to dashboard",
title: "Returns", title: "Returns",
searchLabel: "Search", searchLabel: "Search",
@@ -1113,6 +1136,7 @@ export const en: Dictionary = {
statusInProgress: "In progress", statusInProgress: "In progress",
statusCompleted: "Completed", statusCompleted: "Completed",
statusRejected: "Rejected", statusRejected: "Rejected",
statusSelectLabel: "Inquiry status",
save: "Save", save: "Save",
packageLabel: "Package", packageLabel: "Package",
quantityLabel: "Quantity", quantityLabel: "Quantity",
@@ -1220,6 +1244,19 @@ export const en: Dictionary = {
noVouchersYet: "No vouchers redeemed yet.", noVouchersYet: "No vouchers redeemed yet.",
usedCountLabel: (count, maxUses) => `used ${count}×${maxUses ? ` / ${maxUses}` : ""}`, usedCountLabel: (count, maxUses) => `used ${count}×${maxUses ? ` / ${maxUses}` : ""}`,
viewAllOrders: "View all orders →", viewAllOrders: "View all orders →",
greeting: (part, name) =>
`${part === "morning" ? "Good morning" : part === "day" ? "Hello" : "Good evening"}, ${name}.`,
todayRevenueLabel: "Revenue today",
monthRevenueLabel: "Revenue this month",
vsYesterday: (delta, up) => `${up ? "↑" : "↓"} ${delta} vs. yesterday`,
toDoHeading: "To do",
toDoOrders: (count) => (count === 1 ? "1 order waiting to ship" : `${count} orders waiting to ship`),
toDoReturns: (count) => (count === 1 ? "1 new return" : `${count} new returns`),
toDoReviews: (count) => (count === 1 ? "1 review awaiting approval" : `${count} reviews awaiting approval`),
toDoInquiries: (count) => (count === 1 ? "1 new business inquiry" : `${count} new business inquiries`),
allDoneTitle: "All done.",
allDoneText: "Nothing is waiting for you right now. Time for the bees.",
chartTooltip: (day, value) => `${day}: ${value}`,
}, },
adminContentPage: { adminContentPage: {
backToDashboard: "Back to dashboard", backToDashboard: "Back to dashboard",
@@ -1294,6 +1331,22 @@ export const en: Dictionary = {
closeMenu: "Close menu", closeMenu: "Close menu",
loggedInAs: (name) => `Signed in as ${name}`, loggedInAs: (name) => `Signed in as ${name}`,
logout: "Sign out", logout: "Sign out",
groupSales: "Sales",
groupRange: "Range",
groupCustomers: "Customers",
groupSite: "Site",
paletteOpen: "Quick search",
paletteShortcut: "Ctrl K",
palettePlaceholder: "Where to? Search a page or action …",
paletteNoResults: "Nothing found.",
paletteSectionPages: "Pages",
paletteSectionActions: "Actions",
actionNewProduct: "Create new product",
actionNewVoucher: "Create new voucher",
actionNewUser: "Create new account",
actionNewShippingTier: "Create new shipping tier",
actionViewShop: "View shop",
badgeWaiting: (count) => `${count} waiting`,
twoFactorWarning: twoFactorWarning:
"This admin account doesn't have two-factor authentication enabled yet, recommended for accounts with access to orders, refunds, and customer data.", "This admin account doesn't have two-factor authentication enabled yet, recommended for accounts with access to orders, refunds, and customer data.",
twoFactorSetupLink: "Set up now →", twoFactorSetupLink: "Set up now →",
@@ -1523,8 +1576,8 @@ export const en: Dictionary = {
notifyMeLabel: "Notify me when it's back in stock", notifyMeLabel: "Notify me when it's back in stock",
emailPlaceholder: "Email address", emailPlaceholder: "Email address",
notifySubmit: "Sign up", notifySubmit: "Sign up",
removeFromWishlist: "★ Remove from wishlist", removeFromWishlist: "Remove from wishlist",
addToWishlist: "☆ Add to wishlist", addToWishlist: "Add to wishlist",
loginToSave: "to save this product.", loginToSave: "to save this product.",
loginLinkLabel: "Sign in", loginLinkLabel: "Sign in",
checklist: { checklist: {
+52
View File
@@ -397,12 +397,16 @@ export interface Dictionary {
unlimited: string; unlimited: string;
statusActive: string; statusActive: string;
statusInactive: string; statusInactive: string;
statusExpired: string;
edit: string; edit: string;
deleteConfirm: (code: string) => string; deleteConfirm: (code: string) => string;
delete: string; delete: string;
noVouchersYet: string; noVouchersYet: string;
}; };
adminVoucherEditPage: { adminVoucherEditPage: {
sectionDiscount: string;
sectionConditions: string;
sectionValidity: string;
backToVouchers: string; backToVouchers: string;
title: string; title: string;
codeLabel: string; codeLabel: string;
@@ -420,6 +424,9 @@ export interface Dictionary {
save: string; save: string;
}; };
adminVoucherNewPage: { adminVoucherNewPage: {
sectionDiscount: string;
sectionConditions: string;
sectionValidity: string;
backToVouchers: string; backToVouchers: string;
title: string; title: string;
codeLabel: string; codeLabel: string;
@@ -495,6 +502,8 @@ export interface Dictionary {
noUsersYet: string; noUsersYet: string;
}; };
adminUserEditPage: { adminUserEditPage: {
sectionProfile: string;
sectionDanger: string;
backToUsers: string; backToUsers: string;
createdOn: (date: string) => string; createdOn: (date: string) => string;
firstNameLabel: string; firstNameLabel: string;
@@ -557,6 +566,7 @@ export interface Dictionary {
create: string; create: string;
}; };
adminBatchEditPage: { adminBatchEditPage: {
sectionDetails: string;
backToBatches: string; backToBatches: string;
title: string; title: string;
fillDateLabel: string; fillDateLabel: string;
@@ -662,6 +672,8 @@ export interface Dictionary {
/** created, skipped, failed */ /** created, skipped, failed */
}; };
adminHarvestEditPage: { adminHarvestEditPage: {
sectionDetails: string;
sectionVisibility: string;
backToHarvests: string; backToHarvests: string;
title: string; title: string;
typeLabel: string; typeLabel: string;
@@ -732,6 +744,10 @@ export interface Dictionary {
vatRateHint: string; vatRateHint: string;
imagesLabel: string; imagesLabel: string;
visibleInShop: string; visibleInShop: string;
sectionText: string;
sectionTaste: string;
sectionPriceStock: string;
sectionVisibility: string;
save: string; save: string;
}; };
adminProductNewPage: { adminProductNewPage: {
@@ -763,9 +779,16 @@ export interface Dictionary {
pricePerKgPlaceholder: string; pricePerKgPlaceholder: string;
imagesLabel: string; imagesLabel: string;
visibleInShop: string; visibleInShop: string;
sectionText: string;
sectionCategory: string;
sectionTaste: string;
sectionPriceStock: string;
sectionVisibility: string;
create: string; create: string;
}; };
adminReturnsPage: { adminReturnsPage: {
refundConfirm: (orderNumber: string, amount: string) => string;
rejectConfirm: (orderNumber: string) => string;
backToDashboard: string; backToDashboard: string;
title: string; title: string;
searchLabel: string; searchLabel: string;
@@ -794,6 +817,7 @@ export interface Dictionary {
statusInProgress: string; statusInProgress: string;
statusCompleted: string; statusCompleted: string;
statusRejected: string; statusRejected: string;
statusSelectLabel: string;
save: string; save: string;
packageLabel: string; packageLabel: string;
quantityLabel: string; quantityLabel: string;
@@ -895,6 +919,18 @@ export interface Dictionary {
noVouchersYet: string; noVouchersYet: string;
usedCountLabel: (count: number, maxUses: number | null) => string; usedCountLabel: (count: number, maxUses: number | null) => string;
viewAllOrders: string; viewAllOrders: string;
greeting: (part: "morning" | "day" | "evening", name: string) => string;
todayRevenueLabel: string;
monthRevenueLabel: string;
vsYesterday: (delta: string, up: boolean) => string;
toDoHeading: string;
toDoOrders: (count: number) => string;
toDoReturns: (count: number) => string;
toDoReviews: (count: number) => string;
toDoInquiries: (count: number) => string;
allDoneTitle: string;
allDoneText: string;
chartTooltip: (day: string, value: string) => string;
}; };
adminContentPage: { adminContentPage: {
backToDashboard: string; backToDashboard: string;
@@ -964,6 +1000,22 @@ export interface Dictionary {
closeMenu: string; closeMenu: string;
loggedInAs: (name: string) => string; loggedInAs: (name: string) => string;
logout: string; logout: string;
groupSales: string;
groupRange: string;
groupCustomers: string;
groupSite: string;
paletteOpen: string;
paletteShortcut: string;
palettePlaceholder: string;
paletteNoResults: string;
paletteSectionPages: string;
paletteSectionActions: string;
actionNewProduct: string;
actionNewVoucher: string;
actionNewUser: string;
actionNewShippingTier: string;
actionViewShop: string;
badgeWaiting: (count: number) => string;
twoFactorWarning: string; twoFactorWarning: string;
twoFactorSetupLink: string; twoFactorSetupLink: string;
}; };
+25 -1
View File
@@ -1,5 +1,5 @@
import { describe, test, expect } from "vitest"; import { describe, test, expect } from "vitest";
import { registerSchema, checkoutSchema, reviewSchema, discountCodeSchema } from "./validation"; import { registerSchema, checkoutSchema, reviewSchema, discountCodeSchema, adminProductIdentitySchema } from "./validation";
describe("registerSchema", () => { describe("registerSchema", () => {
test("accepts a valid registration", () => { test("accepts a valid registration", () => {
@@ -182,3 +182,27 @@ describe("discountCodeSchema", () => {
if (result.success) expect(result.data.code).toBe("SOMMER15"); if (result.success) expect(result.data.code).toBe("SOMMER15");
}); });
}); });
describe("adminProductIdentitySchema", () => {
test("bildet den Slug aus dem Namen, wenn keiner angegeben ist", () => {
const result = adminProductIdentitySchema.safeParse({ name: "Wald Honig 2026", slug: "", category: "HONIG" });
expect(result.success).toBe(true);
if (result.success) expect(result.data.slug).toBe("wald-honig-2026");
});
test("bereinigt einen eigenen Slug statt Schraegstriche oder Sonderzeichen durchzulassen", () => {
const result = adminProductIdentitySchema.safeParse({ name: "X", slug: "../Admin/<b>Honig</b>", category: "KERZEN" });
expect(result.success).toBe(true);
if (result.success) expect(result.data.slug).toBe("admin-b-honig-b");
});
test("lehnt eine unbekannte Kategorie ab", () => {
const result = adminProductIdentitySchema.safeParse({ name: "X", slug: "", category: "GIFT" });
expect(result.success).toBe(false);
});
test("lehnt einen Namen ohne verwertbare Zeichen fuer den Slug ab", () => {
const result = adminProductIdentitySchema.safeParse({ name: "???", slug: "", category: "HONIG" });
expect(result.success).toBe(false);
});
});
+33
View File
@@ -129,6 +129,39 @@ export const adminProductNumbersSchema = z.object({
}); });
export type AdminProductNumbersInput = z.infer<typeof adminProductNumbersSchema>; export type AdminProductNumbersInput = z.infer<typeof adminProductNumbersSchema>;
const PRODUCT_SLUG_MAX_LENGTH = 80;
/** Slug als oeffentliche Adresse (/honig/<slug>): nur a-z, 0-9 und Bindestrich. */
function toProductSlug(value: string): string {
return value
.toLowerCase()
.replace(/ä/g, "ae")
.replace(/ö/g, "oe")
.replace(/ü/g, "ue")
.replace(/ß/g, "ss")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, PRODUCT_SLUG_MAX_LENGTH)
.replace(/-+$/, "");
}
// Kategorie und Slug beim Anlegen eines Produkts. Frueher kam die Kategorie
// nur per Typ-Cast aus dem Formular (ein manipulierter Wert endete als
// unbehandelte Prisma-Ausnahme), und ein eigener Slug wurde ungeprueft zur
// oeffentlichen Adresse — samt Schraegstrichen und Sonderzeichen.
export const adminProductIdentitySchema = z
.object({
name: z.string().trim().min(1, "Bitte einen Namen angeben."),
slug: z.string().trim().default(""),
category: z.enum(["HONIG", "KERZEN", "PROPOLIS"], { message: "Unbekannte Kategorie." }),
})
.transform((input) => ({ ...input, slug: toProductSlug(input.slug || input.name) }))
.refine((input) => input.slug.length > 0, {
message: "Aus Name bzw. Slug laesst sich keine Adresse bilden. Bitte einen Slug mit Buchstaben oder Ziffern eintragen.",
path: ["slug"],
});
export type AdminProductIdentityInput = z.infer<typeof adminProductIdentitySchema>;
export const shippingTierSchema = z export const shippingTierSchema = z
.object({ .object({
minQuantity: z.coerce.number().int().min(0, "Die Mindestmenge darf nicht negativ sein."), minQuantity: z.coerce.number().int().min(0, "Die Mindestmenge darf nicht negativ sein."),