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]>
160 lines
7.0 KiB
TypeScript
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>
|
|
);
|
|
}
|