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]>
This commit is contained in:
1 parent
8f0f06fd13
commit
92ac826baa
8 files changed
+246
-213
No files matched your search
@@ -194,11 +194,15 @@ Die serverseitige Prüfung läuft über `src/lib/turnstile.ts` gegen die Cloudfl
|
|||||||
|
|
||||||
ApiCore ist das separate Imkerei-Verwaltungssystem (NestJS-Backend mit eigener Datenbank), in dem Völker, Ernten, Schleuderungen und Abfüllungen gepflegt werden. Wabenhain kann drei Datenarten daraus übernehmen, damit dieselbe Charge nicht zweimal von Hand eingetippt wird:
|
ApiCore ist das separate Imkerei-Verwaltungssystem (NestJS-Backend mit eigener Datenbank), in dem Völker, Ernten, Schleuderungen und Abfüllungen gepflegt werden. Wabenhain kann drei Datenarten daraus übernehmen, damit dieselbe Charge nicht zweimal von Hand eingetippt wird:
|
||||||
|
|
||||||
| ApiCore | Wabenhain | Knopf |
|
**Ein Knopf für alles:** „Alles synchronisieren" steht sowohl unter `/admin/ernten` als auch unter `/admin/produkte` und löst denselben Vorgang aus — es geht danach nur auf die Seite zurück, von der er kam.
|
||||||
|---------|-----------|-------|
|
|
||||||
| `Harvest` (Ernte) | `Harvest` | `/admin/ernten` → „ApiCore-Synchronisierung" |
|
| Schritt | ApiCore | Wabenhain |
|
||||||
| `BottlingBatch` (Abfüllung) | `Batch` (Charge) | derselbe Knopf, läuft direkt nach den Ernten |
|
|---------|---------|-----------|
|
||||||
| `BottlingBatch` → je Abfüllung **ein** `Product` | `Product` (ein Jahrgang) | `/admin/produkte` → „ApiCore-Synchronisierung" |
|
| 1 | `Harvest` (Ernte) | `Harvest` |
|
||||||
|
| 2 | `BottlingBatch` (Abfüllung) | `Batch` (Charge) |
|
||||||
|
| 3 | `BottlingBatch` → je Abfüllung **ein** `Product` | `Product` (ein Jahrgang) |
|
||||||
|
|
||||||
|
Die Reihenfolge ist zwingend, weil jede Stufe auf der vorherigen aufbaut: eine Charge braucht ihre Ernte als Pflicht-Fremdschlüssel, ein Produkt entsteht je Abfüllung und braucht die zugehörige Charge. Genau deshalb steckt sie im Ablauf und nicht im Kopf der bedienenden Person — getrennt ausgelöst musste man sie kennen und zweimal klicken, sonst meldete der erste Produkt-Lauf reihenweise fehlende Chargen. Nebenbei werden die Abfüllungen nur noch einmal von ApiCore geladen statt zweimal.
|
||||||
|
|
||||||
**Grundregel: ApiCore liefert die Rohdaten, Wabenhain trifft die Verkaufsentscheidungen.** Ein Sync überschreibt deshalb nie etwas, das im Admin bewusst gesetzt wurde:
|
**Grundregel: ApiCore liefert die Rohdaten, Wabenhain trifft die Verkaufsentscheidungen.** Ein Sync überschreibt deshalb nie etwas, das im Admin bewusst gesetzt wurde:
|
||||||
|
|
||||||
@@ -211,7 +215,7 @@ ApiCore ist das separate Imkerei-Verwaltungssystem (NestJS-Backend mit eigener D
|
|||||||
|
|
||||||
Ein bereits vorhandener Jahrgang wird übersprungen, nie aktualisiert: genau die Felder, die ApiCore liefert (Name, Beschreibung, Gewicht), zieht der Admin vor der Freischaltung von Hand nach, und ein Update würde diese Arbeit bei jedem weiteren Lauf wegwerfen. Soll ein Jahrgang wirklich frisch aus ApiCore kommen, löscht man ihn in Wabenhain und synchronisiert erneut.
|
Ein bereits vorhandener Jahrgang wird übersprungen, nie aktualisiert: genau die Felder, die ApiCore liefert (Name, Beschreibung, Gewicht), zieht der Admin vor der Freischaltung von Hand nach, und ein Update würde diese Arbeit bei jedem weiteren Lauf wegwerfen. Soll ein Jahrgang wirklich frisch aus ApiCore kommen, löscht man ihn in Wabenhain und synchronisiert erneut.
|
||||||
|
|
||||||
**Reihenfolge:** die Charge muss schon da sein, sonst gäbe es nichts, woran der Jahrgang hängt. Erst „Ernten" synchronisieren, dann „Produkte" — fehlende Chargen meldet der Lauf als „wartet auf den Ernten-Sync", nicht als Fehler.
|
Findet sich zu einer Abfüllung keine Charge, meldet der Lauf das als Hinweis statt als Fehler — meist steht dahinter eine Ernte, die in ApiCore noch fehlt.
|
||||||
- **Chargen** werden ausschließlich neu angelegt, nie aktualisiert. Ein Update würde `remainingGlasses` überschreiben — das ist Wabenhains eigener, durch echte Verkäufe veränderter Lagerbestand.
|
- **Chargen** werden ausschließlich neu angelegt, nie aktualisiert. Ein Update würde `remainingGlasses` überschreiben — das ist Wabenhains eigener, durch echte Verkäufe veränderter Lagerbestand.
|
||||||
- Charge und Ernte hängen von Anfang an am Jahrgang — er entsteht ja aus genau einer Abfüllung. Das ist die Verknüpfung, die die Herkunftsseite hinter dem QR-Code auf dem Glas füttert. Eine Abfüllung, die in ApiCore keinem Artikel zugeordnet ist (etwa Rohware für den Direktverkauf ab Hof), wird bewusst übergangen: daraus ließe sich kein Shop-Produkt bilden, es gäbe weder Namen noch Artikelnummer.
|
- Charge und Ernte hängen von Anfang an am Jahrgang — er entsteht ja aus genau einer Abfüllung. Das ist die Verknüpfung, die die Herkunftsseite hinter dem QR-Code auf dem Glas füttert. Eine Abfüllung, die in ApiCore keinem Artikel zugeordnet ist (etwa Rohware für den Direktverkauf ab Hof), wird bewusst übergangen: daraus ließe sich kein Shop-Produkt bilden, es gäbe weder Namen noch Artikelnummer.
|
||||||
- Ein in ApiCore ausgelaufenes Produkt (`DISCONTINUED`) wird **nicht** automatisch deaktiviert: solange Gläser im Regal stehen, ist „nicht mehr produziert" kein Grund, den Abverkauf zu stoppen. Der Fall wird stattdessen im Ergebnis gemeldet.
|
- Ein in ApiCore ausgelaufenes Produkt (`DISCONTINUED`) wird **nicht** automatisch deaktiviert: solange Gläser im Regal stehen, ist „nicht mehr produziert" kein Grund, den Abverkauf zu stoppen. Der Fall wird stattdessen im Ergebnis gemeldet.
|
||||||
|
|||||||
@@ -359,53 +359,60 @@ async function syncBatches(sources: ApiCoreBottlingBatchWithHarvestId[]): Promis
|
|||||||
return counts;
|
return counts;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Chargen laden und importieren. Ein Fehlschlag beim LADEN ist bewusst nicht
|
|
||||||
* fatal fuer den umgebenden Sync-Lauf: die bereits erfolgreich uebernommenen
|
|
||||||
* Ernten sollen sichtbar bleiben, statt hinter einer Fehlerseite zu
|
|
||||||
* verschwinden.
|
|
||||||
*
|
|
||||||
* Eine nachtraegliche Verknuepfung Produkt<->Charge gibt es nicht mehr: seit
|
|
||||||
* je Abfuellung ein eigenes Produkt entsteht, wird es bereits mit Charge und
|
|
||||||
* Ernte angelegt (siehe syncProducts).
|
|
||||||
*/
|
|
||||||
async function loadAndSyncBatches(
|
|
||||||
session: ApiCoreSession
|
|
||||||
): Promise<{ counts: SyncCounts; loadError: string | null }> {
|
|
||||||
let sources: ApiCoreBottlingBatchWithHarvestId[];
|
|
||||||
try {
|
|
||||||
sources = await fetchApiCoreBottlingBatches(session);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
return {
|
|
||||||
counts: { created: 0, updated: 0, skipped: 0, failed: 0 },
|
|
||||||
loadError: describeError(error),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { counts: await syncBatches(sources), loadError: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Server Actions
|
// Server Actions
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sync-Knopf auf /admin/ernten: zieht Ernten und die daraus entstandenen
|
* Ziele, zu denen nach dem Sync zurueckgesprungen werden darf.
|
||||||
* Chargen. Chargen laufen NACH den Ernten, damit eine gerade erst angelegte
|
*
|
||||||
* Ernte als Pflicht-Fremdschluessel schon bereitsteht.
|
* Feste Liste statt des uebergebenen Werts: "returnTo" kommt aus einem
|
||||||
|
* Formularfeld und waere sonst eine offene Weiterleitung — ein praeparierter
|
||||||
|
* Wert koennte Admins auf eine fremde Seite schicken.
|
||||||
*/
|
*/
|
||||||
export async function syncHarvestsFromApiCoreAction(): Promise<void> {
|
const RETURN_TARGETS = ["/admin/ernten", "/admin/produkte", "/admin/chargen"] as const;
|
||||||
|
|
||||||
|
function returnTargetFrom(formData: FormData): string {
|
||||||
|
const requested = formData.get("returnTo");
|
||||||
|
return RETURN_TARGETS.find((target) => target === requested) ?? "/admin/ernten";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EIN Knopf fuer alles: zieht Ernten, daraus die Chargen und daraus die
|
||||||
|
* Produkte — in genau dieser Reihenfolge, weil jede Stufe auf der
|
||||||
|
* vorherigen aufbaut.
|
||||||
|
*
|
||||||
|
* Eine Charge braucht ihre Ernte als Pflicht-Fremdschluessel, und ein
|
||||||
|
* Produkt entsteht je Abfuellung und braucht die zugehoerige Charge. Wer
|
||||||
|
* das getrennt ausloest, muss die Reihenfolge kennen und zweimal klicken;
|
||||||
|
* beim ersten Durchlauf meldete der Produkt-Sync sonst reihenweise "wartet
|
||||||
|
* auf den Ernten-Sync". Genau diese Stolperfalle faellt damit weg.
|
||||||
|
*
|
||||||
|
* Nebeneffekt: die Abfuellungen werden nur EINMAL von ApiCore geladen statt
|
||||||
|
* zweimal — sie werden fuer Chargen und Produkte gleichermassen gebraucht.
|
||||||
|
*
|
||||||
|
* Ein Fehlschlag beim Laden der Chargen ist bewusst nicht fatal: die bereits
|
||||||
|
* uebernommenen Ernten sollen sichtbar bleiben. Ohne Abfuellungen entfallen
|
||||||
|
* dann allerdings auch die Produkte, denn ohne sie gibt es keine Jahrgaenge.
|
||||||
|
*/
|
||||||
|
export async function syncApiCoreAction(formData: FormData): Promise<void> {
|
||||||
const admin = await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
|
const returnTo = returnTargetFrom(formData);
|
||||||
|
|
||||||
let session: ApiCoreSession;
|
let session: ApiCoreSession;
|
||||||
let sourceHarvests: ApiCoreHarvestWithApiaryName[];
|
let sourceHarvests: ApiCoreHarvestWithApiaryName[];
|
||||||
|
let articles: ApiCoreProduct[];
|
||||||
try {
|
try {
|
||||||
session = await openApiCoreSession();
|
session = await openApiCoreSession();
|
||||||
sourceHarvests = await fetchApiCoreHarvests(session);
|
[sourceHarvests, articles] = await Promise.all([
|
||||||
|
fetchApiCoreHarvests(session),
|
||||||
|
fetchApiCoreProducts(session),
|
||||||
|
]);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
redirect(`/admin/ernten?syncError=${encodeURIComponent(describeError(error))}`);
|
redirect(`${returnTo}?syncError=${encodeURIComponent(describeError(error))}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1. Ernten
|
||||||
const harvests = await syncHarvests(sourceHarvests);
|
const harvests = await syncHarvests(sourceHarvests);
|
||||||
await logAdminAction(admin, {
|
await logAdminAction(admin, {
|
||||||
action: "harvest.sync_apicore",
|
action: "harvest.sync_apicore",
|
||||||
@@ -413,64 +420,45 @@ export async function syncHarvestsFromApiCoreAction(): Promise<void> {
|
|||||||
detail: `${harvests.created} neu, ${harvests.updated} aktualisiert, ${harvests.skipped} übersprungen, ${harvests.failed} fehlgeschlagen`,
|
detail: `${harvests.created} neu, ${harvests.updated} aktualisiert, ${harvests.skipped} übersprungen, ${harvests.failed} fehlgeschlagen`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const batches = await loadAndSyncBatches(session);
|
// 2. Chargen — Abfuellungen einmal laden und fuer Schritt 3 weiterreichen.
|
||||||
|
let bottlings: ApiCoreBottlingBatchWithHarvestId[] = [];
|
||||||
|
let batchLoadError: string | null = null;
|
||||||
|
try {
|
||||||
|
bottlings = await fetchApiCoreBottlingBatches(session);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
batchLoadError = describeError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const batches = batchLoadError
|
||||||
|
? { created: 0, updated: 0, skipped: 0, failed: 0 }
|
||||||
|
: await syncBatches(bottlings);
|
||||||
await logAdminAction(admin, {
|
await logAdminAction(admin, {
|
||||||
action: "batch.sync_apicore",
|
action: "batch.sync_apicore",
|
||||||
entityType: "Batch",
|
entityType: "Batch",
|
||||||
detail: batches.loadError
|
detail: batchLoadError
|
||||||
? `Chargen konnten nicht geladen werden: ${batches.loadError}`
|
? `Chargen konnten nicht geladen werden: ${batchLoadError}`
|
||||||
: `${batches.counts.created} neu, ${batches.counts.skipped} übersprungen, ${batches.counts.failed} fehlgeschlagen`,
|
: `${batches.created} neu, ${batches.skipped} übersprungen, ${batches.failed} fehlgeschlagen`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Produkte — je Abfuellung ein Jahrgang, jetzt mit vorhandenen Chargen.
|
||||||
|
const products = await syncProducts(articles, bottlings);
|
||||||
|
await logAdminAction(admin, {
|
||||||
|
action: "product.sync_apicore",
|
||||||
|
entityType: "Product",
|
||||||
|
detail:
|
||||||
|
`${products.created} neu, ${products.skipped} übersprungen, ${products.failed} fehlgeschlagen, ` +
|
||||||
|
`${products.pending} warten auf die Charge, ${products.discontinued} in ApiCore ausgelaufen`,
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/admin/ernten");
|
revalidatePath("/admin/ernten");
|
||||||
revalidatePath("/admin/chargen");
|
revalidatePath("/admin/chargen");
|
||||||
revalidatePath("/admin/produkte");
|
revalidatePath("/admin/produkte");
|
||||||
redirect(
|
redirect(
|
||||||
`/admin/ernten?synced=${harvests.created}&updated=${harvests.updated}&skipped=${harvests.skipped}&failed=${harvests.failed}` +
|
`${returnTo}?syncDone=1` +
|
||||||
`&batchSynced=${batches.counts.created}&batchSkipped=${batches.counts.skipped}&batchFailed=${batches.counts.failed}` +
|
`&hNew=${harvests.created}&hUpdated=${harvests.updated}&hSkipped=${harvests.skipped}&hFailed=${harvests.failed}` +
|
||||||
(batches.loadError ? `&batchLoadError=${encodeURIComponent(batches.loadError)}` : "")
|
`&bNew=${batches.created}&bSkipped=${batches.skipped}&bFailed=${batches.failed}` +
|
||||||
);
|
`&pNew=${products.created}&pSkipped=${products.skipped}&pFailed=${products.failed}` +
|
||||||
}
|
`&pPending=${products.pending}&pDiscontinued=${products.discontinued}` +
|
||||||
|
(batchLoadError ? `&batchLoadError=${encodeURIComponent(batchLoadError)}` : "")
|
||||||
/**
|
|
||||||
* Sync-Knopf auf /admin/produkte: legt je ApiCore-ABFUELLUNG ein Produkt an,
|
|
||||||
* also einen eigenen Jahrgang (siehe syncProducts).
|
|
||||||
*
|
|
||||||
* Braucht dafuer beides: den Artikelkatalog (Name, Artikelnummer) und die
|
|
||||||
* Abfuellungen (Jahrgang, Glasgroesse, Losnummer). Chargen selbst werden hier
|
|
||||||
* NICHT importiert — das gehoert zum Ernten-Sync, weil eine Charge ohne ihre
|
|
||||||
* Ernte gar nicht anlegbar ist. Fehlt die Charge noch, meldet der Lauf das
|
|
||||||
* als "wartet auf den Ernten-Sync" statt als Fehler.
|
|
||||||
*/
|
|
||||||
export async function syncProductsFromApiCoreAction(): Promise<void> {
|
|
||||||
const admin = await requireAdmin();
|
|
||||||
|
|
||||||
let session: ApiCoreSession;
|
|
||||||
let articles: ApiCoreProduct[];
|
|
||||||
let bottlings: ApiCoreBottlingBatchWithHarvestId[];
|
|
||||||
try {
|
|
||||||
session = await openApiCoreSession();
|
|
||||||
[articles, bottlings] = await Promise.all([
|
|
||||||
fetchApiCoreProducts(session),
|
|
||||||
fetchApiCoreBottlingBatches(session),
|
|
||||||
]);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
redirect(`/admin/produkte?syncError=${encodeURIComponent(describeError(error))}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const products = await syncProducts(articles, bottlings);
|
|
||||||
|
|
||||||
await logAdminAction(admin, {
|
|
||||||
action: "product.sync_apicore",
|
|
||||||
entityType: "Product",
|
|
||||||
detail:
|
|
||||||
`${products.created} neu, ${products.skipped} übersprungen, ${products.failed} fehlgeschlagen, ` +
|
|
||||||
`${products.pending} warten auf den Ernten-Sync, ${products.discontinued} in ApiCore ausgelaufen`,
|
|
||||||
});
|
|
||||||
|
|
||||||
revalidatePath("/admin/produkte");
|
|
||||||
redirect(
|
|
||||||
`/admin/produkte?synced=${products.created}&skipped=${products.skipped}` +
|
|
||||||
`&failed=${products.failed}&pending=${products.pending}&discontinued=${products.discontinued}`
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,19 +3,15 @@ import { prisma } from "@/lib/prisma";
|
|||||||
import { BackLink } from "@/components/back-link";
|
import { BackLink } from "@/components/back-link";
|
||||||
import { ConfirmForm } from "@/components/confirm-form";
|
import { ConfirmForm } from "@/components/confirm-form";
|
||||||
import { formatDate } from "@/lib/format";
|
import { formatDate } from "@/lib/format";
|
||||||
import {
|
import { createHarvestAction, toggleHarvestPublishedAction, deleteHarvestAction } from "../actions";
|
||||||
createHarvestAction,
|
import { ApiCoreSyncPanel, ApiCoreSyncResult } from "@/components/apicore-sync-panel";
|
||||||
toggleHarvestPublishedAction,
|
|
||||||
syncHarvestsFromApiCoreAction,
|
|
||||||
deleteHarvestAction,
|
|
||||||
} 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 AdminHarvestsPage(props: PageProps<"/admin/ernten">) {
|
export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten">) {
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
const { synced, updated, skipped, failed, syncError, batchSynced, batchSkipped, batchFailed, batchLoadError, error } =
|
const searchParams = await props.searchParams;
|
||||||
await props.searchParams;
|
const error = searchParams.error;
|
||||||
const harvests = await prisma.harvest.findMany({
|
const harvests = await prisma.harvest.findMany({
|
||||||
orderBy: [{ year: "desc" }, { harvestDate: "desc" }],
|
orderBy: [{ year: "desc" }, { harvestDate: "desc" }],
|
||||||
include: { _count: { select: { batches: true, products: true } } },
|
include: { _count: { select: { batches: true, products: true } } },
|
||||||
@@ -29,26 +25,7 @@ export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten"
|
|||||||
{error ? (
|
{error ? (
|
||||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||||
) : null}
|
) : null}
|
||||||
{synced !== undefined ? (
|
<ApiCoreSyncResult searchParams={searchParams} />
|
||||||
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">
|
|
||||||
{t.adminHarvestsPage.syncResult(Number(synced), Number(updated ?? 0), Number(skipped ?? 0), Number(failed ?? 0))}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{syncError !== undefined ? (
|
|
||||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
|
|
||||||
{t.adminHarvestsPage.syncError} ({syncError})
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{batchSynced !== undefined ? (
|
|
||||||
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">
|
|
||||||
{t.adminHarvestsPage.batchSyncResult(Number(batchSynced), Number(batchSkipped ?? 0), Number(batchFailed ?? 0))}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{batchLoadError !== undefined ? (
|
|
||||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
|
|
||||||
{t.adminHarvestsPage.batchSyncError} ({batchLoadError})
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="overflow-x-auto bg-background border border-ink/10">
|
<div className="overflow-x-auto bg-background border border-ink/10">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
@@ -139,15 +116,7 @@ export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten"
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-background border border-ink/10 p-6">
|
<ApiCoreSyncPanel returnTo="/admin/ernten" />
|
||||||
<h2 className="text-lg text-forest mb-4">{t.adminHarvestsPage.syncHeading}</h2>
|
|
||||||
<p className="text-sm text-ink/65 mb-4">{t.adminHarvestsPage.syncIntro}</p>
|
|
||||||
<form action={syncHarvestsFromApiCoreAction}>
|
|
||||||
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
|
||||||
{t.adminHarvestsPage.syncButton}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ import { prisma } from "@/lib/prisma";
|
|||||||
import { BackLink } from "@/components/back-link";
|
import { BackLink } from "@/components/back-link";
|
||||||
import { formatPrice } from "@/lib/format";
|
import { formatPrice } from "@/lib/format";
|
||||||
import { ConfirmForm } from "@/components/confirm-form";
|
import { ConfirmForm } from "@/components/confirm-form";
|
||||||
import { deleteProductAction, syncProductsFromApiCoreAction } from "../actions";
|
import { deleteProductAction } from "../actions";
|
||||||
|
import { ApiCoreSyncPanel, ApiCoreSyncResult } from "@/components/apicore-sync-panel";
|
||||||
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 AdminProductsPage(props: PageProps<"/admin/produkte">) {
|
export default async function AdminProductsPage(props: PageProps<"/admin/produkte">) {
|
||||||
const t = getDictionary(await getLocale());
|
const t = getDictionary(await getLocale());
|
||||||
const { synced, skipped, failed, discontinued, pending, syncError } = await props.searchParams;
|
const searchParams = await props.searchParams;
|
||||||
const products = await prisma.product.findMany({
|
const products = await prisma.product.findMany({
|
||||||
include: {
|
include: {
|
||||||
harvest: true,
|
harvest: true,
|
||||||
@@ -31,28 +32,7 @@ export default async function AdminProductsPage(props: PageProps<"/admin/produkt
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{synced !== undefined ? (
|
<ApiCoreSyncResult searchParams={searchParams} />
|
||||||
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">
|
|
||||||
{t.adminProductsPage.syncResult(Number(synced), Number(skipped ?? 0), Number(failed ?? 0))}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{/* "pending" ist kein Fehler, sondern eine Reihenfolge: die Charge des
|
|
||||||
Jahrgangs kommt ueber den Ernten-Sync. Deshalb als Hinweis, nicht rot. */}
|
|
||||||
{Number(pending ?? 0) > 0 ? (
|
|
||||||
<p className="mb-6 text-sm text-honey-dark bg-honey/10 border border-honey/30 px-4 py-3">
|
|
||||||
{t.adminProductsPage.syncPending(Number(pending))}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{Number(discontinued ?? 0) > 0 ? (
|
|
||||||
<p className="mb-6 text-sm text-honey-dark bg-honey/10 border border-honey/30 px-4 py-3">
|
|
||||||
{t.adminProductsPage.syncDiscontinued(Number(discontinued))}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{syncError !== undefined ? (
|
|
||||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
|
|
||||||
{t.adminProductsPage.syncError} ({syncError})
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="overflow-x-auto bg-background border border-ink/10">
|
<div className="overflow-x-auto bg-background border border-ink/10">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
@@ -124,14 +104,8 @@ export default async function AdminProductsPage(props: PageProps<"/admin/produkt
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-10 bg-background border border-ink/10 p-6 max-w-3xl">
|
<div className="mt-10 max-w-3xl">
|
||||||
<h2 className="text-lg text-forest mb-4">{t.adminProductsPage.syncHeading}</h2>
|
<ApiCoreSyncPanel returnTo="/admin/produkte" />
|
||||||
<p className="text-sm text-ink/65 mb-4">{t.adminProductsPage.syncIntro}</p>
|
|
||||||
<form action={syncProductsFromApiCoreAction}>
|
|
||||||
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
|
||||||
{t.adminProductsPage.syncButton}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { syncApiCoreAction } from "@/app/admin/actions";
|
||||||
|
import { getLocale } from "@/i18n/locale";
|
||||||
|
import { getDictionary } from "@/i18n/get-dictionary";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der eine Sync-Knopf und die Anzeige seines Ergebnisses.
|
||||||
|
*
|
||||||
|
* Gemeinsam genutzt von /admin/ernten und /admin/produkte: es ist derselbe
|
||||||
|
* Vorgang, egal von welcher Seite er ausgeloest wird — Ernten, Chargen und
|
||||||
|
* Produkte laufen ohnehin immer zusammen. Zwei getrennte Bloecke in zwei
|
||||||
|
* Seiten waeren dieselbe Anzeige zweimal gepflegt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Rohwerte aus der Adresszeile — Next liefert Query-Parameter so aus. */
|
||||||
|
export type SyncSearchParams = Record<string, string | string[] | undefined>;
|
||||||
|
|
||||||
|
function count(value: string | string[] | undefined): number {
|
||||||
|
return Number(Array.isArray(value) ? value[0] : value) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value: string | string[] | undefined): string | undefined {
|
||||||
|
const first = Array.isArray(value) ? value[0] : value;
|
||||||
|
return first === undefined ? undefined : String(first);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ergebnis des letzten Laufs. Zeigt nichts an, solange gerade nicht
|
||||||
|
* synchronisiert wurde — die Seite soll nach einem spaeteren Aufruf nicht
|
||||||
|
* dauerhaft ein altes Ergebnis behaupten.
|
||||||
|
*/
|
||||||
|
export async function ApiCoreSyncResult({ searchParams }: { searchParams: SyncSearchParams }) {
|
||||||
|
const t = getDictionary(await getLocale()).adminApiCoreSync;
|
||||||
|
const syncError = text(searchParams.syncError);
|
||||||
|
const batchLoadError = text(searchParams.batchLoadError);
|
||||||
|
const done = text(searchParams.syncDone) !== undefined;
|
||||||
|
|
||||||
|
if (!done && syncError === undefined) return null;
|
||||||
|
|
||||||
|
const pending = count(searchParams.pPending);
|
||||||
|
const discontinued = count(searchParams.pDiscontinued);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{syncError !== undefined ? (
|
||||||
|
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
|
||||||
|
{t.error} ({syncError})
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{done ? (
|
||||||
|
<div className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3 space-y-1">
|
||||||
|
<p>
|
||||||
|
{t.resultHarvests(
|
||||||
|
count(searchParams.hNew),
|
||||||
|
count(searchParams.hUpdated),
|
||||||
|
count(searchParams.hSkipped),
|
||||||
|
count(searchParams.hFailed)
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p>{t.resultBatches(count(searchParams.bNew), count(searchParams.bSkipped), count(searchParams.bFailed))}</p>
|
||||||
|
<p>{t.resultProducts(count(searchParams.pNew), count(searchParams.pSkipped), count(searchParams.pFailed))}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{/* Beides sind Hinweise, keine Fehler: die Abfuellung ist in Ordnung,
|
||||||
|
es fehlt nur etwas drumherum bzw. der Artikel laeuft aus. */}
|
||||||
|
{pending > 0 ? (
|
||||||
|
<p className="mb-6 text-sm text-honey-dark bg-honey/10 border border-honey/30 px-4 py-3">
|
||||||
|
{t.pending(pending)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{discontinued > 0 ? (
|
||||||
|
<p className="mb-6 text-sm text-honey-dark bg-honey/10 border border-honey/30 px-4 py-3">
|
||||||
|
{t.discontinued(discontinued)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{batchLoadError !== undefined ? (
|
||||||
|
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
|
||||||
|
{t.batchLoadError} ({batchLoadError})
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Knopf selbst. "returnTo" bestimmt, auf welche Seite es danach
|
||||||
|
* zurueckgeht — die Action prueft den Wert gegen eine feste Liste, damit
|
||||||
|
* daraus keine offene Weiterleitung wird.
|
||||||
|
*/
|
||||||
|
export async function ApiCoreSyncPanel({ returnTo }: { returnTo: string }) {
|
||||||
|
const t = getDictionary(await getLocale()).adminApiCoreSync;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-background border border-ink/10 p-6">
|
||||||
|
<h2 className="text-lg text-forest mb-4">{t.heading}</h2>
|
||||||
|
<p className="text-sm text-ink/65 mb-4">{t.intro}</p>
|
||||||
|
<form action={syncApiCoreAction}>
|
||||||
|
<input type="hidden" name="returnTo" value={returnTo} />
|
||||||
|
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
||||||
|
{t.button}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+18
-21
@@ -903,16 +903,6 @@ export const de: Dictionary = {
|
|||||||
descriptionLabel: "Beschreibung",
|
descriptionLabel: "Beschreibung",
|
||||||
isPublishedLabel: "Sofort veröffentlichen",
|
isPublishedLabel: "Sofort veröffentlichen",
|
||||||
create: "Anlegen",
|
create: "Anlegen",
|
||||||
syncHeading: "ApiCore-Synchronisierung",
|
|
||||||
syncIntro:
|
|
||||||
"Importiert Ernten UND Chargen aus ApiCore. Ernten werden als Entwürfe angelegt (nicht öffentlich sichtbar); bereits veröffentlichte, zuvor synchronisierte Ernten werden dabei nicht verändert. Chargen werden nur neu angelegt (nie überschrieben, damit echte Lagerbestände erhalten bleiben) und brauchen eine bereits vorhandene Ernte. Die Produkte selbst kommen über den eigenen Sync-Knopf unter „Produkte“.",
|
|
||||||
syncButton: "Jetzt synchronisieren",
|
|
||||||
syncResult: (created, updated, skipped, failed) =>
|
|
||||||
`Ernten: ${created} neu angelegt, ${updated} aktualisiert, ${skipped} übersprungen (bereits veröffentlicht)${failed > 0 ? `, ${failed} fehlgeschlagen` : ""}.`,
|
|
||||||
syncError: "Synchronisierung mit ApiCore fehlgeschlagen.",
|
|
||||||
batchSyncResult: (created, skipped, failed) =>
|
|
||||||
`Chargen: ${created} neu angelegt, ${skipped} übersprungen (bereits vorhanden)${failed > 0 ? `, ${failed} fehlgeschlagen (siehe Server-Log)` : ""}.`,
|
|
||||||
batchSyncError: "Chargen konnten nicht von ApiCore geladen werden",
|
|
||||||
},
|
},
|
||||||
adminHarvestEditPage: {
|
adminHarvestEditPage: {
|
||||||
backToHarvests: "Zurück zu Ernten",
|
backToHarvests: "Zurück zu Ernten",
|
||||||
@@ -931,6 +921,24 @@ export const de: Dictionary = {
|
|||||||
publishNow: "Jetzt veröffentlichen",
|
publishNow: "Jetzt veröffentlichen",
|
||||||
save: "Speichern",
|
save: "Speichern",
|
||||||
},
|
},
|
||||||
|
adminApiCoreSync: {
|
||||||
|
heading: "ApiCore-Synchronisierung",
|
||||||
|
intro:
|
||||||
|
"Holt in einem Durchgang Ernten, Chargen und Produkte aus ApiCore — in dieser Reihenfolge, weil jede Stufe auf der vorherigen aufbaut. Ernten und Produkte entstehen als Entwürfe: Ernten unveröffentlicht, Produkte deaktiviert und ohne Preis, denn ApiCore führt keine Verkaufspreise. Je Abfüllung entsteht ein eigener Jahrgang. Bereits freigegebene Einträge werden nie überschrieben.",
|
||||||
|
button: "Alles synchronisieren",
|
||||||
|
resultHarvests: (created, updated, skipped, failed) =>
|
||||||
|
`Ernten: ${created} neu, ${updated} aktualisiert, ${skipped} übersprungen${failed > 0 ? `, ${failed} fehlgeschlagen` : ""}.`,
|
||||||
|
resultBatches: (created, skipped, failed) =>
|
||||||
|
`Chargen: ${created} neu, ${skipped} übersprungen${failed > 0 ? `, ${failed} fehlgeschlagen (siehe Server-Log)` : ""}.`,
|
||||||
|
resultProducts: (created, skipped, failed) =>
|
||||||
|
`Produkte: ${created} neue${created === 1 ? "r Jahrgang" : " Jahrgänge"}, ${skipped} übersprungen${failed > 0 ? `, ${failed} fehlgeschlagen (siehe Server-Log)` : ""}.`,
|
||||||
|
pending: (count) =>
|
||||||
|
`${count} Abfüllung${count === 1 ? "" : "en"} ohne passende Charge — meist ein Hinweis darauf, dass die zugehörige Ernte in ApiCore noch fehlt.`,
|
||||||
|
discontinued: (count) =>
|
||||||
|
`${count} Abfüllung${count === 1 ? "" : "en"} gehör${count === 1 ? "t" : "en"} zu einem Artikel, den ApiCore nicht mehr führt — dafür wurde kein neuer Jahrgang angelegt. Bereits vorhandene bleiben im Verkauf.`,
|
||||||
|
error: "Synchronisierung mit ApiCore fehlgeschlagen.",
|
||||||
|
batchLoadError: "Chargen konnten nicht geladen werden — ohne sie entstehen auch keine Produkte",
|
||||||
|
},
|
||||||
adminProductsPage: {
|
adminProductsPage: {
|
||||||
backToDashboard: "Zurück zum Dashboard",
|
backToDashboard: "Zurück zum Dashboard",
|
||||||
delete: "Löschen",
|
delete: "Löschen",
|
||||||
@@ -951,17 +959,6 @@ export const de: Dictionary = {
|
|||||||
statusInactive: "Inaktiv",
|
statusInactive: "Inaktiv",
|
||||||
edit: "Bearbeiten",
|
edit: "Bearbeiten",
|
||||||
noProductsYet: "Noch keine Produkte angelegt.",
|
noProductsYet: "Noch keine Produkte angelegt.",
|
||||||
syncHeading: "ApiCore-Synchronisierung",
|
|
||||||
syncIntro:
|
|
||||||
"Legt für jede Abfüllung aus ApiCore ein eigenes Produkt an — also einen Jahrgang. „Waldhonig 2026“ und „Waldhonig 2027“ sind damit getrennte Produkte mit eigenem Preis, eigenem Text und eigenen Bewertungen; die Adresse trägt die Jahreszahl. Importierte Produkte sind zunächst deaktiviert und ohne Preis, denn ApiCore führt keine Verkaufspreise. Erst wenn Preis, Gewicht und Beschreibung gepflegt sind, lässt sich ein Produkt aktiv schalten. Vorhandene Produkte werden nur übersprungen, nie überschrieben. Die zugehörige Charge muss schon vorhanden sein — also zuerst den Ernten-Sync ausführen.",
|
|
||||||
syncButton: "Jetzt synchronisieren",
|
|
||||||
syncResult: (created, skipped, failed) =>
|
|
||||||
`Produkte: ${created} neue${created === 1 ? "r Jahrgang" : " Jahrgänge"} angelegt, ${skipped} übersprungen (bereits vorhanden)${failed > 0 ? `, ${failed} fehlgeschlagen (siehe Server-Log)` : ""}.`,
|
|
||||||
syncDiscontinued: (count) =>
|
|
||||||
`${count} Abfüllung${count === 1 ? "" : "en"} gehör${count === 1 ? "t" : "en"} zu einem Artikel, den ApiCore nicht mehr führt — dafür wurde kein neuer Jahrgang angelegt. Bereits vorhandene Jahrgänge bleiben unverändert im Verkauf, damit der Restbestand abverkauft werden kann.`,
|
|
||||||
syncPending: (count) =>
|
|
||||||
`${count} Abfüllung${count === 1 ? "" : "en"} wartet${count === 1 ? "" : "en"} noch auf die zugehörige Charge. Zuerst unter „Ernten“ synchronisieren, dann hier erneut.`,
|
|
||||||
syncError: "Synchronisierung mit ApiCore fehlgeschlagen.",
|
|
||||||
draftHint: "Entwurf: kein Preis gepflegt",
|
draftHint: "Entwurf: kein Preis gepflegt",
|
||||||
},
|
},
|
||||||
adminProductEditPage: {
|
adminProductEditPage: {
|
||||||
|
|||||||
+18
-21
@@ -903,16 +903,6 @@ export const en: Dictionary = {
|
|||||||
descriptionLabel: "Description",
|
descriptionLabel: "Description",
|
||||||
isPublishedLabel: "Publish immediately",
|
isPublishedLabel: "Publish immediately",
|
||||||
create: "Create",
|
create: "Create",
|
||||||
syncHeading: "ApiCore sync",
|
|
||||||
syncIntro:
|
|
||||||
"Imports harvests AND batches from ApiCore. Harvests are created as drafts (not publicly visible); previously synced harvests that are already published are left untouched. Batches are only ever created, never updated (so real stock levels are never overwritten), and need an already-existing harvest. The products themselves have their own sync button under “Products”.",
|
|
||||||
syncButton: "Sync now",
|
|
||||||
syncResult: (created, updated, skipped, failed) =>
|
|
||||||
`Harvests: ${created} created, ${updated} updated, ${skipped} skipped (already published)${failed > 0 ? `, ${failed} failed` : ""}.`,
|
|
||||||
syncError: "Sync with ApiCore failed.",
|
|
||||||
batchSyncResult: (created, skipped, failed) =>
|
|
||||||
`Batches: ${created} created, ${skipped} skipped (already exist)${failed > 0 ? `, ${failed} failed (see server log)` : ""}.`,
|
|
||||||
batchSyncError: "Batches could not be loaded from ApiCore",
|
|
||||||
},
|
},
|
||||||
adminHarvestEditPage: {
|
adminHarvestEditPage: {
|
||||||
backToHarvests: "Back to harvests",
|
backToHarvests: "Back to harvests",
|
||||||
@@ -931,6 +921,24 @@ export const en: Dictionary = {
|
|||||||
publishNow: "Publish now",
|
publishNow: "Publish now",
|
||||||
save: "Save",
|
save: "Save",
|
||||||
},
|
},
|
||||||
|
adminApiCoreSync: {
|
||||||
|
heading: "ApiCore sync",
|
||||||
|
intro:
|
||||||
|
"Fetches harvests, batches and products from ApiCore in one run — in that order, because each step builds on the previous one. Harvests and products are created as drafts: harvests unpublished, products inactive and without a price, because ApiCore does not hold sales prices. Each bottling run becomes its own vintage. Entries already released are never overwritten.",
|
||||||
|
button: "Sync everything",
|
||||||
|
resultHarvests: (created, updated, skipped, failed) =>
|
||||||
|
`Harvests: ${created} created, ${updated} updated, ${skipped} skipped${failed > 0 ? `, ${failed} failed` : ""}.`,
|
||||||
|
resultBatches: (created, skipped, failed) =>
|
||||||
|
`Batches: ${created} created, ${skipped} skipped${failed > 0 ? `, ${failed} failed (see server log)` : ""}.`,
|
||||||
|
resultProducts: (created, skipped, failed) =>
|
||||||
|
`Products: ${created} new vintage${created === 1 ? "" : "s"}, ${skipped} skipped${failed > 0 ? `, ${failed} failed (see server log)` : ""}.`,
|
||||||
|
pending: (count) =>
|
||||||
|
`${count} bottling run${count === 1 ? "" : "s"} without a matching batch — usually a sign that the corresponding harvest is still missing in ApiCore.`,
|
||||||
|
discontinued: (count) =>
|
||||||
|
`${count} bottling run${count === 1 ? "" : "s"} belong to an article ApiCore no longer carries, so no new vintage was created. Existing ones stay on sale.`,
|
||||||
|
error: "Sync with ApiCore failed.",
|
||||||
|
batchLoadError: "Batches could not be loaded — without them no products are created either",
|
||||||
|
},
|
||||||
adminProductsPage: {
|
adminProductsPage: {
|
||||||
backToDashboard: "Back to dashboard",
|
backToDashboard: "Back to dashboard",
|
||||||
delete: "Delete",
|
delete: "Delete",
|
||||||
@@ -950,17 +958,6 @@ export const en: Dictionary = {
|
|||||||
statusInactive: "Inactive",
|
statusInactive: "Inactive",
|
||||||
edit: "Edit",
|
edit: "Edit",
|
||||||
noProductsYet: "No products created yet.",
|
noProductsYet: "No products created yet.",
|
||||||
syncHeading: "ApiCore sync",
|
|
||||||
syncIntro:
|
|
||||||
"Creates one product per bottling run from ApiCore — that is, per vintage. “Waldhonig 2026” and “Waldhonig 2027” are separate products with their own price, copy and reviews; the address carries the year. Imported products start out inactive and without a price, because ApiCore does not hold sales prices. A product can only be activated once price, weight and description have been filled in. Existing products are only ever skipped, never overwritten. The matching batch has to exist already — so run the harvest sync first.",
|
|
||||||
syncButton: "Sync now",
|
|
||||||
syncResult: (created, skipped, failed) =>
|
|
||||||
`Products: ${created} new vintage${created === 1 ? "" : "s"} created, ${skipped} skipped (already exist)${failed > 0 ? `, ${failed} failed (see server log)` : ""}.`,
|
|
||||||
syncDiscontinued: (count) =>
|
|
||||||
`${count} bottling run${count === 1 ? "" : "s"} belong${count === 1 ? "s" : ""} to an article ApiCore no longer carries, so no new vintage was created for ${count === 1 ? "it" : "them"}. Vintages already in the shop stay on sale so remaining stock can be sold through.`,
|
|
||||||
syncPending: (count) =>
|
|
||||||
`${count} bottling run${count === 1 ? " is" : "s are"} still waiting for the matching batch. Sync under “Harvests” first, then run this again.`,
|
|
||||||
syncError: "Sync with ApiCore failed.",
|
|
||||||
draftHint: "Draft: no price set",
|
draftHint: "Draft: no price set",
|
||||||
},
|
},
|
||||||
adminProductEditPage: {
|
adminProductEditPage: {
|
||||||
|
|||||||
+14
-14
@@ -606,15 +606,8 @@ export interface Dictionary {
|
|||||||
descriptionLabel: string;
|
descriptionLabel: string;
|
||||||
isPublishedLabel: string;
|
isPublishedLabel: string;
|
||||||
create: string;
|
create: string;
|
||||||
syncHeading: string;
|
|
||||||
syncIntro: string;
|
|
||||||
syncButton: string;
|
|
||||||
/** created, updated, skipped, failed */
|
/** created, updated, skipped, failed */
|
||||||
syncResult: (created: number, updated: number, skipped: number, failed: number) => string;
|
|
||||||
syncError: string;
|
|
||||||
/** created, skipped, failed */
|
/** created, skipped, failed */
|
||||||
batchSyncResult: (created: number, skipped: number, failed: number) => string;
|
|
||||||
batchSyncError: string;
|
|
||||||
};
|
};
|
||||||
adminHarvestEditPage: {
|
adminHarvestEditPage: {
|
||||||
backToHarvests: string;
|
backToHarvests: string;
|
||||||
@@ -633,6 +626,20 @@ export interface Dictionary {
|
|||||||
publishNow: string;
|
publishNow: string;
|
||||||
save: string;
|
save: string;
|
||||||
};
|
};
|
||||||
|
/** Gemeinsam von /admin/ernten und /admin/produkte genutzt: EIN Sync-Knopf
|
||||||
|
* fuer Ernten, Chargen und Produkte. */
|
||||||
|
adminApiCoreSync: {
|
||||||
|
heading: string;
|
||||||
|
intro: string;
|
||||||
|
button: string;
|
||||||
|
resultHarvests: (created: number, updated: number, skipped: number, failed: number) => string;
|
||||||
|
resultBatches: (created: number, skipped: number, failed: number) => string;
|
||||||
|
resultProducts: (created: number, skipped: number, failed: number) => string;
|
||||||
|
pending: (count: number) => string;
|
||||||
|
discontinued: (count: number) => string;
|
||||||
|
error: string;
|
||||||
|
batchLoadError: string;
|
||||||
|
};
|
||||||
adminProductsPage: {
|
adminProductsPage: {
|
||||||
backToDashboard: string;
|
backToDashboard: string;
|
||||||
delete: string;
|
delete: string;
|
||||||
@@ -652,13 +659,6 @@ export interface Dictionary {
|
|||||||
statusInactive: string;
|
statusInactive: string;
|
||||||
edit: string;
|
edit: string;
|
||||||
noProductsYet: string;
|
noProductsYet: string;
|
||||||
syncHeading: string;
|
|
||||||
syncIntro: string;
|
|
||||||
syncButton: string;
|
|
||||||
syncResult: (created: number, skipped: number, failed: number) => string;
|
|
||||||
syncDiscontinued: (count: number) => string;
|
|
||||||
syncPending: (count: number) => string;
|
|
||||||
syncError: string;
|
|
||||||
draftHint: string;
|
draftHint: string;
|
||||||
};
|
};
|
||||||
adminProductEditPage: {
|
adminProductEditPage: {
|
||||||
|
|||||||
Reference in new issue
Block a user