feat(admin): Produkt anlegen gegliedert wie Produkt bearbeiten

Gemeinsame Formularbausteine in components/admin-form.tsx (Feld,
Auswahl, Abschnitt, Schalter, Formularrahmen mit fester Speicherleiste).
Das Anlegen-Formular nutzt dieselben Abschnitte wie das Bearbeiten und
blendet Ernte/Charge sowie Geschmack per :has() aus, sobald keine
Honig-Kategorie gewaehlt ist. Eingabefelder in Rastern stehen buendig,
auch wenn Beschriftungen mobil umbrechen.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-25 20:41:59 +02:00
1 parent 76ebe7d042
commit c125e91941
6 files changed
+281 -235

No files matched your search

+48 -114
View File
@@ -4,12 +4,21 @@ import { prisma } from "@/lib/prisma";
import { asStringArray } from "@/lib/json-array";
import { BackLink } from "@/components/back-link";
import { AdminImageListField } from "@/components/admin-image-list-field";
import {
AdminField,
AdminFieldset,
AdminFormPanel,
AdminHint,
AdminSwitch,
AdminTextarea,
} from "@/components/admin-form";
import { updateProductAction } from "../../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminProductEditPage(props: PageProps<"/admin/produkte/[id]">) {
const t = getDictionary(await getLocale());
const te = t.adminProductEditPage;
const { id } = await props.params;
const rawProduct = await prisma.product.findUnique({
where: { id },
@@ -24,173 +33,98 @@ export default async function AdminProductEditPage(props: PageProps<"/admin/prod
return (
<div className="max-w-2xl">
<BackLink href="/admin/produkte" label={t.adminProductEditPage.backToProducts} />
<h1 className="text-2xl text-forest mb-2">{t.adminProductEditPage.title}</h1>
<BackLink href="/admin/produkte" label={te.backToProducts} />
<h1 className="text-2xl text-forest mb-2">{te.title}</h1>
<p className="text-sm text-ink/65 mb-8">
{t.common.categories[product.category]}
{product.harvest && product.batch
? t.adminProductEditPage.harvestBatchSuffix(
product.harvest.type,
product.harvest.year,
product.batch.code
)
? te.harvestBatchSuffix(product.harvest.type, product.harvest.year, product.batch.code)
: null}
</p>
<form action={updateProductAction} className="space-y-8 bg-background border border-ink/10 p-6 sm:p-8">
<AdminFormPanel action={updateProductAction} actions={<Button type="submit">{te.save}</Button>}>
<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 />
<label className="block text-sm">
<span className="text-ink/70">{t.adminProductEditPage.descriptionLabel}</span>
<textarea
name="description"
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}
/>
</fieldset>
<AdminFieldset legend={te.sectionText}>
<AdminField name="name" label={te.nameLabel} defaultValue={product.name} required />
<AdminTextarea name="description" label={te.descriptionLabel} defaultValue={product.description} required />
<AdminField name="tasteTags" label={te.tasteTagsLabel} defaultValue={product.tasteTags.join(", ")} />
<AdminImageListField name="images" label={te.imagesLabel} initialValues={product.images} />
</AdminFieldset>
{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
name="consistency"
label={t.adminProductEditPage.consistencyLabel}
defaultValue={product.consistency}
/>
<div className="grid grid-cols-3 gap-4">
<Field
<AdminFieldset legend={te.sectionTaste}>
<AdminField name="consistency" label={te.consistencyLabel} defaultValue={product.consistency} />
<div className="grid items-end gap-4 sm:grid-cols-3">
<AdminField
name="intensityMild"
label={t.adminProductEditPage.intensityMildLabel}
label={te.intensityMildLabel}
type="number"
defaultValue={String(product.intensityMild)}
/>
<Field
<AdminField
name="intensityFloral"
label={t.adminProductEditPage.intensityFloralLabel}
label={te.intensityFloralLabel}
type="number"
defaultValue={String(product.intensityFloral)}
/>
<Field
<AdminField
name="intensityKraeftig"
label={t.adminProductEditPage.intensityKraeftigLabel}
label={te.intensityKraeftigLabel}
type="number"
defaultValue={String(product.intensityKraeftig)}
/>
</div>
</fieldset>
</AdminFieldset>
) : 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">
<Field
<AdminFieldset legend={te.sectionPriceStock}>
<div className="grid grid-cols-2 items-end gap-4">
<AdminField
name="weightGrams"
label={t.adminProductEditPage.weightLabel}
label={te.weightLabel}
type="number"
defaultValue={String(product.weightGrams)}
required
/>
<Field
<AdminField
name="quantity"
label={t.adminProductEditPage.stockLabel}
label={te.stockLabel}
type="number"
defaultValue={String(product.inventory?.quantity ?? 0)}
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Field
<div className="grid grid-cols-2 items-end gap-4">
<AdminField
name="priceCents"
label={t.adminProductEditPage.priceLabel}
label={te.priceLabel}
type="number"
defaultValue={String(product.priceCents)}
required
/>
<Field
<AdminField
name="pricePerKgCents"
label={t.adminProductEditPage.pricePerKgLabel}
label={te.pricePerKgLabel}
type="number"
defaultValue={String(product.pricePerKgCents)}
required
/>
</div>
{/* Optional: nur fuer Artikel, die vom Satz ihrer Kategorie abweichen.
Leer heisst "Satz der Kategorie" — nicht 0, denn 0 waere
steuerfrei nach § 19 UStG (siehe lib/validation.ts). */}
<Field
<AdminField
name="vatRatePercent"
label={t.adminProductEditPage.vatRateLabel}
label={te.vatRateLabel}
type="number"
defaultValue={product.vatRatePercent == null ? "" : String(product.vatRatePercent)}
/>
<p className="text-xs text-ink/65">{t.adminProductEditPage.vatRateHint}</p>
</fieldset>
<AdminHint>{te.vatRateHint}</AdminHint>
</AdminFieldset>
<fieldset>
<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}
</label>
</fieldset>
{/* Speicherleiste bleibt beim Scrollen am unteren Rand — der Knopf
ist bei langen Formularen immer erreichbar. */}
<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>
<AdminFieldset legend={te.sectionVisibility} last>
<AdminSwitch name="isActive" label={te.visibleInShop} defaultChecked={product.isActive} />
</AdminFieldset>
</AdminFormPanel>
</div>
);
}
function Field({
name,
label,
type = "text",
defaultValue,
required = false,
}: {
name: string;
label: string;
type?: string;
defaultValue?: string;
required?: boolean;
}) {
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<input
name={name}
type={type}
defaultValue={defaultValue}
required={required}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
);
}
+70 -121
View File
@@ -1,170 +1,119 @@
import { prisma } from "@/lib/prisma";
import { Button } from "@/components/ui";
import { BackLink } from "@/components/back-link";
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 { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminProductNewPage() {
const t = getDictionary(await getLocale());
const tn = t.adminProductNewPage;
const [harvests, batches] = await Promise.all([
prisma.harvest.findMany({ orderBy: [{ year: "desc" }] }),
prisma.batch.findMany({ orderBy: { fillDate: "desc" }, include: { harvest: true } }),
]);
return (
<div className="max-w-xl">
<BackLink href="/admin/produkte" label={t.adminProductNewPage.backToProducts} />
<h1 className="text-2xl text-forest mb-8">{t.adminProductNewPage.title}</h1>
<form action={createProductAction} className="space-y-4 bg-background border border-ink/10 p-6">
<Field name="name" label={t.adminProductNewPage.nameLabel} required />
<Field name="slug" label={t.adminProductNewPage.slugLabel} />
<div className="max-w-2xl">
<BackLink href="/admin/produkte" label={tn.backToProducts} />
<h1 className="text-2xl text-forest mb-8">{tn.title}</h1>
<AdminFormPanel
action={createProductAction}
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">
<span className="text-ink/70">{t.adminProductNewPage.categoryLabel}</span>
<select name="category" className="mt-1 w-full border border-ink/20 bg-background px-3 py-2">
<AdminFieldset legend={tn.sectionCategory}>
<AdminSelect name="category" label={tn.categoryLabel} defaultValue="HONIG">
<option value="HONIG">{t.common.categories.HONIG}</option>
<option value="KERZEN">{t.common.categories.KERZEN}</option>
<option value="PROPOLIS">{t.common.categories.PROPOLIS}</option>
</select>
</label>
<p className="text-xs text-ink/65">{t.adminProductNewPage.categoryHint}</p>
<div className="grid grid-cols-2 gap-4">
<label className="block text-sm">
<span className="text-ink/70">{t.adminProductNewPage.harvestLabel}</span>
<select name="harvestId" className="mt-1 w-full border border-ink/20 bg-background px-3 py-2">
<option value="">{t.adminProductNewPage.noneOption}</option>
</AdminSelect>
{/* Ernte/Charge gibt es nur bei Honig. Ausgeblendet wird ueber :has()
ohne Client-JS; ohne :has()-Unterstuetzung bleibt alles sichtbar
und der Server ignoriert die Auswahl ohnehin (products.ts). */}
<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">
<AdminSelect name="harvestId" label={tn.harvestLabel} defaultValue="">
<option value="">{tn.noneOption}</option>
{harvests.map((h) => (
<option key={h.id} value={h.id}>
{h.type} {h.year}
</option>
))}
</select>
</label>
<label className="block text-sm">
<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>
</AdminSelect>
<AdminSelect name="batchId" label={tn.batchLabel} defaultValue="">
<option value="">{tn.noneOption}</option>
{batches.map((b) => (
<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>
))}
</select>
</label>
</AdminSelect>
</div>
<AdminHint>{tn.categoryHint}</AdminHint>
</AdminFieldset>
<label className="block text-sm">
<span className="text-ink/70">{t.adminProductNewPage.descriptionLabel}</span>
<textarea name="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.adminProductNewPage.tasteTagsLabel}
placeholder={t.adminProductNewPage.tasteTagsPlaceholder}
/>
<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"
/>
<AdminFieldset
legend={tn.sectionTaste}
className="group-has-[option[value=KERZEN]:checked]/product:hidden group-has-[option[value=PROPOLIS]:checked]/product:hidden"
>
<AdminField name="consistency" label={tn.consistencyLabel} placeholder={tn.consistencyPlaceholder} />
<div className="grid items-end gap-4 sm:grid-cols-3">
<AdminField name="intensityMild" label={tn.intensityMildLabel} type="number" defaultValue="3" />
<AdminField name="intensityFloral" label={tn.intensityFloralLabel} type="number" defaultValue="3" />
<AdminField name="intensityKraeftig" label={tn.intensityKraeftigLabel} type="number" defaultValue="3" />
</div>
</AdminFieldset>
<div className="grid grid-cols-2 gap-4">
<Field name="weightGrams" label={t.adminProductNewPage.weightLabel} type="number" required defaultValue="500" />
<Field name="quantity" label={t.adminProductNewPage.stockLabel} type="number" required />
<AdminFieldset legend={tn.sectionPriceStock}>
<div className="grid grid-cols-2 items-end gap-4">
<AdminField name="weightGrams" label={tn.weightLabel} type="number" required defaultValue="500" />
<AdminField name="quantity" label={tn.stockLabel} type="number" required />
</div>
<div className="grid grid-cols-2 gap-4">
<Field
<div className="grid grid-cols-2 items-end gap-4">
<AdminField
name="priceCents"
label={t.adminProductNewPage.priceLabel}
label={tn.priceLabel}
type="number"
required
placeholder={t.adminProductNewPage.pricePlaceholder}
placeholder={tn.pricePlaceholder}
/>
<Field
<AdminField
name="pricePerKgCents"
label={t.adminProductNewPage.pricePerKgLabel}
label={tn.pricePerKgLabel}
type="number"
required
placeholder={t.adminProductNewPage.pricePerKgPlaceholder}
placeholder={tn.pricePerKgPlaceholder}
/>
</div>
{/* Optional: nur fuer Artikel, die vom Satz ihrer Kategorie abweichen.
Leer heisst "Satz der Kategorie" — nicht 0, denn 0 waere
steuerfrei nach § 19 UStG (siehe lib/validation.ts). */}
<Field name="vatRatePercent" label={t.adminProductNewPage.vatRateLabel} type="number" />
<p className="text-xs text-ink/65">{t.adminProductNewPage.vatRateHint}</p>
<AdminField name="vatRatePercent" label={tn.vatRateLabel} type="number" />
<AdminHint>{tn.vatRateHint}</AdminHint>
</AdminFieldset>
<label className="flex items-center gap-2 text-sm text-ink/70">
<input type="checkbox" name="isActive" defaultChecked />
{t.adminProductNewPage.visibleInShop}
</label>
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
{t.adminProductNewPage.create}
</button>
</form>
<AdminFieldset legend={tn.sectionVisibility} last>
<AdminSwitch name="isActive" label={tn.visibleInShop} defaultChecked />
</AdminFieldset>
</AdminFormPanel>
</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>
);
}
+148
View File
@@ -0,0 +1,148 @@
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,
}: {
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={inputClass}
/>
</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,
children,
}: {
name: string;
label: string;
defaultValue?: 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>
</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>
);
}
+5
View File
@@ -1100,6 +1100,11 @@ export const de: Dictionary = {
pricePerKgPlaceholder: "1980",
imagesLabel: "Produktbilder",
visibleInShop: "Im Shop sichtbar",
sectionText: "Text und Bilder",
sectionCategory: "Kategorie und Herkunft",
sectionTaste: "Geschmack",
sectionPriceStock: "Preis und Bestand",
sectionVisibility: "Sichtbarkeit",
create: "Anlegen",
},
adminReturnsPage: {
+5
View File
@@ -1086,6 +1086,11 @@ export const en: Dictionary = {
pricePerKgPlaceholder: "1980",
imagesLabel: "Product images",
visibleInShop: "Visible in shop",
sectionText: "Text and images",
sectionCategory: "Category and origin",
sectionTaste: "Taste",
sectionPriceStock: "Price and stock",
sectionVisibility: "Visibility",
create: "Create",
},
adminReturnsPage: {
+5
View File
@@ -767,6 +767,11 @@ export interface Dictionary {
pricePerKgPlaceholder: string;
imagesLabel: string;
visibleInShop: string;
sectionText: string;
sectionCategory: string;
sectionTaste: string;
sectionPriceStock: string;
sectionVisibility: string;
create: string;
};
adminReturnsPage: {