Zwei Defekte, die zusammen das gemeldete Muster ergeben.
1) Es gab keine zentrale Behandlung von HTTP 401. AuthProvider prueft die
Anmeldung EINMAL beim Start; laeuft das Token danach ab, bleibt der Zustand
"angemeldet" stehen, waehrend jede Abfrage scheitert. Seiten mit
.catch(() => {}) (18 Stellen) zeigen dann schlicht nichts, andere eine
englische Servermeldung - genau das "mal gar nichts, mal die Haelfte".
Jetzt raeumt der API-Client bei 401 die Sitzung auf und der Login sagt, dass
sie abgelaufen ist. Beim Anmelden selbst greift das nicht, dort heisst 401
"Passwort falsch".
2) Der Start hatte ein blankes `catch`, das das Token bei JEDEM Fehler loeschte -
auch bei 502 oder weggebrochenem Netz. Wer waehrend eines Server-Neustarts F5
drueckte, war deshalb abgemeldet, obwohl die Sitzung voellig in Ordnung war.
Das duerfte der haeufigere Ausloeser gewesen sein, weil in letzter Zeit oft neu
deployt wurde. Jetzt wird nur bei 401/403 abgemeldet; bei allem anderen bleibt
die Anmeldung liegen und es erscheint "Server nicht erreichbar" mit einem
Knopf zum erneuten Versuchen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
350 lines
16 KiB
JavaScript
350 lines
16 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);
|
||
}
|
||
|
||
// 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.
|
||
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".
|
||
if (resp.status === 401 && path !== "/auth/login") {
|
||
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 <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" }),
|
||
};
|