// 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"; const TOKEN_KEY = "project_good_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())}`; } // 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)}`), 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" }), // 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 }), 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}` : ""}`), // Stammdaten listLocations: () => request("/locations"), createLocation: (body) => request("/locations", { method: "POST", body }), deleteLocation: (id) => request(`/locations/${id}`, { method: "DELETE" }), // Kategorien: reine Ordnungshilfe, verschachtelbar (siehe Gruppen fuer Bestaende) 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" }), 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", `project-good-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" }), };