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:
1 parent
76ebe7d042
commit
c125e91941
6 files changed
+281
-235
No files matched your search
@@ -4,12 +4,21 @@ 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 },
|
||||||
@@ -24,173 +33,98 @@ export default async function AdminProductEditPage(props: PageProps<"/admin/prod
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl">
|
<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-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} />
|
<input type="hidden" name="id" value={product.id} />
|
||||||
<fieldset className="space-y-4 border-b border-ink/10 pb-8">
|
<AdminFieldset legend={te.sectionText}>
|
||||||
<legend className="mb-4 font-serif text-xl text-forest">{t.adminProductEditPage.sectionText}</legend>
|
<AdminField name="name" label={te.nameLabel} defaultValue={product.name} required />
|
||||||
<Field name="name" label={t.adminProductEditPage.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(", ")} />
|
||||||
<label className="block text-sm">
|
<AdminImageListField name="images" label={te.imagesLabel} initialValues={product.images} />
|
||||||
<span className="text-ink/70">{t.adminProductEditPage.descriptionLabel}</span>
|
</AdminFieldset>
|
||||||
<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>
|
|
||||||
|
|
||||||
{product.category === "HONIG" ? (
|
{product.category === "HONIG" ? (
|
||||||
<fieldset className="space-y-4 border-b border-ink/10 pb-8">
|
<AdminFieldset legend={te.sectionTaste}>
|
||||||
<legend className="mb-4 font-serif text-xl text-forest">{t.adminProductEditPage.sectionTaste}</legend>
|
<AdminField name="consistency" label={te.consistencyLabel} defaultValue={product.consistency} />
|
||||||
<Field
|
<div className="grid items-end gap-4 sm:grid-cols-3">
|
||||||
name="consistency"
|
<AdminField
|
||||||
label={t.adminProductEditPage.consistencyLabel}
|
|
||||||
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>
|
||||||
</fieldset>
|
</AdminFieldset>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<fieldset className="space-y-4 border-b border-ink/10 pb-8">
|
<AdminFieldset legend={te.sectionPriceStock}>
|
||||||
<legend className="mb-4 font-serif text-xl text-forest">{t.adminProductEditPage.sectionPriceStock}</legend>
|
<div className="grid grid-cols-2 items-end gap-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<AdminField
|
||||||
<Field
|
|
||||||
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>
|
||||||
</fieldset>
|
</AdminFieldset>
|
||||||
|
|
||||||
<fieldset>
|
<AdminFieldset legend={te.sectionVisibility} last>
|
||||||
<legend className="mb-4 font-serif text-xl text-forest">{t.adminProductEditPage.sectionVisibility}</legend>
|
<AdminSwitch name="isActive" label={te.visibleInShop} defaultChecked={product.isActive} />
|
||||||
{/* Schalter statt kleiner Checkbox — dasselbe Formularfeld
|
</AdminFieldset>
|
||||||
(name="isActive"), nur als Umschalter dargestellt. */}
|
</AdminFormPanel>
|
||||||
<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>
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1100,6 +1100,11 @@ 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: {
|
||||||
|
|||||||
@@ -1086,6 +1086,11 @@ 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: {
|
||||||
|
|||||||
@@ -767,6 +767,11 @@ 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: {
|
||||||
|
|||||||
Reference in new issue
Block a user