feat(veri): CSV dışa ve içe aktarma (harcamalar, plan kalemleri, varlık işlemleri)

- Excel uyumlu CSV indirme
- İçe aktarma: önce deneme raporu (geçerli / zaten var / satır satır hata), onayla geçerli satırlar
- Sütunlar adla, esnek tutar/tarih yazımı, ; ve , ayırıcı, Windows-1254 desteği
- Varlıkta tarih sırasına göre eldekinden fazla satış denetimi
- Ayarlar sayfasında Veri bölümü

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-13 02:21:44 +03:00
parent 33f64a0459
commit 4d764689e2
21 changed files with 1961 additions and 5 deletions
@@ -0,0 +1,210 @@
import { useRef, useState } from 'react'
import { CircleCheck, Download, FileUp, Loader2, TriangleAlert } from 'lucide-react'
import { toast } from 'sonner'
import { FormError } from '@/components/form-field'
import { Segmented } from '@/components/segmented'
import { Button } from '@/components/ui/button'
import { Section } from '@/features/planning/plan-read'
import { decodeText } from '@/lib/decode'
import { t } from '@/lib/strings'
import { exportUrl, TRANSFER_KINDS, useImport, type TransferKind } from '@/lib/transfer'
import type { ImportReport } from '@/types/ImportReport'
const s = t.data
const kindLabel: Record<TransferKind, string> = {
entries: s.entries,
'plan-items': s.planItems,
'asset-transactions': s.assetTxs,
}
const MAX_ERRORS = 50
/** Ayarlar → Veri: CSV dışa aktarma ve (denemeli) içe aktarma */
export function DataSection() {
return (
<div className="space-y-4">
<Section title={s.exportTitle}>
<p className="mb-3 text-sm text-muted-foreground">{s.exportHint}</p>
<div className="flex flex-wrap gap-2">
{TRANSFER_KINDS.map((k) => (
<Button key={k} asChild variant="outline">
<a href={exportUrl(k)} download>
<Download aria-hidden />
{kindLabel[k]}
</a>
</Button>
))}
</div>
</Section>
<ImportCsv />
</div>
)
}
function ImportCsv() {
const [kind, setKind] = useState<TransferKind>('entries')
const [file, setFile] = useState<{ name: string; csv: string; encoding: string } | null>(null)
const [report, setReport] = useState<{ r: ImportReport; committed: boolean } | null>(null)
const [error, setError] = useState<string>()
const input = useRef<HTMLInputElement>(null)
const run = useImport()
function reset() {
setFile(null)
setReport(null)
setError(undefined)
if (input.current) input.current.value = ''
}
async function check(f: File, k: TransferKind) {
setError(undefined)
setReport(null)
const { text, encoding } = decodeText(await f.arrayBuffer())
setFile({ name: f.name, csv: text, encoding })
try {
setReport({
r: await run.mutateAsync({ kind: k, csv: text, commit: false }),
committed: false,
})
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}
async function commit() {
if (!file) return
try {
const r = await run.mutateAsync({ kind, csv: file.csv, commit: true })
setReport({ r, committed: true })
toast.success(s.done(r.imported))
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}
const r = report?.r
return (
<Section title={s.importTitle}>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{s.importHint}</p>
<Segmented
label={s.importTitle}
value={kind}
onChange={(k) => {
setKind(k)
reset()
}}
options={TRANSFER_KINDS.map((k) => ({ value: k, label: kindLabel[k] }))}
/>
<p className="text-sm">
{s.columns[kind]} <span className="text-muted-foreground">{s.sampleHint}</span>
</p>
<div className="flex flex-wrap items-center gap-3">
<input
ref={input}
type="file"
accept=".csv,text/csv,text/plain"
className="sr-only"
id="import-file"
onChange={(e) => {
const f = e.target.files?.[0]
if (f) void check(f, kind)
}}
/>
<Button asChild variant="outline" disabled={run.isPending}>
<label htmlFor="import-file" className="cursor-pointer">
<FileUp aria-hidden />
{file ? s.another : s.chooseFile}
</label>
</Button>
{file && (
<span className="text-sm text-muted-foreground">
{s.file(file.name, file.encoding)}
</span>
)}
</div>
{run.isPending && (
<p role="status" className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" aria-hidden />
{report ? s.importing : s.checking}
</p>
)}
<FormError message={error} />
{r && (
<div className="space-y-3" aria-live="polite">
<ul className="space-y-1 text-sm">
<li>{s.summary(r.total)}</li>
{(report.committed ? r.imported : r.valid) > 0 ? (
<li className="flex items-center gap-1.5 font-medium text-income">
<CircleCheck className="size-4" aria-hidden />
{report.committed ? s.added(r.imported) : s.willAdd(r.valid)}
</li>
) : (
<li className="text-muted-foreground">
{report.committed ? s.added(0) : s.willAdd(0)}
</li>
)}
{r.duplicates > 0 && (
<li className="text-muted-foreground">{s.dupes(r.duplicates)}</li>
)}
{r.errors.length > 0 && (
<li className="flex items-center gap-1.5 font-medium text-expense">
<TriangleAlert className="size-4" aria-hidden />
{s.errorsTitle(r.errors.length)}
</li>
)}
</ul>
{r.errors.length > 0 && (
<ul className="max-h-60 space-y-1 overflow-y-auto rounded-xl bg-expense/5 p-3 text-sm">
{r.errors.slice(0, MAX_ERRORS).map((e) => (
<li key={`${e.line}-${e.message}`}>
<span className="font-medium">{s.line(e.line)}:</span> {e.message}
</li>
))}
{r.errors.length > MAX_ERRORS && (
<li className="text-muted-foreground">
{s.moreErrors(r.errors.length - MAX_ERRORS)}
</li>
)}
</ul>
)}
{!report.committed && r.preview.length > 0 && (
<div>
<h4 className="mb-1 text-sm font-medium">{s.previewTitle}</h4>
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<tbody>
{r.preview.map((row, i) => (
<tr key={i} className="border-b last:border-0">
{row.map((c, j) => (
<td key={j} className="px-3 py-1.5 whitespace-nowrap">
{c}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{!report.committed &&
(r.valid > 0 ? (
<Button onClick={() => void commit()} disabled={run.isPending}>
{run.isPending && <Loader2 className="animate-spin" aria-hidden />}
{s.commit(r.valid)}
</Button>
) : (
<p className="text-sm text-muted-foreground">{s.nothing}</p>
))}
</div>
)}
</div>
</Section>
)
}
+10 -1
View File
@@ -1,11 +1,20 @@
import { ComingSoon, PageHeader } from '@/components/page'
import { t } from '@/lib/strings'
import { DataSection } from './data-section'
export function SettingsPage() {
return (
<>
<PageHeader title={t.pages.settings.title} description={t.pages.settings.description} />
<ComingSoon when="ilerleyen fazlarda" />
<div className="space-y-6">
<section aria-labelledby="data-title" className="space-y-3">
<h2 id="data-title" className="text-lg font-semibold">
{t.data.title}
</h2>
<DataSection />
</section>
<ComingSoon when="ilerleyen fazlarda (tema, kategoriler, hane)" />
</div>
</>
)
}
+9
View File
@@ -28,6 +28,15 @@ describe('api', () => {
expect(init.headers).toEqual({ 'Content-Type': 'application/json' })
})
it('CSV metnini olduğu gibi gönderir', async () => {
const fetchMock = vi.fn().mockResolvedValue(Response.json({ total: 1 }))
globalThis.fetch = fetchMock
await api.postCsv('/import/entries', 'Tarih;Tutar\r\n')
const [, init] = fetchMock.mock.calls[0]
expect(init.body).toBe('Tarih;Tutar\r\n')
expect(init.headers['Content-Type']).toBe('text/csv; charset=utf-8')
})
it('204 → undefined', async () => {
mockFetch(204)
await expect(api.post('/auth/logout')).resolves.toBeUndefined()
+17 -2
View File
@@ -31,14 +31,26 @@ const UNAVAILABLE_MESSAGE = 'Sunucu şu an yanıt vermiyor. Birkaç saniye sonra
type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
/** Ham metin gövde (CSV içe aktarma); diğerleri JSON */
class TextBody {
readonly text: string
readonly type: string
constructor(text: string, type: string) {
this.text = text
this.type = type
}
}
async function request<T>(method: Method, path: string, body?: unknown): Promise<T> {
let res: Response
const raw = body instanceof TextBody ? body : null
try {
res = await fetch(`/api${path}`, {
method,
credentials: 'same-origin',
headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
headers:
body === undefined ? undefined : { 'Content-Type': raw ? raw.type : 'application/json' },
body: body === undefined ? undefined : raw ? raw.text : JSON.stringify(body),
})
} catch {
throw new ApiError(0, 'network', NETWORK_MESSAGE)
@@ -64,4 +76,7 @@ export const api = {
put: <T>(path: string, body: unknown) => request<T>('PUT', path, body),
patch: <T>(path: string, body: unknown) => request<T>('PATCH', path, body),
delete: <T = void>(path: string) => request<T>('DELETE', path),
/** 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')),
}
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { decodeText } from './decode'
describe('decodeText', () => {
it('UTF-8 (BOM is stripped by the decoder)', () => {
const buf = new TextEncoder().encode('Açıklama;Şeker').buffer
expect(decodeText(buf)).toEqual({ text: 'Açıklama;Şeker', encoding: 'utf-8' })
})
it('Windows-1254 from Turkish Excel', () => {
// "Şeker;Ödeme" Windows-1254 baytları: Ş = 0xDE, Ö = 0xD6
const buf = new Uint8Array([0xde, 0x65, 0x6b, 0x65, 0x72, 0x3b, 0xd6, 0x64, 0x65, 0x6d, 0x65])
.buffer
expect(decodeText(buf)).toEqual({ text: 'Şeker;Ödeme', encoding: 'windows-1254' })
})
})
+11
View File
@@ -0,0 +1,11 @@
/**
* Yüklenen metin dosyasını çözer: önce UTF-8 (BOM'lu ya da değil); geçersizse Excel'in
* Türkçe Windows'ta varsayılan kaydettiği **Windows-1254**. (Türkçe karakterler bozulmasın.)
*/
export function decodeText(buf: ArrayBuffer): { text: string; encoding: 'utf-8' | 'windows-1254' } {
try {
return { text: new TextDecoder('utf-8', { fatal: true }).decode(buf), encoding: 'utf-8' }
} catch {
return { text: new TextDecoder('windows-1254').decode(buf), encoding: 'windows-1254' }
}
}
+38
View File
@@ -571,6 +571,44 @@ export const t = {
},
},
data: {
title: 'Veri',
exportTitle: 'Dışa aktar (CSV)',
exportHint: 'Excel ve LibreOffice’te doğrudan açılır (Türkçe karakterler ve tutarlar doğru).',
entries: 'Harcamalar',
planItems: 'Plan kalemleri',
assetTxs: 'Varlık işlemleri',
importTitle: 'İçe aktar (CSV)',
importHint:
'Önce dosya denenir, hiçbir şey yazılmaz: kaç satırın ekleneceği ve hatalı satırlar gösterilir. Onaylarsan yalnızca geçerli satırlar eklenir. Aynı dosyayı tekrar yüklemek çift kayıt oluşturmaz.',
columns: {
entries:
'Sütunlar: Tarih, Tutar, Kategori (zorunlu) · Grup, Ödeme türü, Açıklama (isteğe bağlı). Tarihin açık ya da düzeltmedeki bir döneme düşmesi gerekir.',
'plan-items':
'Sütunlar: Dönem başlangıcı, Tür (Gelir / Sabit gider), Ad, Tutar (zorunlu) · Tarih (isteğe bağlı).',
'asset-transactions':
'Sütunlar: Tarih, Varlık kodu (ya da Varlık), İşlem (Alım / Satım), Tutar (zorunlu) · Miktar (TL dışında zorunlu), Birim fiyat, Not.',
},
sampleHint: 'Biçim için önce aynı türü dışa aktarıp bakabilirsin.',
chooseFile: 'Dosya seç',
checking: 'Dosya denetleniyor…',
file: (name: string, enc: string) =>
`${name} · ${enc === 'utf-8' ? 'UTF-8' : 'Windows-1254 (Excel Türkçe)'}`,
summary: (total: number) => `${total} satır okundu`,
willAdd: (n: number) => `${n} satır eklenecek`,
added: (n: number) => `${n} satır eklendi`,
dupes: (n: number) => `${n} satır zaten var, atlanacak`,
errorsTitle: (n: number) => `${n} satır hatalı (eklenmeyecek)`,
line: (n: number) => `Satır ${n}`,
moreErrors: (n: number) => `… ve ${n} hata daha`,
previewTitle: 'Eklenecek satırlardan örnek',
commit: (n: number) => `${n} satırı içe aktar`,
importing: 'Aktarılıyor…',
done: (n: number) => `${n} satır içe aktarıldı`,
another: 'Başka dosya seç',
nothing: 'Eklenecek yeni satır yok.',
},
periods: {
picker: 'Dönem',
status: { open: 'Açık', closed: '🔒 Kapalı', correcting: '✏️ Düzeltmede' },
+31
View File
@@ -0,0 +1,31 @@
/**
* CSV dışa / içe aktarma. İçe aktarma iki adım: deneme (`commit: false`) → onay (`true`).
* Aktarımdan sonra dönem, rapor ve birikim verileri yenilenir.
*/
import { useMutation, useQueryClient } from '@tanstack/react-query'
import type { ImportReport } from '@/types/ImportReport'
import { api } from './api'
import { queryKeys } from './query'
export const TRANSFER_KINDS = ['entries', 'plan-items', 'asset-transactions'] as const
export type TransferKind = (typeof TRANSFER_KINDS)[number]
/** İndirme adresi (çerezle; tarayıcı dosya olarak kaydeder) */
export const exportUrl = (kind: TransferKind) => `/api/export/${kind}.csv`
export function useImport() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ kind, csv, commit }: { kind: TransferKind; csv: string; commit: boolean }) =>
api.postCsv<ImportReport>(`/import/${kind}?commit=${commit}`, csv),
onSuccess: (_, v) => {
if (!v.commit) return
void Promise.all([
qc.invalidateQueries({ queryKey: queryKeys.periods }),
qc.invalidateQueries({ queryKey: ['period'] }),
qc.invalidateQueries({ queryKey: queryKeys.portfolio }),
qc.invalidateQueries({ queryKey: queryKeys.reports }),
])
},
})
}
+7
View File
@@ -0,0 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ImportError = {
/**
* Dosyadaki satır numarası (başlık 1)
*/
line: number, message: string, };
+24
View File
@@ -0,0 +1,24 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ImportError } from "./ImportError";
export type ImportReport = {
/**
* Başlık hariç dolu satır
*/
total: number,
/**
* Eklenebilir (tekrarlar hariç); yazıldıysa yazılan
*/
valid: number,
/**
* Veritabanında zaten olduğu için atlanan
*/
duplicates: number,
/**
* Yazılan (denemede 0)
*/
imported: number, errors: Array<ImportError>,
/**
* İlk geçerli satırlar (gösterime hazır)
*/
preview: Array<Array<string>>, };