feat(admin): Bestelldetail mit Zeitleiste, Produktformular gegliedert
- Bestelldetail: Status-Badge neben der Nummer, Waben-Zeitleiste (dieselbe wie im Kundenkonto) zeigt auf einen Blick den Stand - Produkt bearbeiten: Abschnitte (Text und Bilder, Geschmack, Preis und Bestand, Sichtbarkeit), "Im Shop sichtbar" als Schalter (gleiches Feld isActive), Speicherleiste bleibt beim Scrollen unten erreichbar - Kartentitel im gesamten Admin als lesbare Serifentitel statt winziger Versal-Etiketten (zentral per .admin-content, farbige Titel bleiben) Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
c95c713717
commit
76ebe7d042
6 files changed
+79
-10
No files matched your search
@@ -14,6 +14,7 @@ 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";
|
||||||
|
|
||||||
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 +44,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">
|
||||||
@@ -71,6 +75,25 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
|
|||||||
</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>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
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";
|
||||||
@@ -22,7 +23,7 @@ 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={t.adminProductEditPage.backToProducts} />
|
||||||
<h1 className="text-2xl text-forest mb-2">{t.adminProductEditPage.title}</h1>
|
<h1 className="text-2xl text-forest mb-2">{t.adminProductEditPage.title}</h1>
|
||||||
<p className="text-sm text-ink/65 mb-8">
|
<p className="text-sm text-ink/65 mb-8">
|
||||||
@@ -35,8 +36,10 @@ export default async function AdminProductEditPage(props: PageProps<"/admin/prod
|
|||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
</p>
|
</p>
|
||||||
<form action={updateProductAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
<form action={updateProductAction} className="space-y-8 bg-background border border-ink/10 p-6 sm:p-8">
|
||||||
<input type="hidden" name="id" value={product.id} />
|
<input type="hidden" name="id" value={product.id} />
|
||||||
|
<fieldset className="space-y-4 border-b border-ink/10 pb-8">
|
||||||
|
<legend className="mb-4 font-serif text-xl text-forest">{t.adminProductEditPage.sectionText}</legend>
|
||||||
<Field name="name" label={t.adminProductEditPage.nameLabel} defaultValue={product.name} required />
|
<Field name="name" label={t.adminProductEditPage.nameLabel} defaultValue={product.name} required />
|
||||||
|
|
||||||
<label className="block text-sm">
|
<label className="block text-sm">
|
||||||
@@ -61,9 +64,11 @@ export default async function AdminProductEditPage(props: PageProps<"/admin/prod
|
|||||||
label={t.adminProductEditPage.imagesLabel}
|
label={t.adminProductEditPage.imagesLabel}
|
||||||
initialValues={product.images}
|
initialValues={product.images}
|
||||||
/>
|
/>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
{product.category === "HONIG" ? (
|
{product.category === "HONIG" ? (
|
||||||
<>
|
<fieldset className="space-y-4 border-b border-ink/10 pb-8">
|
||||||
|
<legend className="mb-4 font-serif text-xl text-forest">{t.adminProductEditPage.sectionTaste}</legend>
|
||||||
<Field
|
<Field
|
||||||
name="consistency"
|
name="consistency"
|
||||||
label={t.adminProductEditPage.consistencyLabel}
|
label={t.adminProductEditPage.consistencyLabel}
|
||||||
@@ -89,9 +94,11 @@ export default async function AdminProductEditPage(props: PageProps<"/admin/prod
|
|||||||
defaultValue={String(product.intensityKraeftig)}
|
defaultValue={String(product.intensityKraeftig)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</fieldset>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<fieldset className="space-y-4 border-b border-ink/10 pb-8">
|
||||||
|
<legend className="mb-4 font-serif text-xl text-forest">{t.adminProductEditPage.sectionPriceStock}</legend>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Field
|
<Field
|
||||||
name="weightGrams"
|
name="weightGrams"
|
||||||
@@ -135,15 +142,27 @@ export default async function AdminProductEditPage(props: PageProps<"/admin/prod
|
|||||||
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>
|
<p className="text-xs text-ink/65">{t.adminProductEditPage.vatRateHint}</p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<label className="flex items-center gap-2 text-sm text-ink/70">
|
<fieldset>
|
||||||
<input type="checkbox" name="isActive" defaultChecked={product.isActive} />
|
<legend className="mb-4 font-serif text-xl text-forest">{t.adminProductEditPage.sectionVisibility}</legend>
|
||||||
|
{/* Schalter statt kleiner Checkbox — dasselbe Formularfeld
|
||||||
|
(name="isActive"), nur als Umschalter dargestellt. */}
|
||||||
|
<label className="inline-flex cursor-pointer items-center gap-3 text-sm text-ink/80">
|
||||||
|
<input type="checkbox" name="isActive" defaultChecked={product.isActive} 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"
|
||||||
|
/>
|
||||||
{t.adminProductEditPage.visibleInShop}
|
{t.adminProductEditPage.visibleInShop}
|
||||||
</label>
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
{/* Speicherleiste bleibt beim Scrollen am unteren Rand — der Knopf
|
||||||
{t.adminProductEditPage.save}
|
ist bei langen Formularen immer erreichbar. */}
|
||||||
</button>
|
<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">
|
||||||
|
<Button type="submit">{t.adminProductEditPage.save}</Button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -354,3 +354,18 @@ textarea::placeholder {
|
|||||||
.admin-content textarea:hover {
|
.admin-content textarea:hover {
|
||||||
border-color: color-mix(in oklab, var(--color-ink) 40%, transparent);
|
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);
|
||||||
|
}
|
||||||
@@ -1063,6 +1063,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: {
|
||||||
|
|||||||
@@ -1049,6 +1049,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: {
|
||||||
|
|||||||
@@ -732,6 +732,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: {
|
||||||
|
|||||||
Reference in new issue
Block a user