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