feat(admin): Formulare fuer Gutscheine, Versand, Benutzer, Ernten und Chargen vereinheitlicht

Alle Anlege- und Bearbeiten-Formulare nutzen jetzt die Bausteine aus
components/admin-form.tsx wie das Produktformular: Rahmen mit fester
Speicherleiste, Abschnitte mit Serif-Ueberschrift (Gutschein: Code und
Rabatt / Bedingungen / Zeitraum und Status; Ernte: Angaben / Bilder /
Sichtbarkeit; Charge: Abfuellung und Bestand / Bilder), Schalter statt
Checkbox, buendige Eingabefelder in Rastern.

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

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-25 21:14:10 +02:00
1 parent a09057d960
commit b7a75dddca
12 files changed
+311 -471

No files matched your search

+56 -84
View File
@@ -2,8 +2,11 @@ import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link";
import { ConfirmForm } from "@/components/confirm-form";
import { Button } from "@/components/ui";
import { getCurrentAdmin } from "@/lib/auth";
import { formatDate } from "@/lib/format";
import { AdminField, AdminFieldset, AdminFormPanel, AdminSelect } from "@/components/admin-form";
import { AdminActionButton, AdminBadge } from "@/components/admin-list";
import {
updateCustomerAction,
blockCustomerAction,
@@ -16,6 +19,7 @@ import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminBenutzerEditPage(props: PageProps<"/admin/benutzer/[id]">) {
const t = getDictionary(await getLocale());
const tu = t.adminUserEditPage;
const { id } = await props.params;
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());
return (
<div className="max-w-xl">
<BackLink href="/admin/benutzer" label={t.adminUserEditPage.backToUsers} />
<div className="max-w-2xl">
<BackLink href="/admin/benutzer" label={tu.backToUsers} />
<h1 className="text-2xl text-forest mb-2">
{customer.firstName} {customer.lastName}
</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>
<form action={updateCustomerAction} className="space-y-4 bg-background border border-ink/10 p-6">
<AdminFormPanel action={updateCustomerAction} actions={<Button type="submit">{tu.save}</Button>}>
<input type="hidden" name="id" value={customer.id} />
<div className="grid grid-cols-2 gap-4">
<Field name="firstName" label={t.adminUserEditPage.firstNameLabel} defaultValue={customer.firstName} required />
<Field name="lastName" label={t.adminUserEditPage.lastNameLabel} defaultValue={customer.lastName} required />
<AdminFieldset legend={tu.sectionProfile} last>
<div className="grid grid-cols-2 items-end gap-4">
<AdminField name="firstName" label={tu.firstNameLabel} defaultValue={customer.firstName} required />
<AdminField name="lastName" label={tu.lastNameLabel} defaultValue={customer.lastName} required />
</div>
<Field name="email" label={t.adminUserEditPage.emailLabel} type="email" defaultValue={customer.email} required />
<Field name="phone" label={t.adminUserEditPage.phoneLabel} defaultValue={customer.phone ?? ""} />
<label className="block text-sm">
<span className="text-ink/70">{t.adminUserEditPage.roleLabel}</span>
<select
<AdminField name="email" label={tu.emailLabel} type="email" defaultValue={customer.email} required />
<AdminField name="phone" label={tu.phoneLabel} type="tel" defaultValue={customer.phone ?? ""} />
<AdminSelect
name="role"
label={tu.roleLabel}
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="ADMIN">{t.adminUserEditPage.roleAdmin}</option>
</select>
{isSelf ? (
<span className="mt-1 block text-xs text-ink/65">{t.adminUserEditPage.cannotChangeOwnRole}</span>
) : null}
</label>
<option value="KUNDE">{tu.roleCustomer}</option>
<option value="ADMIN">{tu.roleAdmin}</option>
</AdminSelect>
</AdminFieldset>
</AdminFormPanel>
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
{t.adminUserEditPage.save}
</button>
</form>
<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>
<section className="mt-8 space-y-6 border border-ink/10 bg-background p-6 sm:p-8">
<h2 className="font-serif text-xl text-forest">{tu.securityHeading}</h2>
<dl className="divide-y divide-ink/10 text-sm">
<div className="flex items-center justify-between gap-4 py-3">
<dt className="text-ink/70">{tu.twoFactorLabel}</dt>
<dd>
{customer.blockedAt ? (
<span className="text-red-700">
{t.adminUserEditPage.blockedSince(formatDate(customer.blockedAt))}
</span>
) : isTemporarilyLocked ? (
<span className="text-honey-dark">{t.adminUserEditPage.temporarilyLockedFailedAttempts}</span>
{customer.twoFactorEnabled ? (
<AdminBadge tone="positive">{tu.twoFactorActive}</AdminBadge>
) : (
<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>
</div>
@@ -89,67 +89,39 @@ export default async function AdminBenutzerEditPage(props: PageProps<"/admin/ben
<form action={sendCustomerPasswordResetAction}>
<input type="hidden" name="id" value={customer.id} />
<button type="submit" className="bg-forest text-cream px-5 py-2 text-sm">
{t.adminUserEditPage.sendPasswordReset}
</button>
<Button type="submit" variant="secondary" className="px-5 py-2.5">
{tu.sendPasswordReset}
</Button>
</form>
{/* Sperren/Loeschen des eigenen Kontos ist serverseitig ohnehin
verboten — hier gar nicht erst angeboten. */}
{!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 ? (
<form action={unblockCustomerAction}>
<input type="hidden" name="id" value={customer.id} />
<button type="submit" className="text-forest underline text-sm">
{t.adminUserEditPage.unblock}
</button>
<AdminActionButton>{tu.unblock}</AdminActionButton>
</form>
) : (
<form action={blockCustomerAction}>
<input type="hidden" name="id" value={customer.id} />
<button type="submit" className="text-ink/70 underline text-sm">
{t.adminUserEditPage.block}
</button>
<AdminActionButton tone="muted">{tu.block}</AdminActionButton>
</form>
)}
<ConfirmForm
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} />
<button type="submit" className="text-red-700 underline text-sm">
{t.adminUserEditPage.deleteAccount}
</button>
<AdminActionButton tone="danger">{tu.deleteAccount}</AdminActionButton>
</ConfirmForm>
</div>
</div>
) : null}
</div>
</section>
</div>
);
}
function Field({
name,
label,
type = "text",
defaultValue,
required = false,
}: {
name: string;
label: string;
type?: string;
defaultValue?: string;
required?: boolean;
}) {
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<input
name={name}
type={type}
defaultValue={defaultValue}
required={required}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
);
}
+18 -49
View File
@@ -1,64 +1,33 @@
import { BackLink } from "@/components/back-link";
import { Button } from "@/components/ui";
import { AdminField, AdminFormPanel, AdminSelect } from "@/components/admin-form";
import { createCustomerAction } from "../../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminBenutzerNewPage() {
const t = getDictionary(await getLocale());
const tu = t.adminUserNewPage;
return (
<div className="max-w-xl">
<BackLink href="/admin/benutzer" label={t.adminUserNewPage.backToUsers} />
<h1 className="text-2xl text-forest mb-2">{t.adminUserNewPage.title}</h1>
<p className="text-sm text-ink/70 mb-8">{t.adminUserNewPage.intro}</p>
<BackLink href="/admin/benutzer" label={tu.backToUsers} />
<h1 className="text-2xl text-forest mb-2">{tu.title}</h1>
<p className="text-sm text-ink/70 mb-8">{tu.intro}</p>
<form action={createCustomerAction} className="space-y-4 bg-background border border-ink/10 p-6">
<div className="grid grid-cols-2 gap-4">
<Field name="firstName" label={t.adminUserNewPage.firstNameLabel} required />
<Field name="lastName" label={t.adminUserNewPage.lastNameLabel} required />
<AdminFormPanel action={createCustomerAction} actions={<Button type="submit">{tu.createAndSend}</Button>}>
<div className="space-y-4">
<div className="grid grid-cols-2 items-end gap-4">
<AdminField name="firstName" label={tu.firstNameLabel} required />
<AdminField name="lastName" label={tu.lastNameLabel} required />
</div>
<Field name="email" label={t.adminUserNewPage.emailLabel} type="email" required />
<label className="block text-sm">
<span className="text-ink/70">{t.adminUserNewPage.roleLabel}</span>
<select
name="role"
defaultValue="KUNDE"
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
>
<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>
<AdminField name="email" label={tu.emailLabel} type="email" required />
<AdminSelect name="role" label={tu.roleLabel} defaultValue="KUNDE">
<option value="KUNDE">{tu.roleCustomer}</option>
<option value="ADMIN">{tu.roleAdmin}</option>
</AdminSelect>
</div>
</AdminFormPanel>
</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>
);
}
+30 -47
View File
@@ -2,96 +2,79 @@ import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { asStringArray } from "@/lib/json-array";
import { BackLink } from "@/components/back-link";
import { Button } from "@/components/ui";
import { AdminImageField } from "@/components/admin-image-field";
import { AdminField, AdminFieldset, AdminFormPanel } from "@/components/admin-form";
import { updateBatchAction } from "../../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminBatchEditPage(props: PageProps<"/admin/chargen/[id]">) {
const t = getDictionary(await getLocale());
const tb = t.adminBatchEditPage;
const { id } = await props.params;
const rawBatch = await prisma.batch.findUnique({ where: { id }, include: { harvest: true } });
if (!rawBatch) notFound();
const batch = { ...rawBatch, images: asStringArray(rawBatch.images) };
return (
<div className="max-w-xl">
<BackLink href="/admin/chargen" label={t.adminBatchEditPage.backToBatches} />
<h1 className="text-2xl text-forest mb-2">{t.adminBatchEditPage.title}</h1>
<div className="max-w-2xl">
<BackLink href="/admin/chargen" label={tb.backToBatches} />
<h1 className="text-2xl text-forest mb-2">{tb.title}</h1>
<p className="text-sm text-ink/65 mb-8">
{batch.code} · {batch.harvest.type} {batch.harvest.year}
</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} />
<div className="grid grid-cols-2 gap-4">
<label className="block text-sm">
<span className="text-ink/70">{t.adminBatchEditPage.fillDateLabel}</span>
<input
<AdminFieldset legend={tb.sectionDetails}>
<div className="grid grid-cols-2 items-end gap-4">
<AdminField
name="fillDate"
label={tb.fillDateLabel}
type="date"
defaultValue={batch.fillDate.toISOString().slice(0, 10)}
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.adminBatchEditPage.bestBeforeDateLabel}</span>
<input
<AdminField
name="bestBeforeDate"
label={tb.bestBeforeDateLabel}
type="date"
defaultValue={batch.bestBeforeDate.toISOString().slice(0, 10)}
required
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
</div>
<div className="grid grid-cols-2 gap-4">
<label className="block text-sm">
<span className="text-ink/70">{t.adminBatchEditPage.glassCountLabel}</span>
<input
<div className="grid grid-cols-2 items-end gap-4">
<AdminField
name="glassCount"
label={tb.glassCountLabel}
type="number"
defaultValue={batch.glassCount}
min={0}
defaultValue={String(batch.glassCount)}
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.adminBatchEditPage.remainingGlassesLabel}</span>
<input
<AdminField
name="remainingGlasses"
label={tb.remainingGlassesLabel}
type="number"
defaultValue={batch.remainingGlasses}
min={0}
defaultValue={String(batch.remainingGlasses)}
required
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
</div>
</AdminFieldset>
{/* Zwei einzeln setzbare Slots statt einer Liste: die Position
entscheidet, welche Kachel auf /charge/[code] gefuellt wird.
Beide tragen denselben Feldnamen "images" und werden in dieser
Reihenfolge uebermittelt (siehe imageSlots() in
admin/actions/harvests-batches.ts). Ein leerer Slot faellt auf
das entsprechende Erntebild zurueck. */}
<fieldset className="border border-ink/10 p-4">
<legend className="px-1 text-sm text-ink/70">{t.adminBatchEditPage.imagesLabel}</legend>
<div className="space-y-4">
<AdminImageField
name="images"
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>
<AdminFieldset legend={tb.imagesLabel} last>
<AdminImageField name="images" label={tb.image1Label} initialValue={batch.images[0] ?? ""} />
<AdminImageField name="images" label={tb.image2Label} initialValue={batch.images[1] ?? ""} />
</AdminFieldset>
</AdminFormPanel>
</div>
);
}
+43 -72
View File
@@ -2,13 +2,23 @@ import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { asStringArray } from "@/lib/json-array";
import { BackLink } from "@/components/back-link";
import { Button } from "@/components/ui";
import { AdminImageField } from "@/components/admin-image-field";
import {
AdminField,
AdminFieldset,
AdminFormPanel,
AdminHint,
AdminSwitch,
AdminTextarea,
} from "@/components/admin-form";
import { updateHarvestAction, toggleHarvestPublishedAction } from "../../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminHarvestEditPage(props: PageProps<"/admin/ernten/[id]">) {
const t = getDictionary(await getLocale());
const th = t.adminHarvestEditPage;
const { id } = await props.params;
const rawHarvest = await prisma.harvest.findUnique({ where: { id } });
if (!rawHarvest) notFound();
@@ -17,102 +27,63 @@ export default async function AdminHarvestEditPage(props: PageProps<"/admin/ernt
const harvestDateValue = harvest.harvestDate.toISOString().slice(0, 10);
return (
<div className="max-w-xl">
<BackLink href="/admin/ernten" label={t.adminHarvestEditPage.backToHarvests} />
<h1 className="text-2xl text-forest mb-8">{t.adminHarvestEditPage.title}</h1>
<div className="max-w-2xl">
<BackLink href="/admin/ernten" label={th.backToHarvests} />
<h1 className="text-2xl text-forest mb-8">{th.title}</h1>
{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">
<p className="text-sm text-honey-dark">{t.adminHarvestEditPage.draftNotice}</p>
<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">{th.draftNotice}</p>
<form action={toggleHarvestPublishedAction}>
<input type="hidden" name="id" value={harvest.id} />
<input type="hidden" name="isPublished" value="on" />
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm whitespace-nowrap">
{t.adminHarvestEditPage.publishNow}
</button>
<Button type="submit" className="whitespace-nowrap px-5 py-2.5">
{th.publishNow}
</Button>
</form>
</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} />
<div className="grid grid-cols-2 gap-4">
<label className="block text-sm">
<span className="text-ink/70">{t.adminHarvestEditPage.typeLabel}</span>
<input
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>
<AdminFieldset legend={th.sectionDetails}>
<div className="grid grid-cols-2 items-end gap-4">
<AdminField name="type" label={th.typeLabel} defaultValue={harvest.type} required />
<AdminField name="year" label={th.yearLabel} type="number" defaultValue={String(harvest.year)} required />
</div>
<label className="block text-sm">
<span className="text-ink/70">{t.adminHarvestEditPage.harvestDateLabel}</span>
<input
<div className="grid items-end gap-4 sm:grid-cols-2">
<AdminField
name="harvestDate"
label={th.harvestDateLabel}
type="date"
defaultValue={harvestDateValue}
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.locationLabel}</span>
<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
<AdminField name="location" label={th.locationLabel} defaultValue={harvest.location} />
</div>
<AdminTextarea
name="description"
label={th.descriptionLabel}
defaultValue={harvest.description}
required
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
einer Liste, damit sich das zweite Bild auch ohne erstes setzen
laesst. Slot 1 erscheint zusaetzlich auf /ernten und der
Ernten-Detailseite. */}
<fieldset className="border border-ink/10 p-4">
<legend className="px-1 text-sm text-ink/70">{t.adminHarvestEditPage.imagesLabel}</legend>
<div className="space-y-4">
<AdminImageField
name="images"
label={t.adminHarvestEditPage.image1Label}
initialValue={harvest.images[0] ?? ""}
/>
<AdminImageField
name="images"
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>
<AdminFieldset legend={th.imagesLabel}>
<AdminImageField name="images" label={th.image1Label} initialValue={harvest.images[0] ?? ""} />
<AdminImageField name="images" label={th.image2Label} initialValue={harvest.images[1] ?? ""} />
</AdminFieldset>
<AdminFieldset legend={th.sectionVisibility} last>
<AdminSwitch name="isPublished" label={th.isPublishedLabel} defaultChecked={harvest.isPublished} />
<AdminHint>{th.isPublishedHint}</AdminHint>
</AdminFieldset>
</AdminFormPanel>
</div>
);
}
+53 -74
View File
@@ -1,6 +1,14 @@
import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
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 { getLocale } from "@/i18n/locale";
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]">) {
const t = getDictionary(await getLocale());
const tv = t.adminVoucherEditPage;
const { id } = await props.params;
const code = await prisma.discountCode.findUnique({ where: { id } });
if (!code) notFound();
return (
<div className="max-w-md">
<BackLink href="/admin/gutscheine" label={t.adminVoucherEditPage.backToVouchers} />
<h1 className="text-2xl text-forest mb-8">{t.adminVoucherEditPage.title}</h1>
<div className="max-w-2xl">
<BackLink href="/admin/gutscheine" label={tv.backToVouchers} />
<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>
<form action={updateDiscountCodeAction} className="space-y-4 bg-background border border-ink/10 p-6">
<AdminFormPanel action={updateDiscountCodeAction} actions={<Button type="submit">{tv.save}</Button>}>
<input type="hidden" name="id" value={code.id} />
<Field name="code" label={t.adminVoucherEditPage.codeLabel} defaultValue={code.code} required />
<div className="grid grid-cols-2 gap-4">
<label className="block text-sm">
<span className="text-ink/70">{t.adminVoucherEditPage.typeLabel}</span>
<select
name="type"
defaultValue={code.type}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
>
<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}
<AdminFieldset legend={tv.sectionDiscount}>
<AdminField name="code" label={tv.codeLabel} defaultValue={code.code} required />
<div className="grid grid-cols-2 items-end gap-4">
<AdminSelect name="type" label={tv.typeLabel} defaultValue={code.type}>
<option value="PERCENT">{tv.typePercent}</option>
<option value="FIXED">{tv.typeFixed}</option>
</AdminSelect>
<AdminField
name="value"
label={tv.valueLabel}
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)}
/>
<Field
<AdminField
name="minQuantity"
label={t.adminVoucherEditPage.minQuantityLabel}
label={tv.minQuantityLabel}
type="number"
min={0}
defaultValue={code.minQuantity !== null ? String(code.minQuantity) : ""}
/>
<Field
<AdminField
name="maxUses"
label={t.adminVoucherEditPage.maxUsesLabel}
label={tv.maxUsesLabel}
type="number"
min={0}
defaultValue={code.maxUses !== null ? String(code.maxUses) : ""}
/>
</AdminFieldset>
<div className="grid grid-cols-2 gap-4">
<Field
<AdminFieldset legend={tv.sectionValidity} last>
<div className="grid grid-cols-2 items-end gap-4">
<AdminField
name="validFrom"
label={t.adminVoucherEditPage.validFromLabel}
label={tv.validFromLabel}
type="date"
defaultValue={toDateInputValue(code.validFrom)}
/>
<Field
<AdminField
name="validUntil"
label={t.adminVoucherEditPage.validUntilLabel}
label={tv.validUntilLabel}
type="date"
defaultValue={toDateInputValue(code.validUntil)}
/>
</div>
<label className="flex items-center gap-2 text-sm text-ink/70">
<input type="checkbox" name="isActive" defaultChecked={code.isActive} />
{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>
<AdminSwitch name="isActive" label={tv.activeLabel} defaultChecked={code.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}
min={0}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
);
}
+34 -60
View File
@@ -1,77 +1,51 @@
import { BackLink } from "@/components/back-link";
import { Button } from "@/components/ui";
import {
AdminField,
AdminFieldset,
AdminFormPanel,
AdminSelect,
AdminSwitch,
} from "@/components/admin-form";
import { createDiscountCodeAction } from "../../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminGutscheinNewPage() {
const t = getDictionary(await getLocale());
const tv = t.adminVoucherNewPage;
return (
<div className="max-w-md">
<BackLink href="/admin/gutscheine" label={t.adminVoucherNewPage.backToVouchers} />
<h1 className="text-2xl text-forest mb-8">{t.adminVoucherNewPage.title}</h1>
<div className="max-w-2xl">
<BackLink href="/admin/gutscheine" label={tv.backToVouchers} />
<h1 className="text-2xl text-forest mb-8">{tv.title}</h1>
<form action={createDiscountCodeAction} className="space-y-4 bg-background border border-ink/10 p-6">
<Field name="code" label={t.adminVoucherNewPage.codeLabel} placeholder={t.adminVoucherNewPage.codePlaceholder} required />
<div className="grid grid-cols-2 gap-4">
<label className="block text-sm">
<span className="text-ink/70">{t.adminVoucherNewPage.typeLabel}</span>
<select name="type" className="mt-1 w-full border border-ink/20 bg-background px-3 py-2">
<option value="PERCENT">{t.adminVoucherNewPage.typePercent}</option>
<option value="FIXED">{t.adminVoucherNewPage.typeFixed}</option>
</select>
</label>
<Field name="value" label={t.adminVoucherNewPage.valueLabel} type="number" placeholder="10" required />
<AdminFormPanel action={createDiscountCodeAction} actions={<Button type="submit">{tv.create}</Button>}>
<AdminFieldset legend={tv.sectionDiscount}>
<AdminField name="code" label={tv.codeLabel} placeholder={tv.codePlaceholder} required />
<div className="grid grid-cols-2 items-end gap-4">
<AdminSelect name="type" label={tv.typeLabel} defaultValue="PERCENT">
<option value="PERCENT">{tv.typePercent}</option>
<option value="FIXED">{tv.typeFixed}</option>
</AdminSelect>
<AdminField name="value" label={tv.valueLabel} type="number" min={0} placeholder="10" required />
</div>
</AdminFieldset>
<Field name="minOrderCents" label={t.adminVoucherNewPage.minOrderLabel} type="number" placeholder="0" />
<Field name="minQuantity" label={t.adminVoucherNewPage.minQuantityLabel} type="number" placeholder="3" />
<Field name="maxUses" label={t.adminVoucherNewPage.maxUsesLabel} type="number" />
<AdminFieldset legend={tv.sectionConditions}>
<AdminField name="minOrderCents" label={tv.minOrderLabel} type="number" min={0} placeholder="0" />
<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">
<Field name="validFrom" label={t.adminVoucherNewPage.validFromLabel} type="date" />
<Field name="validUntil" label={t.adminVoucherNewPage.validUntilLabel} type="date" />
<AdminFieldset legend={tv.sectionValidity} last>
<div className="grid grid-cols-2 items-end gap-4">
<AdminField name="validFrom" label={tv.validFromLabel} type="date" />
<AdminField name="validUntil" label={tv.validUntilLabel} type="date" />
</div>
<label className="flex items-center gap-2 text-sm text-ink/70">
<input type="checkbox" name="isActive" defaultChecked />
{t.adminVoucherNewPage.activeLabel}
</label>
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
{t.adminVoucherNewPage.create}
</button>
</form>
<AdminSwitch name="isActive" label={tv.activeLabel} defaultChecked />
</AdminFieldset>
</AdminFormPanel>
</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>
);
}
+20 -44
View File
@@ -1,78 +1,54 @@
import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link";
import { Button } from "@/components/ui";
import { AdminField, AdminFormPanel } from "@/components/admin-form";
import { updateShippingTierAction } from "../../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminVersandEditPage(props: PageProps<"/admin/versand/[id]">) {
const t = getDictionary(await getLocale());
const ts = t.adminShippingEditPage;
const { id } = await props.params;
const tier = await prisma.shippingTier.findUnique({ where: { id } });
if (!tier) notFound();
return (
<div className="max-w-md">
<BackLink href="/admin/versand" label={t.adminShippingEditPage.backToShipping} />
<h1 className="text-2xl text-forest mb-8">{t.adminShippingEditPage.title}</h1>
<div className="max-w-xl">
<BackLink href="/admin/versand" label={ts.backToShipping} />
<h1 className="text-2xl text-forest mb-8">{ts.title}</h1>
<form action={updateShippingTierAction} className="space-y-4 bg-background border border-ink/10 p-6">
<AdminFormPanel action={updateShippingTierAction} actions={<Button type="submit">{ts.save}</Button>}>
<input type="hidden" name="id" value={tier.id} />
<div className="grid grid-cols-2 gap-4">
<Field
<div className="space-y-4">
<div className="grid grid-cols-2 items-end gap-4">
<AdminField
name="minQuantity"
label={t.adminShippingEditPage.minQuantityLabel}
label={ts.minQuantityLabel}
type="number"
min={0}
defaultValue={String(tier.minQuantity)}
required
/>
<Field
<AdminField
name="maxQuantity"
label={t.adminShippingEditPage.maxQuantityLabel}
label={ts.maxQuantityLabel}
type="number"
min={0}
defaultValue={tier.maxQuantity !== null ? String(tier.maxQuantity) : ""}
/>
</div>
<Field
<AdminField
name="priceCents"
label={t.adminShippingEditPage.priceLabel}
label={ts.priceLabel}
type="number"
min={0}
defaultValue={String(tier.priceCents)}
required
/>
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
{t.adminShippingEditPage.save}
</button>
</form>
</div>
</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}
min={0}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
);
}
+14 -41
View File
@@ -1,55 +1,28 @@
import { BackLink } from "@/components/back-link";
import { Button } from "@/components/ui";
import { AdminField, AdminFormPanel } from "@/components/admin-form";
import { createShippingTierAction } from "../../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminVersandNewPage() {
const t = getDictionary(await getLocale());
const ts = t.adminShippingNewPage;
return (
<div className="max-w-md">
<BackLink href="/admin/versand" label={t.adminShippingNewPage.backToShipping} />
<h1 className="text-2xl text-forest mb-8">{t.adminShippingNewPage.title}</h1>
<div className="max-w-xl">
<BackLink href="/admin/versand" label={ts.backToShipping} />
<h1 className="text-2xl text-forest mb-8">{ts.title}</h1>
<form action={createShippingTierAction} className="space-y-4 bg-background border border-ink/10 p-6">
<div className="grid grid-cols-2 gap-4">
<Field name="minQuantity" label={t.adminShippingNewPage.minQuantityLabel} type="number" required placeholder="0" />
<Field name="maxQuantity" label={t.adminShippingNewPage.maxQuantityLabel} type="number" placeholder="3" />
<AdminFormPanel action={createShippingTierAction} actions={<Button type="submit">{ts.create}</Button>}>
<div className="space-y-4">
<div className="grid grid-cols-2 items-end gap-4">
<AdminField name="minQuantity" label={ts.minQuantityLabel} type="number" min={0} required placeholder="0" />
<AdminField name="maxQuantity" label={ts.maxQuantityLabel} type="number" min={0} placeholder="3" />
</div>
<Field name="priceCents" label={t.adminShippingNewPage.priceLabel} type="number" required placeholder="700" />
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
{t.adminShippingNewPage.create}
</button>
</form>
<AdminField name="priceCents" label={ts.priceLabel} type="number" min={0} required placeholder="700" />
</div>
</AdminFormPanel>
</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>
);
}
+10
View File
@@ -13,6 +13,8 @@ export function AdminField({
defaultValue,
placeholder,
required = false,
min,
hint,
}: {
name: string;
label: string;
@@ -20,6 +22,9 @@ export function AdminField({
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">
@@ -30,8 +35,10 @@ export function AdminField({
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>
);
}
@@ -61,11 +68,13 @@ export function AdminSelect({
name,
label,
defaultValue,
hint,
children,
}: {
name: string;
label: string;
defaultValue?: string;
hint?: string;
children: ReactNode;
}) {
return (
@@ -74,6 +83,7 @@ export function AdminSelect({
<select name={name} defaultValue={defaultValue} className={inputClass}>
{children}
</select>
{hint ? <span className="mt-1 block text-xs text-ink/65">{hint}</span> : null}
</label>
);
}
+11
View File
@@ -725,6 +725,9 @@ export const de: Dictionary = {
noVouchersYet: "Noch keine Gutscheine angelegt.",
},
adminVoucherEditPage: {
sectionDiscount: "Code und Rabatt",
sectionConditions: "Bedingungen",
sectionValidity: "Zeitraum und Status",
backToVouchers: "Zurück zu Gutscheinen",
title: "Gutschein bearbeiten",
codeLabel: "Code",
@@ -742,6 +745,9 @@ export const de: Dictionary = {
save: "Speichern",
},
adminVoucherNewPage: {
sectionDiscount: "Code und Rabatt",
sectionConditions: "Bedingungen",
sectionValidity: "Zeitraum und Status",
backToVouchers: "Zurück zu Gutscheinen",
title: "Neuer Gutschein",
codeLabel: "Code",
@@ -818,6 +824,8 @@ export const de: Dictionary = {
noUsersYet: "Noch keine Benutzer angelegt.",
},
adminUserEditPage: {
sectionProfile: "Stammdaten",
sectionDanger: "Konto sperren oder löschen",
backToUsers: "Zurück zu Benutzern",
createdOn: (date) => `Konto erstellt am ${date}`,
firstNameLabel: "Vorname",
@@ -881,6 +889,7 @@ export const de: Dictionary = {
create: "Anlegen",
},
adminBatchEditPage: {
sectionDetails: "Abfüllung und Bestand",
backToBatches: "Zurück zu Chargen",
title: "Charge bearbeiten",
fillDateLabel: "Abfülldatum",
@@ -988,6 +997,8 @@ export const de: Dictionary = {
create: "Anlegen",
},
adminHarvestEditPage: {
sectionDetails: "Angaben",
sectionVisibility: "Sichtbarkeit",
backToHarvests: "Zurück zu Ernten",
title: "Ernte bearbeiten",
typeLabel: "Typ",
+11
View File
@@ -713,6 +713,9 @@ export const en: Dictionary = {
noVouchersYet: "No vouchers created yet.",
},
adminVoucherEditPage: {
sectionDiscount: "Code and discount",
sectionConditions: "Conditions",
sectionValidity: "Period and status",
backToVouchers: "Back to vouchers",
title: "Edit voucher",
codeLabel: "Code",
@@ -730,6 +733,9 @@ export const en: Dictionary = {
save: "Save",
},
adminVoucherNewPage: {
sectionDiscount: "Code and discount",
sectionConditions: "Conditions",
sectionValidity: "Period and status",
backToVouchers: "Back to vouchers",
title: "New voucher",
codeLabel: "Code",
@@ -806,6 +812,8 @@ export const en: Dictionary = {
noUsersYet: "No users created yet.",
},
adminUserEditPage: {
sectionProfile: "Profile",
sectionDanger: "Block or delete account",
backToUsers: "Back to users",
createdOn: (date) => `Account created on ${date}`,
firstNameLabel: "First name",
@@ -869,6 +877,7 @@ export const en: Dictionary = {
create: "Create",
},
adminBatchEditPage: {
sectionDetails: "Filling and stock",
backToBatches: "Back to batches",
title: "Edit batch",
fillDateLabel: "Fill date",
@@ -975,6 +984,8 @@ export const en: Dictionary = {
create: "Create",
},
adminHarvestEditPage: {
sectionDetails: "Details",
sectionVisibility: "Visibility",
backToHarvests: "Back to harvests",
title: "Edit harvest",
typeLabel: "Type",
+11
View File
@@ -404,6 +404,9 @@ export interface Dictionary {
noVouchersYet: string;
};
adminVoucherEditPage: {
sectionDiscount: string;
sectionConditions: string;
sectionValidity: string;
backToVouchers: string;
title: string;
codeLabel: string;
@@ -421,6 +424,9 @@ export interface Dictionary {
save: string;
};
adminVoucherNewPage: {
sectionDiscount: string;
sectionConditions: string;
sectionValidity: string;
backToVouchers: string;
title: string;
codeLabel: string;
@@ -496,6 +502,8 @@ export interface Dictionary {
noUsersYet: string;
};
adminUserEditPage: {
sectionProfile: string;
sectionDanger: string;
backToUsers: string;
createdOn: (date: string) => string;
firstNameLabel: string;
@@ -558,6 +566,7 @@ export interface Dictionary {
create: string;
};
adminBatchEditPage: {
sectionDetails: string;
backToBatches: string;
title: string;
fillDateLabel: string;
@@ -663,6 +672,8 @@ export interface Dictionary {
/** created, skipped, failed */
};
adminHarvestEditPage: {
sectionDetails: string;
sectionVisibility: string;
backToHarvests: string;
title: string;
typeLabel: string;