// 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); } 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 }); 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. */ export async function authorizedObjectUrl(path) { const token = getToken(); const resp = await fetch(`${API_BASE}${path}`, { headers: token ? { Authorization: `Bearer ${token}` } : {}, }); 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"), // 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`), 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" }), // 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 }), deleteLot: (id) => request(`/lots/${id}`, { method: "DELETE" }), // Views shoppingList: () => request("/shopping-list"), groupShoppingList: () => request("/shopping-list/groups"), 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: () => request("/dashboard/by-category"), 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"), createLocation: (body) => request("/locations", { method: "POST", 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`), 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 listSettings: () => request("/settings"), setSetting: (key, value) => request(`/settings/${key}?value=${encodeURIComponent(value)}`, { method: "PUT" }), };