Die Kategorie beantwortet "wofuer war das Geld" und gilt genau einmal. Alles, was quer dazu liegt – "Urlaub Island", "absetzbar", "zurueckfordern" –, hatte bisher keinen Platz, weil eine Buchung davon mehrere auf einmal sein kann. Tags werden auf einer eigenen Seite angelegt, jeder mit seiner Farbe. Gesetzt werden sie an der Buchung: direkt in der Liste oder beim Bearbeiten. Die Spalte laesst sich filtern, auch nach mehreren Tags gleichzeitig. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
437 lines
22 KiB
JavaScript
437 lines
22 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)
|
||
// Anmeldung ueber einen fremden Dienst. Start und Verknuepfen sind LINKS,
|
||
// keine fetch-Aufrufe: Der Browser muss die Seite wirklich wechseln.
|
||
oidcProviders: () => request("/auth/oidc/providers"),
|
||
oidcStartUrl: (name) => `${API_BASE}/auth/oidc/${name}/start`,
|
||
oidcLinkUrl: (name, weiter) =>
|
||
`${API_BASE}/auth/oidc/${name}/link?weiter=${encodeURIComponent(weiter || "/konto")}`,
|
||
oidcIdentities: () => request("/auth/oidc/identities"),
|
||
oidcUnlink: (id) => request(`/auth/oidc/identities/${id}`, { method: "DELETE" }),
|
||
|
||
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" }),
|
||
setAccountOrder: (ids) => request("/accounts/order", { method: "PUT", body: { ids } }),
|
||
|
||
// Schnellerfassung: Gerätemarken und Eingang
|
||
listTokens: () => request("/inbox/tokens"),
|
||
createToken: (body) => request("/inbox/tokens", { method: "POST", body }),
|
||
deleteToken: (id) => request(`/inbox/tokens/${id}`, { method: "DELETE" }),
|
||
listInbox: () => request("/inbox"),
|
||
discardInboxItem: (id) => request(`/inbox/${id}`, { method: "DELETE" }),
|
||
setInboxCategory: (id, categoryId) =>
|
||
request(`/inbox/${id}/category/${categoryId}`, { method: "POST" }),
|
||
bookInboxItem: (id) => request(`/inbox/${id}/book`, { method: "POST" }),
|
||
bookAllInbox: () => request("/inbox/book-all", { method: "POST" }),
|
||
detectFormat: (formData) => request("/imports/detect", { method: "POST", formData }),
|
||
listUploads: () => request("/imports/uploads"),
|
||
deleteUpload: (id) => request(`/imports/uploads/${id}`, { method: "DELETE" }),
|
||
|
||
// Postfächer für den Mail-Abruf
|
||
listMailSources: () => request("/mail/sources"),
|
||
createMailSource: (body) => request("/mail/sources", { method: "POST", body }),
|
||
updateMailSource: (id, body) => request(`/mail/sources/${id}`, { method: "PUT", body }),
|
||
deleteMailSource: (id) => request(`/mail/sources/${id}`, { method: "DELETE" }),
|
||
testMailSource: (id) => request(`/mail/sources/${id}/test`, { method: "POST" }),
|
||
// `tage` uebergeht den letzten Abruf – fuer Mails, die schon vor der
|
||
// Einrichtung im Postfach lagen und sonst ausserhalb des Fensters blieben.
|
||
fetchMailSource: (id, tage) =>
|
||
request(`/mail/sources/${id}/fetch${tage ? `?tage=${tage}` : ""}`,
|
||
{ method: "POST" }),
|
||
listMailSenders: (id) => request(`/mail/sources/${id}/senders`),
|
||
createMailSender: (id, body) => request(`/mail/sources/${id}/senders`, { method: "POST", body }),
|
||
updateMailSender: (id, body) => request(`/mail/senders/${id}`, { method: "PUT", body }),
|
||
deleteMailSender: (id) => request(`/mail/senders/${id}`, { method: "DELETE" }),
|
||
|
||
// Bankanbindung über PSD2
|
||
getBankApp: () => request("/bank/app"),
|
||
saveBankApp: (body) => request("/bank/app", { method: "PUT", body }),
|
||
deleteBankApp: () => request("/bank/app", { method: "DELETE" }),
|
||
listBanks: (country, search) => {
|
||
const p = new URLSearchParams();
|
||
if (country) p.set("country", country);
|
||
if (search) p.set("search", search);
|
||
return request(`/bank/banks?${p.toString()}`);
|
||
},
|
||
connectBank: (body) => request("/bank/connect", { method: "POST", body }),
|
||
bankCallback: (state, code) =>
|
||
request(`/bank/callback?state=${encodeURIComponent(state)}&code=${encodeURIComponent(code)}`,
|
||
{ method: "POST" }),
|
||
// Einen Eingangs-Vorschlag als Kontoübertrag buchen statt als Ausgabe.
|
||
// `trotzdem` nur setzen, wenn der Server mit 409 gewarnt hat und der Nutzer
|
||
// die Warnung gelesen und bestätigt hat.
|
||
bookInboxAsTransfer: (id, toAccountId, trotzdem = false) =>
|
||
request(`/inbox/${id}/transfer`, {
|
||
method: "POST", body: { to_account_id: toAccountId, trotzdem },
|
||
}),
|
||
|
||
// --- Überträge zwischen eigenen Konten -----------------------------------
|
||
// Ändern ausschliesslich die Verknüpfungs-Schicht. Keine Buchung wird
|
||
// angefasst – deshalb ist auch eine falsche Verknüpfung folgenlos.
|
||
// Eine gebuchte Ausgabe oder Einnahme nachtraeglich zum Uebertrag machen.
|
||
makeTransactionTransfer: (id, toAccountId, trotzdem = false) =>
|
||
request(`/transactions/${id}/transfer`, {
|
||
method: "POST", body: { to_account_id: toAccountId, trotzdem },
|
||
}),
|
||
// Regeln nachträglich auf Buchungen ohne Kategorie anwenden. `vorschau`
|
||
// ändert nichts – erst zeigen, dann tun.
|
||
applyRules: (vorschau = true) =>
|
||
request(`/categories/keywords/anwenden?vorschau=${vorschau}`, { method: "POST" }),
|
||
// Buchungen, die gleich aussehen – gemeldet, nicht entschieden.
|
||
duplicateTransactions: () => request("/transactions/dubletten"),
|
||
// Beide behalten: Die Frage ist beantwortet, die Buchungen bleiben.
|
||
keepDuplicates: (ids) =>
|
||
request("/transactions/dubletten/behalten", { method: "POST", body: { ids } }),
|
||
transactionLinks: () => request("/transactions/links"),
|
||
// Nur fragen, nichts tun – fuer Ansichten, die bloss anzeigen wollen,
|
||
// dass hier ein Uebertrag stecken koennte.
|
||
transferSuggestions: () => request("/uebertraege/vorschlaege"),
|
||
searchTransfers: () => request("/uebertraege/suchen", { method: "POST" }),
|
||
connectTransfer: (a_id, b_id) =>
|
||
request("/uebertraege/verbinden", { method: "POST", body: { a_id, b_id } }),
|
||
releaseTransfer: (linkId) =>
|
||
request(`/uebertraege/${linkId}`, { method: "DELETE" }),
|
||
ignoreTransfer: (a_id, b_id) =>
|
||
request("/uebertraege/ignorieren", { method: "POST", body: { a_id, b_id } }),
|
||
// Abgelehnte Vorschlaege ansehen und zuruecknehmen. Ohne das waere
|
||
// „Ignorieren" eine Einbahnstrasse.
|
||
ignoredTransfers: () => request("/uebertraege/ignoriert"),
|
||
unignoreTransfer: (a_id, b_id) =>
|
||
request("/uebertraege/ignoriert", { method: "DELETE", body: { a_id, b_id } }),
|
||
|
||
listBankConnections: () => request("/bank/connections"),
|
||
syncBankConnection: (id) => request(`/bank/connections/${id}/sync`, { method: "POST" }),
|
||
refreshBankBalances: (id) =>
|
||
request(`/bank/connections/${id}/salden`, { method: "POST" }),
|
||
// Nur dieses eine Konto abrufen – ein Abruf bucht, und wer ein Konto holt,
|
||
// will das andere nicht angefasst haben.
|
||
syncBankLink: (linkId, tage) =>
|
||
request(`/bank/links/${linkId}/sync${tage ? `?tage=${tage}` : ""}`,
|
||
{ method: "POST" }),
|
||
// Den Saldo der Bank als richtig uebernehmen – die Differenz wandert in
|
||
// den Anfangsbestand, die Buchungen bleiben unangetastet.
|
||
// Woraus sich ein Saldo zusammensetzt – zum Nachrechnen, wenn er nicht stimmt.
|
||
balanceBreakdown: (accountId) => request(`/accounts/${accountId}/aufschluesselung`),
|
||
adoptBankBalance: (accountId) =>
|
||
request(`/accounts/${accountId}/saldo-uebernehmen`, { method: "POST" }),
|
||
setBankPoll: (id, minutes) =>
|
||
request(`/bank/connections/${id}/takt?minutes=${minutes}`, { method: "PUT" }),
|
||
deleteBankConnection: (id) => request(`/bank/connections/${id}`, { method: "DELETE" }),
|
||
listBankLinks: () => request("/bank/links"),
|
||
setBankLinkAccount: (linkId, accountId) =>
|
||
request(`/bank/links/${linkId}/account/${accountId}`, { method: "POST" }),
|
||
clearBankLinkAccount: (linkId) =>
|
||
request(`/bank/links/${linkId}/account`, { method: "DELETE" }),
|
||
|
||
// Kategorien (Gruppen + Kategorien)
|
||
// Abos & Verträge
|
||
listSubscriptions: () => request("/subscriptions"),
|
||
createSubscription: (body) => request("/subscriptions", { method: "POST", body }),
|
||
updateSubscription: (id, body) => request(`/subscriptions/${id}`, { method: "PATCH", body }),
|
||
deleteSubscription: (id) => request(`/subscriptions/${id}`, { method: "DELETE" }),
|
||
// Zuordnung Buchung -> Vertrag. Beim Zuordnen von Hand lernt der Vertrag
|
||
// den Empfaengertext der Buchung.
|
||
assignSubscription: (subId, txnId) =>
|
||
request(`/subscriptions/${subId}/buchungen/${txnId}`, { method: "POST" }),
|
||
unassignSubscription: (txnId) =>
|
||
request(`/subscriptions/buchungen/${txnId}`, { method: "DELETE" }),
|
||
// Das Logo geht als Datei raus, nicht als JSON – deshalb FormData statt body.
|
||
uploadSubscriptionLogo: (id, datei) => {
|
||
const fd = new FormData();
|
||
fd.append("file", datei);
|
||
return request(`/subscriptions/${id}/logo`, { method: "PUT", formData: fd });
|
||
},
|
||
deleteSubscriptionLogo: (id) => request(`/subscriptions/${id}/logo`, { method: "DELETE" }),
|
||
subscriptionLogoUrl: (id, version) =>
|
||
`/api/subscriptions/${id}/logo${version ? `?v=${version}` : ""}`,
|
||
|
||
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" }),
|
||
|
||
// Tags. Gesetzt werden sie an der Buchung (updateTransaction mit tag_ids),
|
||
// hier geht es nur um die Tags selbst.
|
||
listTags: () => request("/tags"),
|
||
createTag: (body) => request("/tags", { method: "POST", body }),
|
||
updateTag: (id, body) => request(`/tags/${id}`, { method: "PATCH", body }),
|
||
deleteTag: (id) => request(`/tags/${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" }),
|
||
// Betriebsart und Zuschlag einer Budget-Zeile. Getrennt vom Soll-Betrag,
|
||
// weil bei "berechnet" der Betrag gar nicht mehr gesetzt wird.
|
||
setTemplateModus: (catId, modus, zuschlagCents) =>
|
||
request(`/budget/template/category/${catId}?modus=${modus}&zuschlag_cents=${zuschlagCents}`,
|
||
{ method: "PUT" }),
|
||
setMonthModus: (label, catId, modus, zuschlagCents) =>
|
||
request(`/budget/${label}/category/${catId}?modus=${modus}&zuschlag_cents=${zuschlagCents}`,
|
||
{ 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}` : ""}`),
|
||
// Woraus ein Anteil besteht – dieselbe Stelle, die auch die Summe bildet.
|
||
cardExpenseShareRows: (ids) =>
|
||
request(`/dashboard/expense-share/buchungen?${ids.map((i) => `ids=${i}`).join("&")}`),
|
||
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 });
|
||
},
|
||
};
|