Files
certain/web/src/api.js
T
GuidoandClaude Opus 4.8 7461fddb7a feat: Phase 5 – PDF/Bild-Beleg-Import (lokal, Tesseract-OCR)
Backend:
- services/ocr: PDF-Text via PyMuPDF, OCR (Tesseract) für Scans/Bilder,
  heuristischer Parser (Datum + Betrag je Zeile, CH/DE/US-Beträge).
- routers/documents: /documents/preview (Upload -> Text -> Kandidaten ->
  gemeinsame Prüf-/Buchungslogik des CSV-Imports). csv_import refaktoriert:
  detect_rows() von CSV und Beleg gemeinsam genutzt.
- requirements: PyMuPDF, pytesseract, pillow; install.sh installiert tesseract-ocr
  (+ deu). pytest: Heuristik-Parser und PDF-Vorschau.

Frontend:
- Import-Seite: CSV/PDF-Umschalter; PDF-Modus mit Beleg-Auslesen, extrahiertem
  Text zur Kontrolle und Best-Effort-Hinweis; gleiche Prüfansicht + Buchen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-19 20:25:32 +02:00

205 lines
8.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 }),
};