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:
2026-11-13 20:56:51 +03:00
parent 0d70576df7
commit b974525cf6
10 changed files with 463 additions and 3 deletions
+1 -1
View File
@@ -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(..)`.
- 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ç.
- 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.
## Ortam
+2 -1
View File
@@ -1,7 +1,7 @@
# 🧠 Proje Hafızası
## ▶️ 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 ✅.
## 📌 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.
- **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 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
View File
@@ -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
- ✅ 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)
- 🔄 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
@@ -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 { paths } from '@/components/layout/nav-items'
import { Section } from '@/features/planning/plan-read'
import { BackupSection } from './backup-section'
import { decodeText } from '@/lib/decode'
import { t } from '@/lib/strings'
import { exportUrl, TRANSFER_KINDS, useImport, type TransferKind } from '@/lib/transfer'
@@ -24,6 +25,7 @@ const MAX_ERRORS = 50
export function DataSection() {
return (
<div className="space-y-4">
<BackupSection />
<Section title={s.exportTitle}>
<p className="mb-3 text-sm text-muted-foreground">{s.exportHint}</p>
<div className="flex flex-wrap gap-2">
+3
View File
@@ -79,4 +79,7 @@ export const api = {
/** CSV metnini gövde olarak gönderir */
postCsv: <T>(path: string, csv: string) =>
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')),
}
+13
View File
@@ -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
})
})
+54
View File
@@ -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`, {}),
)
+2
View File
@@ -25,6 +25,8 @@ export const queryKeys = {
/** 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,
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,
planItems: (id: string) => ['period', id, 'plan-items'] as const,
summary: (id: string) => ['period', id, 'summary'] as const,
+43
View File
@@ -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: {
title: 'Veri',
exportTitle: 'Dışa aktar (CSV)',