feat(ekstre): banka/kart ekstresi içe aktarma ekranı
- Ayarlar → Ekstre içe aktar: Excel (.xlsx/.xls) ve CSV, dosya tarayıcıda okunur - Başlık satırı ve sütunlar otomatik bulunur; tek sütun / Borç-Alacak; eşleştirme profil olarak saklanır - Gözden geçirme: tür + kategori önerisi, aynı açıklamalara otomatik yayılma, tekrar/engelli satırlar - Öğrenilen kurallarda ilk kelime yedeği (MIGROS İSTANBUL → MIGROS KADIKÖY) - Defterde iade etiketi; iade düzenlemede eksi kalır - SheetJS resmi paketi frontend/vendor altında (npm 12 uzak paket indirmeyi engeller) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Generated
+13
@@ -27,6 +27,7 @@
|
||||
"sonner": "^2.0.8",
|
||||
"tailwindcss": "^4.3.5",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"xlsx": "file:vendor/xlsx-0.20.3.tgz",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -9024,6 +9025,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.20.3",
|
||||
"resolved": "file:vendor/xlsx-0.20.3.tgz",
|
||||
"integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"sonner": "^2.0.8",
|
||||
"tailwindcss": "^4.3.5",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"xlsx": "file:vendor/xlsx-0.20.3.tgz",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -29,6 +29,8 @@ export const paths = {
|
||||
closePeriod: '/degerlendirme/kapat',
|
||||
reports: '/raporlar',
|
||||
settings: '/ayarlar',
|
||||
/** Banka / kart ekstresi içe aktarma */
|
||||
statement: '/ayarlar/ekstre',
|
||||
login: '/giris',
|
||||
register: '/kayit',
|
||||
} as const
|
||||
|
||||
@@ -32,7 +32,8 @@ function initialValues(
|
||||
): EntryForm {
|
||||
if (entry) {
|
||||
return {
|
||||
amount: moneyToText(entry.amount),
|
||||
// İade (ekstreden, eksi tutar) formda artı gösterilir, eksi kaydedilir
|
||||
amount: moneyToText(Math.abs(entry.amount)),
|
||||
group: entry.group,
|
||||
category_id: entry.category_id,
|
||||
payment_method_id: entry.payment_method_id ?? '',
|
||||
@@ -143,7 +144,7 @@ export function EntryForm({
|
||||
id: entry.id,
|
||||
version: entry.version,
|
||||
date: v.date,
|
||||
amount: toKurus(v.amount),
|
||||
amount: entry.amount < 0 ? -toKurus(v.amount) : toKurus(v.amount),
|
||||
category_id: v.category_id,
|
||||
payment_method_id: v.payment_method_id, // '' → kaldır
|
||||
description: v.description.trim(), // '' → kaldır
|
||||
@@ -182,7 +183,11 @@ export function EntryForm({
|
||||
control={control}
|
||||
name="amount"
|
||||
render={({ field }) => (
|
||||
<FormField label={s.amount} error={errors.amount}>
|
||||
<FormField
|
||||
label={s.amount}
|
||||
error={errors.amount}
|
||||
hint={entry && entry.amount < 0 ? s.refundHint : undefined}
|
||||
>
|
||||
{(f) => (
|
||||
<MoneyInput
|
||||
{...f}
|
||||
|
||||
@@ -117,7 +117,10 @@ export function EntryList({
|
||||
{details.slice(1).map((d) => ` · ${d}`)}
|
||||
</p>
|
||||
</div>
|
||||
<span className="money shrink-0 font-medium">{formatMoney(e.amount)}</span>
|
||||
<span className={cn('money shrink-0 font-medium', e.amount < 0 && 'text-income')}>
|
||||
{e.amount < 0 && <span className="mr-1 text-xs font-normal">{s.refund}</span>}
|
||||
{formatMoney(e.amount)}
|
||||
</span>
|
||||
{!readOnly && (
|
||||
<div className="flex shrink-0">
|
||||
<Button
|
||||
|
||||
@@ -4,6 +4,8 @@ import { toast } from 'sonner'
|
||||
import { FormError } from '@/components/form-field'
|
||||
import { Segmented } from '@/components/segmented'
|
||||
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 { decodeText } from '@/lib/decode'
|
||||
import { t } from '@/lib/strings'
|
||||
@@ -35,6 +37,15 @@ export function DataSection() {
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
<Section title={t.statement.entry}>
|
||||
<p className="mb-3 text-sm text-muted-foreground">{t.statement.entryHint}</p>
|
||||
<Button asChild>
|
||||
<Link to={paths.statement}>
|
||||
<FileUp aria-hidden />
|
||||
{t.statement.open}
|
||||
</Link>
|
||||
</Button>
|
||||
</Section>
|
||||
<ImportCsv />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { ArrowLeft, FileUp, Loader2 } from 'lucide-react'
|
||||
import { Link } from 'react-router'
|
||||
import { toast } from 'sonner'
|
||||
import { FormError } from '@/components/form-field'
|
||||
import { paths } from '@/components/layout/nav-items'
|
||||
import { PageHeader } from '@/components/page'
|
||||
import { Segmented } from '@/components/segmented'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Stepper } from '@/features/period-wizard/wizard-ui'
|
||||
import { Section } from '@/features/planning/plan-read'
|
||||
import { useBankPreview } from '@/lib/bank-import'
|
||||
import { usePaymentMethods } from '@/lib/catalog'
|
||||
import { formatDate } from '@/lib/format'
|
||||
import { readSheets, type Sheet } from '@/lib/read-sheet'
|
||||
import {
|
||||
applyProfile,
|
||||
detectHeaderRow,
|
||||
guessMapping,
|
||||
loadProfiles,
|
||||
normalize,
|
||||
parseCellDate,
|
||||
saveProfile,
|
||||
type AmountMode,
|
||||
type Cell,
|
||||
type Mapping,
|
||||
type Source,
|
||||
type StatementRow,
|
||||
} from '@/lib/statement'
|
||||
import { t } from '@/lib/strings'
|
||||
import type { BankPreview } from '@/types/BankPreview'
|
||||
import { Review } from './review'
|
||||
|
||||
const s = t.statement
|
||||
const select = 'h-11 w-full rounded-lg border bg-background px-3 text-sm'
|
||||
const text = (c: Cell) => (c === null || c === undefined ? '' : String(c).trim())
|
||||
|
||||
export type Loaded = {
|
||||
rows: StatementRow[]
|
||||
skipped: number
|
||||
source: Source
|
||||
paymentMethodId: string
|
||||
preview: BankPreview
|
||||
}
|
||||
|
||||
/**
|
||||
* `/ayarlar/ekstre`: Dosya → Eşleştir → Gözden geçir → aktar.
|
||||
* Dosya tarayıcıda okunur; sunucuya yalnızca tarih/açıklama/tutar/yön gider.
|
||||
*/
|
||||
export function StatementPage() {
|
||||
const [sheets, setSheets] = useState<{ file: string; list: Sheet[] } | null>(null)
|
||||
const [loaded, setLoaded] = useState<Loaded | null>(null)
|
||||
const step = loaded ? 3 : sheets ? 2 : 1
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<Link
|
||||
to={paths.settings}
|
||||
className="mb-3 inline-flex min-h-11 items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="size-4" aria-hidden />
|
||||
{s.back}
|
||||
</Link>
|
||||
<PageHeader title={s.title} description={s.description} />
|
||||
<Stepper current={step} steps={s.steps} label={s.title} />
|
||||
{step === 1 && <FileStep onRead={(file, list) => setSheets({ file, list })} />}
|
||||
{step === 2 && sheets && (
|
||||
<MapStep
|
||||
file={sheets.file}
|
||||
sheets={sheets.list}
|
||||
onBack={() => setSheets(null)}
|
||||
onLoaded={setLoaded}
|
||||
/>
|
||||
)}
|
||||
{step === 3 && loaded && (
|
||||
<Review
|
||||
data={loaded}
|
||||
onBack={() => setLoaded(null)}
|
||||
onRestart={() => {
|
||||
setLoaded(null)
|
||||
setSheets(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FileStep({ onRead }: { onRead: (file: string, sheets: Sheet[]) => void }) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string>()
|
||||
const input = useRef<HTMLInputElement>(null)
|
||||
|
||||
async function read(f: File) {
|
||||
setBusy(true)
|
||||
setError(undefined)
|
||||
try {
|
||||
const list = (await readSheets(f)).filter((x) => x.rows.length > 1)
|
||||
if (list.length === 0) throw new Error('empty')
|
||||
onRead(f.name, list)
|
||||
} catch {
|
||||
setError(s.readError)
|
||||
if (input.current) input.current.value = ''
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title={s.steps[0]}>
|
||||
<p className="mb-4 text-sm text-muted-foreground">{s.fileHint}</p>
|
||||
<input
|
||||
ref={input}
|
||||
id="statement-file"
|
||||
type="file"
|
||||
accept=".xlsx,.xls,.csv,.txt,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/csv"
|
||||
className="sr-only"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0]
|
||||
if (f) void read(f)
|
||||
}}
|
||||
/>
|
||||
<Button asChild size="lg" disabled={busy}>
|
||||
<label htmlFor="statement-file" className="cursor-pointer">
|
||||
{busy ? <Loader2 className="animate-spin" aria-hidden /> : <FileUp aria-hidden />}
|
||||
{busy ? s.reading : s.choose}
|
||||
</label>
|
||||
</Button>
|
||||
<FormError message={error} />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
function MapStep({
|
||||
file,
|
||||
sheets,
|
||||
onBack,
|
||||
onLoaded,
|
||||
}: {
|
||||
file: string
|
||||
sheets: Sheet[]
|
||||
onBack: () => void
|
||||
onLoaded: (l: Loaded) => void
|
||||
}) {
|
||||
const methods = usePaymentMethods()
|
||||
const preview = useBankPreview()
|
||||
const [profiles] = useState(loadProfiles)
|
||||
const [sheetIdx, setSheetIdx] = useState(0)
|
||||
const rows = sheets[sheetIdx].rows
|
||||
const [headerRow, setHeaderRow] = useState(() => detectHeaderRow(rows))
|
||||
const header = rows[headerRow] ?? []
|
||||
|
||||
// Başlangıç: başlığa uyan kayıtlı profil, yoksa adlardan tahmin
|
||||
const [initial] = useState(() => {
|
||||
for (const p of profiles) {
|
||||
const m = applyProfile(p, header)
|
||||
if (m) return { m, p }
|
||||
}
|
||||
return null
|
||||
})
|
||||
const guess = guessMapping(header)
|
||||
const [source, setSource] = useState<Source>(initial?.p.source ?? 'card')
|
||||
const [m, setM] = useState<Mapping>(
|
||||
initial?.m ?? {
|
||||
date: guess.date ?? -1,
|
||||
description: guess.description ?? -1,
|
||||
amount: guess.amount ?? -1,
|
||||
credit: guess.credit ?? -1,
|
||||
mode: guess.mode ?? 'signed-out-positive',
|
||||
},
|
||||
)
|
||||
const [pm, setPm] = useState(initial?.p.paymentMethodId ?? '')
|
||||
const [profileName, setProfileName] = useState(initial?.p.name ?? '')
|
||||
const [error, setError] = useState<string>()
|
||||
useEffect(() => {
|
||||
if (initial) toast.success(s.profileApplied(initial.p.name))
|
||||
}, [initial])
|
||||
|
||||
const cols = header.map((c, i) => ({ i, label: text(c) || `${i + 1}. sütun` }))
|
||||
const ready =
|
||||
m.date >= 0 &&
|
||||
m.description >= 0 &&
|
||||
m.amount >= 0 &&
|
||||
(m.mode !== 'debit-credit' || m.credit >= 0)
|
||||
|
||||
async function next() {
|
||||
setError(undefined)
|
||||
const n = normalize(rows, headerRow, m)
|
||||
if (n.rows.length === 0) return setError(s.noRows)
|
||||
if (profileName.trim()) {
|
||||
saveProfile({
|
||||
name: profileName.trim(),
|
||||
source,
|
||||
mode: m.mode,
|
||||
paymentMethodId: pm || undefined,
|
||||
columns: {
|
||||
date: text(header[m.date]),
|
||||
description: text(header[m.description]),
|
||||
amount: text(header[m.amount]),
|
||||
credit: m.mode === 'debit-credit' ? text(header[m.credit]) : undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
try {
|
||||
const p = await preview.mutateAsync({ source, rows: n.rows })
|
||||
onLoaded({ rows: n.rows, skipped: n.skipped.length, source, paymentMethodId: pm, preview: p })
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
|
||||
const colSelect = (label: string, value: number, onChange: (v: number) => void, id: string) => (
|
||||
<Field label={label} id={id}>
|
||||
<select
|
||||
id={id}
|
||||
name={id}
|
||||
className={select}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
>
|
||||
<option value={-1}>{s.pick}</option>
|
||||
{cols.map((c) => (
|
||||
<option key={c.i} value={c.i}>
|
||||
{c.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Section title={`${s.steps[1]} · ${file}`}>
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{sheets.length > 1 && (
|
||||
<Field label={s.sheet} id="sheet">
|
||||
<select
|
||||
id="sheet"
|
||||
className={select}
|
||||
value={sheetIdx}
|
||||
onChange={(e) => {
|
||||
const i = Number(e.target.value)
|
||||
setSheetIdx(i)
|
||||
setHeaderRow(detectHeaderRow(sheets[i].rows))
|
||||
}}
|
||||
>
|
||||
{sheets.map((x, i) => (
|
||||
<option key={x.name} value={i}>
|
||||
{x.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
<Field label={s.headerRow} id="header-row">
|
||||
<select
|
||||
id="header-row"
|
||||
name="headerRow"
|
||||
className={select}
|
||||
value={headerRow}
|
||||
onChange={(e) => setHeaderRow(Number(e.target.value))}
|
||||
>
|
||||
{rows.slice(0, 40).map((r, i) => (
|
||||
<option key={i} value={i}>
|
||||
{s.rowN(i + 1)}: {r.map(text).filter(Boolean).join(' · ').slice(0, 60)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
{profiles.length > 0 && (
|
||||
<Field label={s.profile} id="profile">
|
||||
<select
|
||||
id="profile"
|
||||
className={select}
|
||||
defaultValue={initial?.p.name ?? ''}
|
||||
onChange={(e) => {
|
||||
const p = profiles.find((x) => x.name === e.target.value)
|
||||
const mm = p && applyProfile(p, header)
|
||||
if (p && mm) {
|
||||
setM(mm)
|
||||
setSource(p.source)
|
||||
setPm(p.paymentMethodId ?? '')
|
||||
setProfileName(p.name)
|
||||
toast.success(s.profileApplied(p.name))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">{s.noProfile}</option>
|
||||
{profiles.map((p) => (
|
||||
<option key={p.name} value={p.name}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">{s.source}</p>
|
||||
<Segmented
|
||||
label={s.source}
|
||||
value={source}
|
||||
onChange={(v) => {
|
||||
setSource(v)
|
||||
if (m.mode !== 'debit-credit') {
|
||||
setM({ ...m, mode: v === 'card' ? 'signed-out-positive' : 'signed-out-negative' })
|
||||
}
|
||||
}}
|
||||
options={[
|
||||
{ value: 'card', label: s.card },
|
||||
{ value: 'account', label: s.account },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{colSelect(s.dateCol, m.date, (v) => setM({ ...m, date: v }), 'col-date')}
|
||||
{colSelect(s.descCol, m.description, (v) => setM({ ...m, description: v }), 'col-desc')}
|
||||
<Field label={s.amountMode} id="mode">
|
||||
<select
|
||||
id="mode"
|
||||
name="mode"
|
||||
className={select}
|
||||
value={m.mode}
|
||||
onChange={(e) => setM({ ...m, mode: e.target.value as AmountMode })}
|
||||
>
|
||||
{(Object.keys(s.modes) as AmountMode[]).map((k) => (
|
||||
<option key={k} value={k}>
|
||||
{s.modes[k]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
{colSelect(
|
||||
m.mode === 'debit-credit' ? s.debitCol : s.amountCol,
|
||||
m.amount,
|
||||
(v) => setM({ ...m, amount: v }),
|
||||
'col-amount',
|
||||
)}
|
||||
{m.mode === 'debit-credit' &&
|
||||
colSelect(s.creditCol, m.credit, (v) => setM({ ...m, credit: v }), 'col-credit')}
|
||||
<Field label={s.method} id="method">
|
||||
<select
|
||||
id="method"
|
||||
name="method"
|
||||
className={select}
|
||||
value={pm}
|
||||
onChange={(e) => setPm(e.target.value)}
|
||||
>
|
||||
<option value="">{s.noMethod}</option>
|
||||
{methods.data?.map((x) => (
|
||||
<option key={x.id} value={x.id}>
|
||||
{x.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={s.profileName} id="profile-name">
|
||||
<Input
|
||||
id="profile-name"
|
||||
name="profileName"
|
||||
maxLength={40}
|
||||
placeholder={s.profileNamePh}
|
||||
value={profileName}
|
||||
onChange={(e) => setProfileName(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title={s.preview}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-xs text-muted-foreground">
|
||||
{cols.map((c) => (
|
||||
<th key={c.i} className="px-2 py-2 font-medium whitespace-nowrap">
|
||||
{c.label}
|
||||
{c.i === m.date && ' 📅'}
|
||||
{c.i === m.description && ' 📝'}
|
||||
{(c.i === m.amount || (m.mode === 'debit-credit' && c.i === m.credit)) && ' 💰'}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.slice(headerRow + 1, headerRow + 7).map((r, i) => (
|
||||
<tr key={i} className="border-b last:border-0">
|
||||
{cols.map((c) => (
|
||||
<td key={c.i} className="max-w-56 truncate px-2 py-1.5 whitespace-nowrap">
|
||||
{/* Tarih sütunu yorumlanmış haliyle (Excel seri numarası → 10.09.2026) */}
|
||||
{c.i === m.date && parseCellDate(r[c.i])
|
||||
? formatDate(parseCellDate(r[c.i]) ?? '')
|
||||
: text(r[c.i])}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<FormError message={error} />
|
||||
<div className="flex gap-3 border-t pt-5">
|
||||
<Button variant="outline" size="lg" onClick={onBack} disabled={preview.isPending}>
|
||||
<ArrowLeft aria-hidden />
|
||||
{t.wizard.back}
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
className="ml-auto"
|
||||
disabled={!ready || preview.isPending}
|
||||
onClick={() => void next()}
|
||||
>
|
||||
{preview.isPending && <Loader2 className="animate-spin" aria-hidden />}
|
||||
{preview.isPending ? s.checking : s.continue}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Field({ label, id, children }: { label: string; id: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label htmlFor={id} className="text-sm font-medium">
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { ArrowLeft, CircleCheck, Info, Loader2, Sparkles } from 'lucide-react'
|
||||
import { Link } from 'react-router'
|
||||
import { toast } from 'sonner'
|
||||
import { FormError } from '@/components/form-field'
|
||||
import { paths } from '@/components/layout/nav-items'
|
||||
import { Segmented } from '@/components/segmented'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Section } from '@/features/planning/plan-read'
|
||||
import { useBankCommit } from '@/lib/bank-import'
|
||||
import { useCategories } from '@/lib/catalog'
|
||||
import { formatDate, formatMoney } from '@/lib/format'
|
||||
import { GROUPS } from '@/lib/groups'
|
||||
import { ruleKey } from '@/lib/statement'
|
||||
import { t } from '@/lib/strings'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { BankImportReport } from '@/types/BankImportReport'
|
||||
import type { Loaded } from './page'
|
||||
|
||||
const s = t.statement
|
||||
const PAGE = 50
|
||||
type Kind = 'expense' | 'refund' | 'income' | 'ignore'
|
||||
type Decision = { kind: Kind; category: string; touched: boolean }
|
||||
type Filter = 'all' | 'missing' | 'ignore' | 'skip'
|
||||
const needsCategory = (k: Kind) => k === 'expense' || k === 'refund'
|
||||
|
||||
/** 3. adım: satır satır tür + kategori; aynı açıklamalara otomatik yayılır */
|
||||
export function Review({
|
||||
data,
|
||||
onBack,
|
||||
onRestart,
|
||||
}: {
|
||||
data: Loaded
|
||||
onBack: () => void
|
||||
onRestart: () => void
|
||||
}) {
|
||||
const cats = useCategories()
|
||||
const commit = useBankCommit()
|
||||
const { rows, preview } = data
|
||||
const keys = useMemo(() => rows.map((r) => ruleKey(r.description)), [rows])
|
||||
const [dec, setDec] = useState<Decision[]>(() =>
|
||||
preview.rows.map((p) => ({ kind: p.kind, category: p.category_id ?? '', touched: false })),
|
||||
)
|
||||
const [filter, setFilter] = useState<Filter>('all')
|
||||
const [page, setPage] = useState(0)
|
||||
const [error, setError] = useState<string>()
|
||||
const [report, setReport] = useState<BankImportReport | null>(null)
|
||||
|
||||
/** Atlanacak: daha önce aktarılmış ya da aktarılamaz (kapalı dönem, gelecek, hatalı) */
|
||||
const skip = (i: number) => preview.rows[i].duplicate || preview.rows[i].period === 'blocked'
|
||||
const active = rows.map((_, i) => i).filter((i) => !skip(i))
|
||||
const missing = active.filter((i) => needsCategory(dec[i].kind) && !dec[i].category)
|
||||
const duplicates = preview.rows.filter((r) => r.duplicate).length
|
||||
const blocked = preview.rows.filter((r) => r.period === 'blocked' && !r.duplicate).length
|
||||
const toSend = active.filter((i) => dec[i].kind !== 'ignore').length
|
||||
|
||||
const shown = rows
|
||||
.map((_, i) => i)
|
||||
.filter((i) =>
|
||||
filter === 'missing'
|
||||
? missing.includes(i)
|
||||
: filter === 'ignore'
|
||||
? !skip(i) && dec[i].kind === 'ignore'
|
||||
: filter === 'skip'
|
||||
? skip(i)
|
||||
: true,
|
||||
)
|
||||
const pageRows = shown.slice(page * PAGE, page * PAGE + PAGE)
|
||||
|
||||
/** Değişikliği bu satıra ve aynı açıklama anahtarlı, elle değiştirilmemiş satırlara uygula */
|
||||
function change(i: number, patch: Partial<Decision>) {
|
||||
let similar = 0
|
||||
const next = dec.map((d, j) => {
|
||||
if (j === i) return { ...d, ...patch, touched: true }
|
||||
if (keys[i] && keys[j] === keys[i] && !d.touched && !skip(j)) {
|
||||
similar++
|
||||
return { ...d, ...patch }
|
||||
}
|
||||
return d
|
||||
})
|
||||
setDec(next)
|
||||
if (similar > 0) toast(s.appliedSimilar(similar))
|
||||
}
|
||||
|
||||
async function run() {
|
||||
setError(undefined)
|
||||
try {
|
||||
const r = await commit.mutateAsync({
|
||||
payment_method_id: data.paymentMethodId || undefined,
|
||||
rows: active.map((i) => ({
|
||||
...rows[i],
|
||||
kind: dec[i].kind,
|
||||
category_id: needsCategory(dec[i].kind) ? dec[i].category : undefined,
|
||||
})),
|
||||
})
|
||||
setReport(r)
|
||||
window.scrollTo({ top: 0 })
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
|
||||
if (report) {
|
||||
// Hata sırası gönderilen satırlara göre → kullanıcıya tarih + açıklama
|
||||
const label = (idx: number) => {
|
||||
const r = rows[active[idx]]
|
||||
return r ? `${formatDate(r.date)} · ${r.description}` : `#${idx + 1}`
|
||||
}
|
||||
return <Result report={report} label={label} onRestart={onRestart} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Section title={s.steps[2]}>
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm">{s.summary(rows.length, duplicates, blocked)}</p>
|
||||
{data.skipped > 0 && (
|
||||
<p className="text-sm text-muted-foreground">{s.skipped(data.skipped)}</p>
|
||||
)}
|
||||
{preview.archives.length > 0 && (
|
||||
<div className="rounded-xl bg-muted p-3 text-sm">
|
||||
<p className="flex items-center gap-1.5 font-medium">
|
||||
<Info className="size-4" aria-hidden />
|
||||
{s.archivesTitle}: {preview.archives.map((a) => a.name).join(', ')}
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground">{s.archivesHint}</p>
|
||||
</div>
|
||||
)}
|
||||
<Segmented
|
||||
label={s.steps[2]}
|
||||
value={filter}
|
||||
onChange={(f) => {
|
||||
setFilter(f)
|
||||
setPage(0)
|
||||
}}
|
||||
options={[
|
||||
{ value: 'all', label: `${s.filters.all} (${rows.length})` },
|
||||
{ value: 'missing', label: `${s.filters.missing} (${missing.length})` },
|
||||
{ value: 'ignore', label: s.filters.ignore },
|
||||
{ value: 'skip', label: `${s.filters.skip} (${duplicates + blocked})` },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Liste: telefonda kart, geniş ekranda sütunlu satır */}
|
||||
<div className="rounded-2xl border bg-card">
|
||||
<div className="hidden grid-cols-[6.5rem_1fr_8rem_minmax(15rem,24rem)] gap-3 border-b px-3 py-2 text-xs text-muted-foreground md:grid">
|
||||
<span>{t.ledger.date}</span>
|
||||
<span>{t.ledger.description}</span>
|
||||
<span className="text-right">{t.ledger.amount}</span>
|
||||
<span>{s.steps[2]}</span>
|
||||
</div>
|
||||
<ul className="divide-y">
|
||||
{pageRows.map((i) => {
|
||||
const r = rows[i]
|
||||
const p = preview.rows[i]
|
||||
const d = dec[i]
|
||||
const off = skip(i)
|
||||
return (
|
||||
<li
|
||||
key={i}
|
||||
className={cn(
|
||||
'grid grid-cols-[1fr_auto] gap-x-3 gap-y-1.5 px-3 py-2.5 text-sm md:grid-cols-[6.5rem_1fr_8rem_minmax(15rem,24rem)] md:items-start',
|
||||
off && 'opacity-55',
|
||||
)}
|
||||
>
|
||||
<span className="text-muted-foreground md:text-foreground">
|
||||
{formatDate(r.date)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'money text-right font-medium whitespace-nowrap md:order-3',
|
||||
r.direction === 'in' && 'text-income',
|
||||
)}
|
||||
>
|
||||
{r.direction === 'in' ? '+' : ''}
|
||||
{formatMoney(r.amount)}
|
||||
</span>
|
||||
<div className="col-span-2 min-w-0 md:order-2 md:col-span-1">
|
||||
<div className="break-words">{r.description || '—'}</div>
|
||||
<div className="mt-0.5 flex flex-wrap gap-x-2 text-xs text-muted-foreground">
|
||||
{p.period_name && <span>{p.period_name}</span>}
|
||||
{p.from_rule && !d.touched && (
|
||||
<span className="inline-flex items-center gap-0.5 text-primary">
|
||||
<Sparkles className="size-3" aria-hidden />
|
||||
{s.learned}
|
||||
</span>
|
||||
)}
|
||||
{p.duplicate && <span className="font-medium">{s.duplicate}</span>}
|
||||
{!p.duplicate && p.message && (
|
||||
<span className="font-medium text-expense">{p.message}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!off && (
|
||||
<div className="col-span-2 flex gap-1.5 md:order-4 md:col-span-1">
|
||||
<select
|
||||
aria-label={`${formatDate(r.date)} ${r.description}: ${s.steps[2]}`}
|
||||
name={`kind-${i}`}
|
||||
className="h-10 shrink-0 rounded-lg border bg-background px-2 text-sm"
|
||||
value={d.kind}
|
||||
onChange={(e) => change(i, { kind: e.target.value as Kind })}
|
||||
>
|
||||
{(Object.keys(s.kinds) as Kind[]).map((k) => (
|
||||
<option key={k} value={k}>
|
||||
{s.kinds[k]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{needsCategory(d.kind) && (
|
||||
<select
|
||||
aria-label={`${formatDate(r.date)} ${r.description}: ${s.category}`}
|
||||
name={`cat-${i}`}
|
||||
className={cn(
|
||||
'h-10 min-w-0 flex-1 rounded-lg border bg-background px-2 text-sm',
|
||||
!d.category && 'border-warning',
|
||||
)}
|
||||
value={d.category}
|
||||
onChange={(e) => change(i, { category: e.target.value })}
|
||||
>
|
||||
<option value="">{s.category}…</option>
|
||||
{GROUPS.map((g) => (
|
||||
<optgroup key={g.code} label={`${g.emoji} ${g.label}`}>
|
||||
{cats.data
|
||||
?.filter((c) => c.group === g.code)
|
||||
.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.emoji ? `${c.emoji} ` : ''}
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{shown.length > PAGE && (
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
<Button variant="outline" disabled={page === 0} onClick={() => setPage(page - 1)}>
|
||||
{s.prev}
|
||||
</Button>
|
||||
<span className="text-muted-foreground">
|
||||
{s.page(page * PAGE + 1, Math.min(shown.length, page * PAGE + PAGE), shown.length)}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={(page + 1) * PAGE >= shown.length}
|
||||
onClick={() => setPage(page + 1)}
|
||||
>
|
||||
{s.next}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{missing.length > 0 && <p className="text-sm text-warning">{s.missing(missing.length)}</p>}
|
||||
<FormError message={error} />
|
||||
<div className="flex gap-3 border-t pt-5">
|
||||
<Button variant="outline" size="lg" onClick={onBack} disabled={commit.isPending}>
|
||||
<ArrowLeft aria-hidden />
|
||||
{t.wizard.back}
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
className="ml-auto"
|
||||
disabled={missing.length > 0 || active.length === 0 || commit.isPending}
|
||||
onClick={() => void run()}
|
||||
>
|
||||
{commit.isPending && <Loader2 className="animate-spin" aria-hidden />}
|
||||
{commit.isPending ? s.committing : s.commit(toSend)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Result({
|
||||
report: r,
|
||||
label,
|
||||
onRestart,
|
||||
}: {
|
||||
report: BankImportReport
|
||||
label: (index: number) => string
|
||||
onRestart: () => void
|
||||
}) {
|
||||
const parts = [
|
||||
s.result.entries(r.entries),
|
||||
s.result.refunds(r.refunds),
|
||||
s.result.incomes(r.incomes),
|
||||
s.result.ignored(r.ignored),
|
||||
...(r.duplicates ? [s.result.duplicates(r.duplicates)] : []),
|
||||
]
|
||||
return (
|
||||
<Section title={s.done}>
|
||||
<div className="space-y-3">
|
||||
<p className="flex items-center gap-2 font-medium">
|
||||
<CircleCheck className="size-5 text-income" aria-hidden />
|
||||
{parts.join(' · ')}
|
||||
</p>
|
||||
{r.archives.length > 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{s.result.archives(r.archives.join(', '))}
|
||||
</p>
|
||||
)}
|
||||
{r.errors.length > 0 && (
|
||||
<div className="rounded-xl bg-expense/5 p-3 text-sm">
|
||||
<p className="mb-1 font-medium text-expense">{s.errorsTitle}</p>
|
||||
<ul className="space-y-0.5">
|
||||
{r.errors.map((e) => (
|
||||
<li key={e.index}>
|
||||
{label(e.index)}: {e.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild>
|
||||
<Link to={paths.reports}>{s.toReports}</Link>
|
||||
</Button>
|
||||
<Button variant="outline" onClick={onRestart}>
|
||||
{s.another}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Ekstre içe aktarma: önizleme (öneri + tekrar + dönem) ve aktarma (`/bank-import/*`).
|
||||
*/
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import type { BankCommitRequest } from '@/types/BankCommitRequest'
|
||||
import type { BankImportReport } from '@/types/BankImportReport'
|
||||
import type { BankPreview } from '@/types/BankPreview'
|
||||
import type { BankPreviewRequest } from '@/types/BankPreviewRequest'
|
||||
import { api } from './api'
|
||||
import { queryKeys } from './query'
|
||||
|
||||
export const useBankPreview = () =>
|
||||
useMutation({
|
||||
mutationFn: (body: BankPreviewRequest) => api.post<BankPreview>('/bank-import/preview', body),
|
||||
})
|
||||
|
||||
export function useBankCommit() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: BankCommitRequest) =>
|
||||
api.post<BankImportReport>('/bank-import/commit', body),
|
||||
onSuccess: () =>
|
||||
Promise.all([
|
||||
qc.invalidateQueries({ queryKey: queryKeys.periods }),
|
||||
qc.invalidateQueries({ queryKey: ['period'] }),
|
||||
qc.invalidateQueries({ queryKey: queryKeys.reports }),
|
||||
]),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import * as XLSX from 'xlsx'
|
||||
import { parseCsvText, readSheets } from './read-sheet'
|
||||
import { normalize, parseCellDate } from './statement'
|
||||
|
||||
describe('read-sheet', () => {
|
||||
it('CSV with quotes and Turkish numbers stays text', () => {
|
||||
expect(parseCsvText('Tarih;Açıklama;Tutar\r\n13.11.2026;"Kafe; tatlı";-1.250,50\r\n')).toEqual([
|
||||
['Tarih', 'Açıklama', 'Tutar'],
|
||||
['13.11.2026', 'Kafe; tatlı', '-1.250,50'],
|
||||
])
|
||||
})
|
||||
|
||||
it('reads a real .xlsx (dates and amounts as numbers)', async () => {
|
||||
const ws = XLSX.utils.aoa_to_sheet([
|
||||
['Banka', null, null],
|
||||
['İşlem Tarihi', 'Açıklama', 'Tutar'],
|
||||
[46339, 'MİGROS', -45.9],
|
||||
])
|
||||
const wb = XLSX.utils.book_new()
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Hareketler')
|
||||
const out = XLSX.write(wb, { type: 'array', bookType: 'xlsx' }) as ArrayBuffer
|
||||
const sheets = await readSheets(new File([out], 'ekstre.xlsx'))
|
||||
expect(sheets[0].name).toBe('Hareketler')
|
||||
expect(sheets[0].rows[2]).toEqual([46339, 'MİGROS', -45.9])
|
||||
expect(parseCellDate(sheets[0].rows[2][0])).toBe('2026-11-13')
|
||||
const n = normalize(sheets[0].rows, 1, {
|
||||
date: 0,
|
||||
description: 1,
|
||||
amount: 2,
|
||||
credit: -1,
|
||||
mode: 'signed-out-negative',
|
||||
})
|
||||
expect(n.rows).toEqual([
|
||||
{ date: '2026-11-13', description: 'MİGROS', amount: 4_590, direction: 'out' },
|
||||
])
|
||||
})
|
||||
|
||||
it('CSV file in Windows-1254', async () => {
|
||||
const bytes = new Uint8Array([0x54, 0x3b, 0x41, 0x0a, 0x31, 0x3b, 0xde]) // "T;A\n1;Ş"
|
||||
const sheets = await readSheets(new File([bytes], 'ekstre.csv'))
|
||||
expect(sheets[0].rows).toEqual([
|
||||
['T', 'A'],
|
||||
['1', 'Ş'],
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Ekstre dosyasını tablo satırlarına çevirir (tarayıcıda; dosya hiçbir yere gönderilmez).
|
||||
* - Excel (.xlsx, .xls, bankaların "HTML-xls"i): SheetJS — yalnızca gerektiğinde yüklenir.
|
||||
* Hücreler ham değer: tarih ve tutar sayı olarak gelir (`statement.ts` çözer).
|
||||
* - CSV / metin: kendi ayrıştırıcımız (SheetJS CSV'de "1.250,50" gibi Türkçe sayıları
|
||||
* yanlış yorumlayabilir); kodlama `decodeText` (UTF-8 / Windows-1254).
|
||||
*/
|
||||
import { decodeText } from './decode'
|
||||
import type { Cell } from './statement'
|
||||
|
||||
export type Sheet = { name: string; rows: Cell[][] }
|
||||
|
||||
/** CSV metni → satırlar; ayırıcı ilk satırdan (`;`, sekme, `,`) */
|
||||
export function parseCsvText(text: string): string[][] {
|
||||
const first = text.split(/\r?\n/, 1)[0] ?? ''
|
||||
const delim = [';', '\t', ','].reduce((a, d) =>
|
||||
first.split(d).length > first.split(a).length ? d : a,
|
||||
)
|
||||
const rows: string[][] = []
|
||||
let row: string[] = []
|
||||
let cell = ''
|
||||
let q = false
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text[i]
|
||||
if (q) {
|
||||
if (c === '"' && text[i + 1] === '"') {
|
||||
cell += '"'
|
||||
i++
|
||||
} else if (c === '"') q = false
|
||||
else cell += c
|
||||
} else if (c === '"' && cell === '') q = true
|
||||
else if (c === delim) {
|
||||
row.push(cell)
|
||||
cell = ''
|
||||
} else if (c === '\n') {
|
||||
row.push(cell)
|
||||
rows.push(row)
|
||||
row = []
|
||||
cell = ''
|
||||
} else if (c !== '\r') cell += c
|
||||
}
|
||||
if (cell !== '' || row.length) {
|
||||
row.push(cell)
|
||||
rows.push(row)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
export async function readSheets(file: File): Promise<Sheet[]> {
|
||||
const buf = await file.arrayBuffer()
|
||||
if (/\.(csv|txt)$/i.test(file.name)) {
|
||||
return [{ name: file.name, rows: parseCsvText(decodeText(buf).text) }]
|
||||
}
|
||||
const XLSX = await import('xlsx')
|
||||
const wb = XLSX.read(buf, { type: 'array', cellDates: false, raw: false })
|
||||
return wb.SheetNames.map((name) => ({
|
||||
name,
|
||||
rows: XLSX.utils.sheet_to_json<Cell[]>(wb.Sheets[name], {
|
||||
header: 1,
|
||||
raw: true,
|
||||
defval: null,
|
||||
// Boş satırlar korunur → satır numaraları Excel’dekiyle aynı
|
||||
blankrows: true,
|
||||
}),
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
applyProfile,
|
||||
detectHeaderRow,
|
||||
excelSerialToIso,
|
||||
guessMapping,
|
||||
normalize,
|
||||
parseAmount,
|
||||
parseCellDate,
|
||||
ruleKey,
|
||||
type Cell,
|
||||
} from './statement'
|
||||
|
||||
describe('statement parsing', () => {
|
||||
it('dates: text, Excel serial, invalid', () => {
|
||||
expect(parseCellDate('13.11.2026')).toBe('2026-11-13')
|
||||
expect(parseCellDate('3/1/2026 14:22')).toBe('2026-01-03')
|
||||
expect(parseCellDate('2026-11-13')).toBe('2026-11-13')
|
||||
expect(parseCellDate('13-11-2026')).toBe('2026-11-13')
|
||||
expect(excelSerialToIso(46339)).toBe('2026-11-13')
|
||||
expect(parseCellDate(46339.6)).toBe('2026-11-13') // saat kısmı
|
||||
expect(parseCellDate('31.02.2026')).toBeNull()
|
||||
expect(parseCellDate('Toplam')).toBeNull()
|
||||
expect(parseCellDate(123)).toBeNull()
|
||||
})
|
||||
|
||||
it('amounts in Turkish and English styles', () => {
|
||||
expect(parseAmount('1.250,50')).toBe(125_050)
|
||||
expect(parseAmount('-45,90 TL')).toBe(-4_590)
|
||||
expect(parseAmount('1,250.50')).toBe(125_050)
|
||||
expect(parseAmount('(45,90)')).toBe(-4_590)
|
||||
expect(parseAmount('45,90-')).toBe(-4_590)
|
||||
expect(parseAmount('1.000')).toBe(100_000)
|
||||
expect(parseAmount('12.5')).toBe(1_250)
|
||||
expect(parseAmount(-1250.5)).toBe(-125_050)
|
||||
expect(parseAmount('+20')).toBe(2_000)
|
||||
expect(parseAmount('')).toBeNull()
|
||||
expect(parseAmount('abc')).toBeNull()
|
||||
})
|
||||
|
||||
it('finds the table header below bank info lines and guesses columns', () => {
|
||||
const rows: Cell[][] = [
|
||||
['GARANTİ BBVA', null, null],
|
||||
['Hesap No: 123', null, null],
|
||||
[],
|
||||
['İşlem Tarihi', 'Açıklama', 'Tutar', 'Bakiye'],
|
||||
['13.11.2026', 'MIGROS', '-45,90', '1.000,00'],
|
||||
]
|
||||
expect(detectHeaderRow(rows)).toBe(3)
|
||||
expect(guessMapping(rows[3])).toEqual({ date: 0, description: 1, amount: 2 })
|
||||
expect(guessMapping(['Tarih', 'İşlem', 'Borç', 'Alacak'])).toMatchObject({
|
||||
date: 0,
|
||||
amount: 2,
|
||||
credit: 3,
|
||||
mode: 'debit-credit',
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes direction by mode and skips unreadable lines', () => {
|
||||
const rows: Cell[][] = [
|
||||
['Tarih', 'Açıklama', 'Tutar'],
|
||||
['13.11.2026', ' MIGROS ISTANBUL ', '45,90'],
|
||||
['14.11.2026', 'IADE', '-10,00'],
|
||||
['', 'Toplam', '35,90'],
|
||||
['15.11.2026', 'SIFIR', '0'],
|
||||
]
|
||||
const card = normalize(rows, 0, {
|
||||
date: 0,
|
||||
description: 1,
|
||||
amount: 2,
|
||||
credit: -1,
|
||||
mode: 'signed-out-positive',
|
||||
})
|
||||
expect(card.rows).toEqual([
|
||||
{ date: '2026-11-13', description: 'MIGROS ISTANBUL', amount: 4_590, direction: 'out' },
|
||||
{ date: '2026-11-14', description: 'IADE', amount: 1_000, direction: 'in' },
|
||||
])
|
||||
expect(card.skipped).toEqual([4, 5])
|
||||
const acc = normalize(rows, 0, {
|
||||
date: 0,
|
||||
description: 1,
|
||||
amount: 2,
|
||||
credit: -1,
|
||||
mode: 'signed-out-negative',
|
||||
})
|
||||
expect(acc.rows.map((r) => r.direction)).toEqual(['in', 'out'])
|
||||
const dc = normalize(
|
||||
[
|
||||
['T', 'A', 'Borç', 'Alacak'],
|
||||
['01.11.2026', 'MAAŞ', '', '45.000,00'],
|
||||
['02.11.2026', 'KİRA', '15.000,00', ''],
|
||||
],
|
||||
0,
|
||||
{ date: 0, description: 1, amount: 2, credit: 3, mode: 'debit-credit' },
|
||||
)
|
||||
expect(dc.rows.map((r) => [r.direction, r.amount])).toEqual([
|
||||
['in', 4_500_000],
|
||||
['out', 1_500_000],
|
||||
])
|
||||
})
|
||||
|
||||
it('rule key equals the backend one', () => {
|
||||
expect(ruleKey('MİGROS 1234 İSTANBUL TR')).toBe('migros istanbul')
|
||||
expect(ruleKey('A101 MAGAZACILIK*456')).toBe('a101 magazacilik')
|
||||
expect(ruleKey('ÖDEME ŞÜKRÜ')).toBe('odeme sukru')
|
||||
expect(ruleKey('12/11 *')).toBe('')
|
||||
})
|
||||
|
||||
it('profiles match columns by name, not position', () => {
|
||||
const p = {
|
||||
name: 'Kart',
|
||||
source: 'card' as const,
|
||||
columns: { date: 'İşlem Tarihi', description: 'Açıklama', amount: 'Tutar' },
|
||||
mode: 'signed-out-positive' as const,
|
||||
}
|
||||
expect(applyProfile(p, ['Açıklama', 'Tutar', 'İŞLEM TARİHİ'])).toMatchObject({
|
||||
date: 2,
|
||||
description: 0,
|
||||
amount: 1,
|
||||
})
|
||||
expect(applyProfile(p, ['Tarih', 'Açıklama', 'Tutar'])).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Banka / kart ekstresi okuma (saf hesaplar). Dosya tarayıcıda okunur; sunucuya yalnızca
|
||||
* tarih, açıklama, tutar (kuruş) ve yön gider (`/bank-import/*`).
|
||||
*
|
||||
* - Hücreler: Excel'den sayı (tarih seri numarası, tutar) ya da metin; CSV'den metin.
|
||||
* - Tutar: tek sütun (işaretli; kartta artı = harcama, hesapta eksi = harcama) ya da Borç/Alacak.
|
||||
* - Eşleştirme sütun **adlarıyla** profil olarak saklanır (her ay aynı banka dosyası).
|
||||
*/
|
||||
|
||||
export type Cell = string | number | boolean | null | undefined
|
||||
|
||||
export type Source = 'card' | 'account'
|
||||
/** `signed-out-negative`: eksi = çıkış (hesap) · `signed-out-positive`: artı = çıkış (kart) · `debit-credit`: Borç/Alacak */
|
||||
export type AmountMode = 'signed-out-negative' | 'signed-out-positive' | 'debit-credit'
|
||||
|
||||
export type Mapping = {
|
||||
date: number
|
||||
description: number
|
||||
amount: number
|
||||
/** `debit-credit` için */
|
||||
credit: number
|
||||
mode: AmountMode
|
||||
}
|
||||
|
||||
export type StatementRow = {
|
||||
date: string
|
||||
description: string
|
||||
/** Kuruş, > 0 */
|
||||
amount: number
|
||||
direction: 'in' | 'out'
|
||||
}
|
||||
|
||||
const text = (c: Cell) => (c === null || c === undefined ? '' : String(c).trim())
|
||||
|
||||
// ---------- Tarih ----------
|
||||
|
||||
/** Excel seri numarası (1900 sistemi) → `"YYYY-MM-DD"` (saat dilimi kaymasız) */
|
||||
export function excelSerialToIso(n: number): string | null {
|
||||
if (!Number.isFinite(n) || n < 20_000 || n > 80_000) return null // ~1954–2119
|
||||
const ms = Math.round((Math.floor(n) - 25_569) * 86_400_000) // 25569 = 1970-01-01
|
||||
return new Date(ms).toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/** `13.11.2026` · `13/11/2026` · `13-11-2026` · `2026-11-13` (+ saat) · Excel seri numarası */
|
||||
export function parseCellDate(c: Cell): string | null {
|
||||
if (typeof c === 'number') return excelSerialToIso(c)
|
||||
const s = text(c).split(/[\sT]/)[0]
|
||||
let y: number, m: number, d: number
|
||||
let mt = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(s)
|
||||
if (mt) [y, m, d] = [Number(mt[1]), Number(mt[2]), Number(mt[3])]
|
||||
else {
|
||||
mt = /^(\d{1,2})[./-](\d{1,2})[./-](\d{4})$/.exec(s)
|
||||
if (!mt) return null
|
||||
;[d, m, y] = [Number(mt[1]), Number(mt[2]), Number(mt[3])]
|
||||
}
|
||||
const iso = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
|
||||
const dt = new Date(`${iso}T00:00:00Z`)
|
||||
return dt.getUTCFullYear() === y && dt.getUTCMonth() + 1 === m && dt.getUTCDate() === d
|
||||
? iso
|
||||
: null
|
||||
}
|
||||
|
||||
// ---------- Tutar ----------
|
||||
|
||||
/**
|
||||
* Hücre → işaretli kuruş. `1.250,50` · `-45,90 TL` · `1,250.50` · `(45,90)` · `45,90-` · sayı.
|
||||
* Hem nokta hem virgül varsa sondaki ondalıktır; yalnız virgül → ondalık;
|
||||
* yalnız tek nokta + tam 3 rakam → binlik (`1.000`), değilse ondalık (`12.50`).
|
||||
*/
|
||||
export function parseAmount(c: Cell): number | null {
|
||||
if (typeof c === 'number') return Number.isFinite(c) ? Math.round(c * 100) : null
|
||||
let s = text(c).replace(/\s|TL|TRY|₺/gi, '')
|
||||
if (!s) return null
|
||||
let neg = false
|
||||
if (/^\(.*\)$/.test(s)) [neg, s] = [true, s.slice(1, -1)]
|
||||
if (s.endsWith('-')) [neg, s] = [true, s.slice(0, -1)]
|
||||
if (s.startsWith('-')) [neg, s] = [!neg, s.slice(1)]
|
||||
else if (s.startsWith('+')) s = s.slice(1)
|
||||
let int: string
|
||||
let frac = ''
|
||||
const lastDot = s.lastIndexOf('.')
|
||||
const lastComma = s.lastIndexOf(',')
|
||||
if (lastDot >= 0 && lastComma >= 0) {
|
||||
const dec = lastDot > lastComma ? '.' : ','
|
||||
const [i, f] = [
|
||||
s.slice(0, Math.max(lastDot, lastComma)),
|
||||
s.slice(Math.max(lastDot, lastComma) + 1),
|
||||
]
|
||||
int = i.replace(dec === '.' ? /,/g : /\./g, '')
|
||||
frac = f
|
||||
} else if (lastComma >= 0) {
|
||||
const parts = s.split(',')
|
||||
if (parts.length !== 2) return null
|
||||
;[int, frac] = parts
|
||||
} else if (lastDot >= 0) {
|
||||
const parts = s.split('.')
|
||||
if (parts.length === 2 && !(parts[1].length === 3 && parts[0] !== '0' && parts[0] !== '')) {
|
||||
;[int, frac] = parts
|
||||
} else int = parts.join('')
|
||||
} else int = s
|
||||
if (int === '') int = '0'
|
||||
if (!/^\d+$/.test(int) || !/^\d{0,2}$/.test(frac)) return null
|
||||
const v = Number(int) * 100 + Number(frac.padEnd(2, '0'))
|
||||
return neg ? -v : v
|
||||
}
|
||||
|
||||
// ---------- Başlık satırı ----------
|
||||
|
||||
/** İlk "tablo başlığı": ≥ 3 dolu hücre ve hemen altında tarih içeren bir satır */
|
||||
export function detectHeaderRow(rows: Cell[][]): number {
|
||||
for (let i = 0; i < Math.min(rows.length - 1, 40); i++) {
|
||||
const filled = rows[i].filter((c) => text(c) !== '').length
|
||||
if (filled >= 3 && rows[i + 1].some((c) => parseCellDate(c) !== null)) return i
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const norm = (s: string) => fold(s).replace(/[^a-z0-9]/g, '')
|
||||
|
||||
/** Başlık adlarından tahmini eşleştirme (Türk bankalarının yaygın adları) */
|
||||
export function guessMapping(header: Cell[]): Partial<Mapping> {
|
||||
const h = header.map((c) => norm(text(c)))
|
||||
const find = (...names: string[]) => {
|
||||
const i = h.findIndex((x) => names.some((n) => x === n || x.startsWith(n)))
|
||||
return i >= 0 ? i : undefined
|
||||
}
|
||||
const debit = find('borc', 'cikan', 'harcama')
|
||||
const credit = find('alacak', 'giren', 'yatan')
|
||||
return {
|
||||
date: find('islemtarihi', 'tarih', 'date'),
|
||||
description: find('aciklama', 'islemaciklamasi', 'description', 'detay', 'isyeri'),
|
||||
...(debit !== undefined && credit !== undefined
|
||||
? { amount: debit, credit, mode: 'debit-credit' as const }
|
||||
: { amount: find('tutar', 'islemtutari', 'amount', 'miktar') }),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Satırlar ----------
|
||||
|
||||
export type Normalized = {
|
||||
rows: StatementRow[]
|
||||
/** Okunamayan (tarih/tutar yok, sıfır) satır numaraları, dosyadaki (1'den) */
|
||||
skipped: number[]
|
||||
}
|
||||
|
||||
export function normalize(rows: Cell[][], headerRow: number, m: Mapping): Normalized {
|
||||
const out: StatementRow[] = []
|
||||
const skipped: number[] = []
|
||||
rows.slice(headerRow + 1).forEach((r, i) => {
|
||||
const line = headerRow + 2 + i
|
||||
if (r.every((c) => text(c) === '')) return
|
||||
const date = parseCellDate(r[m.date])
|
||||
let signed: number | null
|
||||
if (m.mode === 'debit-credit') {
|
||||
const debit = parseAmount(r[m.amount]) ?? 0
|
||||
const credit = parseAmount(r[m.credit]) ?? 0
|
||||
signed = debit !== 0 ? -Math.abs(debit) : credit !== 0 ? Math.abs(credit) : null
|
||||
} else {
|
||||
const v = parseAmount(r[m.amount])
|
||||
signed = v === null ? null : m.mode === 'signed-out-positive' ? -v : v
|
||||
}
|
||||
if (!date || !signed) {
|
||||
skipped.push(line)
|
||||
return
|
||||
}
|
||||
out.push({
|
||||
date,
|
||||
description: text(r[m.description]).replace(/\s+/g, ' '),
|
||||
amount: Math.abs(signed),
|
||||
direction: signed < 0 ? 'out' : 'in',
|
||||
})
|
||||
})
|
||||
return { rows: out, skipped }
|
||||
}
|
||||
|
||||
// ---------- Açıklama anahtarı (backend `bank_import::rule_key` ile aynı) ----------
|
||||
|
||||
const FOLD: Record<string, string> = {
|
||||
ı: 'i',
|
||||
ş: 's',
|
||||
ğ: 'g',
|
||||
ü: 'u',
|
||||
ö: 'o',
|
||||
ç: 'c',
|
||||
â: 'a',
|
||||
î: 'i',
|
||||
û: 'u',
|
||||
}
|
||||
|
||||
export function fold(s: string): string {
|
||||
return s
|
||||
.replace(/İ/g, 'i')
|
||||
.toLowerCase()
|
||||
.replace(/̇/g, '')
|
||||
.replace(/[ışğüöçâîû]/g, (c) => FOLD[c] ?? c)
|
||||
}
|
||||
|
||||
export function ruleKey(desc: string): string {
|
||||
return fold(desc)
|
||||
.split(/[^\p{L}\p{N}]+/u)
|
||||
.filter((w) => w.length >= 2 && !/^\d+$/.test(w))
|
||||
.slice(0, 2)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
// ---------- Profiller (bu tarayıcıda) ----------
|
||||
|
||||
export type Profile = {
|
||||
name: string
|
||||
source: Source
|
||||
/** Sütun adları (sıra değişse de bulunur) */
|
||||
columns: { date: string; description: string; amount: string; credit?: string }
|
||||
mode: AmountMode
|
||||
paymentMethodId?: string
|
||||
}
|
||||
|
||||
const KEY = 'kakeibo.statement.profiles'
|
||||
|
||||
export function loadProfiles(): Profile[] {
|
||||
try {
|
||||
const v = JSON.parse(localStorage.getItem(KEY) ?? '[]')
|
||||
return Array.isArray(v) ? v : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function saveProfile(p: Profile) {
|
||||
const list = loadProfiles().filter((x) => x.name !== p.name)
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify([p, ...list].slice(0, 20)))
|
||||
} catch {
|
||||
// Gizli pencere vb.: profil kaydedilemez, akış sürer
|
||||
}
|
||||
}
|
||||
|
||||
/** Profili başlığa uygula: tüm sütun adları bulunursa eşleştirme */
|
||||
export function applyProfile(p: Profile, header: Cell[]): Mapping | null {
|
||||
const h = header.map((c) => norm(text(c)))
|
||||
const at = (name?: string) => (name ? h.indexOf(norm(name)) : -1)
|
||||
const m = {
|
||||
date: at(p.columns.date),
|
||||
description: at(p.columns.description),
|
||||
amount: at(p.columns.amount),
|
||||
credit: p.mode === 'debit-credit' ? at(p.columns.credit) : -1,
|
||||
mode: p.mode,
|
||||
}
|
||||
if (m.date < 0 || m.description < 0 || m.amount < 0) return null
|
||||
if (p.mode === 'debit-credit' && m.credit < 0) return null
|
||||
return m
|
||||
}
|
||||
@@ -250,6 +250,8 @@ export const t = {
|
||||
},
|
||||
|
||||
ledger: {
|
||||
refund: 'İade',
|
||||
refundHint: 'İade kaydı: bu tutar kategorinin harcamasından düşülür.',
|
||||
readOnly:
|
||||
'Bu dönem kapalı; kayıtlar yalnızca görüntülenebilir. Düzeltme için hane sahibi Değerlendirme’den yeniden açabilir.',
|
||||
week: (n: number, count: number) => `${n}. hafta / ${count}`,
|
||||
@@ -612,6 +614,82 @@ export const t = {
|
||||
nothing: 'Eklenecek yeni satır yok.',
|
||||
},
|
||||
|
||||
statement: {
|
||||
title: 'Ekstre içe aktar',
|
||||
description: 'Banka hesabı ve kredi kartı ekstrelerini deftere aktar.',
|
||||
entry: 'Banka / kart ekstresi',
|
||||
entryHint:
|
||||
'Excel ya da CSV ekstreyi yükle; satırlar harcama, iade, gelir ya da yok say olarak ayrılır. Dönemi olmayan aylar için arşiv dönemi açılır.',
|
||||
open: 'Ekstre içe aktar',
|
||||
back: 'Ayarlar',
|
||||
steps: ['Dosya', 'Eşleştir', 'Gözden geçir'],
|
||||
fileHint:
|
||||
'İnternet bankacılığından “Excel’e aktar” ile indirdiğin dosyayı seç (.xlsx, .xls ya da .csv). Dosya bu cihazda okunur; yalnızca tarih, açıklama ve tutar gönderilir.',
|
||||
choose: 'Dosya seç',
|
||||
reading: 'Dosya okunuyor…',
|
||||
readError: 'Dosya okunamadı. Excel (.xlsx/.xls) ya da CSV olduğundan emin ol.',
|
||||
sheet: 'Sayfa',
|
||||
headerRow: 'Başlık satırı',
|
||||
rowN: (n: number) => `${n}. satır`,
|
||||
profile: 'Kayıtlı eşleştirme',
|
||||
noProfile: '— Yeni —',
|
||||
profileApplied: (name: string) => `“${name}” eşleştirmesi uygulandı`,
|
||||
source: 'Ekstre türü',
|
||||
card: 'Kredi kartı',
|
||||
account: 'Banka hesabı',
|
||||
dateCol: 'Tarih sütunu',
|
||||
descCol: 'Açıklama sütunu',
|
||||
amountMode: 'Tutar',
|
||||
modes: {
|
||||
'signed-out-positive': 'Tek sütun · artı = harcama (kart)',
|
||||
'signed-out-negative': 'Tek sütun · eksi = harcama (hesap)',
|
||||
'debit-credit': 'İki sütun · Borç / Alacak',
|
||||
},
|
||||
amountCol: 'Tutar sütunu',
|
||||
debitCol: 'Borç (çıkan) sütunu',
|
||||
creditCol: 'Alacak (giren) sütunu',
|
||||
pick: 'Seç…',
|
||||
method: 'Ödeme türü (harcamalara yazılır)',
|
||||
noMethod: 'Yok',
|
||||
profileName: 'Eşleştirmeyi kaydet (ad)',
|
||||
profileNamePh: 'ör. Garanti Kart',
|
||||
preview: 'Dosyadan ilk satırlar',
|
||||
continue: 'Devam',
|
||||
checking: 'Satırlar denetleniyor…',
|
||||
noRows: 'Bu eşleştirmeyle okunabilen satır yok. Başlık satırını ve sütunları kontrol et.',
|
||||
skipped: (n: number) =>
|
||||
`${n} satır okunamadı (tarih ya da tutar yok; ör. toplam satırları) ve atlandı.`,
|
||||
kinds: { expense: 'Harcama', refund: 'İade', income: 'Gelir', ignore: 'Yok say' },
|
||||
category: 'Kategori',
|
||||
learned: 'öğrenildi',
|
||||
duplicate: 'Zaten aktarıldı',
|
||||
archivesTitle: 'Açılacak arşiv dönemleri',
|
||||
archivesHint:
|
||||
'Bu aylar için dönem yok; geçmiş (arşiv) dönem olarak açılacak. Arşivler kapatılmaz ve bütçe önerilerinde kullanılır.',
|
||||
summary: (n: number, d: number, b: number) =>
|
||||
`${n} satır · ${d} zaten aktarılmış · ${b} aktarılamaz`,
|
||||
filters: { all: 'Tümü', missing: 'Kategorisiz', ignore: 'Yok sayılan', skip: 'Atlanacak' },
|
||||
missing: (n: number) => `${n} satırda kategori seçilmedi`,
|
||||
appliedSimilar: (n: number) => `${n} benzer satıra da uygulandı`,
|
||||
commit: (n: number) => `${n} satırı aktar`,
|
||||
committing: 'Aktarılıyor…',
|
||||
page: (a: number, b: number, n: number) => `${a}–${b} / ${n}`,
|
||||
prev: 'Önceki',
|
||||
next: 'Sonraki',
|
||||
done: 'Aktarım tamamlandı',
|
||||
result: {
|
||||
entries: (n: number) => `${n} harcama`,
|
||||
refunds: (n: number) => `${n} iade`,
|
||||
incomes: (n: number) => `${n} gelir`,
|
||||
ignored: (n: number) => `${n} yok sayıldı`,
|
||||
duplicates: (n: number) => `${n} zaten vardı`,
|
||||
archives: (names: string) => `Açılan arşiv dönemleri: ${names}`,
|
||||
},
|
||||
errorsTitle: 'Aktarılamayan satırlar',
|
||||
toReports: 'Raporlara git',
|
||||
another: 'Başka ekstre',
|
||||
},
|
||||
|
||||
periods: {
|
||||
picker: 'Dönem',
|
||||
status: {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { ReviewPage } from '@/features/review/page'
|
||||
import { AssetPage } from '@/features/savings/asset-page'
|
||||
import { SavingsPage } from '@/features/savings/page'
|
||||
import { SettingsPage } from '@/features/settings/page'
|
||||
import { StatementPage } from '@/features/statement/page'
|
||||
|
||||
const routes: RouteObject[] = [
|
||||
// Oturumsuz
|
||||
@@ -44,6 +45,7 @@ const routes: RouteObject[] = [
|
||||
{ path: paths.closePeriod, element: <ClosePeriodPage /> },
|
||||
{ path: paths.reports, element: <ReportsPage /> },
|
||||
{ path: paths.settings, element: <SettingsPage /> },
|
||||
{ path: paths.statement, element: <StatementPage /> },
|
||||
{ path: paths.more, element: <MorePage /> },
|
||||
{ path: '*', element: <NotFoundPage /> },
|
||||
],
|
||||
|
||||
Vendored
BIN
Binary file not shown.
Reference in New Issue
Block a user