Zeigt für den laufenden Budget-Monat, wie sich die tatsächlichen Ausgaben prozentual auf die Kategorien verteilen – absteigend sortiert, mit Balken in der Kategoriefarbe, Prozentwert und Betrag. Anders als der Budget-Donut hängt sie nicht am geplanten Budget: Sie zeigt reine Ist-Ausgaben, auch wenn für den Monat nichts geplant wurde. Umschaltbar zwischen Kategorien (Standard) und Gruppen. Die Anteile rechnet die Oberfläche aus Betrag und Summe – gerundete Prozente vom Server addierten sich sonst nicht auf 100 %. Wird die Karte schmal, gibt zuerst der Betrag nach; ohne Kategorienamen sagt die Zeile nichts mehr aus. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
278 lines
12 KiB
JavaScript
278 lines
12 KiB
JavaScript
// 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())}`
|
||
);
|
||
}
|
||
|
||
/** Dateiname aus dem Content-Disposition-Kopf, sonst der Rückfallwert. */
|
||
function dateinameAusKopf(resp, rueckfall) {
|
||
const kopf = resp.headers.get("content-disposition") || "";
|
||
const treffer = /filename="([^"]+)"/.exec(kopf);
|
||
return treffer ? treffer[1] : rueckfall;
|
||
}
|
||
|
||
/**
|
||
* Datei (mit Sitzungs-Cookie) laden und im Browser als Download anbieten.
|
||
*
|
||
* Den Namen gibt der Server vor – sonst hiesse dieselbe Datei je nachdem, ob
|
||
* man sie über die Oberfläche oder direkt über die API holt, anders. `rueckfall`
|
||
* greift nur, wenn der Kopf fehlt.
|
||
*/
|
||
export async function downloadFile(path, rueckfall) {
|
||
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 = dateinameAusKopf(resp, rueckfall);
|
||
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"),
|
||
|
||
// Zwei-Faktor. verifyTwoFactor ist der zweite Anmeldeschritt und läuft noch
|
||
// über das kurzlebige Zwischen-Cookie – dort ist ein 401 normal, also nicht
|
||
// vom globalen Abmelde-Griff behandeln lassen.
|
||
verifyTwoFactor: (code, recovery = false) =>
|
||
request("/auth/2fa/verify", { method: "POST", body: { code, recovery }, skipAuthHandler: true }),
|
||
twoFactorStatus: () => request("/auth/2fa/status"),
|
||
twoFactorSetup: () => request("/auth/2fa/setup", { method: "POST" }),
|
||
twoFactorActivate: (code) => request("/auth/2fa/activate", { method: "POST", body: { code } }),
|
||
twoFactorDisable: (password) =>
|
||
request("/auth/2fa/disable", { method: "POST", body: { password } }),
|
||
newRecoveryCodes: (password) =>
|
||
request("/auth/2fa/recovery-codes", { method: "POST", body: { password } }),
|
||
|
||
// Passwortrichtlinie (lesen: alle, ändern: Admin)
|
||
getPolicy: () => request("/policy"),
|
||
setPolicy: (body) => request("/policy", { method: "PUT", body }),
|
||
|
||
// 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" }),
|
||
resetUserTwoFactor: (id) => request(`/users/${id}/2fa/reset`, { method: "POST" }),
|
||
|
||
// 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" }),
|
||
|
||
// Schlagwörter je Kategorie (manuelle Auto-Zuordnung, optional mit Betrag) + Prüfmeldungen
|
||
listKeywords: (categoryId) => request(`/categories/${categoryId}/keywords`),
|
||
addKeyword: (categoryId, body) => request(`/categories/${categoryId}/keywords`, { method: "POST", body }),
|
||
updateKeyword: (ruleId, body) => request(`/categories/keywords/${ruleId}`, { method: "PATCH", body }),
|
||
deleteKeyword: (ruleId) => request(`/categories/keywords/${ruleId}`, { method: "DELETE" }),
|
||
flaggedKeywords: () => request("/categories/keywords/flagged"),
|
||
confirmKeyword: (ruleId) => request(`/categories/keywords/${ruleId}/confirm`, { method: "POST" }),
|
||
// Transfer-Schlagwörter (Text -> Transfer, Zielkonto über Konto-Texte)
|
||
listTransferKeywords: () => request("/categories/transfer-keywords"),
|
||
addTransferKeyword: (body) => request("/categories/transfer-keywords", { method: "POST", body }),
|
||
|
||
// 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" }),
|
||
budgetTemplateFromMonth: (label) => request(`/budget/template/from-month/${label}`, { method: "POST" }),
|
||
budgetMonthFromTemplate: (label) => request(`/budget/${label}/from-template`, { method: "POST" }),
|
||
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: (groupBy) => request(`/dashboard/budget-donut${groupBy ? `?group_by=${groupBy}` : ""}`),
|
||
cardExpenseShare: (groupBy) => request(`/dashboard/expense-share${groupBy ? `?group_by=${groupBy}` : ""}`),
|
||
cardBudgetProgress: (groupBy) => request(`/dashboard/budget-progress${groupBy ? `?group_by=${groupBy}` : ""}`),
|
||
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" }),
|
||
removeImportBatch: (id) => request(`/imports/batches/${id}/record`, { method: "DELETE" }),
|
||
|
||
// PDF/Bild-Beleg
|
||
documentPreview: (formData) => request("/documents/preview", { method: "POST", formData }),
|
||
|
||
// Backup & Export
|
||
// Die Dateinamen kommen aus dem Content-Disposition-Kopf (backend/app/routers/
|
||
// backup.py, _dateiname). Was hier steht, ist nur der Rückfall.
|
||
downloadDbBackup: () =>
|
||
downloadFile("/backup/db", `certain-finance_datenbank_${zeitstempel()}.db`),
|
||
exportTransactionsCsv: () =>
|
||
downloadFile("/export/transactions.csv", `certain-finance_buchungen_${zeitstempel()}.csv`),
|
||
exportTransactionsJson: () =>
|
||
downloadFile("/export/transactions.json", `certain-finance_buchungen_${zeitstempel()}.json`),
|
||
exportAllJson: () =>
|
||
downloadFile("/export/all.json", `certain-finance_datenuebersicht_${zeitstempel()}.json`),
|
||
exportBackupJson: () =>
|
||
downloadFile("/export/backup.json", `certain-finance_vollbackup_${zeitstempel()}.json`),
|
||
exportSystemBackupJson: () =>
|
||
downloadFile("/export/system-backup.json", `certain-finance_vollbackup-system_${zeitstempel()}.json`),
|
||
restorePreview: (file) => {
|
||
const fd = new FormData();
|
||
fd.append("file", file);
|
||
return request("/backup/restore/preview", { method: "POST", formData: fd });
|
||
},
|
||
restoreBackup: (file, mode) => {
|
||
const fd = new FormData();
|
||
fd.append("file", file);
|
||
fd.append("mode", mode);
|
||
return request("/backup/restore", { method: "POST", formData: fd });
|
||
},
|
||
};
|