Neue Seite "Rezepte" (nur sichtbar, wenn Mealie eingerichtet ist): links
die Rezeptliste mit Suche, rechts das gewaehlte Rezept mit allen Zutaten
und der einen Frage, die zaehlt - kann ich das heute kochen, oder muss ich
vorher einkaufen? Je Zutat steht der Zustand: vorhanden, zu wenig, fehlt,
Menge unklar oder nicht zugeordnet; Fehlendes ist rot.
Die Portionenzahl laesst sich hochdrehen. Vorrania rechnet die Mengen dann
hoch, bevor es sie mit dem Bestand vergleicht - ein Rezept fuer 4, das man
fuer 8 kocht, braucht die doppelte Menge, und genau danach soll der
Abgleich gehen. Nennt das Rezept keine Portionszahl, wird nicht skaliert
und die Seite sagt das auch.
Drei Dinge entscheidet der Abgleich nacheinander (services/rezepte.py,
bewusst ohne HTTP, damit sich alles mit erfundenen Zutaten pruefen laesst):
1. Welcher Artikel ist gemeint? Unscharf ueber den Namen, gegen Artikel
UND Gruppen - "Milch" ist markenuebergreifend gemeint, und genau dafuer
gibt es Gruppen. Die Schwelle liegt hoeher als beim Kassenzettel: Dort
sieht ein Mensch die Vorschlaege durch, hier darf nicht stillschweigend
das Falsche als vorhanden gelten. Unter der Schwelle bleibt es bei
"nicht zugeordnet" samt Kandidatenliste.
2. Wie viel ist das in Gramm? Ueber die Einheiten und die Hausmasse der
Kategorie - "2 EL Zucker" wird zu 24 g. Fehlt das Mass fuer diese
Kategorie, meldet die Zutat genau das zurueck (Massname und
Kategorie-ID), damit daraus ein "Mass jetzt anlegen" werden kann.
3. Reicht der Bestand? Bei Gruppen ueber dieselbe gewichtete Summe wie in
der Einkaufsliste, damit Untergruppen und Zweiteinheiten gleich zaehlen.
Die Fuzzy-Heuristik lag in routers/products.py und ist nach
services/matching.py gezogen: Der Rezeptabgleich stellt dieselbe Frage, und
zwei Kopien waeren mit der Zeit auseinandergelaufen. Der Kassenzettel-Scan
nutzt sie unveraendert weiter, test_receipt_match.py bleibt gruen.
Dazu zwei Dinge aus deiner Rueckmeldung zur Einstellungs-Karte:
- Der verbundene Zustand bleibt jetzt stehen, statt nur als Meldung
aufzublitzen: Adresse, Version und "Dauertoken hinterlegt" samt den
Knoepfen Pruefen / Zugang aendern / Trennen. Das Formular erscheint nur,
solange nichts eingerichtet ist - ein Dauerzustand gehoert nicht in ein
Eingabefeld, sonst sieht man nie, ob es geklappt hat.
- Leere Felder melden sich auf Deutsch ("Bitte das Passwort eingeben.")
statt mit der rohen Schema-Meldung "String should have at least 1
character", die niemandem sagt, welches Feld gemeint ist.
Der Knopf "Fehlende zur Einkaufsliste" fehlt noch: Vorranias Einkaufsliste
wird heute vollstaendig aus Mindestbestaenden gerechnet und hat keinen Ort
fuer einen eigenen Eintrag. Das ist der naechste Schritt; die Seite sagt es
auch so.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
466 lines
22 KiB
JavaScript
466 lines
22 KiB
JavaScript
// Zentraler API-Client. In Produktion und Dev läuft alles über /api
|
||
// (nginx bzw. Vite-Proxy leiten an das FastAPI-Backend weiter).
|
||
|
||
const API_BASE = "/api";
|
||
|
||
// Wird dieser Schluessel je wieder umbenannt, sind alle offenen Sitzungen
|
||
// ungueltig - die Anmeldung liegt im localStorage des Browsers.
|
||
const TOKEN_KEY = "vorrania_token";
|
||
|
||
export function getToken() {
|
||
return localStorage.getItem(TOKEN_KEY);
|
||
}
|
||
export function setToken(token) {
|
||
if (token) localStorage.setItem(TOKEN_KEY, token);
|
||
else localStorage.removeItem(TOKEN_KEY);
|
||
}
|
||
|
||
// Wird bei einer abgelaufenen/ungueltigen Anmeldung gerufen (HTTP 401).
|
||
// Ohne das bleibt die Oberflaeche „angemeldet", waehrend jede Abfrage scheitert –
|
||
// man sieht dann leere Seiten statt eines Hinweises.
|
||
// Pfade, bei denen ein 401 NICHT „Sitzung abgelaufen" heisst: beim Anmelden
|
||
// selbst heisst er „Passwort falsch", und die Anbieterliste ist oeffentlich.
|
||
const OHNE_ABMELDUNG = ["/auth/login", "/auth/oidc/providers"];
|
||
// Beim Verknuepfen einer Erstanmeldung heisst 401 „Passwort falsch".
|
||
const OHNE_ABMELDUNG_PRAEFIX = ["/auth/oidc/pending/"];
|
||
|
||
let beiAbmeldung = null;
|
||
export function setUnauthorizedHandler(fn) {
|
||
beiAbmeldung = fn;
|
||
}
|
||
|
||
export class ApiError extends Error {
|
||
constructor(message, status) {
|
||
super(message);
|
||
this.status = status;
|
||
}
|
||
}
|
||
|
||
async function request(path, { method = "GET", body, form, formData } = {}) {
|
||
const headers = {};
|
||
const token = getToken();
|
||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||
|
||
let payload;
|
||
if (formData) {
|
||
// Content-Type (inkl. boundary) setzt der Browser selbst.
|
||
payload = formData;
|
||
} else if (form) {
|
||
headers["Content-Type"] = "application/x-www-form-urlencoded";
|
||
payload = new URLSearchParams(form).toString();
|
||
} else if (body !== undefined) {
|
||
headers["Content-Type"] = "application/json";
|
||
payload = JSON.stringify(body);
|
||
}
|
||
|
||
const resp = await fetch(`${API_BASE}${path}`, { method, headers, body: payload });
|
||
|
||
// Abgelaufene Anmeldung: EINMAL zentral behandeln statt in jeder Seite.
|
||
// Beim Anmelden selbst nicht – dort heisst 401 schlicht „Passwort falsch".
|
||
const ausgenommen = OHNE_ABMELDUNG.includes(path)
|
||
|| OHNE_ABMELDUNG_PRAEFIX.some((p) => path.startsWith(p));
|
||
if (resp.status === 401 && !ausgenommen) {
|
||
beiAbmeldung?.();
|
||
}
|
||
|
||
if (resp.status === 204) return null;
|
||
|
||
let data = null;
|
||
const text = await resp.text();
|
||
if (text) {
|
||
try {
|
||
data = JSON.parse(text);
|
||
} catch {
|
||
data = text;
|
||
}
|
||
}
|
||
|
||
if (!resp.ok) {
|
||
let detail;
|
||
if (data && data.detail) {
|
||
detail = Array.isArray(data.detail)
|
||
? data.detail.map((d) => d.msg).join(", ")
|
||
: data.detail;
|
||
} else if (resp.status === 502 || resp.status === 503 || resp.status === 504) {
|
||
detail = `Server nicht erreichbar (${resp.status}). Läuft der Backend-Dienst? Prüfe: docker compose logs backend`;
|
||
} else if (typeof data === "string" && data.trim().startsWith("<")) {
|
||
// HTML-Fehlerseite (z.B. von nginx) nicht roh anzeigen
|
||
detail = `Server-Fehler (${resp.status}).`;
|
||
} else if (typeof data === "string" && data) {
|
||
detail = data;
|
||
} else {
|
||
detail = `Fehler ${resp.status}`;
|
||
}
|
||
throw new ApiError(detail, resp.status);
|
||
}
|
||
return data;
|
||
}
|
||
|
||
/** Ortszeit als YYYY-MM-DD_HHMM fuer Dateinamen. */
|
||
function zeitstempel() {
|
||
const jetzt = new Date();
|
||
const zwei = (n) => String(n).padStart(2, "0");
|
||
return `${jetzt.getFullYear()}-${zwei(jetzt.getMonth() + 1)}-${zwei(jetzt.getDate())}`
|
||
+ `_${zwei(jetzt.getHours())}${zwei(jetzt.getMinutes())}`;
|
||
}
|
||
|
||
/**
|
||
* Bild mit Anmeldung laden und als lokale Objekt-URL zurückgeben.
|
||
*
|
||
* Nötig, weil ein <img src="…"> keinen Authorization-Header mitschickt. Ohne
|
||
* diesen Umweg müsste die Bildroute offen sein – dann liesse sich ohne Konto
|
||
* ablesen, was im Vorrat liegt.
|
||
*
|
||
* Gibt null zurück, wenn es kein Bild gibt; das ist der Normalfall und kein
|
||
* Fehler. Wer die URL nicht mehr braucht, gibt sie mit URL.revokeObjectURL frei.
|
||
*
|
||
* `signal` bricht den Abruf ab. Das ist bei Bildern kein Feinschliff, sondern
|
||
* nötig: Ein Browser hält je Host nur rund sechs Verbindungen offen. Eine Liste
|
||
* mit vierzig Vorschaubildern belegt sie alle – und wer währenddessen eine Zeile
|
||
* anklickt, dessen Artikelseite steht dahinter Schlange, obwohl die Bilder
|
||
* niemanden mehr interessieren.
|
||
*/
|
||
export async function authorizedObjectUrl(path, signal) {
|
||
const token = getToken();
|
||
const resp = await fetch(`${API_BASE}${path}`, {
|
||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||
signal,
|
||
});
|
||
if (!resp.ok) return null;
|
||
return URL.createObjectURL(await resp.blob());
|
||
}
|
||
|
||
// Datei mit Auth-Header laden und im Browser als Download anbieten.
|
||
export async function downloadFile(path, filename) {
|
||
const token = getToken();
|
||
const resp = await fetch(`${API_BASE}${path}`, {
|
||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||
});
|
||
if (!resp.ok) {
|
||
throw new ApiError(`Download fehlgeschlagen (${resp.status})`, resp.status);
|
||
}
|
||
const blob = await resp.blob();
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement("a");
|
||
link.href = url;
|
||
link.download = filename;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
link.remove();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
export const api = {
|
||
login: (username, password) =>
|
||
request("/auth/login", { method: "POST", form: { username, password } }),
|
||
me: () => request("/auth/me"),
|
||
// Schiebt die Frist der laufenden Sitzung nach vorn. Wird beim Start
|
||
// gerufen, damit die eingestellte Dauer ab dem letzten Besuch zaehlt und
|
||
// nicht ab dem Anmelden.
|
||
sitzungVerlaengern: () => request("/auth/refresh", { method: "POST" }),
|
||
// Welche Single-Sign-on-Anbieter eingerichtet sind. Kommt vom Server, weil die
|
||
// Oberflaeche keine Build-Variablen kennt – ohne Anbieter bleibt die Liste leer
|
||
// und es taucht kein SSO-Knopf auf.
|
||
oidcProviders: () => request("/auth/oidc/providers"),
|
||
// Mit welchen Anmeldediensten das eigene Konto verknuepft ist.
|
||
oidcIdentities: () => request("/auth/oidc/identities"),
|
||
// Startet die Verknuepfung. Muss POST sein, damit das Bearer-Token der
|
||
// laufenden Sitzung mitgeht - bei einer Seitennavigation ginge das nicht.
|
||
oidcLinkStart: (slug) => request(`/auth/oidc/${slug}/link`, { method: "POST" }),
|
||
oidcUnlink: (slug) => request(`/auth/oidc/${slug}/link`, { method: "DELETE" }),
|
||
// Erstanmeldung ohne passendes Konto: der Einmalcode aus der Adresse ist der
|
||
// Nachweis, dass die Anmeldung beim Dienst stattgefunden hat.
|
||
oidcPending: (code) => request(`/auth/oidc/pending/${code}`),
|
||
oidcPendingAnlegen: (code) =>
|
||
request(`/auth/oidc/pending/${code}/anlegen`, { method: "POST" }),
|
||
oidcPendingVerknuepfen: (code, username, password) =>
|
||
request(`/auth/oidc/pending/${code}/verknuepfen`, {
|
||
method: "POST", body: { username, password },
|
||
}),
|
||
|
||
// Produkte
|
||
listProducts: (q, categoryId) => {
|
||
// categoryId 0 = "ohne Kategorie"; eine echte ID schliesst Unterkategorien ein.
|
||
const params = new URLSearchParams();
|
||
if (q) params.set("q", q);
|
||
if (categoryId !== undefined && categoryId !== null && categoryId !== "") {
|
||
params.set("category_id", categoryId);
|
||
}
|
||
const qs = params.toString();
|
||
return request(`/products${qs ? `?${qs}` : ""}`);
|
||
},
|
||
getProduct: (id) => request(`/products/${id}`),
|
||
lookup: (barcode) => request(`/products/lookup?barcode=${encodeURIComponent(barcode)}`),
|
||
// Fragt OFF auch fuer einen bereits angelegten Artikel - zum Vergleichen.
|
||
offCompare: (id) => request(`/products/${id}/off`),
|
||
createProduct: (body) => request("/products", { method: "POST", body }),
|
||
updateProduct: (id, body) => request(`/products/${id}`, { method: "PATCH", body }),
|
||
deleteProduct: (id) => request(`/products/${id}`, { method: "DELETE" }),
|
||
addProductBarcode: (id, body) => request(`/products/${id}/barcodes`, { method: "POST", body }),
|
||
deleteProductBarcode: (id, code) =>
|
||
request(`/products/${id}/barcodes/${encodeURIComponent(code)}`, { method: "DELETE" }),
|
||
// Artikelfoto hochladen (Kamera/Galerie) bzw. entfernen.
|
||
uploadProductImage: (id, file) => {
|
||
const fd = new FormData();
|
||
fd.append("file", file);
|
||
return request(`/products/${id}/image`, { method: "PUT", formData: fd });
|
||
},
|
||
deleteProductImage: (id) => request(`/products/${id}/image`, { method: "DELETE" }),
|
||
|
||
// Einzelstücke (Items mit UID/QR)
|
||
listItems: (productId) => request(`/products/${productId}/items`),
|
||
listAllItems: () => request("/items"),
|
||
createItems: (productId, body) => request(`/products/${productId}/items`, { method: "POST", body }),
|
||
itemByUid: (uid) => request(`/items/by-uid/${encodeURIComponent(uid)}`),
|
||
updateItem: (id, body) => request(`/items/${id}`, { method: "PATCH", body }),
|
||
removeItem: (id, body) => request(`/items/${id}/remove`, { method: "POST", body }),
|
||
deleteItem: (id) => request(`/items/${id}`, { method: "DELETE" }),
|
||
// Belege je Einzelstück (Rechnung/Garantieschein). Der Upload liefert einen
|
||
// vorgeschlagenen "Garantie bis"-Wert zurück (nur aus PDFs).
|
||
// Beleg nur analysieren (nichts speichern) – zum Vorbefüllen beim Anlegen.
|
||
analyzeItemDocument: (file) => {
|
||
const fd = new FormData();
|
||
fd.append("file", file);
|
||
return request("/items/analyze-document", { method: "POST", formData: fd });
|
||
},
|
||
uploadItemDocument: (itemId, file) => {
|
||
const fd = new FormData();
|
||
fd.append("file", file);
|
||
return request(`/items/${itemId}/documents`, { method: "POST", formData: fd });
|
||
},
|
||
deleteItemDocument: (itemId, docId) =>
|
||
request(`/items/${itemId}/documents/${docId}`, { method: "DELETE" }),
|
||
|
||
// Verleih von Einzelstücken. Der Lagerort bleibt dabei stehen – dorthin
|
||
// gehört das Stück zurück; verliehen zählt es aber nicht mehr als Bestand.
|
||
lendItem: (itemId, body) => request(`/items/${itemId}/loan`, { method: "POST", body }),
|
||
returnItem: (itemId, body = {}) => request(`/items/${itemId}/return`, { method: "POST", body }),
|
||
itemLoans: (itemId) => request(`/items/${itemId}/loans`),
|
||
// Zubehör läuft bewusst nicht über updateItem: dessen Schema sendet immer alle
|
||
// Felder mit, ein vergessenes Feld würde die Beziehung still kappen.
|
||
zubehoerZuordnen: (itemId, childItemId) =>
|
||
request(`/items/${itemId}/zubehoer`, { method: "POST", body: { child_item_id: childItemId } }),
|
||
zubehoerLoesen: (itemId, childItemId) =>
|
||
request(`/items/${itemId}/zubehoer/${childItemId}`, { method: "DELETE" }),
|
||
// Offene Verleihe, fälligste zuerst. dueIn grenzt auf die ein, deren Rückgabe
|
||
// in so vielen Tagen ansteht (überfällige sind immer dabei).
|
||
loans: ({ openOnly = true, dueIn } = {}) => {
|
||
const p = new URLSearchParams();
|
||
if (!openOnly) p.set("open_only", "false");
|
||
if (dueIn != null) p.set("due_in", String(dueIn));
|
||
const q = p.toString();
|
||
return request(`/loans${q ? `?${q}` : ""}`);
|
||
},
|
||
updateLoan: (loanId, body) => request(`/loans/${loanId}`, { method: "PATCH", body }),
|
||
deleteLoan: (loanId) => request(`/loans/${loanId}`, { method: "DELETE" }),
|
||
|
||
// Bestand
|
||
checkIn: (body) => request("/stock/checkin", { method: "POST", body }),
|
||
checkInBatch: (body) => request("/stock/checkin/batch", { method: "POST", body }),
|
||
checkOut: (body) => request("/stock/checkout", { method: "POST", body }),
|
||
// Gegenstände: umlagern (ohne Grund) und entfernen (Grund Pflicht)
|
||
relocateStock: (body) => request("/stock/relocate", { method: "POST", body }),
|
||
removeStock: (body) => request("/stock/remove", { method: "POST", body }),
|
||
productRemovals: (id) => request(`/products/${id}/removals`),
|
||
listLots: (productId) =>
|
||
request(`/lots${productId ? `?product_id=${productId}` : ""}`),
|
||
updateLot: (id, body) => request(`/lots/${id}`, { method: "PATCH", body }),
|
||
// Alle Chargen (über alle Artikel) mit Artikel-Infos.
|
||
listAllLots: () => request("/lots/rows"),
|
||
// Lagerort mehrerer Chargen auf einmal setzen (null = leeren).
|
||
bulkLotLocation: (lotIds, locationId) =>
|
||
request("/lots/bulk-location", { method: "POST", body: { lot_ids: lotIds, location_id: locationId || null } }),
|
||
// Mehrere Chargen ausbuchen: ohne Menge komplett, mit Menge je Charge
|
||
// dieselbe (nur bei einheitlichem Artikel erlaubt).
|
||
bulkLotCheckout: (lotIds, { quantity = null, unit = null, note = null } = {}) =>
|
||
request("/lots/bulk-checkout", {
|
||
method: "POST",
|
||
body: { lot_ids: lotIds, quantity, unit, note },
|
||
}),
|
||
// Mehrere Chargen als Korrektur entfernen (nicht als Verbrauch buchen).
|
||
bulkLotDelete: (lotIds) =>
|
||
request("/lots/bulk-delete", { method: "POST", body: { lot_ids: lotIds } }),
|
||
deleteLot: (id) => request(`/lots/${id}`, { method: "DELETE" }),
|
||
// Charge aufteilen: Teilmenge (Basiseinheiten) an einen anderen Lagerort abzweigen.
|
||
splitLot: (id, body) => request(`/lots/${id}/split`, { method: "POST", body }),
|
||
|
||
// Mindestbestand je Lagerort (ersetzt jeweils die komplette Liste).
|
||
setProductLocationMinStock: (id, list) =>
|
||
request(`/products/${id}/location-min-stock`, { method: "PUT", body: list }),
|
||
setGroupLocationMinStock: (id, list) =>
|
||
request(`/groups/${id}/location-min-stock`, { method: "PUT", body: list }),
|
||
|
||
// Stammdaten sichern/wiederherstellen (Kategorien, Lagerorte, Einheiten, Gebinde)
|
||
exportMasterData: () => request("/export/master-data"),
|
||
importMasterData: (data, mode = "skip") =>
|
||
request(`/import/master-data?mode=${mode}`, { method: "POST", body: data }),
|
||
|
||
// Views
|
||
shoppingList: () => request("/shopping-list"),
|
||
groupShoppingList: () => request("/shopping-list/groups"),
|
||
shoppingByLocation: () => request("/shopping-list/by-location"),
|
||
expiring: (days) => request(`/expiring${days != null ? `?days=${days}` : ""}`),
|
||
listMovements: (limit) => request(`/movements${limit ? `?limit=${limit}` : ""}`),
|
||
|
||
// Startseite: Dashboards und Auswertungen
|
||
dashboards: () => request("/dashboard/layouts"),
|
||
createDashboard: (body) => request("/dashboard/layouts", { method: "POST", body }),
|
||
updateDashboard: (id, body) =>
|
||
request(`/dashboard/layouts/${id}`, { method: "PATCH", body }),
|
||
deleteDashboard: (id) => request(`/dashboard/layouts/${id}`, { method: "DELETE" }),
|
||
resetDashboards: () => request("/dashboard/layouts", { method: "DELETE" }),
|
||
saveDashboardDefault: () => request("/dashboard/layouts/default", { method: "PUT" }),
|
||
setDashboardEnforced: (value) =>
|
||
request(`/dashboard/layout/enforced?value=${value ? "true" : "false"}`, { method: "PUT" }),
|
||
dashboardStats: () => request("/dashboard/stats"),
|
||
dashboardExpirySplit: () => request("/dashboard/expiry-split"),
|
||
dashboardByCategory: (depth) => request(`/dashboard/by-category${depth ? `?depth=${depth}` : ""}`),
|
||
dashboardTimeline: (days, productId) =>
|
||
request(`/dashboard/timeline?days=${days}${productId ? `&product_id=${productId}` : ""}`),
|
||
dashboardActivity: (days) => request(`/dashboard/activity?days=${days}`),
|
||
dashboardFlow: (days, productId) =>
|
||
request(`/dashboard/flow?days=${days}${productId ? `&product_id=${productId}` : ""}`),
|
||
|
||
// Stammdaten
|
||
listLocations: () => request("/locations"),
|
||
// Was liegt an einem Lagerort (inkl. Unterorte)? Ziel des Lagerort-QR.
|
||
locationContents: (code) => request(`/location/${encodeURIComponent(code)}`),
|
||
createLocation: (body) => request("/locations", { method: "POST", body }),
|
||
updateLocation: (id, body) => request(`/locations/${id}`, { method: "PATCH", body }),
|
||
deleteLocation: (id) => request(`/locations/${id}`, { method: "DELETE" }),
|
||
|
||
// Kategorien: Ordnungshilfe + Verwaltungsart (food/object), verschachtelbar
|
||
listCategories: () => request("/categories"),
|
||
createCategory: (body) => request("/categories", { method: "POST", body }),
|
||
updateCategory: (id, body) => request(`/categories/${id}`, { method: "PATCH", body }),
|
||
deleteCategory: (id) => request(`/categories/${id}`, { method: "DELETE" }),
|
||
// Effektive (vererbte) Felder einer Kategorie – für das Artikelformular.
|
||
categoryFields: (id) => request(`/categories/${id}/fields`),
|
||
|
||
// Selbst definierte Felder je Kategorie
|
||
listFieldDefinitions: (categoryId) =>
|
||
request(`/field-definitions${categoryId != null ? `?category_id=${categoryId}` : ""}`),
|
||
createFieldDefinition: (body) => request("/field-definitions", { method: "POST", body }),
|
||
updateFieldDefinition: (id, body) =>
|
||
request(`/field-definitions/${id}`, { method: "PATCH", body }),
|
||
deleteFieldDefinition: (id) => request(`/field-definitions/${id}`, { method: "DELETE" }),
|
||
|
||
// Shops / Bezugsquellen (nur für Gegenstände)
|
||
listShops: () => request("/shops"),
|
||
createShop: (body) => request("/shops", { method: "POST", body }),
|
||
updateShop: (id, body) => request(`/shops/${id}`, { method: "PATCH", body }),
|
||
deleteShop: (id) => request(`/shops/${id}`, { method: "DELETE" }),
|
||
|
||
// Gebinde (Einzahl/Mehrzahl): "Glas" -> "Gläser"
|
||
listPackageTypes: () => request("/package-types"),
|
||
createPackageType: (body) => request("/package-types", { method: "POST", body }),
|
||
updatePackageType: (id, body) => request(`/package-types/${id}`, { method: "PATCH", body }),
|
||
deletePackageType: (id) => request(`/package-types/${id}`, { method: "DELETE" }),
|
||
|
||
listGroups: () => request("/groups"),
|
||
createGroup: (body) => request("/groups", { method: "POST", body }),
|
||
updateGroup: (id, body) => request(`/groups/${id}`, { method: "PATCH", body }),
|
||
deleteGroup: (id) => request(`/groups/${id}`, { method: "DELETE" }),
|
||
addGroupBarcode: (id, body) => request(`/groups/${id}/barcodes`, { method: "POST", body }),
|
||
updateGroupBarcodeNote: (id, code, body) =>
|
||
request(`/groups/${id}/barcodes/${encodeURIComponent(code)}`, { method: "PATCH", body }),
|
||
deleteGroupBarcode: (id, code) =>
|
||
request(`/groups/${id}/barcodes/${encodeURIComponent(code)}`, { method: "DELETE" }),
|
||
|
||
// Export / Import
|
||
// Zeitstempel im Dateinamen, sonst heissen mehrere Ausleitungen alle gleich.
|
||
exportCsv: () => downloadFile("/export/stock.csv", `bestand_${zeitstempel()}.csv`),
|
||
exportJson: () =>
|
||
downloadFile("/export/backup.json", `vorrania-backup_${zeitstempel()}.json`),
|
||
// Zeilen für den QR-Etiketten-Export (P-touch); QR-Inhalt setzt das Web dazu.
|
||
// ---- Etiketten-Vorlagen ----
|
||
etikettenVorlagen: () => request("/etiketten/vorlagen"),
|
||
etikettenSpalten: (art, categoryIds) => {
|
||
const p = new URLSearchParams({ art });
|
||
if (categoryIds && categoryIds.length) p.set("kategorie_ids", categoryIds.join(","));
|
||
return request(`/etiketten/spalten?${p}`);
|
||
},
|
||
etikettenVorlageAnlegen: (body) =>
|
||
request("/etiketten/vorlagen", { method: "POST", body }),
|
||
etikettenVorlageAendern: (id, body) =>
|
||
request(`/etiketten/vorlagen/${id}`, { method: "PATCH", body }),
|
||
etikettenVorlageLoeschen: (id) =>
|
||
request(`/etiketten/vorlagen/${id}`, { method: "DELETE" }),
|
||
// Die CSV baut der Server. basis_url braucht nur der Lagerort-QR (…/l/<id>);
|
||
// nur der Browser weiss, unter welcher Adresse er gerade erreichbar ist.
|
||
etikettenCsv: (id, name) =>
|
||
downloadFile(
|
||
`/etiketten/vorlagen/${id}/csv?basis_url=${encodeURIComponent(window.location.origin)}`,
|
||
`${name || "etiketten"}.csv`,
|
||
),
|
||
etikettenDateiHochladen: (id, file) => {
|
||
const fd = new FormData();
|
||
fd.append("file", file);
|
||
return request(`/etiketten/vorlagen/${id}/datei`, { method: "POST", formData: fd });
|
||
},
|
||
etikettenDatei: (id, name) =>
|
||
downloadFile(`/etiketten/vorlagen/${id}/datei`, name || "vorlage.lbx"),
|
||
etikettenDateiEntfernen: (id) =>
|
||
request(`/etiketten/vorlagen/${id}/datei`, { method: "DELETE" }),
|
||
|
||
labelRows: (categoryIds) =>
|
||
request(`/export/labels${categoryIds && categoryIds.length ? `?category_ids=${categoryIds.join(",")}` : ""}`),
|
||
importStock: (file, mode = "add") => {
|
||
const fd = new FormData();
|
||
fd.append("file", file);
|
||
return request(`/import/stock?mode=${encodeURIComponent(mode)}`, {
|
||
method: "POST",
|
||
formData: fd,
|
||
});
|
||
},
|
||
|
||
listUnits: () => request("/units"),
|
||
createUnit: (body) => request("/units", { method: "POST", body }),
|
||
deleteUnit: (id) => request(`/units/${id}`, { method: "DELETE" }),
|
||
|
||
// Benutzer
|
||
listUsers: () => request("/users"),
|
||
createUser: (body) => request("/users", { method: "POST", body }),
|
||
updateUser: (id, body) => request(`/users/${id}`, { method: "PATCH", body }),
|
||
deleteUser: (id) => request(`/users/${id}`, { method: "DELETE" }),
|
||
|
||
// API-Tokens (externe Zugriffe)
|
||
listApiTokens: () => request("/api-tokens"),
|
||
createApiToken: (body) => request("/api-tokens", { method: "POST", body }),
|
||
deleteApiToken: (id) => request(`/api-tokens/${id}`, { method: "DELETE" }),
|
||
|
||
// Eigenes Logo / Favicon
|
||
uploadBranding: (kind, file) => {
|
||
const formData = new FormData();
|
||
formData.append("file", file);
|
||
return request(`/branding/${kind}`, { method: "PUT", formData });
|
||
},
|
||
deleteBranding: (kind) => request(`/branding/${kind}`, { method: "DELETE" }),
|
||
|
||
// Gefahrenbereich (nur Admin)
|
||
maintenanceSummary: () => request("/maintenance/summary"),
|
||
resetStock: () => request("/maintenance/reset/stock", { method: "POST" }),
|
||
resetProducts: () => request("/maintenance/reset/products", { method: "POST" }),
|
||
resetAll: () => request("/maintenance/reset/all", { method: "POST" }),
|
||
|
||
// Einstellungen
|
||
// Mealie: Adresse und Token nimmt der Server entgegen, gibt sie aber nie
|
||
// zurueck – die Oberflaeche erfaehrt nur „eingerichtet ja/nein".
|
||
mealieStatus: (pruefen = false) => request(`/mealie/status${pruefen ? "?pruefen=true" : ""}`),
|
||
mealieVerfuegbar: () => request("/mealie/available"),
|
||
mealieKonfigurieren: (body) => request("/mealie/config", { method: "PUT", body }),
|
||
mealieToken: (body) => request("/mealie/token", { method: "PUT", body }),
|
||
mealieTrennen: () => request("/mealie/config", { method: "DELETE" }),
|
||
// Rezepte aus Mealie, gegen den Bestand geprueft.
|
||
recipes: ({ q, page = 1, perPage = 50 } = {}) => {
|
||
const s = new URLSearchParams({ page: String(page), per_page: String(perPage) });
|
||
if (q) s.set("q", q);
|
||
return request(`/recipes?${s.toString()}`);
|
||
},
|
||
recipe: (slug, servings) =>
|
||
request(`/recipes/${encodeURIComponent(slug)}${servings ? `?servings=${servings}` : ""}`),
|
||
listSettings: () => request("/settings"),
|
||
setSetting: (key, value) =>
|
||
request(`/settings/${key}?value=${encodeURIComponent(value)}`, { method: "PUT" }),
|
||
};
|