feat: Backup/Export + Import-UX klarer + Raiffeisen-Preset-Fix
Backup & Export: - routers/backup: /backup/db (Admin, konsistentes SQLite-Online-Backup), /export/transactions.csv|json und /export/all.json (owner-gescoped). - Einstellungen: Bereich „Backup & Export" mit Download-Buttons. docs/BACKUP.md (Wiederherstellung + LXC-Sicherung). pytest für Export + Admin-Schutz. Import-Verbesserungen (Nutzer-Feedback zum Ablauf): - Klarer Hinweis, dass die Zuordnungstabelle nur Beispielzeilen zeigt und „Vorschau erstellen" die vollständige Prüfliste + Buchen-Button erzeugt. - Zusammenfassung (erkannt/gebucht) und Warnung, wenn keine gültige Zeile erkannt wurde (Hinweis auf Format/Bank-Vorlage). - Fix: Raiffeisen-Preset hat has_header=true (der Export hat eine Kopfzeile). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@ from .migrate import upgrade_to_head
|
|||||||
from .routers import (
|
from .routers import (
|
||||||
accounts,
|
accounts,
|
||||||
auth,
|
auth,
|
||||||
|
backup,
|
||||||
budgets,
|
budgets,
|
||||||
categories,
|
categories,
|
||||||
dashboard,
|
dashboard,
|
||||||
@@ -107,6 +108,7 @@ api.include_router(budgets.router)
|
|||||||
api.include_router(dashboard.router)
|
api.include_router(dashboard.router)
|
||||||
api.include_router(imports.router)
|
api.include_router(imports.router)
|
||||||
api.include_router(documents.router)
|
api.include_router(documents.router)
|
||||||
|
api.include_router(backup.router)
|
||||||
api.include_router(rates.router)
|
api.include_router(rates.router)
|
||||||
|
|
||||||
app.include_router(api)
|
app.include_router(api)
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
"""Backup & Export: kompletter DB-Download (Admin) und Datenexport je Nutzer."""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from starlette.background import BackgroundTask
|
||||||
|
|
||||||
|
from ..config import get_settings
|
||||||
|
from ..database import get_db
|
||||||
|
from ..deps import get_current_user, require_admin
|
||||||
|
from ..models import (
|
||||||
|
Account,
|
||||||
|
AccountIdentifier,
|
||||||
|
BudgetTemplateLine,
|
||||||
|
Category,
|
||||||
|
CategoryGroup,
|
||||||
|
DashboardLayout,
|
||||||
|
PlannedIncomeLine,
|
||||||
|
Transaction,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(tags=["backup"])
|
||||||
|
|
||||||
|
|
||||||
|
def _ts() -> str:
|
||||||
|
return datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||||
|
|
||||||
|
|
||||||
|
def _money(cents: int | None) -> str:
|
||||||
|
return f"{(cents or 0) / 100:.2f}"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Komplettes DB-Backup (nur Admin) --------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/backup/db")
|
||||||
|
def backup_db(_: User = Depends(require_admin)):
|
||||||
|
url = get_settings().effective_database_url
|
||||||
|
if not url.startswith("sqlite:///"):
|
||||||
|
raise HTTPException(status_code=400, detail="DB-Backup ist nur für SQLite verfügbar.")
|
||||||
|
db_path = url.replace("sqlite:///", "", 1)
|
||||||
|
tmp = tempfile.NamedTemporaryFile(prefix="cf-backup-", suffix=".db", delete=False)
|
||||||
|
tmp.close()
|
||||||
|
# Online-Backup-API: konsistente Kopie auch bei laufendem Betrieb.
|
||||||
|
src = sqlite3.connect(db_path)
|
||||||
|
dst = sqlite3.connect(tmp.name)
|
||||||
|
try:
|
||||||
|
with dst:
|
||||||
|
src.backup(dst)
|
||||||
|
finally:
|
||||||
|
dst.close()
|
||||||
|
src.close()
|
||||||
|
return FileResponse(
|
||||||
|
tmp.name,
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
filename=f"certain-finance-{_ts()}.db",
|
||||||
|
background=BackgroundTask(os.remove, tmp.name),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Transaktionsexport ----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _maps(db: Session, user: User) -> tuple[dict[int, Account], dict[int, Category]]:
|
||||||
|
accs = {a.id: a for a in db.query(Account).filter(Account.owner_id == user.id)}
|
||||||
|
cats = {c.id: c for c in db.query(Category).filter(Category.owner_id == user.id)}
|
||||||
|
return accs, cats
|
||||||
|
|
||||||
|
|
||||||
|
def _txns(db: Session, user: User) -> list[Transaction]:
|
||||||
|
return (
|
||||||
|
db.query(Transaction)
|
||||||
|
.filter(Transaction.owner_id == user.id)
|
||||||
|
.order_by(Transaction.date, Transaction.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export/transactions.csv")
|
||||||
|
def export_transactions_csv(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
accs, cats = _maps(db, user)
|
||||||
|
buf = io.StringIO()
|
||||||
|
w = csv.writer(buf, delimiter=";")
|
||||||
|
w.writerow(["Datum", "Typ", "Konto", "Betrag", "Waehrung", "EUR-Wert",
|
||||||
|
"Zielkonto", "Kategorie", "Empfaenger", "Verwendungszweck", "Notiz"])
|
||||||
|
for t in _txns(db, user):
|
||||||
|
acc = accs.get(t.account_id)
|
||||||
|
w.writerow([
|
||||||
|
t.date.isoformat(), t.type.value, acc.name if acc else "",
|
||||||
|
_money(t.amount_cents), acc.currency if acc else "", _money(t.amount_eur_cents),
|
||||||
|
accs[t.to_account_id].name if t.to_account_id and t.to_account_id in accs else "",
|
||||||
|
cats[t.category_id].name if t.category_id and t.category_id in cats else "",
|
||||||
|
t.payee or "", t.description or "", t.notes or "",
|
||||||
|
])
|
||||||
|
return Response(
|
||||||
|
content=buf.getvalue().encode("utf-8-sig"),
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="transaktionen-{_ts()}.csv"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _txn_dict(t: Transaction, accs: dict[int, Account], cats: dict[int, Category]) -> dict:
|
||||||
|
acc = accs.get(t.account_id)
|
||||||
|
return {
|
||||||
|
"date": t.date.isoformat(),
|
||||||
|
"type": t.type.value,
|
||||||
|
"account": acc.name if acc else None,
|
||||||
|
"currency": acc.currency if acc else None,
|
||||||
|
"amount_cents": t.amount_cents,
|
||||||
|
"amount_eur_cents": t.amount_eur_cents,
|
||||||
|
"to_account": accs[t.to_account_id].name if t.to_account_id in accs else None,
|
||||||
|
"to_amount_cents": t.to_amount_cents,
|
||||||
|
"category": cats[t.category_id].name if t.category_id in cats else None,
|
||||||
|
"payee": t.payee,
|
||||||
|
"description": t.description,
|
||||||
|
"notes": t.notes,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export/transactions.json")
|
||||||
|
def export_transactions_json(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
accs, cats = _maps(db, user)
|
||||||
|
data = [_txn_dict(t, accs, cats) for t in _txns(db, user)]
|
||||||
|
return Response(
|
||||||
|
content=_json_bytes(data),
|
||||||
|
media_type="application/json",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="transaktionen-{_ts()}.json"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Gesamt-Export (alle eigenen Daten als JSON) ---------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _json_bytes(obj) -> bytes:
|
||||||
|
import json
|
||||||
|
|
||||||
|
return json.dumps(obj, ensure_ascii=False, indent=2).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export/all.json")
|
||||||
|
def export_all_json(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
|
||||||
|
accs, cats = _maps(db, user)
|
||||||
|
idents: dict[int, list] = {}
|
||||||
|
for i in db.query(AccountIdentifier).join(Account).filter(Account.owner_id == user.id):
|
||||||
|
idents.setdefault(i.account_id, []).append({"kind": i.kind.value, "value": i.value})
|
||||||
|
|
||||||
|
accounts = [{
|
||||||
|
"name": a.name, "type": a.type.value, "currency": a.currency,
|
||||||
|
"opening_balance_cents": a.opening_balance_cents,
|
||||||
|
"opening_date": a.opening_date.isoformat() if a.opening_date else None,
|
||||||
|
"icon": a.icon, "color": a.color, "archived": a.is_archived,
|
||||||
|
"identifiers": idents.get(a.id, []),
|
||||||
|
} for a in accs.values()]
|
||||||
|
|
||||||
|
groups = []
|
||||||
|
for g in db.query(CategoryGroup).filter(CategoryGroup.owner_id == user.id).order_by(CategoryGroup.position):
|
||||||
|
groups.append({
|
||||||
|
"name": g.name, "kind": g.kind.value, "icon": g.icon, "color": g.color,
|
||||||
|
"categories": [
|
||||||
|
{"name": c.name, "icon": c.icon, "color": c.color}
|
||||||
|
for c in sorted(g.categories, key=lambda x: x.position)
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
budget_template = [
|
||||||
|
{"category": cats[l.category_id].name if l.category_id in cats else None, "planned_cents": l.planned_cents}
|
||||||
|
for l in db.query(BudgetTemplateLine).filter(BudgetTemplateLine.owner_id == user.id)
|
||||||
|
]
|
||||||
|
planned_income = [
|
||||||
|
{"label": l.label, "planned_cents": l.planned_cents,
|
||||||
|
"category": cats[l.category_id].name if l.category_id in cats else None,
|
||||||
|
"target_account": accs[l.target_account_id].name if l.target_account_id in accs else None}
|
||||||
|
for l in db.query(PlannedIncomeLine).filter(PlannedIncomeLine.owner_id == user.id)
|
||||||
|
]
|
||||||
|
dash = db.query(DashboardLayout).filter(DashboardLayout.owner_id == user.id).first()
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"app": "Certain Finance",
|
||||||
|
"user": user.username,
|
||||||
|
"accounts": accounts,
|
||||||
|
"category_groups": groups,
|
||||||
|
"transactions": [_txn_dict(t, accs, cats) for t in _txns(db, user)],
|
||||||
|
"budget_template": budget_template,
|
||||||
|
"planned_income": planned_income,
|
||||||
|
"dashboard_layout": dash.layout if dash else None,
|
||||||
|
}
|
||||||
|
return Response(
|
||||||
|
content=_json_bytes(data),
|
||||||
|
media_type="application/json",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="certain-finance-export-{_ts()}.json"'},
|
||||||
|
)
|
||||||
@@ -39,7 +39,7 @@ PRESETS: list[dict] = [
|
|||||||
"decimal_sep": ".",
|
"decimal_sep": ".",
|
||||||
"date_format": "%Y-%m-%d %H:%M:%S.%f",
|
"date_format": "%Y-%m-%d %H:%M:%S.%f",
|
||||||
"encoding": "utf-8",
|
"encoding": "utf-8",
|
||||||
"has_header": False,
|
"has_header": True,
|
||||||
"skip_rows": 0,
|
"skip_rows": 0,
|
||||||
"amount_mode": "signed",
|
"amount_mode": "signed",
|
||||||
# Spalten: 0=eigene IBAN, 1=Datum, 2=Text, 3=Betrag, 4=Saldo, 5=Valuta.
|
# Spalten: 0=eigene IBAN, 1=Datum, 2=Text, 3=Betrag, 4=Saldo, 5=Valuta.
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
|
||||||
|
def _setup(admin):
|
||||||
|
acc = admin.post("/api/accounts", json={"name": "Exp", "currency": "EUR"}).json()
|
||||||
|
admin.post("/api/transactions", json={
|
||||||
|
"type": "expense", "date": str(date.today()), "account_id": acc["id"],
|
||||||
|
"amount_cents": 4200, "payee": "Testladen", "description": "Etwas",
|
||||||
|
})
|
||||||
|
return acc
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_transactions_csv(admin):
|
||||||
|
_setup(admin)
|
||||||
|
r = admin.get("/api/export/transactions.csv")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "text/csv" in r.headers["content-type"]
|
||||||
|
body = r.content.decode("utf-8-sig")
|
||||||
|
assert "Datum;Typ;Konto" in body.splitlines()[0]
|
||||||
|
assert "Testladen" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_transactions_json(admin):
|
||||||
|
_setup(admin)
|
||||||
|
r = admin.get("/api/export/transactions.json")
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = json.loads(r.content)
|
||||||
|
assert any(t["payee"] == "Testladen" for t in data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_all_json(admin):
|
||||||
|
_setup(admin)
|
||||||
|
r = admin.get("/api/export/all.json")
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = json.loads(r.content)
|
||||||
|
assert data["app"] == "Certain Finance"
|
||||||
|
assert len(data["accounts"]) >= 1
|
||||||
|
assert len(data["transactions"]) >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_db_backup_admin_only(admin):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
# Admin darf.
|
||||||
|
r = admin.get("/api/backup/db")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.content[:16].startswith(b"SQLite format 3")
|
||||||
|
|
||||||
|
# Normaler Nutzer darf nicht.
|
||||||
|
u, p = "u_" + secrets.token_hex(4), "pw"
|
||||||
|
admin.post("/api/users", json={"username": u, "password": p, "role": "user"})
|
||||||
|
other = TestClient(app)
|
||||||
|
other.post("/api/auth/login", data={"username": u, "password": p})
|
||||||
|
assert other.get("/api/backup/db").status_code == 403
|
||||||
|
# ...aber seinen eigenen Export schon (leer).
|
||||||
|
assert other.get("/api/export/all.json").status_code == 200
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Backup & Wiederherstellung
|
||||||
|
|
||||||
|
## In der App
|
||||||
|
|
||||||
|
**Einstellungen → Backup & Export:**
|
||||||
|
|
||||||
|
- **Buchungen (CSV/JSON)** und **Alle Daten (JSON)** — exportiert die *eigenen*
|
||||||
|
Daten des angemeldeten Nutzers (owner-gescoped) zum Archivieren/Weiterverarbeiten.
|
||||||
|
- **Komplettes DB-Backup** (nur Admin) — lädt eine konsistente Kopie der gesamten
|
||||||
|
SQLite-Datenbank herunter (alle Nutzer). Erzeugt über die SQLite-Online-Backup-API,
|
||||||
|
also auch bei laufendem Betrieb gefahrlos.
|
||||||
|
|
||||||
|
## Wiederherstellen (DB-Backup)
|
||||||
|
|
||||||
|
Die heruntergeladene `.db`-Datei ist die komplette Datenbank. Zum Wiederherstellen
|
||||||
|
im LXC:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl stop certain-finance
|
||||||
|
cp certain-finance-YYYYMMDD-HHMMSS.db /var/lib/certain-finance/app.db
|
||||||
|
# WAL-/SHM-Reste entfernen, damit die Kopie sauber übernommen wird:
|
||||||
|
rm -f /var/lib/certain-finance/app.db-wal /var/lib/certain-finance/app.db-shm
|
||||||
|
chown certain-finance:certain-finance /var/lib/certain-finance/app.db 2>/dev/null || true
|
||||||
|
systemctl start certain-finance
|
||||||
|
```
|
||||||
|
|
||||||
|
## Datei-Backup (LXC)
|
||||||
|
|
||||||
|
Die Datenbank liegt unter `/var/lib/certain-finance/app.db`.
|
||||||
|
|
||||||
|
- **Proxmox-Snapshot:** `vzdump` des Containers sichert alles (empfohlen).
|
||||||
|
- **Cron-Kopie:** täglich eine konsistente Kopie ablegen und rotieren:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# /etc/cron.daily/certain-finance-backup (ausführbar machen)
|
||||||
|
#!/bin/sh
|
||||||
|
D=/var/lib/certain-finance
|
||||||
|
mkdir -p "$D/backups"
|
||||||
|
sqlite3 "$D/app.db" ".backup '$D/backups/app-$(date +\%Y\%m\%d).db'"
|
||||||
|
# älter als 30 Tage löschen
|
||||||
|
find "$D/backups" -name 'app-*.db' -mtime +30 -delete
|
||||||
|
```
|
||||||
|
|
||||||
|
`update.sh` legt außerdem vor jeder Migration automatisch eine Kopie unter
|
||||||
|
`/var/lib/certain-finance/backups/pre-update-*.db` an.
|
||||||
+3
-2
@@ -32,5 +32,6 @@ via IBAN, Batch-Undo. Vorgefertigte Bank-Templates folgen aus Nutzer-Beispielen.
|
|||||||
Lokal: PyMuPDF (PDF-Text) + Tesseract-OCR (Scans/Bilder), heuristischer
|
Lokal: PyMuPDF (PDF-Text) + Tesseract-OCR (Scans/Bilder), heuristischer
|
||||||
Zeilen-Parser (Datum + Betrag), speist dieselbe Prüfansicht. Best-Effort.
|
Zeilen-Parser (Datum + Betrag), speist dieselbe Prüfansicht. Best-Effort.
|
||||||
|
|
||||||
## Querschnitt – Backup/Export
|
## Querschnitt – Backup/Export ✅
|
||||||
DB-Backup-Download + Export je Entität (CSV/JSON); dokumentierte LXC-Sicherung.
|
DB-Backup-Download (Admin) + Export je Entität (CSV/JSON); dokumentierte
|
||||||
|
LXC-Sicherung (docs/BACKUP.md).
|
||||||
|
|||||||
@@ -201,4 +201,10 @@ export const api = {
|
|||||||
|
|
||||||
// PDF/Bild-Beleg
|
// PDF/Bild-Beleg
|
||||||
documentPreview: (formData) => request("/documents/preview", { method: "POST", formData }),
|
documentPreview: (formData) => request("/documents/preview", { method: "POST", formData }),
|
||||||
|
|
||||||
|
// Backup & Export
|
||||||
|
downloadDbBackup: () => downloadFile("/backup/db", `certain-finance-${zeitstempel()}.db`),
|
||||||
|
exportTransactionsCsv: () => downloadFile("/export/transactions.csv", `transaktionen_${zeitstempel()}.csv`),
|
||||||
|
exportTransactionsJson: () => downloadFile("/export/transactions.json", `transaktionen_${zeitstempel()}.json`),
|
||||||
|
exportAllJson: () => downloadFile("/export/all.json", `certain-finance-export_${zeitstempel()}.json`),
|
||||||
};
|
};
|
||||||
|
|||||||
+12
-1
@@ -72,7 +72,14 @@
|
|||||||
"dateFormat": "Datumsformat",
|
"dateFormat": "Datumsformat",
|
||||||
"saved": "Gespeichert.",
|
"saved": "Gespeichert.",
|
||||||
"languageDe": "Deutsch",
|
"languageDe": "Deutsch",
|
||||||
"languageEn": "Englisch"
|
"languageEn": "Englisch",
|
||||||
|
"backup": "Backup & Export",
|
||||||
|
"exportHint": "Exportiere deine eigenen Daten zum Archivieren oder Weiterverarbeiten.",
|
||||||
|
"exportTxnCsv": "Buchungen (CSV)",
|
||||||
|
"exportTxnJson": "Buchungen (JSON)",
|
||||||
|
"exportAll": "Alle Daten (JSON)",
|
||||||
|
"dbBackup": "Komplettes DB-Backup herunterladen",
|
||||||
|
"dbBackupHint": "Vollständige Datenbank aller Nutzer (nur Admin) – zum Wiederherstellen sichern."
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Übersicht",
|
"title": "Übersicht",
|
||||||
@@ -234,6 +241,10 @@
|
|||||||
"colPurpose": "Verwendungszweck",
|
"colPurpose": "Verwendungszweck",
|
||||||
"colIban": "Gegen-IBAN",
|
"colIban": "Gegen-IBAN",
|
||||||
"makePreviewHint": "Ordne mindestens Datum und Betrag zu.",
|
"makePreviewHint": "Ordne mindestens Datum und Betrag zu.",
|
||||||
|
"rawHint": "Nur die ersten Zeilen – sie dienen nur der Spaltenzuordnung.",
|
||||||
|
"makePreviewNote": "„Vorschau erstellen“ zeigt danach ALLE Buchungen zum Prüfen und Buchen.",
|
||||||
|
"summary": "{{total}} Zeilen erkannt · {{n}} werden gebucht (Duplikate/ungültige ausgenommen).",
|
||||||
|
"noneValid": "Keine gültige Buchung erkannt. Prüfe Datumsformat, Dezimaltrenner und Spaltenzuordnung – am einfachsten oben die passende Bank-Vorlage wählen.",
|
||||||
"commit": "{{n}} Buchungen übernehmen",
|
"commit": "{{n}} Buchungen übernehmen",
|
||||||
"imported": "{{n}} Buchungen importiert.",
|
"imported": "{{n}} Buchungen importiert.",
|
||||||
"importedCount": "{{n}} gebucht",
|
"importedCount": "{{n}} gebucht",
|
||||||
|
|||||||
+12
-1
@@ -72,7 +72,14 @@
|
|||||||
"dateFormat": "Date format",
|
"dateFormat": "Date format",
|
||||||
"saved": "Saved.",
|
"saved": "Saved.",
|
||||||
"languageDe": "German",
|
"languageDe": "German",
|
||||||
"languageEn": "English"
|
"languageEn": "English",
|
||||||
|
"backup": "Backup & export",
|
||||||
|
"exportHint": "Export your own data for archiving or further processing.",
|
||||||
|
"exportTxnCsv": "Transactions (CSV)",
|
||||||
|
"exportTxnJson": "Transactions (JSON)",
|
||||||
|
"exportAll": "All data (JSON)",
|
||||||
|
"dbBackup": "Download full DB backup",
|
||||||
|
"dbBackupHint": "Complete database of all users (admin only) — keep it for restore."
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Overview",
|
"title": "Overview",
|
||||||
@@ -234,6 +241,10 @@
|
|||||||
"colPurpose": "Purpose",
|
"colPurpose": "Purpose",
|
||||||
"colIban": "Counterparty IBAN",
|
"colIban": "Counterparty IBAN",
|
||||||
"makePreviewHint": "Map at least date and amount.",
|
"makePreviewHint": "Map at least date and amount.",
|
||||||
|
"rawHint": "Only the first rows – just for mapping the columns.",
|
||||||
|
"makePreviewNote": "“Build preview” then shows ALL transactions to review and import.",
|
||||||
|
"summary": "{{total}} rows detected · {{n}} will be imported (duplicates/invalid excluded).",
|
||||||
|
"noneValid": "No valid transaction detected. Check date format, decimal separator and column mapping – easiest is to pick the matching bank preset above.",
|
||||||
"commit": "Import {{n}} transactions",
|
"commit": "Import {{n}} transactions",
|
||||||
"imported": "Imported {{n}} transactions.",
|
"imported": "Imported {{n}} transactions.",
|
||||||
"importedCount": "{{n}} imported",
|
"importedCount": "{{n}} imported",
|
||||||
|
|||||||
@@ -315,6 +315,7 @@ export default function Import() {
|
|||||||
{raw && (
|
{raw && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h2>{t("import.step3")}</h2>
|
<h2>{t("import.step3")}</h2>
|
||||||
|
<p className="muted small">{t("import.rawHint")}</p>
|
||||||
<div className="table-scroll">
|
<div className="table-scroll">
|
||||||
<table className="table raw-table">
|
<table className="table raw-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -357,6 +358,7 @@ export default function Import() {
|
|||||||
<button className="btn sm" onClick={saveTemplate}>{t("import.saveTemplate")}</button>
|
<button className="btn sm" onClick={saveTemplate}>{t("import.saveTemplate")}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="muted small" style={{ marginTop: "var(--sp-2)" }}>{t("import.makePreviewNote")}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -369,6 +371,13 @@ export default function Import() {
|
|||||||
<Icon name="download" size={15} /> {t("import.commit", { n: includeCount })}
|
<Icon name="download" size={15} /> {t("import.commit", { n: includeCount })}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="muted small">{t("import.summary", { total: rows.length, n: includeCount })}</p>
|
||||||
|
{rows.length > 0 && includeCount === 0 && (
|
||||||
|
<div className="alert error" style={{ marginBottom: "var(--sp-3)" }}>
|
||||||
|
<Icon name="alert" size={16} />
|
||||||
|
<span>{t("import.noneValid")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="table-scroll">
|
<div className="table-scroll">
|
||||||
<table className="table">
|
<table className="table">
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useSettings } from "../settings";
|
import { useSettings } from "../settings";
|
||||||
|
import { useAuth } from "../auth";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
|
import { api } from "../api";
|
||||||
|
import Icon from "../components/Icon";
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const s = useSettings();
|
const s = useSettings();
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
async function set(key, value) {
|
async function set(key, value) {
|
||||||
@@ -16,6 +20,14 @@ export default function Settings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function dl(fn) {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>{t("settings.title")}</h1>
|
<h1>{t("settings.title")}</h1>
|
||||||
@@ -62,6 +74,30 @@ export default function Settings() {
|
|||||||
</label>
|
</label>
|
||||||
<p className="muted small" style={{ marginTop: -8 }}>{t("settings.budgetStartDayHint")}</p>
|
<p className="muted small" style={{ marginTop: -8 }}>{t("settings.budgetStartDayHint")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ maxWidth: 520, marginTop: "var(--sp-4)" }}>
|
||||||
|
<h2>{t("settings.backup")}</h2>
|
||||||
|
<p className="muted small">{t("settings.exportHint")}</p>
|
||||||
|
<div className="field-inline" style={{ flexWrap: "wrap" }}>
|
||||||
|
<button className="btn" onClick={() => dl(api.exportTransactionsCsv)}>
|
||||||
|
<Icon name="download" size={15} /> {t("settings.exportTxnCsv")}
|
||||||
|
</button>
|
||||||
|
<button className="btn" onClick={() => dl(api.exportTransactionsJson)}>
|
||||||
|
<Icon name="download" size={15} /> {t("settings.exportTxnJson")}
|
||||||
|
</button>
|
||||||
|
<button className="btn" onClick={() => dl(api.exportAllJson)}>
|
||||||
|
<Icon name="download" size={15} /> {t("settings.exportAll")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<p className="muted small" style={{ marginTop: "var(--sp-3)" }}>{t("settings.dbBackupHint")}</p>
|
||||||
|
<button className="btn primary" onClick={() => dl(api.downloadDbBackup)}>
|
||||||
|
<Icon name="download" size={15} /> {t("settings.dbBackup")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user