Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae2726b0ee | ||
|
|
11eccec01b | ||
|
|
b7a75dddca | ||
|
|
a09057d960 | ||
|
|
95b4b5953d | ||
|
|
c125e91941 | ||
|
|
76ebe7d042 | ||
|
|
c95c713717 |
No files matched your search
@@ -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";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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}</>;
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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."),
|
||||||
|
|||||||
Reference in new issue
Block a user