feat(yedek): JSON yedeği arayüzü (indir, önizle, geri yükle, geri al)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -81,7 +81,7 @@ npm run db:restore -- <dosya|latest> <hedef_db> [--force]
|
|||||||
- Kayıt politikası: `REGISTRATION=invite_only` (varsayılan; ilk hane serbest, sonra yalnızca davet) | `open`. Backend'de zorunlu (403 `registration_closed`), `GET /api/auth/registration` arayüze bildirir. Testler `TestApp::new()` = open, `with_registration(..)`.
|
- Kayıt politikası: `REGISTRATION=invite_only` (varsayılan; ilk hane serbest, sonra yalnızca davet) | `open`. Backend'de zorunlu (403 `registration_closed`), `GET /api/auth/registration` arayüze bildirir. Testler `TestApp::new()` = open, `with_registration(..)`.
|
||||||
- Excel okuma: SheetJS **yerel paket** `frontend/vendor/xlsx-0.20.3.tgz` (npm 12 uzak tarball'u engeller; npm'deki `xlsx` eski/açıklı). Güncellerken: cdn.sheetjs.com'dan indir, içeriği/sha256'yı kontrol et, `npm i ./vendor/…`; Dockerfile `vendor/`'u `npm ci`'den önce kopyalar. Lazy yüklenir.
|
- Excel okuma: SheetJS **yerel paket** `frontend/vendor/xlsx-0.20.3.tgz` (npm 12 uzak tarball'u engeller; npm'deki `xlsx` eski/açıklı). Güncellerken: cdn.sheetjs.com'dan indir, içeriği/sha256'yı kontrol et, `npm i ./vendor/…`; Dockerfile `vendor/`'u `npm ci`'den önce kopyalar. Lazy yüklenir.
|
||||||
- Biçim: Prettier (`frontend/.prettierrc.json`: tek tırnak, `;` yok, 100 sütun); `npm --prefix frontend run format`. `src/types/` ve `components/ui/` hariç.
|
- Biçim: Prettier (`frontend/.prettierrc.json`: tek tırnak, `;` yok, 100 sütun); `npm --prefix frontend run format`. `src/types/` ve `components/ui/` hariç.
|
||||||
- JSON yedeği: `services/backup.rs` (`collect` → `to_json`; `parse` doğrular; `restore` = geri dönüş noktası + tam değiştirme tek transaction; `retarget` kimlik çevirir). Yeni hane koleksiyonu eklenirse `backup::KINDS`'a da ekle. Biçim: DATA_MODEL §3.14b.
|
- JSON yedeği: `services/backup.rs` (`collect` → `to_json`; `parse` doğrular; `restore` = geri dönüş noktası + tam değiştirme tek transaction; `retarget` kimlik çevirir). Yeni hane koleksiyonu eklenirse `backup::KINDS`'a da ekle. Biçim: DATA_MODEL §3.14b. Arayüz: `features/settings/backup-section.tsx`, `lib/backup.ts` (geri yükleme/geri alma sonrası tüm sorgular yenilenir).
|
||||||
- Hane izolasyonu: hane kimliği yalnızca `AuthUser.household_id`'den; sorgular `scope(hid)` ile başlar; başka hane → 404.
|
- Hane izolasyonu: hane kimliği yalnızca `AuthUser.household_id`'den; sorgular `scope(hid)` ile başlar; başka hane → 404.
|
||||||
|
|
||||||
## Ortam
|
## Ortam
|
||||||
|
|||||||
+2
-1
@@ -1,7 +1,7 @@
|
|||||||
# 🧠 Proje Hafızası
|
# 🧠 Proje Hafızası
|
||||||
|
|
||||||
## ▶️ Sıradaki Adım
|
## ▶️ Sıradaki Adım
|
||||||
**Faz 9 – Adım 5b:** JSON yedeği arayüzü (Ayarlar → Veri): "Yedeği indir" (son yedek X gün önce / hiç alınmadı hatırlatması), sahip için "Yedekten geri yükle" → dosya seç → önizleme (kaynak hane, tarih, kayıt sayıları yedek/şimdi, tarih aralığı, uyarılar) → onay kutusu → geri yükle → sonuç + "Geri al"; geri dönüş noktaları listesi (tarih, neden, kayıt sayısı, "Bu duruma dön" onaylı). Tipler hazır: `BackupStatus`, `BackupPreview`, `BackupCount`, `RestorePoint`, `RestoreResult`. Gövde: dosya metni (`api.postCsv` benzeri, `application/json`). Sonra Adım 6 aylık rapor.
|
**Faz 9 – Adım 6:** Aylık değerlendirme çıktısı — `templates/monthly_report.md`, Claude tasarlar (müşteri gözüyle: ne yaptım / ne iyi-kötü gitti / ne yapılabilirdi / ne yapmalıydım / gelecek ay önerileri). Önce taslak yapıyı kullanıcıya sun (bölümler, hangi veriden), sonra Markdown üretimi + yazdır/PDF.
|
||||||
**Bekleyen (kullanıcıda):** Faz 5 – Adım 5 canlı yedek kurulumu (`docs/DEPLOY.md` §6.1–6.3) → çıktılar gelince Faz 5 ✅.
|
**Bekleyen (kullanıcıda):** Faz 5 – Adım 5 canlı yedek kurulumu (`docs/DEPLOY.md` §6.1–6.3) → çıktılar gelince Faz 5 ✅.
|
||||||
|
|
||||||
## 📌 Alınan Kararlar
|
## 📌 Alınan Kararlar
|
||||||
@@ -143,3 +143,4 @@
|
|||||||
- **Adım 4b:** Ekstre arayüzü `/ayarlar/ekstre` (`features/statement/`: `page.tsx` Dosya → Eşleştir (başlık satırı otomatik `detectHeaderRow`, sütun tahmini `guessMapping`, kaynak kart/hesap → tutar modu, Borç/Alacak, ödeme türü, profil adı → localStorage `kakeibo.statement.profiles`, ad ile eşleşir; önizlemede tarih yorumlanmış) ; `review.tsx` (tür+kategori, değişiklik aynı `ruleKey`'li elle değiştirilmemiş satırlara yayılır + bildirim, filtreler, sayfalama 50, telefonda kart/masaüstünde sütunlu liste, atlanacaklar soluk, arşiv bildirimi, kategori eksikse aktarma kapalı, sonuçta hata satırları tarih+açıklama ile)). `lib/statement.ts` (Excel seri tarih, `parseAmount` TR/EN/parantez/sondaki eksi, `normalize`, `fold`/`ruleKey` = backend, profiller), `lib/read-sheet.ts` (SheetJS lazy 160 KB gz, `blankrows: true` → satır no Excel ile aynı; CSV kendi ayrıştırıcı + `decodeText`), `lib/bank-import.ts`. Backend: kural önerisine **ilk kelime yedeği** (`suggest`, genel kelimeler hariç). SheetJS yerel paket (`frontend/vendor`, Dockerfile kopyalar; Docker web aşaması doğrulandı). Defter: iade satırı "İade" etiketi, düzenlemede artı gösterilip eksi kaydedilir. Frontend 134, backend 178 test; e2e 25 kontrol.
|
- **Adım 4b:** Ekstre arayüzü `/ayarlar/ekstre` (`features/statement/`: `page.tsx` Dosya → Eşleştir (başlık satırı otomatik `detectHeaderRow`, sütun tahmini `guessMapping`, kaynak kart/hesap → tutar modu, Borç/Alacak, ödeme türü, profil adı → localStorage `kakeibo.statement.profiles`, ad ile eşleşir; önizlemede tarih yorumlanmış) ; `review.tsx` (tür+kategori, değişiklik aynı `ruleKey`'li elle değiştirilmemiş satırlara yayılır + bildirim, filtreler, sayfalama 50, telefonda kart/masaüstünde sütunlu liste, atlanacaklar soluk, arşiv bildirimi, kategori eksikse aktarma kapalı, sonuçta hata satırları tarih+açıklama ile)). `lib/statement.ts` (Excel seri tarih, `parseAmount` TR/EN/parantez/sondaki eksi, `normalize`, `fold`/`ruleKey` = backend, profiller), `lib/read-sheet.ts` (SheetJS lazy 160 KB gz, `blankrows: true` → satır no Excel ile aynı; CSV kendi ayrıştırıcı + `decodeText`), `lib/bank-import.ts`. Backend: kural önerisine **ilk kelime yedeği** (`suggest`, genel kelimeler hariç). SheetJS yerel paket (`frontend/vendor`, Dockerfile kopyalar; Docker web aşaması doğrulandı). Defter: iade satırı "İade" etiketi, düzenlemede artı gösterilip eksi kaydedilir. Frontend 134, backend 178 test; e2e 25 kontrol.
|
||||||
- **Hafta düzeltmesi (2026-11-13):** `calendar::weeks` yeni kural (testler: Kasım/Aralık/Şubat/artık yıl, ay ortası dönem, 13/14/62 gün). Bütçe/defter/değerlendirme entegrasyon testleri yeni sınırlara göre güncellendi (backend 178). Frontend: `LedgerView` haftayı `useState`'te tutar, `parseWeekParam` kaldırıldı, dönem kısalırsa son haftaya düşer; `budget-preview`/`ledger` testleri yeni Aralık (8,7,7,9) ile (133). E2E 9 kontrol (8091, DB silindi). Dev verisinde elle haftalık bütçe yoktu → taşıma gerekmedi; canlıda varsa eşleşmeyen eski hafta yok sayılır (aylık tutar korunur).
|
- **Hafta düzeltmesi (2026-11-13):** `calendar::weeks` yeni kural (testler: Kasım/Aralık/Şubat/artık yıl, ay ortası dönem, 13/14/62 gün). Bütçe/defter/değerlendirme entegrasyon testleri yeni sınırlara göre güncellendi (backend 178). Frontend: `LedgerView` haftayı `useState`'te tutar, `parseWeekParam` kaldırıldı, dönem kısalırsa son haftaya düşer; `budget-preview`/`ledger` testleri yeni Aralık (8,7,7,9) ile (133). E2E 9 kontrol (8091, DB silindi). Dev verisinde elle haftalık bütçe yoktu → taşıma gerekmedi; canlıda varsa eşleşmeyen eski hafta yok sayılır (aylık tutar korunur).
|
||||||
- **Faz 9 – Adım 5a (2026-11-13):** `services/backup.rs` + `routes/backup.rs` (`/backup/download|status|preview|restore`, `/backup/restore-points/{id}/restore`; preview/restore/undo **sahip**, 20 MB). Biçim ham BSON belgeleri üzerinden (modeller eksik alanlı olduğu için tür tabanlı değil): `$uuid`/`$date`, `INT32_KEYS` (version, sort_order) + ayarlar i32 (`small_ints`), SHA-256 kanonik (sıralı anahtar) → yeniden biçimlendirilmiş dosya geçerli, değiştirilmiş reddedilir. **Silinmiş kayıtlar dahil** (tasarımdan sapma: referans bütünlüğü + birebir geri al). Migration v3 `restore_points` (transaction içinde ilk yazım için) + indeks. `flate2`, `sha2` doğrudan eklendi (zaten bağımlılık ağacında). Testler: 4 birim + 3 entegrasyon (aynı hane + geri al zinciri + 3 nokta sınırı, başka haneye iki kez kopya, bozuk/yabancı/yeni sürüm dosya, üye 403, başka hane 404). 3 bilinçli bozma yakalandı. Backend 190 test.
|
- **Faz 9 – Adım 5a (2026-11-13):** `services/backup.rs` + `routes/backup.rs` (`/backup/download|status|preview|restore`, `/backup/restore-points/{id}/restore`; preview/restore/undo **sahip**, 20 MB). Biçim ham BSON belgeleri üzerinden (modeller eksik alanlı olduğu için tür tabanlı değil): `$uuid`/`$date`, `INT32_KEYS` (version, sort_order) + ayarlar i32 (`small_ints`), SHA-256 kanonik (sıralı anahtar) → yeniden biçimlendirilmiş dosya geçerli, değiştirilmiş reddedilir. **Silinmiş kayıtlar dahil** (tasarımdan sapma: referans bütünlüğü + birebir geri al). Migration v3 `restore_points` (transaction içinde ilk yazım için) + indeks. `flate2`, `sha2` doğrudan eklendi (zaten bağımlılık ağacında). Testler: 4 birim + 3 entegrasyon (aynı hane + geri al zinciri + 3 nokta sınırı, başka haneye iki kez kopya, bozuk/yabancı/yeni sürüm dosya, üye 403, başka hane 404). 3 bilinçli bozma yakalandı. Backend 190 test.
|
||||||
|
- **Faz 9 – Adım 5b (2026-11-13):** `features/settings/backup-section.tsx` (Veri bölümünün en üstü): "Yedeği indir" (`<a download>`, tıklayınca 1,5 sn sonra durum yenilenir) + hatırlatma (`daysSince`, hiç yok ya da ≥ 30 gün → uyarı rengi); sahip: dosya → önizleme (kaynak, aynı/başka hane, dönem aralığı, sayılar yedek/şimdi — farklı olan uyarı rengi, uyarılar) → onay kutusu (`#backup-confirm`; Radix `name`'i düğmeye taşımaz) → "Geri yükle" (destructive) → sonuç kutusu + "Geri al"; geri dönüş noktaları listesi (`window.confirm`). Üye: yalnız indirme + "yalnız sahip" notu. `lib/backup.ts` (geri yükleme/geri alma → `qc.invalidateQueries()` tümü), `api.postJsonText`, `queryKeys.backup`, `t.backup`. Frontend 134 test; e2e 27 kontrol (8091, DB silindi).
|
||||||
|
|||||||
+1
-1
@@ -83,5 +83,5 @@
|
|||||||
- ✅ 2. Frontend: Raporlar sayfası — filtreler (adreste), gelir–gider çubuk grafiği, kategori kırılımı (tablo + grafik), bütçe sapması, ödeme türü dağılımı, birikim oranı trendi, varlık performansı; her rapor CSV (UTF-8 BOM, `;`) + yazdır
|
- ✅ 2. Frontend: Raporlar sayfası — filtreler (adreste), gelir–gider çubuk grafiği, kategori kırılımı (tablo + grafik), bütçe sapması, ödeme türü dağılımı, birikim oranı trendi, varlık performansı; her rapor CSV (UTF-8 BOM, `;`) + yazdır
|
||||||
- ✅ 3. CSV dışa/içe aktarma (uygulamanın kendi biçimi): harcamalar, plan kalemleri, varlık işlemleri; içe aktarmada önizleme + satır satır hata raporu
|
- ✅ 3. CSV dışa/içe aktarma (uygulamanın kendi biçimi): harcamalar, plan kalemleri, varlık işlemleri; içe aktarmada önizleme + satır satır hata raporu
|
||||||
- ✅ 4. **Banka / kredi kartı ekstresi içe aktarma** — 4a backend (arşiv dönemleri, iade, gelir, yoksay, tekrar, öğrenen kurallar) + 4b arayüz (`/ayarlar/ekstre`: Excel/CSV, sütun eşleştirme + profil, gözden geçirme)
|
- ✅ 4. **Banka / kredi kartı ekstresi içe aktarma** — 4a backend (arşiv dönemleri, iade, gelir, yoksay, tekrar, öğrenen kurallar) + 4b arayüz (`/ayarlar/ekstre`: Excel/CSV, sütun eşleştirme + profil, gözden geçirme)
|
||||||
- 🔄 5. JSON tam hane yedeği — ✅ 5a backend (sürümlü dosya + SHA-256, önizleme, tam değiştirme tek transaction, geri dönüş noktaları + "geri al", başka haneye kimlik çevirerek taşıma) · ⏳ 5b arayüz (Ayarlar → Veri)
|
- ✅ 5. JSON tam hane yedeği — 5a backend (sürümlü dosya + SHA-256, önizleme, tam değiştirme tek transaction, geri dönüş noktaları + "geri al", başka haneye kimlik çevirerek taşıma) · 5b arayüz (Ayarlar → Veri: indir + "son yedek" hatırlatması, sahip için önizleme → onay → geri yükle → "Geri al", geri dönüş noktaları)
|
||||||
- ⏳ 6. Aylık değerlendirme çıktısı: `templates/monthly_report.md` (Claude tasarlar — müşteri gözüyle: ne yaptım, ne iyi/kötü gitti, ne yapmalıydım, gelecek ay önerileri) → Markdown + yazdırılabilir/PDF
|
- ⏳ 6. Aylık değerlendirme çıktısı: `templates/monthly_report.md` (Claude tasarlar — müşteri gözüyle: ne yaptım, ne iyi/kötü gitti, ne yapmalıydım, gelecek ay önerileri) → Markdown + yazdırılabilir/PDF
|
||||||
|
|||||||
@@ -0,0 +1,342 @@
|
|||||||
|
import { useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
CircleCheck,
|
||||||
|
Download,
|
||||||
|
FileUp,
|
||||||
|
History,
|
||||||
|
Loader2,
|
||||||
|
RotateCcw,
|
||||||
|
TriangleAlert,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { FormError } from '@/components/form-field'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { Section } from '@/features/planning/plan-read'
|
||||||
|
import { useMe } from '@/lib/auth'
|
||||||
|
import {
|
||||||
|
BACKUP_DOWNLOAD_URL,
|
||||||
|
daysSince,
|
||||||
|
MAX_BACKUP_BYTES,
|
||||||
|
STALE_DAYS,
|
||||||
|
useBackupPreview,
|
||||||
|
useBackupStatus,
|
||||||
|
useRestoreBackup,
|
||||||
|
useUndoRestore,
|
||||||
|
} from '@/lib/backup'
|
||||||
|
import { queryKeys } from '@/lib/query'
|
||||||
|
import { formatDate, formatDateTime } from '@/lib/format'
|
||||||
|
import { t } from '@/lib/strings'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import type { BackupPreview } from '@/types/BackupPreview'
|
||||||
|
import type { RestorePoint } from '@/types/RestorePoint'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
const s = t.backup
|
||||||
|
const message = (e: unknown) => (e instanceof Error ? e.message : String(e))
|
||||||
|
|
||||||
|
/** Ayarlar → Veri: JSON yedeği indir; sahip için geri yükle + geri dönüş noktaları */
|
||||||
|
export function BackupSection() {
|
||||||
|
const me = useMe()
|
||||||
|
const status = useBackupStatus()
|
||||||
|
const isOwner = me.data?.user.role === 'owner'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section title={s.title}>
|
||||||
|
<div className="space-y-5">
|
||||||
|
<DownloadPart lastBackupAt={status.data?.last_backup_at ?? null} loaded={!!status.data} />
|
||||||
|
{isOwner ? (
|
||||||
|
<>
|
||||||
|
<RestorePart />
|
||||||
|
{status.data && status.data.restore_points.length > 0 && (
|
||||||
|
<Points
|
||||||
|
points={status.data.restore_points}
|
||||||
|
keep={[status.data.keep_points, status.data.keep_days]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
me.data && <p className="text-sm text-muted-foreground">{s.ownerOnly}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DownloadPart({ lastBackupAt, loaded }: { lastBackupAt: string | null; loaded: boolean }) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const [now] = useState(() => new Date())
|
||||||
|
const days = daysSince(lastBackupAt, now)
|
||||||
|
const stale = days === null || days >= STALE_DAYS
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground">{s.hint}</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Button asChild>
|
||||||
|
<a
|
||||||
|
href={BACKUP_DOWNLOAD_URL}
|
||||||
|
download
|
||||||
|
// İndirme bitince "son yedek" bilgisi yenilensin
|
||||||
|
onClick={() =>
|
||||||
|
setTimeout(() => void qc.invalidateQueries({ queryKey: queryKeys.backup }), 1500)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Download aria-hidden />
|
||||||
|
{s.download}
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
{loaded && (
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 text-sm',
|
||||||
|
stale ? 'text-warning' : 'text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{stale && <TriangleAlert className="size-4 shrink-0" aria-hidden />}
|
||||||
|
{days === null
|
||||||
|
? s.never
|
||||||
|
: days === 0
|
||||||
|
? s.today
|
||||||
|
: s.daysAgo(days, formatDate(lastBackupAt!))}
|
||||||
|
{stale && days !== null && ` · ${s.stale}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RestorePart() {
|
||||||
|
const input = useRef<HTMLInputElement>(null)
|
||||||
|
const [file, setFile] = useState<{ name: string; text: string } | null>(null)
|
||||||
|
const [preview, setPreview] = useState<BackupPreview | null>(null)
|
||||||
|
const [agreed, setAgreed] = useState(false)
|
||||||
|
const [error, setError] = useState<string>()
|
||||||
|
const [pointId, setPointId] = useState<string | null>(null)
|
||||||
|
const check = useBackupPreview()
|
||||||
|
const restore = useRestoreBackup()
|
||||||
|
const undo = useUndoRestore()
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
setFile(null)
|
||||||
|
setPreview(null)
|
||||||
|
setAgreed(false)
|
||||||
|
setError(undefined)
|
||||||
|
if (input.current) input.current.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function choose(f: File) {
|
||||||
|
reset()
|
||||||
|
setPointId(null)
|
||||||
|
if (f.size > MAX_BACKUP_BYTES) return setError(s.tooBig)
|
||||||
|
const text = await f.text()
|
||||||
|
setFile({ name: f.name, text })
|
||||||
|
try {
|
||||||
|
setPreview(await check.mutateAsync(text))
|
||||||
|
} catch (e) {
|
||||||
|
setError(message(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
if (!file) return
|
||||||
|
setError(undefined)
|
||||||
|
try {
|
||||||
|
const r = await restore.mutateAsync(file.text)
|
||||||
|
reset()
|
||||||
|
setPointId(r.restore_point_id)
|
||||||
|
toast.success(s.restored)
|
||||||
|
} catch (e) {
|
||||||
|
setError(message(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revert(id: string) {
|
||||||
|
try {
|
||||||
|
await undo.mutateAsync(id)
|
||||||
|
setPointId(null)
|
||||||
|
toast.success(s.undone)
|
||||||
|
} catch (e) {
|
||||||
|
setError(message(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const busy = check.isPending || restore.isPending || undo.isPending
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 border-t pt-5">
|
||||||
|
<h4 className="font-medium">{s.restoreTitle}</h4>
|
||||||
|
<p className="text-sm text-muted-foreground">{s.restoreHint}</p>
|
||||||
|
|
||||||
|
{pointId && (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className="flex flex-wrap items-center gap-3 rounded-xl bg-income/10 p-3 text-sm"
|
||||||
|
>
|
||||||
|
<CircleCheck className="size-5 shrink-0 text-income" aria-hidden />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-medium">{s.restored}</p>
|
||||||
|
<p className="text-muted-foreground">{s.restoredHint}</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={() => void revert(pointId)} disabled={busy}>
|
||||||
|
{undo.isPending ? (
|
||||||
|
<Loader2 className="animate-spin" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<RotateCcw aria-hidden />
|
||||||
|
)}
|
||||||
|
{s.undo}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<input
|
||||||
|
ref={input}
|
||||||
|
id="backup-file"
|
||||||
|
type="file"
|
||||||
|
accept=".json,application/json"
|
||||||
|
className="sr-only"
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0]
|
||||||
|
if (f) void choose(f)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button asChild variant="outline" disabled={busy}>
|
||||||
|
<label htmlFor="backup-file" className="cursor-pointer">
|
||||||
|
<FileUp aria-hidden />
|
||||||
|
{file ? s.another : s.chooseFile}
|
||||||
|
</label>
|
||||||
|
</Button>
|
||||||
|
{file && <span className="text-sm break-all text-muted-foreground">{file.name}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{check.isPending && (
|
||||||
|
<p role="status" className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" aria-hidden />
|
||||||
|
{s.checking}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<FormError message={error} />
|
||||||
|
|
||||||
|
{preview && (
|
||||||
|
<div className="space-y-4 rounded-xl border p-4" aria-live="polite">
|
||||||
|
<div className="space-y-0.5 text-sm">
|
||||||
|
<p className="font-medium">
|
||||||
|
{s.source(preview.source_name, formatDateTime(preview.exported_at))}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{preview.same_household ? s.sameHousehold : s.otherHousehold}
|
||||||
|
{preview.first_date &&
|
||||||
|
preview.last_date &&
|
||||||
|
` · ${s.range(formatDate(preview.first_date), formatDate(preview.last_date))}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left text-xs text-muted-foreground">
|
||||||
|
<th className="py-1.5 font-normal">{s.colKind}</th>
|
||||||
|
<th className="py-1.5 text-right font-normal">{s.colBackup}</th>
|
||||||
|
<th className="py-1.5 text-right font-normal">{s.colCurrent}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{preview.counts
|
||||||
|
.filter((c) => c.backup > 0 || c.current > 0)
|
||||||
|
.map((c) => (
|
||||||
|
<tr key={c.kind} className="border-b last:border-0">
|
||||||
|
<td className="py-1.5">{c.label}</td>
|
||||||
|
<td className="money py-1.5 text-right font-medium">{c.backup}</td>
|
||||||
|
<td
|
||||||
|
className={cn(
|
||||||
|
'money py-1.5 text-right',
|
||||||
|
c.current !== c.backup ? 'text-warning' : 'text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{c.current}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{preview.warnings.length > 0 && (
|
||||||
|
<ul className="space-y-1.5 rounded-xl bg-warning/10 p-3 text-sm">
|
||||||
|
{preview.warnings.map((w) => (
|
||||||
|
<li key={w} className="flex gap-2">
|
||||||
|
<TriangleAlert className="mt-0.5 size-4 shrink-0 text-warning" aria-hidden />
|
||||||
|
{w}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="flex cursor-pointer items-start gap-3 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
className="mt-0.5"
|
||||||
|
checked={agreed}
|
||||||
|
onCheckedChange={(v) => setAgreed(v === true)}
|
||||||
|
id="backup-confirm"
|
||||||
|
/>
|
||||||
|
{s.confirm}
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button variant="destructive" disabled={!agreed || busy} onClick={() => void run()}>
|
||||||
|
{restore.isPending && <Loader2 className="animate-spin" aria-hidden />}
|
||||||
|
{restore.isPending ? s.restoring : s.restore}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={reset} disabled={busy}>
|
||||||
|
{s.cancel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Points({ points, keep }: { points: RestorePoint[]; keep: [number, number] }) {
|
||||||
|
const undo = useUndoRestore()
|
||||||
|
const [error, setError] = useState<string>()
|
||||||
|
|
||||||
|
async function go(p: RestorePoint) {
|
||||||
|
if (!window.confirm(s.confirmUndo(formatDateTime(p.created_at)))) return
|
||||||
|
setError(undefined)
|
||||||
|
try {
|
||||||
|
await undo.mutateAsync(p.id)
|
||||||
|
toast.success(s.undone)
|
||||||
|
} catch (e) {
|
||||||
|
setError(message(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 border-t pt-5">
|
||||||
|
<h4 className="flex items-center gap-1.5 font-medium">
|
||||||
|
<History className="size-4" aria-hidden />
|
||||||
|
{s.pointsTitle}
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-muted-foreground">{s.pointsHint(...keep)}</p>
|
||||||
|
<ul className="divide-y rounded-xl border">
|
||||||
|
{points.map((p) => (
|
||||||
|
<li key={p.id} className="flex flex-wrap items-center gap-x-3 gap-y-2 px-3 py-2.5">
|
||||||
|
<div className="min-w-0 flex-1 text-sm">
|
||||||
|
<p className="font-medium">{formatDateTime(p.created_at)}</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{s.reason[p.reason]} · {s.pointCounts(p.periods, p.entries)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={() => void go(p)} disabled={undo.isPending}>
|
||||||
|
{undo.isPending && undo.variables === p.id ? (
|
||||||
|
<Loader2 className="animate-spin" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<RotateCcw aria-hidden />
|
||||||
|
)}
|
||||||
|
{s.goBack}
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<FormError message={error} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Link } from 'react-router'
|
import { Link } from 'react-router'
|
||||||
import { paths } from '@/components/layout/nav-items'
|
import { paths } from '@/components/layout/nav-items'
|
||||||
import { Section } from '@/features/planning/plan-read'
|
import { Section } from '@/features/planning/plan-read'
|
||||||
|
import { BackupSection } from './backup-section'
|
||||||
import { decodeText } from '@/lib/decode'
|
import { decodeText } from '@/lib/decode'
|
||||||
import { t } from '@/lib/strings'
|
import { t } from '@/lib/strings'
|
||||||
import { exportUrl, TRANSFER_KINDS, useImport, type TransferKind } from '@/lib/transfer'
|
import { exportUrl, TRANSFER_KINDS, useImport, type TransferKind } from '@/lib/transfer'
|
||||||
@@ -24,6 +25,7 @@ const MAX_ERRORS = 50
|
|||||||
export function DataSection() {
|
export function DataSection() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<BackupSection />
|
||||||
<Section title={s.exportTitle}>
|
<Section title={s.exportTitle}>
|
||||||
<p className="mb-3 text-sm text-muted-foreground">{s.exportHint}</p>
|
<p className="mb-3 text-sm text-muted-foreground">{s.exportHint}</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
|||||||
@@ -79,4 +79,7 @@ export const api = {
|
|||||||
/** CSV metnini gövde olarak gönderir */
|
/** CSV metnini gövde olarak gönderir */
|
||||||
postCsv: <T>(path: string, csv: string) =>
|
postCsv: <T>(path: string, csv: string) =>
|
||||||
request<T>('POST', path, new TextBody(csv, 'text/csv; charset=utf-8')),
|
request<T>('POST', path, new TextBody(csv, 'text/csv; charset=utf-8')),
|
||||||
|
/** Hazır JSON metni (ör. yedek dosyası) olduğu gibi gönderilir */
|
||||||
|
postJsonText: <T>(path: string, json: string) =>
|
||||||
|
request<T>('POST', path, new TextBody(json, 'application/json; charset=utf-8')),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { daysSince } from './backup'
|
||||||
|
|
||||||
|
describe('backup reminder', () => {
|
||||||
|
it('counts calendar days since the last backup', () => {
|
||||||
|
const now = new Date(2026, 10, 13, 9, 0)
|
||||||
|
expect(daysSince(null, now)).toBeNull()
|
||||||
|
expect(daysSince(new Date(2026, 10, 13, 0, 5).toISOString(), now)).toBe(0)
|
||||||
|
expect(daysSince(new Date(2026, 10, 12, 23, 59).toISOString(), now)).toBe(1) // dün gece
|
||||||
|
expect(daysSince(new Date(2026, 9, 14, 12, 0).toISOString(), now)).toBe(30)
|
||||||
|
expect(daysSince(new Date(2026, 10, 14).toISOString(), now)).toBe(0) // saat farkı → eksi olmaz
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* JSON tam hane yedeği (`/api/backup/*`). Geri yükleme ve geri alma hanenin tüm verisini
|
||||||
|
* değiştirdiği için başarıdan sonra **tüm** sorgular yenilenir.
|
||||||
|
*/
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import type { BackupPreview } from '@/types/BackupPreview'
|
||||||
|
import type { BackupStatus } from '@/types/BackupStatus'
|
||||||
|
import type { RestoreResult } from '@/types/RestoreResult'
|
||||||
|
import { api } from './api'
|
||||||
|
import { queryKeys } from './query'
|
||||||
|
|
||||||
|
export const BACKUP_DOWNLOAD_URL = '/api/backup/download'
|
||||||
|
/** Sunucudaki sınırla aynı (`backup::MAX_BYTES`) */
|
||||||
|
export const MAX_BACKUP_BYTES = 20 * 1024 * 1024
|
||||||
|
/** Bu kadar günden eski yedek → hatırlatma vurgulanır */
|
||||||
|
export const STALE_DAYS = 30
|
||||||
|
|
||||||
|
/** Son yedekten bu yana geçen tam gün (yerel takvim günü); hiç yoksa `null` */
|
||||||
|
export function daysSince(iso: string | null, now: Date): number | null {
|
||||||
|
if (!iso) return null
|
||||||
|
const d = new Date(iso)
|
||||||
|
const day = (x: Date) => Date.UTC(x.getFullYear(), x.getMonth(), x.getDate())
|
||||||
|
return Math.max(0, Math.round((day(now) - day(d)) / 86_400_000))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBackupStatus() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.backup,
|
||||||
|
queryFn: () => api.get<BackupStatus>('/backup/status'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBackupPreview() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (json: string) => api.postJsonText<BackupPreview>('/backup/preview', json),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function useReplaceAll<V>(fn: (v: V) => Promise<RestoreResult>) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: fn,
|
||||||
|
// Hane verisinin tamamı değişti → her şey yeniden okunur
|
||||||
|
onSuccess: () => qc.invalidateQueries(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useRestoreBackup = () =>
|
||||||
|
useReplaceAll((json: string) => api.postJsonText<RestoreResult>('/backup/restore', json))
|
||||||
|
|
||||||
|
export const useUndoRestore = () =>
|
||||||
|
useReplaceAll((pointId: string) =>
|
||||||
|
api.post<RestoreResult>(`/backup/restore-points/${pointId}/restore`, {}),
|
||||||
|
)
|
||||||
@@ -25,6 +25,8 @@ export const queryKeys = {
|
|||||||
/** Rapor kırılımı (`query` = sunucu sorgu metni); kayıt değişince `reports` öneki yenilenir */
|
/** Rapor kırılımı (`query` = sunucu sorgu metni); kayıt değişince `reports` öneki yenilenir */
|
||||||
reportBreakdown: (query: string) => ['reports', 'breakdown', query] as const,
|
reportBreakdown: (query: string) => ['reports', 'breakdown', query] as const,
|
||||||
reports: ['reports'] as const,
|
reports: ['reports'] as const,
|
||||||
|
/** JSON yedeği: son yedek zamanı + geri dönüş noktaları */
|
||||||
|
backup: ['backup'] as const,
|
||||||
period: (id: string) => ['period', id] as const,
|
period: (id: string) => ['period', id] as const,
|
||||||
planItems: (id: string) => ['period', id, 'plan-items'] as const,
|
planItems: (id: string) => ['period', id, 'plan-items'] as const,
|
||||||
summary: (id: string) => ['period', id, 'summary'] as const,
|
summary: (id: string) => ['period', id, 'summary'] as const,
|
||||||
|
|||||||
@@ -576,6 +576,49 @@ export const t = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
backup: {
|
||||||
|
title: 'Yedek (JSON)',
|
||||||
|
hint: 'Hanenin tüm verisi tek dosyada: dönemler, plan, bütçe, harcamalar, notlar, birikimler, kategoriler. Şifreler ve hesaplar dosyaya girmez. Dosyada mali bilgileriniz olduğu için güvenli bir yerde saklayın.',
|
||||||
|
download: 'Yedeği indir',
|
||||||
|
never: 'Henüz hiç yedek almadınız.',
|
||||||
|
today: 'Son yedek: bugün',
|
||||||
|
daysAgo: (n: number, date: string) => `Son yedek: ${n} gün önce (${date})`,
|
||||||
|
stale: 'Ayda bir yedek almanızı öneririz.',
|
||||||
|
restoreTitle: 'Yedekten geri yükle',
|
||||||
|
restoreHint:
|
||||||
|
'Önce dosya incelenir, hiçbir şey değişmez. Onaylarsanız mevcut verileriniz yedektekiyle değiştirilir; öncesindeki durum otomatik saklanır ve “Geri al” ile dönebilirsiniz.',
|
||||||
|
ownerOnly: 'Yedekten geri yükleme yalnızca hane sahibi tarafından yapılabilir.',
|
||||||
|
chooseFile: 'Yedek dosyası seç',
|
||||||
|
another: 'Başka dosya seç',
|
||||||
|
checking: 'Yedek inceleniyor…',
|
||||||
|
tooBig: 'Dosya çok büyük (en fazla 20 MB).',
|
||||||
|
source: (name: string, date: string) => `“${name}” hanesinin ${date} tarihli yedeği`,
|
||||||
|
sameHousehold: 'Bu hanenin yedeği',
|
||||||
|
otherHousehold: 'Başka bir hanenin yedeği',
|
||||||
|
range: (from: string, to: string) => `Dönemler: ${from} – ${to}`,
|
||||||
|
colKind: 'Kayıt',
|
||||||
|
colBackup: 'Yedekte',
|
||||||
|
colCurrent: 'Şu an',
|
||||||
|
confirm: 'Mevcut verilerimin yedektekiyle değiştirileceğini anladım.',
|
||||||
|
restore: 'Geri yükle',
|
||||||
|
restoring: 'Geri yükleniyor…',
|
||||||
|
cancel: 'Vazgeç',
|
||||||
|
restored: 'Yedek geri yüklendi.',
|
||||||
|
restoredHint: 'Önceki durum saklandı. Yanlışlıkla yaptıysanız hemen geri alabilirsiniz.',
|
||||||
|
undo: 'Geri al',
|
||||||
|
undone: 'Önceki duruma dönüldü.',
|
||||||
|
confirmUndo: (date: string) =>
|
||||||
|
`Veriler ${date} tarihindeki duruma döndürülsün mü? Şu anki durum da saklanır; bu işlem de geri alınabilir.`,
|
||||||
|
pointsTitle: 'Geri dönüş noktaları',
|
||||||
|
pointsHint: (n: number, days: number) =>
|
||||||
|
`Her geri yükleme ve geri almadan önce otomatik oluşur. Son ${n} nokta, en fazla ${days} gün saklanır.`,
|
||||||
|
reason: {
|
||||||
|
before_restore: 'Yedekten geri yüklemeden önce',
|
||||||
|
before_undo: 'Geri almadan önce',
|
||||||
|
},
|
||||||
|
pointCounts: (periods: number, entries: number) => `${periods} dönem · ${entries} harcama`,
|
||||||
|
goBack: 'Bu duruma dön',
|
||||||
|
},
|
||||||
data: {
|
data: {
|
||||||
title: 'Veri',
|
title: 'Veri',
|
||||||
exportTitle: 'Dışa aktar (CSV)',
|
exportTitle: 'Dışa aktar (CSV)',
|
||||||
|
|||||||
Reference in New Issue
Block a user