// 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 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/); // 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" }), };