Files
Vorrania/web/src/api.js
T
ScarriffleandClaude Opus 5 a3fd9819c4 Rueckgabe sichtbar machen, Zubehoerteile am Einzelstueck
RUECKGABE
Der Knopf war da, nur nicht zu finden: ein 16-px-Symbol in einer Spalte ohne
Ueberschrift, 52 px breit, ganz rechts in einer horizontal scrollenden Tabelle.
Beschriftet war er allein ueber title= - ein Tooltip, den auf einem Touchgeraet
nie jemand sieht.

Jetzt traegt er Text ("Zuruecknehmen"), die Spalte heisst "Verleih" und ist
breit genug, und beide Knoepfe haben ein aria-label. Dazu eine neue Seite
"Verliehen" in der Seitenleiste: alles Draussene an einem Ort, Rueckgabe direkt
aus der Liste. Bisher gab es diese Uebersicht nur als zwei Dashboard-Karten, und
die stehen nicht im Standard-Layout - wer sie nicht selbst hinzufuegte, hatte
gar keine.

In der App fuehrte der einzige Weg ueber das Bearbeiten-Formular des Stuecks;
jetzt geht es per Wischgeste und Kontextmenue direkt aus der Liste. Der
Verleih-Filter dort sass hinter einem nackten Symbol und ist beschriftet.

Zurueckgenommen wird nur noch nach Rueckfrage, in Web und App.

ZUBEHOER
Ein Netzteil gehoert zu einem Laptop: items.parent_item_id, genau ein Geraet je
Teil, beliebig viele Teile je Geraet. Eine Spalte genuegt dafuer, es braucht
keine Verknuepfungstabelle. ON DELETE SET NULL, damit das Netzteil den Laptop
ueberlebt statt mitgeloescht zu werden - und weil SQLite Fremdschluessel nicht
erzwingt, loest der Router die Zuordnung beim Loeschen zusaetzlich selbst.

Ringe (A traegt B, B traegt A) weist die Zuordnung mit 409 ab; die Elternkette
wird dafuer hochgelaufen, wie beim Kategoriebaum.

Die Beziehung laeuft ueber eigene Endpunkte und bewusst NICHT ueber ItemUpdate:
dessen Schema sendet immer alle Felder mit, auch leere, damit sich Felder
loeschen lassen. Genau so gingen schon einmal Kaufpreis und Waehrung verloren,
als ein Aufrufer ein Feld vergass.

Beim Verleihen fragt die Oberflaeche, welche Teile mitgehen (vorausgewaehlt:
alles, was im Haus ist), beim Zuruecknehmen kommen sie zusammen zurueck. Ohne
das Spiegelbild kaeme das Geraet zurueck und sein Netzteil bliebe ewig
"verliehen", weil niemand mehr danach sucht. Ungefragt nimmt der Server nie
etwas mit.

In der App sind zubehoer und gehoert_zu optional dekodiert: Die App spricht mit
mehreren Serverprofilen, und ein aelterer Server schickt die Felder nicht - ein
Vorgabewert reicht dafuer nicht, Swift nimmt ihn beim Dekodieren nicht her.

12 neue Tests (320 gesamt). Der Umlauf ist ausserdem gegen ein lokales Backend
im Browser durchgespielt: verleihen mit Zubehoer, Rueckfrage, beide zusammen
zurueck.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 14:08:11 +02:00

407 lines
19 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);
}
// 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 <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.
*
* `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"),
// 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 } }),
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" }),
};