From c063186e80899ab754280fe08d0683f4bb8d5ba6 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Sat, 14 Nov 2026 04:40:51 +0300 Subject: [PATCH] =?UTF-8?q?feat(bor=C3=A7):=20bor=C3=A7=20takibi=20ekranla?= =?UTF-8?q?r=C4=B1=20(Panel=20kart=C4=B1,=20/borc,=20/taksitler)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- docs/MEMORY.md | 3 +- docs/PHASES.md | 4 +- frontend/src/components/layout/nav-items.ts | 18 +- frontend/src/features/dashboard/page.tsx | 9 + frontend/src/features/debt/debt-card.tsx | 109 +++++++ frontend/src/features/debt/debt-chart.tsx | 96 ++++++ frontend/src/features/debt/history.tsx | 99 +++++++ frontend/src/features/debt/import-section.tsx | 125 ++++++++ .../src/features/debt/installments-page.tsx | 280 ++++++++++++++++++ frontend/src/features/debt/page.tsx | 107 +++++++ frontend/src/features/debt/snapshot-form.tsx | 185 ++++++++++++ frontend/src/features/debt/targets-editor.tsx | 106 +++++++ frontend/src/lib/debt.test.ts | 81 +++++ frontend/src/lib/debt.ts | 171 +++++++++++ frontend/src/lib/query.ts | 4 + frontend/src/lib/strings.ts | 92 ++++++ frontend/src/router.tsx | 5 + 18 files changed, 1490 insertions(+), 6 deletions(-) create mode 100644 frontend/src/features/debt/debt-card.tsx create mode 100644 frontend/src/features/debt/debt-chart.tsx create mode 100644 frontend/src/features/debt/history.tsx create mode 100644 frontend/src/features/debt/import-section.tsx create mode 100644 frontend/src/features/debt/installments-page.tsx create mode 100644 frontend/src/features/debt/page.tsx create mode 100644 frontend/src/features/debt/snapshot-form.tsx create mode 100644 frontend/src/features/debt/targets-editor.tsx create mode 100644 frontend/src/lib/debt.test.ts create mode 100644 frontend/src/lib/debt.ts diff --git a/CLAUDE.md b/CLAUDE.md index 26d1ebd..9cf6488 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,7 +88,7 @@ npm run db:restore -- [--force] - Erişilebilirlik kuralları: `--muted-foreground` açık temada 0.52 (gri zeminde AA); kart bileşenleri (`Section`, rapor bölümü) `min-w-0` — ızgara öğesi içeriğine uzayıp telefonda taşmasın; `Button` `sm`/`icon-sm` dokunmatikte (`pointer-coarse`) 44 px; tutarlar `formatMoney` ile **bölünmez boşluklu** (`1.250,00\u00a0₺`; testlerde `\u00a0`); bütçe çubuğu `budgetLevel` (≥ %80 turuncu + "Bütçe kullanımı %N", aşım kırmızı); `Markdown shiftHeadings` (sayfada tek h1). Cümlelerde sayıdan sonra ek yok. - PWA: `vite.config.ts` `VitePWA` (registerType `prompt`, `/api` asla önbelleğe alınmaz, `navigateFallbackDenylist /api`, xlsx + yabancı font alt kümeleri ön önbellekte yok); `lib/pwa.tsx` (`PwaUpdater` "Yeni sürüm hazır → Yenile", saatlik yoklama; `OfflineBanner`), `lib/online.ts`. `main.tsx` `onlineManager.setOnline(navigator.onLine)`. Oturum sorgusu ağ hatası/duraklama → `RequireAuth` giriş sayfasına **atmaz**, "Tekrar dene" gösterir. Geliştirmede SW yok. DEPLOY §7. - Giriş güvenliği: `auth/guard.rs` (IP başına üst üste 3 hata → giriş+kayıt 15 dk kilit, bellek içi; e-postaya göre kilit YOK), `auth/captcha.rs` (Cloudflare Turnstile; anahtar yoksa kapalı; testte `Captcha::Fixed`), `auth/client_ip.rs` (`TRUST_PROXY` → en sağdaki X-Forwarded-For). Hata kodları `locked` (429 + Retry-After), `captcha` (400), `invalid_credentials` (kalan hak mesajda). `GET /auth/guard`. Arayüz `features/auth/guard.tsx` (`useAuthSecurity`), `lock-notice.tsx`, `components/turnstile.tsx`. Testler `TestApp::with_captcha`, `send_with` (X-Forwarded-For). DEPLOY §8. -- Borç takibi (docs/BORC_TAKIBI.md): `services/debt.rs` (saf: `total`, `installment_load`, `remaining_after`, `summarize`, `period_debt`; içe aktarma `parse_import` kaynak belge JSON + `asOf`, TL → kuruş `tl_to_kurus` kayan noktasız), `routes/debt.rs` (`/debt*`, `/periods/{id}/debt`, `/periods/{id}/opening-debt`). Koleksiyonlar `debt_snapshots`, `installment_plans` (`first_month`), `debt_targets`; toplam saklanmaz. Kakeibo `carryovers` ile karıştırma. Kişisel seed verisi repoya girmez. +- Borç takibi (docs/BORC_TAKIBI.md): `services/debt.rs` (saf: `total`, `installment_load`, `remaining_after`, `summarize`, `period_debt`; içe aktarma `parse_import` kaynak belge JSON + `asOf`, TL → kuruş `tl_to_kurus` kayan noktasız), `routes/debt.rs` (`/debt*`, `/periods/{id}/debt`, `/periods/{id}/opening-debt`). Koleksiyonlar `debt_snapshots`, `installment_plans` (`first_month`), `debt_targets`; toplam saklanmaz. Kakeibo `carryovers` ile karıştırma. Kişisel seed verisi repoya girmez. Arayüz `features/debt/` (`debt-card.tsx` Panel en üstü + /borc, `debt-chart.tsx` lazy Recharts, `snapshot-form.tsx` kalan taksit önerisi `remainingAfter`, `history.tsx`, `targets-editor.tsx`, `import-section.tsx`, `installments-page.tsx`), `lib/debt.ts` (saf hesaplar backend ile aynı). Adresler `/borc`, `/taksitler`; menüde "Borç" (masaüstü + Diğer). - Hane izolasyonu: hane kimliği yalnızca `AuthUser.household_id`'den; sorgular `scope(hid)` ile başlar; başka hane → 404. ## Ortam diff --git a/docs/MEMORY.md b/docs/MEMORY.md index 0bccdfe..23ea35e 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -1,7 +1,7 @@ # 🧠 Proje Hafızası ## ▶️ Sıradaki Adım -**Borç takibi – B2 (arayüz):** Panel en üstüne borç kartı (toplam, geçen aya göre ± renkli, başlangıç→0 ilerleme + tahmini bitiş, KMH·Kart·Kalan taksit), `/borc` (Recharts: ölçümler düz çizgi + `debt_targets` kesikli; tablo tarih/KMH/kart/taksit/toplam/değişim; "Yeni ölçüm" formu: toplam canlı, kalan taksit önerisi = taksit planlarından `remaining_after(ay)`; hedefleri düzenle; "İçe aktar (JSON)" ön izleme→onay), `/taksitler` (açık planlar, 6 ay çubuk; "Yeni taksit"te uyarı "Borç sıfırlanana kadar yeni taksit planı yok."). Tipler hazır: `DebtSummary`, `DebtSnapshot`, `InstallmentPlan`, `DebtTarget`, `MonthLoad`, `DebtImportReport`, `PeriodDebt`. Sonra B3. Kullanıcı canlıyı (kakeibo.ta1sph.website) kullanıyor → içe aktarma dosyası B3'te scratchpad'de hazırlanıp verilecek. +**Borç takibi – B3:** Aylık plan entegrasyonu: Plan sayfasında borç bölümü (`usePeriodDebt`: ay başı toplam [son ölçüm, elle düzelt → `PUT /periods/{id}/opening-debt`], devreden borç, "Borç ödemesi" = birikim hedefi etiketi (borç > 0 iken plan özetinde "Birikim" yerine), taksit yükü + "Taksitler" sabit gideri önerisi (yoksa tek tıkla ekle), hedef ay sonu borç (salt okunur) + yol haritası hedefi; dönem içi son ölçümle karşılaştırma). Sonra içe aktarma dosyası: scratchpad `borc/kakeibo-borc-baslangic.json` (kaynak belgeden + `asOf: 2026-11`) kullanıcıya verilecek (repo dışı); canlıda /borc → İçe aktar. **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 @@ -157,3 +157,4 @@ - **Faz 10 – Adım 5 (2026-11-13) → Faz 10 ✅:** `vite-plugin-pwa` 2.0 + `workbox-window` (dev): manifest (tr, standalone, ikonlar + maskable, kısayollar Harcama ekle/Panel), Workbox precache 98–101 dosya (xlsx ve vietnamca/yunanca/kiril font alt kümeleri hariç → runtime CacheFirst `/assets/`), `navigateFallback` index.html (`/api` hariç). `registerType: 'prompt'` → `PwaUpdater` toast + saatlik `reg.update()`. `OfflineBanner` (useSyncExternalStore). Backend `spa.rs` `cache_policy` (sw.js, manifest, registerSW → no-cache) + birim testi. **Bulunan hata:** çevrimdışı açılışta `/auth/me` ağ hatası → RequireAuth kullanıcıyı giriş sayfasına atıyordu; ayrıca TanStack onlineManager açılış durumunu bilmediği için birkaç sn boş açılış → `SessionUnknown` ("Oturum bilgisi alınamadı… Tekrar dene") + `onlineManager.setOnline(navigator.onLine)`; bağlantı gelince kendiliğinden toparlanır. E2E 14 kontrol (CDP `Page.getInstallabilityErrors` boş, önbellekte /api 0, çevrimdışı, güncelleme: dist kopyası + sw.js index revizyonu değiştirilip sunucu yeniden başlatıldı → bildirim → Yenile → yeni sürüm, oturum/sayfa korunur). npm audit: yeni `glob` (orta) yalnız derleme zamanı; diğer uyarılar mevcut `shadcn` CLI'dan. Docker web aşaması geçti. DEPLOY §7 kurulum. - **Giriş güvenliği (2026-11-14):** Backend `auth/guard.rs` (AttemptGuard, ardışık sayaç, başarıda sıfır, süre bitince yeni tur, 1 sa dokunulmayan kayıt silinir), `auth/captcha.rs` (siteverify, 8 sn zaman aşımı, ağ hatası → `Unavailable` sayılmaz ama reddedilir, `internal-error` de öyle), `auth/client_ip.rs` (en sağ XFF, yoksa ConnectInfo; `main.rs` `into_make_service_with_connect_info`). Login/register `precheck` (kilit → captcha) + sayılan hatalar (yanlış şifre `InvalidCredentialsLeft(n)`, RegistrationClosed / BadRequest davet / Conflict e-posta). E-postaya göre giriş limiter'ı kaldırıldı (şifre değiştirmede kullanıcı başına kaldı). Config `TURNSTILE_SITE_KEY`/`SECRET_KEY` (ikisi birden), `TRUST_PROXY` (varsayılan true: Traefik arkasında herkes aynı IP olmasın). Testler `tests/security.rs` (4) + guard/XFF birim; 2 bilinçli bozma yakalandı; backend 214. Frontend `useAuthSecurity` (jeton, her hatada widget reset, kilit → `useAuthGuard` refetch, `dataUpdatedAt`+saniye geri sayım), Turnstile explicit render (tr, tema, flexible), betik yüklenemezse açıklama + düğme kapalı. E2E: Cloudflare test anahtarlarıyla gerçek widget + siteverify; kalan hak 2/1, kilit 14:57 sayaç, yenileme/kayıt/API kilitli; Cloudflare engelli → düğme kapalı, jetonsuz API 400 captcha. **Kullanıcıda:** Cloudflare'den anahtar alıp Dokploy'a girmek (DEPLOY §8). - **Borç takibi B1 (2026-11-14):** Migration v4 (3 koleksiyon) + indeksler + `backup::KINDS`. Taksit planı `first_month` ile (içe aktarmada `asOf − paidCount + 1`); kaynak belgedeki tüm kabul rakamları birim + entegrasyon testinde birebir (78.530,48 / 79.078,85 / devreden 64.352,79 / yükler 4.311,82…182,02 / kalan 14.177,69 ve 9.865,87). Ay sonu hedefi toplam borç cinsinden (= ay başı − C − taksit; yol haritası da toplam). Kart/IBAN: 12+ haneli sayı reddedilir. Testler `tests/debt.rs` (3) + 6 birim; 2 bilinçli bozma yakalandı. +- **Borç takibi B2 (2026-11-14):** Panel en üstünde `DebtCard` (yalnız ölçüm varsa; değişim yeşil/kırmızı ok, başlangıç→0 ilerleme, tahmini bitiş, döküm), `/borc` (grafik: gerçek düz + hedef kesikli, ilk ölçümden başlar, eksen ay başları; ölçüm tablosu tam genişlik; form [toplam canlı, kalan taksit önerisi; form anahtarı taksit planlarına bağlı → içe aktarmadan sonra öneri dolar]; yol haritası düzenleyici; içe aktar ön izleme→onay), `/taksitler` (CSS çubuk 6 ay, planlar ödenen/kalan, yeni taksitte borç varken uyarı). Menüde "Borç". E2E 33 kontrol (belge JSON'u içe aktarılarak; kabul rakamları ekranda). Bulunan: öneri boş kalıyordu (form içe aktarmadan önce kurulmuştu); eksende aynı ay iki kez; tablo sıkışması. diff --git a/docs/PHASES.md b/docs/PHASES.md index fdbdf09..aa86699 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -95,5 +95,5 @@ ## Borç Takibi Adımları (docs/BORC_TAKIBI.md, kullanıcı isteği 2026-11-14) - ✅ B1. Backend: ölçümler, taksit planları (ilk ay + adet → aylık yük/kalan kendiliğinden), yol haritası, özet, dönem borcu (ay başı, devreden, hedef), elle ay başı, kaynak belge JSON'unu içe aktarma (ön izleme/onay), JSON yedeğine dahil -- ⏳ B2. Arayüz: Panel borç kartı, `/borc` (gerçek + hedef grafik, tablo, yeni ölçüm, hedefler, içe aktar), `/taksitler` -- ⏳ B3. Aylık plan entegrasyonu ("Borç ödemesi", ay başı/devreden/hedef, taksit önerisi) + içe aktarma dosyası (repo dışı) +- ✅ B2. Arayüz: Panel borç kartı, `/borc` (gerçek + hedef grafik, tablo, yeni ölçüm, hedefler, içe aktar), `/taksitler` +- 🔄 B3. Aylık plan entegrasyonu ("Borç ödemesi", ay başı/devreden/hedef, taksit önerisi) + içe aktarma dosyası (repo dışı) diff --git a/frontend/src/components/layout/nav-items.ts b/frontend/src/components/layout/nav-items.ts index 737e157..9bed63b 100644 --- a/frontend/src/components/layout/nav-items.ts +++ b/frontend/src/components/layout/nav-items.ts @@ -2,6 +2,7 @@ import { CalendarDays, ChartLine, ClipboardCheck, + Landmark, LayoutDashboard, Menu, NotebookPen, @@ -28,6 +29,9 @@ export const paths = { /** Dönem kapatma sihirbazı */ closePeriod: '/degerlendirme/kapat', monthlyReport: '/degerlendirme/rapor', + /** Borç takibi */ + debt: '/borc', + installments: '/taksitler', reports: '/raporlar', settings: '/ayarlar', settingsCategories: '/ayarlar/kategoriler', @@ -51,6 +55,7 @@ const planning: NavItem = { to: paths.planning, label: t.nav.planning, icon: Cal const savings: NavItem = { to: paths.savings, label: t.nav.savings, icon: PiggyBank } const review: NavItem = { to: paths.review, label: t.nav.review, icon: ClipboardCheck } const reports: NavItem = { to: paths.reports, label: t.nav.reports, icon: ChartLine } +const debt: NavItem = { to: paths.debt, label: t.nav.debt, icon: Landmark } const settings: NavItem = { to: paths.settings, label: t.nav.settings, icon: Settings } /** Mobil alt menü: 5 öğe (KAKEIBO_PROMPT §9) */ @@ -63,7 +68,16 @@ export const bottomNav: NavItem[] = [ ] /** "Diğer" altında toplananlar (mobil) */ -export const moreNav: NavItem[] = [review, reports, settings] +export const moreNav: NavItem[] = [debt, review, reports, settings] /** Masaüstü sol menü: hepsi doğrudan */ -export const sideNav: NavItem[] = [dashboard, ledger, planning, review, savings, reports, settings] +export const sideNav: NavItem[] = [ + dashboard, + ledger, + planning, + review, + savings, + debt, + reports, + settings, +] diff --git a/frontend/src/features/dashboard/page.tsx b/frontend/src/features/dashboard/page.tsx index 1f67512..2efbd22 100644 --- a/frontend/src/features/dashboard/page.tsx +++ b/frontend/src/features/dashboard/page.tsx @@ -3,7 +3,9 @@ import { Link } from 'react-router' import { paths } from '@/components/layout/nav-items' import { PageError, PageHeader } from '@/components/page' import { Button } from '@/components/ui/button' +import { DebtCard } from '@/features/debt/debt-card' import { NoPeriodCard } from '@/features/period-wizard/no-period-card' +import { useDebt } from '@/lib/debt' import { dashboardPeriod } from '@/lib/dashboard' import { useCurrentPeriod, usePeriods } from '@/lib/periods' import { t } from '@/lib/strings' @@ -26,6 +28,8 @@ export function DashboardPage() { const ready = current.isSuccess && periods.isSuccess const period = ready ? dashboardPeriod(periods.data, current.data) : undefined const canAdd = !!current.data + // Borç takibi: ölçüm varsa en üstte (docs/BORC_TAKIBI.md) + const debt = useDebt() return ( <> @@ -43,6 +47,11 @@ export function DashboardPage() { ) } /> + {debt.data?.latest && ( +
+ +
+ )} {failed?.error ? ( +
+
+

+ + {s.total} +

+

+ {l.total <= 0 ? s.noDebt : formatMoney(l.total)} +

+ {change !== null && ( +

+ {change !== 0 && + (down ? ( + + ) : ( + + ))} + {change === 0 + ? s.sameAsLastMonth + : s.vsLastMonth(formatMoney(Math.abs(change)), down)} +

+ )} +
+ {link && ( + + {s.details} + + + )} +
+ + {debt.start && ( +
+
+
+
+
+ + {s.start(formatDate(debt.start.date), formatMoney(debt.start.total))} ·{' '} + {s.progress(formatPercent(pct, 0))} + + {debt.estimated_end && ( + + {s.estimatedEnd(formatMonth(debt.estimated_end))} + + )} +
+
+ )} + +
+ {( + [ + [s.kmh, l.kmh], + [s.card, l.card_balance], + [s.remaining, l.remaining_installments], + ] as const + ).map(([label, v]) => ( +
+
{label}
+
{formatMoney(v)}
+
+ ))} +
+

+ {s.measuredOn(formatDate(l.date))} + {l.positive_balance > 0 && ` · ${s.positive} −${formatMoney(l.positive_balance)}`} +

+ + ) +} diff --git a/frontend/src/features/debt/debt-chart.tsx b/frontend/src/features/debt/debt-chart.tsx new file mode 100644 index 0000000..e251149 --- /dev/null +++ b/frontend/src/features/debt/debt-chart.tsx @@ -0,0 +1,96 @@ +import { + CartesianGrid, + Legend, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts' +import { debtSeries } from '@/lib/debt' +import { formatCompact, formatDate, formatMoney } from '@/lib/format' +import { t } from '@/lib/strings' +import type { DebtSummary } from '@/types/DebtSummary' + +const s = t.debt +const shortDate = new Intl.DateTimeFormat('tr-TR', { + month: 'short', + year: '2-digit', + timeZone: 'UTC', +}) + +/** Gerçek (ölçümler, düz) + hedef (yol haritası, kesikli). Ayrı JS parçası (lazy). */ +export default function DebtChart({ debt }: { debt: DebtSummary }) { + const data = debtSeries(debt.snapshots, debt.targets) + // Eksen etiketleri ay başlarında (veri noktalarında olursa aynı ay iki kez yazılır) + const ticks: number[] = [] + if (data.length > 0) { + const first = new Date(data[0].t) + for ( + let d = Date.UTC(first.getUTCFullYear(), first.getUTCMonth() + 1, 1); + d <= data[data.length - 1].t; + d = Date.UTC(new Date(d).getUTCFullYear(), new Date(d).getUTCMonth() + 1, 1) + ) + ticks.push(d) + } + return ( +
+ + + + 0 ? ticks : undefined} + tickFormatter={(v: number) => shortDate.format(new Date(v))} + stroke="var(--muted-foreground)" + fontSize={12} + /> + formatCompact(v)} + stroke="var(--muted-foreground)" + fontSize={12} + width={48} + /> + formatDate(new Date(Number(v)).toISOString().slice(0, 10))} + formatter={(v, name) => [formatMoney(Number(v)), name]} + contentStyle={{ + background: 'var(--card)', + border: '1px solid var(--border)', + borderRadius: 8, + color: 'var(--foreground)', + }} + /> + + + + + +
+ ) +} diff --git a/frontend/src/features/debt/history.tsx b/frontend/src/features/debt/history.tsx new file mode 100644 index 0000000..4489324 --- /dev/null +++ b/frontend/src/features/debt/history.tsx @@ -0,0 +1,99 @@ +import { Pencil, Trash2 } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { useDeleteSnapshot } from '@/lib/debt' +import { formatDate, formatMoney } from '@/lib/format' +import { t } from '@/lib/strings' +import { cn } from '@/lib/utils' +import type { DebtSnapshot } from '@/types/DebtSnapshot' + +const s = t.debt + +/** Ölçüm tablosu (en yeni önce): tarih, KMH, kart, taksit, toplam, değişim */ +export function History({ + snapshots, + onEdit, +}: { + snapshots: DebtSnapshot[] + onEdit: (s: DebtSnapshot) => void +}) { + const del = useDeleteSnapshot() + function remove(sn: DebtSnapshot) { + if (!window.confirm(s.removeConfirm(formatDate(sn.date)))) return + del.mutate(sn.id, { + onSuccess: () => toast(s.removed), + onError: (e) => toast.error(e.message), + }) + } + return ( +
+ + + + + + + + + + + + + + {snapshots.map((sn) => ( + + + {[sn.kmh, sn.card_balance, sn.remaining_installments, sn.positive_balance].map( + (v, i) => ( + + ), + )} + + + + + ))} + +
{s.date}{s.kmh}{s.card}{s.remaining}{s.positive}{s.total}{s.change} +
+ {formatDate(sn.date)} + {sn.note && ( +
+ {sn.note} +
+ )} +
+ {formatMoney(v)} + + {formatMoney(sn.total)} + 0 && 'text-expense', + )} + > + {sn.change === null ? '—' : formatMoney(sn.change, 'TRY', { signed: true })} + + + +
+
+ ) +} diff --git a/frontend/src/features/debt/import-section.tsx b/frontend/src/features/debt/import-section.tsx new file mode 100644 index 0000000..a707164 --- /dev/null +++ b/frontend/src/features/debt/import-section.tsx @@ -0,0 +1,125 @@ +import { useRef, useState } from 'react' +import { CircleCheck, FileUp, Loader2 } from 'lucide-react' +import { toast } from 'sonner' +import { FormError } from '@/components/form-field' +import { Button } from '@/components/ui/button' +import { useDebtImport } from '@/lib/debt' +import { formatDate, formatMoney } from '@/lib/format' +import { formatMonth } from '@/lib/reports' +import { t } from '@/lib/strings' +import { cn } from '@/lib/utils' +import type { DebtImportReport } from '@/types/DebtImportReport' + +const s = t.debt + +/** Borç verisi dosyası → ön izleme → onay (aynı dosya iki kez eklenmez) */ +export function ImportSection() { + const input = useRef(null) + const run = useDebtImport() + const [json, setJson] = useState(null) + const [report, setReport] = useState(null) + const [error, setError] = useState() + + async function choose(f: File) { + setError(undefined) + setReport(null) + try { + const parsed: unknown = JSON.parse(await f.text()) + setJson(parsed) + setReport(await run.mutateAsync({ json: parsed, commit: false })) + } catch (e) { + setError(e instanceof SyntaxError ? 'Dosya okunamadı: geçerli bir JSON değil.' : String(e)) + } + } + + async function commit() { + try { + const r = await run.mutateAsync({ json, commit: true }) + setReport(r) + toast.success(s.imported) + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } + } + + const fresh = report?.rows.filter((r) => r.status === 'new' || r.status === 'update').length ?? 0 + const label = (kind: string, l: string) => + kind === 'snapshot' ? formatDate(l) : kind === 'target' ? formatMonth(l) : l + + return ( +
+

{s.importHint}

+ { + const f = e.target.files?.[0] + if (f) void choose(f) + e.target.value = '' + }} + /> + + + {report && ( +
+ {report.errors.length > 0 && ( +
    + {report.errors.map((e) => ( +
  • {e}
  • + ))} +
+ )} +
+ + + {report.rows.map((r, i) => ( + + + + + + + ))} + +
{s.kinds[r.kind]}{label(r.kind, r.label)} + {formatMoney(r.amount)} + + {s.statuses[r.status]} + {r.message &&
{r.message}
} +
+
+ {report.committed ? ( +

+ + {s.imported} +

+ ) : fresh > 0 ? ( + + ) : ( +

{s.nothingNew}

+ )} +
+ )} +
+ ) +} diff --git a/frontend/src/features/debt/installments-page.tsx b/frontend/src/features/debt/installments-page.tsx new file mode 100644 index 0000000..dd1a69c --- /dev/null +++ b/frontend/src/features/debt/installments-page.tsx @@ -0,0 +1,280 @@ +import { useState } from 'react' +import { zodResolver } from '@hookform/resolvers/zod' +import { ChevronLeft, Loader2, Pencil, Plus, Trash2, TriangleAlert } from 'lucide-react' +import { Controller, useForm } from 'react-hook-form' +import { Link } from 'react-router' +import { toast } from 'sonner' +import { z } from 'zod' +import { FormError, FormField } from '@/components/form-field' +import { paths } from '@/components/layout/nav-items' +import { MoneyInput } from '@/components/money-input' +import { PageError, PageHeader, PageLoading } from '@/components/page' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Section } from '@/features/planning/plan-read' +import { useDebt, useDeleteInstallment, useSaveInstallment } from '@/lib/debt' +import { formatMoney, formatNumber } from '@/lib/format' +import { moneyText, toKurus } from '@/lib/money-schema' +import { formatMonth } from '@/lib/reports' +import { t } from '@/lib/strings' +import { cn } from '@/lib/utils' +import type { InstallmentPlan } from '@/types/InstallmentPlan' +import type { MonthLoad } from '@/types/MonthLoad' + +const s = t.debt + +/** /taksitler: 6 aylık taksit yükü, açık planlar, yeni taksit (uyarıyla) */ +export function InstallmentsPage() { + const debt = useDebt() + const [form, setForm] = useState(null) + const d = debt.data + const inDebt = (d?.latest?.total ?? 0) > 0 + const plans = d ? [...d.installments].sort((a, b) => b.remaining_count - a.remaining_count) : [] + + return ( + <> + + + {s.title} + + + {debt.isError ? ( + + ) : !d ? ( + + ) : ( +
+
+ +
+
setForm('new')}> + + {s.addInstallment} + + ) + } + > +
+ {form !== null && ( +
+ {form === 'new' && inDebt && ( +
+ +
+

{s.noNewInstallments}

+

{s.noNewInstallmentsHint}

+
+
+ )} + setForm(null)} + /> +
+ )} + {plans.length === 0 ? ( +

{s.noInstallments}

+ ) : ( +
    + {plans.map((p) => ( + setForm(p)} /> + ))} +
+ )} +
+
+
+ )} + + ) +} + +/** Hafif çubuk grafik (grafik kütüphanesi gerekmez) */ +function LoadBars({ schedule }: { schedule: MonthLoad[] }) { + const max = Math.max(1, ...schedule.map((m) => m.amount)) + return ( +
    + {schedule.map((m) => ( +
  • + {formatMonth(m.month)} +
    +
    +
    + {formatMoney(m.amount)} +
  • + ))} +
+ ) +} + +function PlanRow({ p, onEdit }: { p: InstallmentPlan; onEdit: () => void }) { + const del = useDeleteInstallment() + const done = p.remaining_count === 0 + return ( +
  • +
    +

    {p.merchant}

    +

    + {s.paid(p.paid_count, p.total_count)} · {s.until(formatMonth(p.last_month))} +

    +
    +
    +
    +
    +
    +

    {formatMoney(p.monthly_amount)}

    +

    + {done ? s.finished : s.left(formatMoney(p.remaining_amount))} +

    +
    +
    + + +
    +
  • + ) +} + +const schema = z.object({ + merchant: z.string().trim().min(1, 'İşyeri yazın').max(60, 'En fazla 60 karakter'), + total_amount: moneyText('positive'), + monthly_amount: moneyText('positive'), + total_count: z.coerce + .number({ message: '1–120 arası' }) + .int('Tam sayı') + .min(1, '1–120 arası') + .max(120, '1–120 arası'), + first_month: z.string().regex(/^\d{4}-\d{2}$/, 'Ay seçin'), +}) +type Form = z.input +type Values = z.output + +function InstallmentForm({ + editing, + defaultMonth, + onDone, +}: { + editing?: InstallmentPlan + defaultMonth: string + onDone: () => void +}) { + const save = useSaveInstallment() + const text = (k: number) => formatNumber(k / 100) + const { + register, + control, + handleSubmit, + setError, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(schema), + mode: 'onTouched', + defaultValues: editing + ? { + merchant: editing.merchant, + total_amount: text(editing.total_amount), + monthly_amount: text(editing.monthly_amount), + total_count: editing.total_count, + first_month: editing.first_month, + } + : { + merchant: '', + total_amount: '', + monthly_amount: '', + total_count: '', + first_month: defaultMonth, + }, + }) + const onSubmit = handleSubmit(async (v) => { + try { + await save.mutateAsync({ + id: editing?.id, + version: editing?.version, + merchant: v.merchant, + total_amount: toKurus(v.total_amount), + monthly_amount: toKurus(v.monthly_amount), + total_count: v.total_count, + first_month: v.first_month, + }) + toast.success(s.installmentSaved) + onDone() + } catch (e) { + setError('root', { message: e instanceof Error ? e.message : String(e) }) + } + }) + const money = (name: 'total_amount' | 'monthly_amount', label: string) => ( + ( + + {(f) => ( + + )} + + )} + /> + ) + return ( +
    + + {(f) => } + +
    + {money('total_amount', s.totalAmount)} + {money('monthly_amount', s.monthly)} + + {(f) => } + + + {(f) => } + +
    + +
    + + +
    + + ) +} diff --git a/frontend/src/features/debt/page.tsx b/frontend/src/features/debt/page.tsx new file mode 100644 index 0000000..8c62a7e --- /dev/null +++ b/frontend/src/features/debt/page.tsx @@ -0,0 +1,107 @@ +import { lazy, Suspense, useState } from 'react' +import { CreditCard, Landmark } from 'lucide-react' +import { Link } from 'react-router' +import { paths } from '@/components/layout/nav-items' +import { PageError, PageHeader, PageLoading } from '@/components/page' +import { Skeleton } from '@/components/skeleton' +import { Button } from '@/components/ui/button' +import { Section } from '@/features/planning/plan-read' +import { useDebt } from '@/lib/debt' +import { t } from '@/lib/strings' +import type { DebtSnapshot } from '@/types/DebtSnapshot' +import { DebtCard } from './debt-card' +import { History } from './history' +import { ImportSection } from './import-section' +import { SnapshotForm } from './snapshot-form' +import { TargetsEditor } from './targets-editor' + +const DebtChart = lazy(() => import('./debt-chart')) +const s = t.debt + +/** /borc: toplam borç, gerçek + hedef grafiği, ölçümler, yeni ölçüm, yol haritası, içe aktarma */ +export function DebtPage() { + const debt = useDebt() + const [editing, setEditing] = useState(null) + const d = debt.data + + return ( + <> + + + + {s.toInstallments} + + + } + /> + {debt.isError ? ( + + ) : !d ? ( + + ) : ( +
    + {d.latest ? ( + + ) : ( +
    +
    + +
    +

    {s.emptyTitle}

    +

    {s.emptyText}

    +
    + )} + {d.snapshots.length > 0 && ( +
    + }> + + +
    + )} + {d.snapshots.length > 0 && ( +
    + { + setEditing(sn) + document + .getElementById('debt-form') + ?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + }} + /> +
    + )} +
    +
    +
    + `${p.id}:${p.version}`).join()}` + } + plans={d.installments} + editing={editing ?? undefined} + onDone={() => setEditing(null)} + /> +
    +
    +
    + `${x.month}:${x.closing_target}`).join()} + targets={d.targets} + thisMonth={d.this_month} + /> +
    +
    + +
    +
    +
    + )} + + ) +} diff --git a/frontend/src/features/debt/snapshot-form.tsx b/frontend/src/features/debt/snapshot-form.tsx new file mode 100644 index 0000000..788324c --- /dev/null +++ b/frontend/src/features/debt/snapshot-form.tsx @@ -0,0 +1,185 @@ +import { useState } from 'react' +import { zodResolver } from '@hookform/resolvers/zod' +import { Loader2 } from 'lucide-react' +import { Controller, useForm, useWatch } from 'react-hook-form' +import { toast } from 'sonner' +import { z } from 'zod' +import { FormError, FormField } from '@/components/form-field' +import { MoneyInput } from '@/components/money-input' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { debtTotal, monthIndex, remainingAfter, useSaveSnapshot } from '@/lib/debt' +import { formatMoney, formatNumber, toIsoDate } from '@/lib/format' +import { moneyText, toKurus } from '@/lib/money-schema' +import { t } from '@/lib/strings' +import type { DebtSnapshot } from '@/types/DebtSnapshot' +import type { InstallmentPlan } from '@/types/InstallmentPlan' + +const s = t.debt + +const schema = z.object({ + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Tarih seçin'), + kmh: moneyText('optional'), + card_balance: moneyText('optional'), + remaining_installments: moneyText('optional'), + positive_balance: moneyText('optional'), + note: z.string().max(200, 'En fazla 200 karakter'), +}) +type Form = z.infer + +const text = (k: number) => (k === 0 ? '' : formatNumber(k / 100)) + +/** "Yeni ölçüm" ya da düzenleme. Toplam canlı; kalan taksit planlardan önerilir. */ +export function SnapshotForm({ + plans, + editing, + onDone, +}: { + plans: InstallmentPlan[] + editing?: DebtSnapshot + onDone?: () => void +}) { + const save = useSaveSnapshot() + const suggestFor = (date: string) => { + const m = monthIndex(date.slice(0, 7)) + return m === null ? 0 : remainingAfter(plans, m) + } + const [today] = useState(() => toIsoDate(new Date())) + const { + register, + control, + handleSubmit, + setValue, + setError, + reset, + formState: { errors, isSubmitting }, + } = useForm
    ({ + resolver: zodResolver(schema), + mode: 'onTouched', + defaultValues: editing + ? { + date: editing.date, + kmh: text(editing.kmh), + card_balance: text(editing.card_balance), + remaining_installments: text(editing.remaining_installments), + positive_balance: text(editing.positive_balance), + note: editing.note ?? '', + } + : { + date: today, + kmh: '', + card_balance: '', + remaining_installments: text(suggestFor(today)), + positive_balance: '', + note: '', + }, + }) + const v = useWatch({ control }) + const live = debtTotal({ + kmh: toKurus(v.kmh ?? ''), + card_balance: toKurus(v.card_balance ?? ''), + remaining_installments: toKurus(v.remaining_installments ?? ''), + positive_balance: toKurus(v.positive_balance ?? ''), + }) + const suggestion = plans.length > 0 && v.date ? suggestFor(v.date) : null + const showSuggestion = + suggestion !== null && toKurus(v.remaining_installments ?? '') !== suggestion + + const onSubmit = handleSubmit(async (f) => { + try { + await save.mutateAsync({ + id: editing?.id, + version: editing?.version, + date: f.date, + kmh: toKurus(f.kmh), + card_balance: toKurus(f.card_balance), + remaining_installments: toKurus(f.remaining_installments), + positive_balance: toKurus(f.positive_balance), + note: f.note, + }) + toast.success(s.saved) + if (!editing) reset({ ...f, kmh: '', card_balance: '', positive_balance: '', note: '' }) + onDone?.() + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + if (/IBAN|Not/.test(msg)) setError('note', { message: msg }) + else setError('root', { message: msg }) + } + }) + + const money = ( + name: 'kmh' | 'card_balance' | 'remaining_installments' | 'positive_balance', + label: string, + hint: string, + ) => ( + ( + + {(f) => ( + + )} + + )} + /> + ) + + return ( + +

    {s.formHint}

    + + {(f) => } + +
    + {money('kmh', s.kmh, s.kmhHint)} + {money('card_balance', s.card, s.cardHint)} +
    + {money('remaining_installments', s.remaining, s.remainingHint)} + {showSuggestion && ( +

    + {s.suggested(formatMoney(suggestion))} + +

    + )} +
    + {money('positive_balance', s.positive, s.positiveHint)} +
    + + {(f) => } + +

    + {s.liveTotal(formatMoney(live))} +

    + +
    + + {editing && ( + + )} +
    + + ) +} diff --git a/frontend/src/features/debt/targets-editor.tsx b/frontend/src/features/debt/targets-editor.tsx new file mode 100644 index 0000000..80f2e62 --- /dev/null +++ b/frontend/src/features/debt/targets-editor.tsx @@ -0,0 +1,106 @@ +import { useState } from 'react' +import { Loader2, Plus, X } from 'lucide-react' +import { toast } from 'sonner' +import { FormError } from '@/components/form-field' +import { MoneyInput } from '@/components/money-input' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { monthIndex, monthName, useSetTargets } from '@/lib/debt' +import { formatNumber, parseMoney } from '@/lib/format' +import { formatMonth } from '@/lib/reports' +import { t } from '@/lib/strings' +import type { DebtTarget } from '@/types/DebtTarget' + +const s = t.debt +type Row = { key: number; month: string; amount: string } + +/** Yol haritası: ay → ay sonu toplam borç hedefi (liste olduğu gibi kaydedilir) */ +export function TargetsEditor({ + targets, + thisMonth, +}: { + targets: DebtTarget[] + thisMonth: string +}) { + const save = useSetTargets() + const [rows, setRows] = useState(() => + targets.map((tg, i) => ({ + key: i, + month: tg.month, + amount: formatNumber(tg.closing_target / 100), + })), + ) + const [error, setError] = useState() + + function add() { + const last = rows.length ? monthIndex(rows[rows.length - 1].month) : monthIndex(thisMonth) + setRows([ + ...rows, + { key: Date.now(), month: monthName((last ?? 0) + (rows.length ? 1 : 0)), amount: '' }, + ]) + } + + async function submit() { + setError(undefined) + const out: DebtTarget[] = [] + for (const r of rows) { + const k = parseMoney(r.amount) + if (monthIndex(r.month) === null || k === null || k < 0) + return setError(`${r.month || '?'}: ay ya da tutar geçersiz`) + out.push({ month: r.month, closing_target: k }) + } + try { + await save.mutateAsync(out.sort((a, b) => a.month.localeCompare(b.month))) + toast.success(s.targetsSaved) + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } + } + + return ( +
    +

    {s.targetsHint}

    +
      + {rows.map((r, i) => ( +
    • + + setRows(rows.map((x, j) => (j === i ? { ...x, month: e.target.value } : x))) + } + className="w-40 shrink-0" + /> +
      + setRows(rows.map((x, j) => (j === i ? { ...x, amount: v } : x)))} + /> +
      + +
    • + ))} +
    + +
    + + +
    +
    + ) +} diff --git a/frontend/src/lib/debt.test.ts b/frontend/src/lib/debt.test.ts new file mode 100644 index 0000000..cc74671 --- /dev/null +++ b/frontend/src/lib/debt.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import { + debtSeries, + debtTotal, + installmentLoad, + monthEnd, + monthIndex, + monthName, + remainingAfter, +} from './debt' + +// Kaynak belgedeki 8 taksit (paidCount Kasım 2026 itibarıyla → ilk ay = Kasım − paid + 1) +const nov = monthIndex('2026-11')! +const plans = ( + [ + [271_761, 7, 5], + [33_324, 9, 5], + [11_945, 7, 5], + [12_717, 7, 4], + [17_067, 7, 3], + [7_428, 7, 3], + [18_202, 7, 2], + [58_738, 3, 2], + ] as const +).map(([monthly_amount, total_count, paid]) => ({ + monthly_amount, + total_count, + first_month: monthName(nov - paid + 1), +})) + +describe('debt (backend ile aynı, kabul rakamları)', () => { + it('totals', () => { + expect( + debtTotal({ + kmh: 0, + positive_balance: 236_311, + card_balance: 6_671_590, + remaining_installments: 1_417_769, + }), + ).toBe(7_853_048) + expect( + debtTotal({ + kmh: 3_926_444, + positive_balance: 0, + card_balance: 2_994_854, + remaining_installments: 986_587, + }), + ).toBe(7_907_885) + }) + + it('installment loads and remaining', () => { + const loads = ['2026-11', '2026-12', '2027-01', '2027-02', '2027-03', '2027-04'].map((m) => + installmentLoad(plans, monthIndex(m)!), + ) + expect(loads).toEqual([431_182, 431_182, 372_444, 88_738, 76_021, 18_202]) + expect(remainingAfter(plans, monthIndex('2026-10')!)).toBe(1_417_769) + expect(remainingAfter(plans, nov)).toBe(986_587) + }) + + it('months', () => { + expect(monthName(monthIndex('2026-12')! + 1)).toBe('2027-01') + expect(monthIndex('2026-13')).toBeNull() + expect(monthEnd('2026-11')).toBe('2026-11-30') + expect(monthEnd('2028-02')).toBe('2028-02-29') + }) + + it('chart series: actual + target from the first snapshot', () => { + const s = debtSeries( + [ + { date: '2026-11-13', total: 7_907_885 }, + { date: '2026-10-31', total: 7_853_048 }, + ], + [{ month: '2026-11', closing_target: 6_699_500 }], + ) + expect(s.map((p) => [p.date, p.actual, p.target])).toEqual([ + ['2026-10-31', 7_853_048, 7_853_048], + ['2026-11-13', 7_907_885, undefined], + ['2026-11-30', undefined, 6_699_500], + ]) + }) +}) diff --git a/frontend/src/lib/debt.ts b/frontend/src/lib/debt.ts new file mode 100644 index 0000000..30900b4 --- /dev/null +++ b/frontend/src/lib/debt.ts @@ -0,0 +1,171 @@ +/** + * Borç takibi (docs/BORC_TAKIBI.md): sorgu kancaları ve saf hesaplar. + * Hesaplar backend `services/debt.rs` ile aynı tutulmalı (form önizlemesi, grafik). + */ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import type { DebtImportReport } from '@/types/DebtImportReport' +import type { DebtSnapshot } from '@/types/DebtSnapshot' +import type { DebtSnapshotRequest } from '@/types/DebtSnapshotRequest' +import type { DebtSummary } from '@/types/DebtSummary' +import type { DebtTarget } from '@/types/DebtTarget' +import type { InstallmentPlan } from '@/types/InstallmentPlan' +import type { InstallmentRequest } from '@/types/InstallmentRequest' +import type { PeriodDebt } from '@/types/PeriodDebt' +import { api } from './api' +import { queryKeys } from './query' + +// ---------- Saf hesaplar ---------- + +/** KMH + kart + kalan taksit − artı bakiye */ +export function debtTotal(x: { + kmh: number + card_balance: number + remaining_installments: number + positive_balance: number +}): number { + return x.kmh + x.card_balance + x.remaining_installments - x.positive_balance +} + +/** `"2026-11"` → ay sırası; geçersizse `null` */ +export function monthIndex(m: string): number | null { + const r = /^(\d{4})-(\d{2})$/.exec(m) + if (!r) return null + const mo = Number(r[2]) + return mo >= 1 && mo <= 12 ? Number(r[1]) * 12 + mo - 1 : null +} + +export function monthName(i: number): string { + return `${Math.floor(i / 12)}-${String((i % 12) + 1).padStart(2, '0')}` +} + +type PlanShape = Pick + +/** `month`'tan sonra ekstreye gelecek taksitler (ölçümdeki "kalan taksit" önerisi) */ +export function remainingAfter(plans: readonly PlanShape[], month: number): number { + return plans.reduce((sum, p) => { + const first = monthIndex(p.first_month) + if (first === null) return sum + const paid = Math.min(Math.max(month - first + 1, 0), p.total_count) + return sum + (p.total_count - paid) * p.monthly_amount + }, 0) +} + +/** Ayın taksit yükü */ +export function installmentLoad(plans: readonly PlanShape[], month: number): number { + return plans.reduce((sum, p) => { + const first = monthIndex(p.first_month) + if (first === null) return sum + return month >= first && month <= first + p.total_count - 1 ? sum + p.monthly_amount : sum + }, 0) +} + +/** Ayın son günü `"YYYY-MM-DD"` (hedef noktaları grafikte ay sonuna konur) */ +export function monthEnd(ym: string): string { + const i = monthIndex(ym) + if (i === null) return ym + const d = new Date(Date.UTC(Math.floor(i / 12), (i % 12) + 1, 0)) + return d.toISOString().slice(0, 10) +} + +export type DebtPoint = { t: number; date: string; actual?: number; target?: number } + +/** + * Grafik: ölçümler (gerçek) + yol haritası (hedef, ay sonları). Hedef çizgisi ilk ölçümden + * başlar ki başlangıçla bağlansın. Zaman ekseni gün (ms). + */ +export function debtSeries( + snapshots: readonly Pick[], + targets: readonly DebtTarget[], +): DebtPoint[] { + const map = new Map() + const at = (date: string) => { + let p = map.get(date) + if (!p) map.set(date, (p = { t: Date.parse(`${date}T00:00:00Z`), date })) + return p + } + const sorted = [...snapshots].sort((a, b) => a.date.localeCompare(b.date)) + for (const s of sorted) at(s.date).actual = s.total + if (sorted.length > 0 && targets.length > 0) at(sorted[0].date).target = sorted[0].total + for (const tg of targets) at(monthEnd(tg.month)).target = tg.closing_target + return [...map.values()].sort((a, b) => a.t - b.t) +} + +// ---------- Sorgular ---------- + +export function useDebt() { + return useQuery({ queryKey: queryKeys.debt, queryFn: () => api.get('/debt') }) +} + +export function usePeriodDebt(periodId: string) { + return useQuery({ + queryKey: queryKeys.periodDebt(periodId), + queryFn: () => api.get(`/periods/${periodId}/debt`), + }) +} + +/** Borç verisi değişince özet ve dönem borçları yenilenir */ +function useInvalidateDebt() { + const qc = useQueryClient() + return () => + Promise.all([ + qc.invalidateQueries({ queryKey: queryKeys.debt }), + qc.invalidateQueries({ queryKey: ['period'] }), + ]) +} + +export function useSaveSnapshot() { + const invalidate = useInvalidateDebt() + return useMutation({ + mutationFn: ({ id, ...body }: DebtSnapshotRequest & { id?: string }) => + id + ? api.patch(`/debt/snapshots/${id}`, body) + : api.post('/debt/snapshots', body), + onSuccess: invalidate, + }) +} + +export function useDeleteSnapshot() { + const invalidate = useInvalidateDebt() + return useMutation({ + mutationFn: (id: string) => api.delete(`/debt/snapshots/${id}`), + onSettled: invalidate, + }) +} + +export function useSaveInstallment() { + const invalidate = useInvalidateDebt() + return useMutation({ + mutationFn: ({ id, ...body }: InstallmentRequest & { id?: string }) => + id + ? api.patch(`/debt/installments/${id}`, body) + : api.post('/debt/installments', body), + onSuccess: invalidate, + }) +} + +export function useDeleteInstallment() { + const invalidate = useInvalidateDebt() + return useMutation({ + mutationFn: (id: string) => api.delete(`/debt/installments/${id}`), + onSettled: invalidate, + }) +} + +export function useSetTargets() { + const invalidate = useInvalidateDebt() + return useMutation({ + mutationFn: (targets: DebtTarget[]) => api.put('/debt/targets', { targets }), + onSuccess: invalidate, + }) +} + +export function useDebtImport() { + const invalidate = useInvalidateDebt() + return useMutation({ + mutationFn: ({ json, commit }: { json: unknown; commit: boolean }) => + api.post(`/debt/import?commit=${commit}`, json), + onSuccess: (_, v) => { + if (v.commit) void invalidate() + }, + }) +} diff --git a/frontend/src/lib/query.ts b/frontend/src/lib/query.ts index d6f6c63..5d23bb2 100644 --- a/frontend/src/lib/query.ts +++ b/frontend/src/lib/query.ts @@ -30,6 +30,10 @@ export const queryKeys = { reports: ['reports'] as const, /** JSON yedeği: son yedek zamanı + geri dönüş noktaları */ backup: ['backup'] as const, + /** Borç takibi özeti */ + debt: ['debt'] as const, + /** Dönemin borç bilgisi (dönem önekiyle de yenilenir) */ + periodDebt: (id: string) => ['period', id, 'debt'] as const, period: (id: string) => ['period', id] as const, planItems: (id: string) => ['period', id, 'plan-items'] as const, summary: (id: string) => ['period', id, 'summary'] as const, diff --git a/frontend/src/lib/strings.ts b/frontend/src/lib/strings.ts index 3c90ba8..eef8f87 100644 --- a/frontend/src/lib/strings.ts +++ b/frontend/src/lib/strings.ts @@ -17,6 +17,7 @@ export const t = { review: 'Değerlendirme', reports: 'Raporlar', settings: 'Ayarlar', + debt: 'Borç', main: 'Ana menü', }, @@ -434,6 +435,97 @@ export const t = { 'Kategorinin grubu sonradan değiştirilemez (eski harcamaların grubu bozulmasın); gerekirse doğru grupta yeni kategori aç, eskisini pasifleştir.', }, }, + debt: { + title: 'Borç Takibi', + description: 'KMH, kredi kartı ve taksitler: toplam borcun her ay nasıl küçüldüğü.', + installmentsTitle: 'Taksitler', + installmentsDescription: 'Açık taksit planları ve önümüzdeki ayların taksit yükü.', + total: 'Toplam borç', + noDebt: 'Borç yok 🎉', + vsLastMonth: (amount: string, down: boolean) => + down ? `Geçen aya göre ${amount} azaldı` : `Geçen aya göre ${amount} arttı`, + sameAsLastMonth: 'Geçen ayla aynı', + progress: (pct: string) => `Ödenen: ${pct}`, + start: (date: string, amount: string) => `Başlangıç ${date}: ${amount}`, + estimatedEnd: (month: string) => `Tahmini bitiş: ${month}`, + kmh: 'KMH', + card: 'Kart', + remaining: 'Kalan taksit', + positive: 'Artı bakiye', + measuredOn: (date: string) => `Ölçüm: ${date}`, + details: 'Ayrıntılar', + emptyTitle: 'Borç takibi henüz başlamadı', + emptyText: + 'Ayın son günü, maaş yattıktan sonra bankadaki üç rakamı (KMH, kart borcu, kalan taksitler) gir; toplam borcun her ay nasıl küçüldüğünü buradan izle.', + chartTitle: 'Gerçek ve hedef', + actual: 'Gerçek', + target: 'Hedef', + historyTitle: 'Ölçümler', + date: 'Tarih', + change: 'Değişim', + note: 'Not', + edit: (date: string) => `${date} ölçümünü düzenle`, + remove: (date: string) => `${date} ölçümünü sil`, + removeConfirm: (date: string) => `${date} ölçümü silinsin mi?`, + removed: 'Ölçüm silindi', + formTitle: 'Yeni ölçüm', + formEditTitle: 'Ölçümü düzenle', + formHint: + 'Ayın son günü, maaş yattıktan sonra ölç. Tutarları bankadaki gibi, artı sayı olarak yaz.', + kmhHint: 'Eksi bakiye, artı sayı olarak (artıdaysa 0).', + cardHint: 'Güncel borç + provizyon.', + remainingHint: 'Ekstreye henüz gelmemiş taksitler.', + positiveHint: 'Maaş hesabı artıdaysa bakiye.', + suggested: (amount: string) => `Taksit planlarına göre: ${amount}`, + useSuggested: 'Bunu kullan', + liveTotal: (amount: string) => `Toplam: ${amount}`, + noteHint: 'Ör. "maaş sonrası". Kart ya da IBAN numarası yazma.', + save: 'Kaydet', + cancel: 'Vazgeç', + saved: 'Ölçüm kaydedildi', + targetsTitle: 'Yol haritası', + targetsHint: 'Her ayın sonunda ulaşmak istediğin toplam borç. Grafikte kesikli çizgi.', + addTarget: 'Ay ekle', + month: 'Ay', + targetAmount: 'Ay sonu hedefi', + removeTarget: (m: string) => `${m} hedefini kaldır`, + saveTargets: 'Hedefleri kaydet', + targetsSaved: 'Yol haritası kaydedildi', + importTitle: 'İçe aktar (JSON)', + importHint: + 'Borç takibi dosyası (ölçümler, taksitler, hedefler). Önce ön izleme gösterilir, onaylarsan eklenir; aynı dosya iki kez eklenmez.', + chooseFile: 'Dosya seç', + importCommit: (n: number) => `${n} kaydı ekle`, + imported: 'Borç verisi içe aktarıldı', + nothingNew: 'Eklenecek yeni kayıt yok.', + kinds: { snapshot: 'Ölçüm', installment: 'Taksit', target: 'Hedef' } as Record, + statuses: { + new: 'Yeni', + exists: 'Zaten var', + update: 'Güncellenecek', + error: 'Hatalı', + } as Record, + loadTitle: 'Önümüzdeki 6 ayın taksit yükü', + plansTitle: 'Taksit planları', + merchant: 'İşyeri', + monthly: 'Aylık', + totalAmount: 'Toplam tutar', + count: 'Taksit sayısı', + firstMonth: 'İlk taksit ayı', + firstMonthHint: 'İlk taksitin ekstreye geldiği ay.', + paid: (paid: number, total: number) => `${paid} / ${total} ödendi`, + left: (amount: string) => `Kalan ${amount}`, + until: (month: string) => `Son taksit ${month}`, + finished: 'Bitti', + addInstallment: 'Yeni taksit', + noNewInstallments: 'Borç sıfırlanana kadar yeni taksit planı yok.', + noNewInstallmentsHint: 'Var olan bir taksiti kaydetmek için devam edebilirsin.', + installmentSaved: 'Taksit kaydedildi', + removeInstallment: (m: string) => `${m} taksitini sil`, + removeInstallmentConfirm: (m: string) => `${m} taksit planı silinsin mi?`, + noInstallments: 'Kayıtlı taksit planı yok.', + toInstallments: 'Taksitler', + }, monthlyReport: { title: 'Aylık Rapor', description: 'Dönemin değerlendirmesi: ne yaptın, ne iyi gitti, gelecek ay için öneriler.', diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 826115a..b8fb316 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -64,6 +64,11 @@ const routes: RouteObject[] = [ path: paths.reports, lazy: page(() => import('@/features/reports/page'), 'ReportsPage'), }, + { path: paths.debt, lazy: page(() => import('@/features/debt/page'), 'DebtPage') }, + { + path: paths.installments, + lazy: page(() => import('@/features/debt/installments-page'), 'InstallmentsPage'), + }, { path: paths.settings, lazy: page(() => import('@/features/settings/page'), 'SettingsPage'),