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:
@@ -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">
|
||||
|
||||
@@ -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')),
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
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,
|
||||
|
||||
@@ -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)',
|
||||
|
||||
Reference in New Issue
Block a user