Files
Vorrania/web/src/api.js
Scarriffle 5b952524d7 Gegenstands-Verwaltung (Non-Food) neben Lebensmitteln
Die Kategorie bestimmt die Verwaltungsart (food/object). Gegenstaende:
Menge je Lagerort statt Chargen/MHD, Umlagern (ohne Grund) und Entfernen
mit Pflicht-Grund samt Entnahme-Statistik. Beliebig viele eigene Felder
je Kategorie (vererbt an Unterkategorien), "gekauft bei" ueber eine
verwaltbare Shop-Liste und ein Produktlink. Barcode-Lookup zusaetzlich
ueber Open Products Facts.

Der Lebensmittel-Teil (Chargen/MHD/FEFO) bleibt unveraendert. Umgesetzt in
Backend (FastAPI, +Tests gruen), Web (React) und iOS (SwiftUI).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:33:56 +02:00

280 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 <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.
*/
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" }),
// 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" }),
};