// Zentraler API-Client. Alles läuft über /api (im Dev leitet der Vite-Proxy an // das Backend, in Produktion liefert derselbe FastAPI-Prozess API und SPA aus). // Die Anmeldung liegt in einem HttpOnly-Cookie – deshalb IMMER credentials:"include" // und kein Token im JavaScript. const API_BASE = "/api"; // Wird bei einer abgelaufenen/ungültigen Sitzung (HTTP 401) gerufen, damit die // Oberfläche nicht „angemeldet" bleibt, während jede Abfrage scheitert. 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, skipAuthHandler = false } = {}) { const headers = {}; let payload; if (formData) { payload = formData; // Content-Type setzt der Browser selbst } 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, credentials: "include", }); // Abgelaufene Sitzung zentral behandeln – aber nicht beim Login selbst und // nicht bei der stillen Startprüfung (dort ist 401 = „noch nicht angemeldet"). if (resp.status === 401 && path !== "/auth/login" && !skipAuthHandler) { 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}).`; } else if (typeof data === "string" && data.trim().startsWith("<")) { 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 für 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 Sitzungs-Cookie) laden und im Browser als Download anbieten. */ export async function downloadFile(path, filename) { const resp = await fetch(`${API_BASE}${path}`, { credentials: "include" }); 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 { zeitstempel }; export const api = { // Auth login: (username, password) => request("/auth/login", { method: "POST", form: { username, password } }), logout: () => request("/auth/logout", { method: "POST" }), me: (opts) => request("/auth/me", { skipAuthHandler: opts?.silent }), changePassword: (current_password, new_password) => request("/auth/change-password", { method: "POST", body: { current_password, new_password } }), version: () => request("/version"), // Benutzer (Admin) 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" }), // Einstellungen (je Benutzer) listSettings: () => request("/settings"), setSetting: (key, value) => request(`/settings/${key}?value=${encodeURIComponent(value)}`, { method: "PUT" }), // Konten listAccounts: () => request("/accounts"), getAccount: (id) => request(`/accounts/${id}`), createAccount: (body) => request("/accounts", { method: "POST", body }), updateAccount: (id, body) => request(`/accounts/${id}`, { method: "PATCH", body }), deleteAccount: (id) => request(`/accounts/${id}`, { method: "DELETE" }), // Kategorien (Gruppen + Kategorien) listCategories: () => request("/categories"), createGroup: (body) => request("/categories/groups", { method: "POST", body }), updateGroup: (id, body) => request(`/categories/groups/${id}`, { method: "PATCH", body }), deleteGroup: (id) => request(`/categories/groups/${id}`, { method: "DELETE" }), createCategory: (body) => request("/categories", { method: "POST", body }), updateCategory: (id, body) => request(`/categories/${id}`, { method: "PATCH", body }), deleteCategory: (id) => request(`/categories/${id}`, { method: "DELETE" }), // Transaktionen listTransactions: (params = {}) => { const qs = new URLSearchParams(); for (const [k, v] of Object.entries(params)) { if (v !== undefined && v !== null && v !== "") qs.set(k, v); } const s = qs.toString(); return request(`/transactions${s ? `?${s}` : ""}`); }, getTransaction: (id) => request(`/transactions/${id}`), createTransaction: (body) => request("/transactions", { method: "POST", body }), updateTransaction: (id, body) => request(`/transactions/${id}`, { method: "PATCH", body }), deleteTransaction: (id) => request(`/transactions/${id}`, { method: "DELETE" }), // Wechselkurse rateStatus: () => request("/rates/status"), convertRate: (amountCents, from, to, on) => { const qs = new URLSearchParams({ amount_cents: amountCents, from_currency: from, to_currency: to }); if (on) qs.set("on", on); return request(`/rates/convert?${qs.toString()}`); }, // Budget budgetCurrent: () => request("/budget/current"), budget: (label) => request(`/budget/${label}`), budgetTemplate: () => request("/budget/template"), setTemplateCategory: (catId, cents) => request(`/budget/template/category/${catId}?planned_cents=${cents}`, { method: "PUT" }), setMonthCategory: (label, catId, cents) => request(`/budget/${label}/category/${catId}?planned_cents=${cents}`, { method: "PUT" }), resetMonth: (label) => request(`/budget/${label}`, { method: "DELETE" }), createIncome: (body) => request("/budget/income", { method: "POST", body }), updateIncome: (id, body) => request(`/budget/income/${id}`, { method: "PATCH", body }), deleteIncome: (id) => request(`/budget/income/${id}`, { method: "DELETE" }), // Dashboard dashboardLayout: () => request("/dashboard/layout"), saveDashboardLayout: (layout) => request("/dashboard/layout", { method: "PUT", body: layout }), resetDashboardLayout: () => request("/dashboard/layout", { method: "DELETE" }), cardBudgetDonut: () => request("/dashboard/budget-donut"), cardBudgetProgress: () => request("/dashboard/budget-progress"), cardIncomeExpense: () => request("/dashboard/income-expense"), cardBalances: () => request("/dashboard/balances"), cardNetWorth: (days) => request(`/dashboard/net-worth${days ? `?days=${days}` : ""}`), cardRecent: (limit) => request(`/dashboard/recent${limit ? `?limit=${limit}` : ""}`), // CSV-Import importPresets: () => request("/imports/presets"), importTemplates: () => request("/imports/templates"), createImportTemplate: (body) => request("/imports/templates", { method: "POST", body }), updateImportTemplate: (id, body) => request(`/imports/templates/${id}`, { method: "PATCH", body }), deleteImportTemplate: (id) => request(`/imports/templates/${id}`, { method: "DELETE" }), importRaw: (formData) => request("/imports/raw", { method: "POST", formData }), importPreview: (formData) => request("/imports/preview", { method: "POST", formData }), importCommit: (body) => request("/imports/commit", { method: "POST", body }), importBatches: () => request("/imports/batches"), undoImportBatch: (id) => request(`/imports/batches/${id}`, { method: "DELETE" }), // PDF/Bild-Beleg documentPreview: (formData) => request("/documents/preview", { method: "POST", formData }), };