Waehlt man eine Datei (oder uebernimmt eine vom Telefon), erkennt Certain Trennzeichen, Dezimalzeichen, Datumsformat und Spaltenzuordnung selbst und traegt sie ein. Darueber steht, WAS erkannt wurde – "Format erkannt: Revolut" oder die Spaltenzuordnung im Klartext –, damit man es pruefen kann, statt ihm blind zu vertrauen. War etwas nicht eindeutig, steht das ausdruecklich da mit dem Hinweis, die Zuordnung unten anzusehen. Das Formular bleibt also erreichbar, muss aber im Normalfall nicht mehr angefasst werden. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
305 lines
14 KiB
JavaScript
305 lines
14 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" }),
|
||
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" }),
|
||
fetchMailSource: (id) => request(`/mail/sources/${id}/fetch`, { 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" }),
|
||
|
||
// 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 });
|
||
},
|
||
};
|