/dashboard/by-category bekommt depth: rollt jede Artikel-Kategorie auf ihren Vorfahren der gewuenschten Stufe hoch (1 = oberste Ebene), ohne depth zaehlt jede zugeordnete Kategorie einzeln (feinste). Neue Karten-Konfig select Kategorie-Tiefe (Feinste / oberste / bis 2. / bis 3. Stufe). KartenConfig zeigt jetzt auch bei select den Hinweis. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
336 lines
15 KiB
JavaScript
336 lines
15 KiB
JavaScript
// 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" }),
|
||
// 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" }),
|
||
|
||
// 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 } }),
|
||
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.
|
||
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
|
||
listSettings: () => request("/settings"),
|
||
setSetting: (key, value) =>
|
||
request(`/settings/${key}?value=${encodeURIComponent(value)}`, { method: "PUT" }),
|
||
};
|