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 (
|
||||
accounts,
|
||||
auth,
|
||||
backup,
|
||||
budgets,
|
||||
categories,
|
||||
dashboard,
|
||||
@@ -107,6 +108,7 @@ api.include_router(budgets.router)
|
||||
api.include_router(dashboard.router)
|
||||
api.include_router(imports.router)
|
||||
api.include_router(documents.router)
|
||||
api.include_router(backup.router)
|
||||
api.include_router(rates.router)
|
||||
|
||||
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": ".",
|
||||
"date_format": "%Y-%m-%d %H:%M:%S.%f",
|
||||
"encoding": "utf-8",
|
||||
"has_header": False,
|
||||
"has_header": True,
|
||||
"skip_rows": 0,
|
||||
"amount_mode": "signed",
|
||||
# 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
|
||||
Zeilen-Parser (Datum + Betrag), speist dieselbe Prüfansicht. Best-Effort.
|
||||
|
||||
## Querschnitt – Backup/Export
|
||||
DB-Backup-Download + Export je Entität (CSV/JSON); dokumentierte LXC-Sicherung.
|
||||
## Querschnitt – Backup/Export ✅
|
||||
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
|
||||
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",
|
||||
"saved": "Gespeichert.",
|
||||
"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": {
|
||||
"title": "Übersicht",
|
||||
@@ -234,6 +241,10 @@
|
||||
"colPurpose": "Verwendungszweck",
|
||||
"colIban": "Gegen-IBAN",
|
||||
"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",
|
||||
"imported": "{{n}} Buchungen importiert.",
|
||||
"importedCount": "{{n}} gebucht",
|
||||
|
||||
+12
-1
@@ -72,7 +72,14 @@
|
||||
"dateFormat": "Date format",
|
||||
"saved": "Saved.",
|
||||
"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": {
|
||||
"title": "Overview",
|
||||
@@ -234,6 +241,10 @@
|
||||
"colPurpose": "Purpose",
|
||||
"colIban": "Counterparty IBAN",
|
||||
"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",
|
||||
"imported": "Imported {{n}} transactions.",
|
||||
"importedCount": "{{n}} imported",
|
||||
|
||||
@@ -315,6 +315,7 @@ export default function Import() {
|
||||
{raw && (
|
||||
<div className="card">
|
||||
<h2>{t("import.step3")}</h2>
|
||||
<p className="muted small">{t("import.rawHint")}</p>
|
||||
<div className="table-scroll">
|
||||
<table className="table raw-table">
|
||||
<thead>
|
||||
@@ -357,6 +358,7 @@ export default function Import() {
|
||||
<button className="btn sm" onClick={saveTemplate}>{t("import.saveTemplate")}</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="muted small" style={{ marginTop: "var(--sp-2)" }}>{t("import.makePreviewNote")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -369,6 +371,13 @@ export default function Import() {
|
||||
<Icon name="download" size={15} /> {t("import.commit", { n: includeCount })}
|
||||
</button>
|
||||
</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">
|
||||
<table className="table">
|
||||
<thead>
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSettings } from "../settings";
|
||||
import { useAuth } from "../auth";
|
||||
import { useToast } from "../toast";
|
||||
import { api } from "../api";
|
||||
import Icon from "../components/Icon";
|
||||
|
||||
export default function Settings() {
|
||||
const { t } = useTranslation();
|
||||
const s = useSettings();
|
||||
const { isAdmin } = useAuth();
|
||||
const toast = useToast();
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<h1>{t("settings.title")}</h1>
|
||||
@@ -62,6 +74,30 @@ export default function Settings() {
|
||||
</label>
|
||||
<p className="muted small" style={{ marginTop: -8 }}>{t("settings.budgetStartDayHint")}</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user