Files
Wabenhain/src/app/admin/ernten/page.tsx
T
Nick SzaunigandClaude Opus 5 92ac826baa feat: ein Knopf synchronisiert Ernten, Chargen und Produkte zusammen
Bisher gab es zwei Knoepfe auf zwei Seiten, und ihre Reihenfolge war nicht
beliebig: eine Charge braucht ihre Ernte als Pflicht-Fremdschluessel, ein
Produkt entsteht je Abfuellung und braucht die zugehoerige Charge. Wer mit
den Produkten anfing, bekam reihenweise "wartet auf den Ernten-Sync" und
musste danach noch einmal klicken. Diese Reihenfolge gehoert in den Ablauf,
nicht in den Kopf der bedienenden Person.

syncApiCoreAction ersetzt beide Actions und laeuft Ernten -> Chargen ->
Produkte in einem Durchgang. "Alles synchronisieren" steht auf beiden Seiten
und springt danach dorthin zurueck, von wo er ausgeloest wurde. Das Ziel
kommt aus einem Formularfeld und wird gegen eine feste Liste geprueft — ein
ungeprueftes returnTo waere eine offene Weiterleitung.

Nebeneffekt: die Abfuellungen werden nur noch EINMAL von ApiCore geladen.
Vorher holten Chargen- und Produkt-Sync dieselbe Liste getrennt.

Knopf und Ergebnisanzeige liegen jetzt in einer gemeinsamen Komponente
(components/apicore-sync-panel.tsx) statt zweimal in zwei Seiten. Die
Ergebnisse stehen als ein Block beieinander, je eine Zeile fuer Ernten,
Chargen und Produkte, plus die beiden Hinweise (Abfuellung ohne Charge,
ausgelaufener Artikel) als Hinweis statt als Fehler.

Damit wurden zehn i18n-Schluessel ueberfluessig, die vorher die beiden
getrennten Anzeigen bedienten; sie sind aus dem Vertrag und beiden
Sprachdateien entfernt.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 18:26:09 +02:00

160 lines
7.0 KiB
TypeScript

import Link from "next/link";
import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link";
import { ConfirmForm } from "@/components/confirm-form";
import { formatDate } from "@/lib/format";
import { createHarvestAction, toggleHarvestPublishedAction, deleteHarvestAction } from "../actions";
import { ApiCoreSyncPanel, ApiCoreSyncResult } from "@/components/apicore-sync-panel";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten">) {
const t = getDictionary(await getLocale());
const searchParams = await props.searchParams;
const error = searchParams.error;
const harvests = await prisma.harvest.findMany({
orderBy: [{ year: "desc" }, { harvestDate: "desc" }],
include: { _count: { select: { batches: true, products: true } } },
});
return (
<div>
<BackLink href="/admin" label={t.adminHarvestsPage.backToDashboard} />
<h1 className="text-2xl text-forest mb-8">{t.adminHarvestsPage.title}</h1>
{error ? (
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
) : null}
<ApiCoreSyncResult searchParams={searchParams} />
<div className="overflow-x-auto bg-background border border-ink/10">
<table className="w-full text-sm">
<thead className="text-left text-ink/65 border-b border-ink/10">
<tr>
<th className="p-4">{t.adminHarvestsPage.colType}</th>
<th className="p-4">{t.adminHarvestsPage.colYear}</th>
<th className="p-4">{t.adminHarvestsPage.colHarvestDate}</th>
<th className="p-4">{t.adminHarvestsPage.colLocation}</th>
<th className="p-4">{t.adminHarvestsPage.colBatches}</th>
<th className="p-4">{t.adminHarvestsPage.colProducts}</th>
<th className="p-4">{t.adminHarvestsPage.colStatus}</th>
<th className="p-4" />
</tr>
</thead>
<tbody className="divide-y divide-ink/10">
{harvests.map((harvest) => (
<tr key={harvest.id} className="transition-colors hover:bg-ink/[0.03]">
<td className="p-4 text-forest">{harvest.type}</td>
<td className="p-4">{harvest.year}</td>
<td className="p-4">{formatDate(harvest.harvestDate)}</td>
<td className="p-4">{harvest.location}</td>
<td className="p-4">{harvest._count.batches}</td>
<td className="p-4">{harvest._count.products}</td>
<td className="p-4">
<span
className={
harvest.isPublished
? "text-forest bg-forest/10 px-2 py-1 text-xs"
: "text-honey-dark bg-honey/10 px-2 py-1 text-xs"
}
>
{harvest.isPublished ? t.adminHarvestsPage.statusPublished : t.adminHarvestsPage.statusDraft}
</span>
</td>
<td className="p-4 whitespace-nowrap">
<Link href={`/admin/ernten/${harvest.id}`} className="text-forest underline mr-4">
{t.adminHarvestsPage.edit}
</Link>
<form action={toggleHarvestPublishedAction} className="inline">
<input type="hidden" name="id" value={harvest.id} />
{harvest.isPublished ? null : <input type="hidden" name="isPublished" value="on" />}
<button type="submit" className="text-forest underline mr-4">
{harvest.isPublished ? t.adminHarvestsPage.unpublish : t.adminHarvestsPage.publish}
</button>
</form>
{harvest._count.batches === 0 && harvest._count.products === 0 ? (
<ConfirmForm
action={deleteHarvestAction}
confirmMessage={t.adminHarvestsPage.deleteConfirm(`${harvest.type} ${harvest.year}`)}
className="inline"
>
<input type="hidden" name="id" value={harvest.id} />
<button type="submit" className="text-red-700 underline">
{t.adminHarvestsPage.delete}
</button>
</ConfirmForm>
) : (
// Statt den Knopf wortlos wegzulassen: der Grund steht da.
<span className="text-xs text-ink/50">{t.adminHarvestsPage.notDeletable}</span>
)}
</td>
</tr>
))}
</tbody>
</table>
{harvests.length === 0 ? <p className="p-6 text-ink/65">{t.adminHarvestsPage.noHarvestsYet}</p> : null}
</div>
<div className="mt-10 grid md:grid-cols-2 gap-6">
<div className="bg-background border border-ink/10 p-6">
<h2 className="text-lg text-forest mb-4">{t.adminHarvestsPage.newHarvestHeading}</h2>
<form action={createHarvestAction} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<Field name="type" label={t.adminHarvestsPage.typeLabel} required />
<Field name="year" label={t.adminHarvestsPage.yearLabel} type="number" required />
</div>
<Field name="harvestDate" label={t.adminHarvestsPage.harvestDateLabel} type="date" required />
<Field name="location" label={t.adminHarvestsPage.locationLabel} defaultValue="Altenmünster" />
<TextArea name="description" label={t.adminHarvestsPage.descriptionLabel} required />
<label className="flex items-center gap-2 text-sm text-ink/70">
<input type="checkbox" name="isPublished" defaultChecked />
{t.adminHarvestsPage.isPublishedLabel}
</label>
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
{t.adminHarvestsPage.create}
</button>
</form>
</div>
<ApiCoreSyncPanel returnTo="/admin/ernten" />
</div>
</div>
);
}
function Field({
name,
label,
type = "text",
defaultValue,
required = false,
}: {
name: string;
label: string;
type?: string;
defaultValue?: string;
required?: boolean;
}) {
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<input
name={name}
type={type}
defaultValue={defaultValue}
required={required}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/>
</label>
);
}
function TextArea({ name, label, required = false }: { name: string; label: string; required?: boolean }) {
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<textarea name={name} required={required} rows={3} className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" />
</label>
);
}