Auf ausdruecklichen Wunsch jetzt auch die internen Bezeichner, weil die Installation noch keine echten Daten enthaelt: POSTGRES_USER/PASSWORD/DB, der Volume-Name, die Standard-DATABASE_URL, der localStorage-Schluessel und die iOS-Zeichenketten inklusive Keychain-Konto. Das setzt eine leere Datenbank voraus: Der neue Volume-Name legt eine neue, leere Datenbank an. Die alte bleibt als verwaistes Volume liegen und muss von Hand entfernt werden. Die Warnung in docker-compose.yml bleibt stehen, damit eine spaetere Umbenennung nicht versehentlich mit Daten im Volume passiert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
231 lines
9.3 KiB
JavaScript
231 lines
9.3 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())}`;
|
|
}
|
|
|
|
// 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)}`),
|
|
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 }),
|
|
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: Anordnung und Auswertungen
|
|
dashboardLayout: () => request("/dashboard/layout"),
|
|
saveDashboardLayout: (layout) => request("/dashboard/layout", { method: "PUT", body: { layout } }),
|
|
resetDashboardLayout: () => request("/dashboard/layout", { method: "DELETE" }),
|
|
saveDashboardDefault: (layout) =>
|
|
request("/dashboard/layout/default", { method: "PUT", body: { layout } }),
|
|
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: reine Ordnungshilfe, verschachtelbar (siehe Gruppen fuer Bestaende)
|
|
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" }),
|
|
|
|
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" }),
|
|
};
|