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:
2026-11-13 08:58:04 +03:00
parent d1a24afc3c
commit 233907e3d9
24 changed files with 1493 additions and 8 deletions
+1
View File
@@ -9,3 +9,4 @@
backups/
.vscode/
*.log
samples/
+3
View File
@@ -17,3 +17,6 @@ dist/
# Yedekler
backups/
# Örnek ekstreler (kişisel veri; depoya girmez)
samples/
+1
View File
@@ -79,6 +79,7 @@ npm run db:restore -- <dosya|latest> <hedef_db> [--force]
- Canlı: `Dockerfile` (tek imaj), `docs/DEPLOY.md` (ortam değişkenleri, Dokploy, MongoDB, yedek). `routes::app()` = API + `STATIC_DIR` varsa SPA (`routes/spa.rs`) + sıkıştırma + TraceLayer. Testler `routes::router()` kullanır.
- Backend = lib (`src/lib.rs`) + bin (`src/main.rs`). Entegrasyon testleri `backend/tests/` (her test geçici `kakeibo_test_<uuid>` DB, `tests/common` yardımcıları).
- Kayıt politikası: `REGISTRATION=invite_only` (varsayılan; ilk hane serbest, sonra yalnızca davet) | `open`. Backend'de zorunlu (403 `registration_closed`), `GET /api/auth/registration` arayüze bildirir. Testler `TestApp::new()` = open, `with_registration(..)`.
- Excel okuma: SheetJS **yerel paket** `frontend/vendor/xlsx-0.20.3.tgz` (npm 12 uzak tarball'u engeller; npm'deki `xlsx` eski/açıklı). Güncellerken: cdn.sheetjs.com'dan indir, içeriği/sha256'yı kontrol et, `npm i ./vendor/…`; Dockerfile `vendor/`'u `npm ci`'den önce kopyalar. Lazy yüklenir.
- Biçim: Prettier (`frontend/.prettierrc.json`: tek tırnak, `;` yok, 100 sütun); `npm --prefix frontend run format`. `src/types/` ve `components/ui/` hariç.
- Hane izolasyonu: hane kimliği yalnızca `AuthUser.household_id`'den; sorgular `scope(hid)` ile başlar; başka hane → 404.
+2
View File
@@ -6,6 +6,8 @@ FROM node:26-alpine AS web
WORKDIR /app/frontend
# Önce yalnızca paket dosyaları → bağımlılık katmanı önbellekte kalır
COPY frontend/package.json frontend/package-lock.json ./
# Yerel paketler (SheetJS resmi dağıtımı; npm 12 uzak adres indirmeyi engeller)
COPY frontend/vendor/ ./vendor/
RUN npm ci --no-audit --no-fund
COPY frontend/ ./
RUN npm run build
+75 -1
View File
@@ -129,6 +129,47 @@ pub fn rule_key(desc: &str) -> String {
.join(" ")
}
/// İlk kelime eşleşmesinde kullanılmayacak genel sözcükler (satıcı adı değil)
const GENERIC: &[&str] = &[
"iade",
"odeme",
"pos",
"isyeri",
"eft",
"havale",
"fast",
"virman",
"kredi",
"kart",
"hesap",
"para",
"borc",
"harcama",
"alisveris",
"nakit",
"atm",
"taksit",
"ucret",
];
/// Öneri: tam kural (ilk 2 kelime); yoksa ilk kelimesi aynı kurallar hepsi aynı şeyi
/// söylüyorsa o (ör. "migros istanbul" öğrenildi → "MIGROS KADIKOY" de Market)
fn suggest<'a>(rules: &'a HashMap<String, Rule>, desc: &str) -> Option<&'a Rule> {
if let Some(r) = rules.get(&rule_key(desc)) {
return Some(r);
}
let first = words(desc).into_iter().next()?;
if first.len() < 3 || GENERIC.contains(&first.as_str()) {
return None;
}
let mut same = rules
.values()
.filter(|r| r.pattern.split(' ').next() == Some(first.as_str()));
let r = same.next()?;
same.all(|x| x.kind == r.kind && x.category_id == r.category_id)
.then_some(r)
}
/// Tekrar anahtarı (tüm açıklama, sadeleştirilmiş)
pub fn import_key(date: &str, dir: Direction, amount: i64, desc: &str) -> String {
let d = match dir {
@@ -359,7 +400,7 @@ pub async fn preview(
&mut keys,
&import_key(&r.date, r.direction, r.amount, &r.description),
);
let rule = rules.get(&rule_key(&r.description));
let rule = suggest(&rules, &r.description);
PreviewRow {
kind: rule.map_or_else(
|| default_kind(source, r.direction, &r.description),
@@ -586,6 +627,39 @@ mod tests {
);
}
#[test]
fn suggestion_falls_back_to_first_word() {
let market = Uuid::new();
let ulasim = Uuid::new();
let rule = |p: &str, c: Uuid| {
(
p.to_string(),
Rule {
pattern: p.into(),
kind: Kind::Expense,
category_id: Some(c),
},
)
};
let mut rules: HashMap<String, Rule> =
[rule("migros istanbul", market), rule("iade migros", market)].into();
assert_eq!(
suggest(&rules, "MIGROS 99 ISTANBUL").unwrap().category_id,
Some(market),
"tam"
);
assert_eq!(
suggest(&rules, "MİGROS KADIKÖY").unwrap().category_id,
Some(market),
"ilk kelime"
);
assert!(suggest(&rules, "IADE SHELL").is_none(), "genel ilk kelime");
assert!(suggest(&rules, "A101 SUBE").is_none());
// İlk kelimesi aynı ama farklı kategoriler → öneri yok
rules.extend([rule("migros jet", ulasim)]);
assert!(suggest(&rules, "MIGROS ANKARA").is_none());
}
#[test]
fn default_kinds() {
use Direction::*;
+1 -1
View File
@@ -146,7 +146,7 @@ Renkler ve ikonlar frontend tasarım token'larında.
{ _id, household_id, pattern /* açıklamanın ilk 2 anlamlı kelimesi, ASCII küçük: "migros istanbul" */,
kind: "expense" | "refund" | "income" | "ignore", category_id, uses, created_at, updated_at, version }
```
- Aktarmada her seçim kural olarak yazılır (upsert); sonraki ekstrede aynı `pattern` öneri olur.
- Aktarmada her seçim kural olarak yazılır (upsert); sonraki ekstrede aynı `pattern` öneri olur. Tam eşleşme yoksa **ilk kelimesi aynı** kurallar hepsi aynı tür+kategoriyi söylüyorsa o önerilir ("migros istanbul" → "MIGROS KADIKOY"); genel ilk kelimeler (iade, ödeme, pos, eft…) hariç.
### 3.10 `carryovers` — Devirler
```js
+2 -1
View File
@@ -1,7 +1,7 @@
# 🧠 Proje Hafızası
## ▶️ Sıradaki Adım
**Faz 9 – Adım 4b:** Ekstre arayüzü (Ayarlar → Veri ya da ayrı sayfa `/ice-aktar`). Excel okuma: SheetJS (`xlsx` npm sürümü eski/açıklı → `https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz` tarball; lazy; gerekçe ver) — .xlsx/.xls (+ HTML-xls), CSV `lib/decode.ts`. Akış: dosya → sayfa/başlık satırı seç → sütun eşleştir (tarih, açıklama, tutar ya da Borç/Alacak iki sütun, işaret: kart/hesap) + **profil** kaydet (localStorage, banka adıyla) → `POST /bank-import/preview` → tablo: satır başına tür (harcama/iade/gelir/yoksay) + kategori seçimi (öneri, `from_rule` rozeti), tekrar/engelli satırlar soluk, açılacak arşivler listesi, toplu "aynı açıklamaya uygula" → ödeme türü seç → `commit` → rapor. Tarih biçimleri (GG.AA.YYYY, GG/AA/YYYY, Excel seri sayı), tutar (1.250,50 / -45,90 / Borç-Alacak). Defter: iade satırı "İade" rozeti; iade düzenlemede tutar alanı eksi/mutlak (şu an istemci şeması eksiyi reddeder → düzelt).
**Faz 9 – Adım 5:** JSON tam hane yedeği. Önce best practice analizi + tasarımı kullanıcıya sun (kullanıcı "üzmeyen, işi kolaylaştıran" istedi): sürümlü biçim (uygulama sürümü + şema/migration sürümü + dışa aktarma zamanı + hane adı), içerik (dönemler, plan, bütçe, harcamalar, devirler, notlar, kategoriler, ödeme türleri, varlıklar, işlemler, manuel kurlar, ekstre kuralları; kullanıcı/şifre/davet **yok**), geri yükleme yalnız sahip, önizleme (sayılar, tarih aralığı, kaynak hane), **sunucuda otomatik geri dönüş noktası** + "geri yüklemeyi geri al" (süre? örn. 7 gün / son 3), başka haneden taşıma (kimlikler yeniden üretilir mi korunur mu — iç referanslar!), transaction, büyük dosya sınırı. Sonra Adım 6: aylık rapor (Claude tasarlar).
**Bekleyen (kullanıcıda):** Faz 5 – Adım 5 canlı yedek kurulumu (`docs/DEPLOY.md` §6.1–6.3) → çıktılar gelince Faz 5 ✅.
## 📌 Alınan Kararlar
@@ -139,3 +139,4 @@
- **Adım 2:** Raporlar sayfası (`features/reports/`): `page.tsx` (filtre adreste: `donem`/`baslangic`/`bitis`/`grup`/`kategori`/`odeme` (`yok` = girilmemiş), dönem varsa tarih yok sayılır), `filters.tsx` (Kapsam: tüm zamanlar / dönem / tarih aralığı (yıl başı → bugün varsayılan), grup seçilince kategori listesi daralır, mobilde katlanır), `section.tsx` (`ReportSection`: `data-report`, CSV indir + bölüm yazdır; `ShareTable`: satır içi CSS pay çubukları — yazdırmada da görünür), `sections.tsx` (gruplar, ödeme türleri, **tüm** kategoriler, aylar = filtreli; bütçe sapması = seçili/açık/son dönem, grup + hafta; gelir–gider + birikim oranı = son 12 dönem; varlıklar), `charts.tsx` lazy (bar + çizgi). `lib/csv.ts` (BOM, `;`, CRLF, tırnaklama, `csvMoney` binliksiz), `lib/reports.ts` (`parseFilter`/`filterToParams`/`filterToQuery`, `describeFilter` → yazdırmada kapsam satırı, `savingsRate`, `deviation`, `formatMonth`), `lib/print.ts` (`installPrintTheme`: beforeprint'te koyu tema kaldırılır; `printSection`: `data-print-one` + `data-print-target`), index.css `@media print`, layout `print:hidden`. Kayıt değişince `['reports']` yenilenir. Frontend 121 test; e2e 34 kontrol (CSV baytları, yazdırma hedefi, tema, filtreler). Not: `Blob.text()` BOM'u siler → BOM'u baytlardan doğrula.
- **Adım 3:** CSV dışa/içe aktarma. Backend `services/csv_io.rs` (saf: yazma BOM/`;`/CRLF/`1250,50`/GG.AA.YYYY; okuma: BOM, `;`/`,`/sekme otomatik, tırnak, satır numarası, `norm` başlık eşleme (Türkçe İ/ı), `parse_money` (1.250,50 · 1250.50 · 1.000=bin), `parse_e6`, `parse_date_any`), `services/transfer.rs` (harcama/plan/varlık: dışa aktar; içe aktar `commit=false` deneme → rapor {total, valid, duplicates, imported, errors[(satır, neden)], preview}; tekrar sayacı `Existing` (dosyada 2, DB'de 1 → 1 eklenir); harcamada dönem tarihten, kapalı/yok → satır hatası, kategori adla (+grup), aynı ad 2 grupta → hata; plan `plan::create` ile; varlıkta tarih+dosya sırasına göre eldekinden fazla satış simülasyonu, yazma `asset::create_tx`). `Group::label()`. Rotalar `routes/transfer.rs` (`/export/*.csv`, `POST /import/{tür}` 5 MB). `tests/transfer.rs` (3) + csv_io birim (4); mutasyon 3/3. Frontend: Ayarlar → Veri (`features/settings/data-section.tsx`: indirme bağlantıları, içe aktarma: tür seç → dosya → deneme raporu → onay), `api.postCsv`, `lib/decode.ts` (UTF-8 fatal → Windows-1254), `lib/transfer.ts`. Backend 164, frontend 125 test; e2e 16 kontrol.
- **Adım 4a:** Ekstre backend. `PeriodStatus::Archived` (yazılabilir; kapatılmaz 409; devir zincirinde atlanır: `period::create` önceki, `closing::next_period`, reopen engeli; `budget::previous_closed` kapalı+arşiv), `period::create_archive`. `services/bank_import.rs`: `fold` (Türkçe → ASCII; **"İ".to_lowercase() = i + U+0307** düzeltildi), `rule_key` (ilk 2 anlamlı kelime), `import_key`, `default_kind` (ödeme/virman → yoksay; kartta giriş → iade, hesapta → gelir), `target` (içeren yazılabilir dönem / takvim ayı arşivi (kırpılmış) / gelecek-kapalı engel), `preview`, `commit` (arşivleri açar, harcama/iade insert_many + `import_key`, gelir `plan::create` + `import_key`, kurallar upsert `import_rules`). Entry update: iade eksi kalır. Rotalar `routes/bank_import.rs` (`/bank-import/preview|commit`, 5 MB). `tests/bank_import.rs` (3, devir zinciri dahil); mutasyon 5/5. Frontend: `archived` durum metni, Değerlendirme'de arşiv bilgisi, kapatma sihirbazı arşivi reddeder. Backend 177 test.
- **Adım 4b:** Ekstre arayüzü `/ayarlar/ekstre` (`features/statement/`: `page.tsx` Dosya → Eşleştir (başlık satırı otomatik `detectHeaderRow`, sütun tahmini `guessMapping`, kaynak kart/hesap → tutar modu, Borç/Alacak, ödeme türü, profil adı → localStorage `kakeibo.statement.profiles`, ad ile eşleşir; önizlemede tarih yorumlanmış) ; `review.tsx` (tür+kategori, değişiklik aynı `ruleKey`'li elle değiştirilmemiş satırlara yayılır + bildirim, filtreler, sayfalama 50, telefonda kart/masaüstünde sütunlu liste, atlanacaklar soluk, arşiv bildirimi, kategori eksikse aktarma kapalı, sonuçta hata satırları tarih+açıklama ile)). `lib/statement.ts` (Excel seri tarih, `parseAmount` TR/EN/parantez/sondaki eksi, `normalize`, `fold`/`ruleKey` = backend, profiller), `lib/read-sheet.ts` (SheetJS lazy 160 KB gz, `blankrows: true` → satır no Excel ile aynı; CSV kendi ayrıştırıcı + `decodeText`), `lib/bank-import.ts`. Backend: kural önerisine **ilk kelime yedeği** (`suggest`, genel kelimeler hariç). SheetJS yerel paket (`frontend/vendor`, Dockerfile kopyalar; Docker web aşaması doğrulandı). Defter: iade satırı "İade" etiketi, düzenlemede artı gösterilip eksi kaydedilir. Frontend 134, backend 178 test; e2e 25 kontrol.
+1 -1
View File
@@ -82,6 +82,6 @@
- ✅ 1. Backend: rapor verileri — dönem istatistiklerine gelir/sabit gider/devir/gerçekleşen birikim (birikim oranı için) + `GET /reports/breakdown` (grup / **tüm kategoriler** / ödeme türü / ay; filtre: dönem ya da tarih aralığı, grup, kategori, ödeme türü). Bütçe sapması mevcut `budget` verisinden
- ✅ 2. Frontend: Raporlar sayfası — filtreler (adreste), gelir–gider çubuk grafiği, kategori kırılımı (tablo + grafik), bütçe sapması, ödeme türü dağılımı, birikim oranı trendi, varlık performansı; her rapor CSV (UTF-8 BOM, `;`) + yazdır
- ✅ 3. CSV dışa/içe aktarma (uygulamanın kendi biçimi): harcamalar, plan kalemleri, varlık işlemleri; içe aktarmada önizleme + satır satır hata raporu
- 🔄 4. **Banka / kredi kartı ekstresi içe aktarma** — ✅ 4a backend (arşiv dönemleri, iade, gelir, yoksay, tekrar, öğrenen kurallar) · ⏳ 4b arayüz (Excel/CSV okuma, sütun eşleştirme + banka profili, satır gözden geçirme)
- ✅ 4. **Banka / kredi kartı ekstresi içe aktarma** — 4a backend (arşiv dönemleri, iade, gelir, yoksay, tekrar, öğrenen kurallar) + 4b arayüz (`/ayarlar/ekstre`: Excel/CSV, sütun eşleştirme + profil, gözden geçirme)
- ⏳ 5. JSON tam hane yedeği: sürümlü dışa aktarma; geri yüklemede önizleme + onay + sunucuda otomatik geri dönüş noktası ("geri yüklemeyi geri al"); başka haneden taşıma
- ⏳ 6. Aylık değerlendirme çıktısı: `templates/monthly_report.md` (Claude tasarlar — müşteri gözüyle: ne yaptım, ne iyi/kötü gitti, ne yapmalıydım, gelecek ay önerileri) → Markdown + yazdırılabilir/PDF
+13
View File
@@ -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",
+1
View File
@@ -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
+8 -3
View File
@@ -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}
+4 -1
View File
@@ -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>
)
+437
View File
@@ -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>
)
}
+335
View File
@@ -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>
)
}
+29
View File
@@ -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 }),
]),
})
}
+47
View File
@@ -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', 'Ş'],
])
})
})
+66
View File
@@ -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,
}),
}))
}
+123
View File
@@ -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()
})
})
+251
View File
@@ -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
}
+78
View File
@@ -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: {
+2
View File
@@ -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 /> },
],
BIN
View File
Binary file not shown.