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