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
@@ -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)',