feat(borç): aylık planda bu ayın borcu, borç ödemesi etiketi, taksit önerisi

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-14 04:47:59 +03:00
parent c063186e80
commit dbed3da1b1
12 changed files with 376 additions and 9 deletions
+1 -1
View File
@@ -88,7 +88,7 @@ npm run db:restore -- <dosya|latest> <hedef_db> [--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. 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).
- 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). Plan: `features/debt/period-debt.tsx` (`PeriodDebtSection`: ay başı [elle düzelt], devreden banka borcu, borç ödemesi, taksit, hedef ay sonu + yol haritası, dönem içi ilerleme, "Taksitler" sabit gideri önerisi `findInstallmentItem`); borç > 0 iken `SummaryTiles debtPayment` → "Borç ödemesi" (Plan, düzenleme, sihirbaz). Kakeibo'nun "Devreden borç"u ile karışmasın diye banka borcu "Devreden banka borcu".
- Hane izolasyonu: hane kimliği yalnızca `AuthUser.household_id`'den; sorgular `scope(hid)` ile başlar; başka hane → 404.
## Ortam
+3 -1
View File
@@ -57,4 +57,6 @@ dizisi reddedilir.
## Adımlar
- B1 backend (modeller, hesaplar, uç noktalar, içe aktarma, testler)
- B2 arayüz: Panel kartı, `/borc`, `/taksitler`
- B3 aylık plan entegrasyonu + içe aktarma dosyasının hazırlanması (kullanıcıya, repo dışı)
- B3 aylık plan entegrasyonu + içe aktarma dosyasının hazırlanması (kullanıcıya, repo dışı: `samples/kakeibo-borc-baslangic.json`, git ve Docker dışında)
**Durum (2026-11-14): B1–B3 tamamlandı.**
+2 -1
View File
@@ -1,7 +1,7 @@
# 🧠 Proje Hafızası
## ▶️ Sıradaki Adım
**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.
**Sıradaki:** Kullanıcının canlıda (kakeibo.ta1sph.website) testi. Borç başlangıç verisi: `samples/kakeibo-borc-baslangic.json` (git/Docker dışı) → canlıda /borc → İçe aktar. Kullanıcının bulduğu hatalar/istekler önce. Faz 11 (Tauri) yalnız kullanıcı isteyince.
**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
@@ -158,3 +158,4 @@
- **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ı.
- **Borç takibi B3 (2026-11-14) → B1–B3 ✅:** Plan'da "Bu ayın borcu" (`PeriodDebtSection`, yalnız borç verisi varsa): ay başı (ölçümden / elle, `useSetOpeningDebt`, "Son ölçüme dön"), devreden banka borcu, borç ödemesi (C), bu ayın taksiti, hedef ay sonu + yol haritası farkı (üstünde kırmızı / altında yeşil), dönem içi son ölçüme göre azalma vs C+taksit çubuğu, "Taksitler" sabit gideri tek tık (adı `findInstallmentItem`: taksit/taksitler, tr küçük harf; tutar farklıysa uyarı). `SummaryTiles debtPayment`. E2E 18 kontrol (hedef 67.218,66 = 78.530,48 − 7.000 − 4.311,82; harcanabilir 53.000 → 48.688,18). İçe aktarma dosyası `samples/` (gitignore) — 12+ haneli sayı yok.
+1 -1
View File
@@ -96,4 +96,4 @@
## 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ışı)
- ✅ B3. Aylık plan entegrasyonu ("Borç ödemesi", ay başı/devreden/hedef, taksit önerisi) + içe aktarma dosyası (repo dışı)
+283
View File
@@ -0,0 +1,283 @@
import { useState } from 'react'
import { CircleCheck, Landmark, Loader2, Pencil, Plus } 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 { MoneyInput } from '@/components/money-input'
import { Button } from '@/components/ui/button'
import { findInstallmentItem, usePeriodDebt, useSetOpeningDebt } from '@/lib/debt'
import { formatDate, formatMoney, formatNumber, parseMoney } from '@/lib/format'
import { useCreatePlanItem } from '@/lib/periods'
import { t } from '@/lib/strings'
import { cn } from '@/lib/utils'
import type { Period } from '@/types/Period'
import type { PeriodDebt } from '@/types/PeriodDebt'
import type { PlanItem } from '@/types/PlanItem'
const s = t.debt.period
/**
* Aylık plan: bu ayın banka borcu (docs/BORC_TAKIBI.md §3). Ay başı borç son ölçümden
* (elle düzeltilebilir), devreden, borç ödemesi (= birikim hedefi), taksit yükü, hedef ay sonu.
* Borç takibinde hiç veri yoksa görünmez.
*/
export function PeriodDebtSection({
period,
items,
writable,
}: {
period: Period
items: PlanItem[]
writable: boolean
}) {
const q = usePeriodDebt(period.id)
const d = q.data
if (!d || (d.opening_total === null && d.installment_load === 0)) return null
return (
<section
aria-labelledby="period-debt-title"
className="min-w-0 rounded-2xl border bg-card p-4 sm:p-5"
>
<div className="mb-1 flex flex-wrap items-center justify-between gap-2">
<h3 id="period-debt-title" className="flex items-center gap-1.5 font-semibold">
<Landmark className="size-4" aria-hidden />
{s.title}
</h3>
<Link
to={paths.debt}
className="inline-flex min-h-11 items-center px-2 text-sm text-primary"
>
{s.toDebt}
</Link>
</div>
<p className="mb-3 text-sm text-muted-foreground">{s.hint}</p>
<dl className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
<Opening period={period} d={d} writable={writable} />
<Tile label={s.carried} hint={s.carriedHint} amount={d.carried} />
<Tile label={s.payment} sign="−" amount={d.payment} />
<Tile label={s.load} sign="−" amount={d.installment_load} />
<Target d={d} />
</dl>
<Progress d={d} />
{writable && (
<InstallmentsSuggestion period={period} items={items} load={d.installment_load} />
)}
</section>
)
}
function Tile({
label,
amount,
sign,
hint,
}: {
label: string
amount: number | null
sign?: string
hint?: string
}) {
return (
<div className="rounded-xl border p-3">
<dt className="text-xs text-muted-foreground">
{sign && <span aria-hidden>{sign} </span>}
{label}
</dt>
<dd className="money mt-1 font-semibold">{amount === null ? '—' : formatMoney(amount)}</dd>
{hint && <p className="mt-0.5 text-[11px] text-muted-foreground">{hint}</p>}
</div>
)
}
/** Ay başı borç: kaynağı yazılı; düzeltilebilir (dönem açıkken) */
function Opening({ period, d, writable }: { period: Period; d: PeriodDebt; writable: boolean }) {
const set = useSetOpeningDebt(period.id)
const [editing, setEditing] = useState(false)
const [value, setValue] = useState('')
const [error, setError] = useState<string>()
const source =
d.opening_source === 'manual'
? s.manual
: d.opening_date
? s.fromSnapshot(formatDate(d.opening_date))
: s.noOpening
async function save(amount: number | null) {
setError(undefined)
try {
await set.mutateAsync(amount)
setEditing(false)
toast.success(s.openingSaved)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}
return (
<div
className={cn('rounded-xl border p-3', editing && 'col-span-2 sm:col-span-3 lg:col-span-5')}
>
<dt className="flex items-center justify-between gap-1 text-xs text-muted-foreground">
{s.opening}
{writable && !editing && (
<button
type="button"
aria-label={s.editOpening}
className="-m-2 grid size-11 place-items-center rounded-lg hover:bg-muted"
onClick={() => {
setValue(d.opening_total === null ? '' : formatNumber(d.opening_total / 100))
setEditing(true)
}}
>
<Pencil className="size-3.5" aria-hidden />
</button>
)}
</dt>
{editing ? (
<div className="mt-2 space-y-2">
<MoneyInput aria-label={s.opening} value={value} onChange={setValue} autoFocus />
<FormError message={error} />
<div className="flex flex-wrap gap-2">
<Button
size="sm"
disabled={set.isPending}
onClick={() => {
const k = parseMoney(value)
if (k === null || k < 0) return setError('Geçerli bir tutar yazın')
void save(k)
}}
>
{set.isPending && <Loader2 className="animate-spin" aria-hidden />}
{t.debt.save}
</Button>
{d.opening_source === 'manual' && (
<Button size="sm" variant="outline" onClick={() => void save(null)}>
{s.resetOpening}
</Button>
)}
<Button size="sm" variant="ghost" onClick={() => setEditing(false)}>
{t.debt.cancel}
</Button>
</div>
</div>
) : (
<>
<dd className="money mt-1 font-semibold">
{d.opening_total === null ? '—' : formatMoney(d.opening_total)}
</dd>
<p className="mt-0.5 text-[11px] text-muted-foreground">{source}</p>
</>
)}
</div>
)
}
/** Hedef ay sonu borç (+ yol haritasıyla karşılaştırma) */
function Target({ d }: { d: PeriodDebt }) {
const diff =
d.closing_target !== null && d.roadmap_target !== null
? d.closing_target - d.roadmap_target
: null
return (
<div className="col-span-2 rounded-xl border-2 border-primary/40 bg-primary/5 p-3 sm:col-span-1">
<dt className="text-xs font-medium text-muted-foreground">
<span aria-hidden>= </span>
{s.target}
</dt>
<dd className="money mt-1 text-lg font-bold">
{d.closing_target === null ? '—' : formatMoney(d.closing_target)}
</dd>
{d.roadmap_target !== null && (
<p className="mt-0.5 text-[11px] text-muted-foreground">
{s.roadmap(formatMoney(d.roadmap_target))}
{diff !== null && diff !== 0 && (
<span className={diff > 0 ? 'text-expense' : 'text-income'}>
{' · '}
{diff > 0
? s.vsRoadmapAbove(formatMoney(diff))
: s.vsRoadmapBelow(formatMoney(-diff))}
</span>
)}
</p>
)}
</div>
)
}
/** Aylık başarı: ay başı − son ölçüm ≥ borç ödemesi + taksit */
function Progress({ d }: { d: PeriodDebt }) {
if (d.opening_total === null || d.latest_total === null || !d.latest_date) return null
const down = d.opening_total - d.latest_total
const needed = d.payment + d.installment_load
const ok = down >= needed && needed > 0
const ratio = needed > 0 ? Math.min(Math.max(down / needed, 0), 1) : 0
return (
<div className="mt-3 space-y-1.5 rounded-xl bg-muted p-3 text-sm">
<p className="text-xs text-muted-foreground">{s.progressTitle(formatDate(d.latest_date))}</p>
<p className={cn('font-medium', down < 0 ? 'text-expense' : ok ? 'text-income' : '')}>
{ok && <CircleCheck className="mr-1 inline size-4" aria-hidden />}
{down >= 0 ? s.paidDown(formatMoney(down)) : s.grew(formatMoney(-down))}
<span className="font-normal text-muted-foreground">
{' '}
· {s.needed(formatMoney(needed))}
</span>
</p>
<div
className="h-2 overflow-hidden rounded-full bg-background"
role="progressbar"
aria-label={s.needed(formatMoney(needed))}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(ratio * 100)}
>
<div className="h-full rounded-full bg-income" style={{ width: `${ratio * 100}%` }} />
</div>
</div>
)
}
/** Taksit yükü planda "Taksitler" sabit gideri olarak yoksa: tek tıkla ekle */
function InstallmentsSuggestion({
period,
items,
load,
}: {
period: Period
items: PlanItem[]
load: number
}) {
const create = useCreatePlanItem(period.id)
if (load <= 0) return null
const existing = findInstallmentItem(items)
if (existing) {
if (existing.amount === load) return null
return (
<p className="mt-3 text-sm text-warning">
{s.installmentsMismatch(formatMoney(existing.amount), formatMoney(load))}
</p>
)
}
return (
<Button
variant="outline"
className="mt-3"
disabled={create.isPending}
onClick={() =>
create.mutate(
{ kind: 'expense', name: s.installmentsName, amount: load },
{
onSuccess: () => toast.success(s.installmentsAdded),
onError: (e) => toast.error(e.message),
},
)
}
>
{create.isPending ? <Loader2 className="animate-spin" aria-hidden /> : <Plus aria-hidden />}
{s.addInstallments(formatMoney(load))}
</Button>
)
}
@@ -13,6 +13,7 @@ import { useHousehold } from '@/lib/household'
import { toKurus } from '@/lib/money-schema'
import { useBudget, useSetBudget, useSuggestion, useSummary } from '@/lib/periods'
import { moneyToText } from '@/lib/plan-draft'
import { useDebt } from '@/lib/debt'
import { t } from '@/lib/strings'
import { cn } from '@/lib/utils'
import type { GroupAmounts } from '@/types/GroupAmounts'
@@ -87,6 +88,7 @@ function BudgetStepForm({
onBack: () => void
onDone: () => void
}) {
const debt = useDebt()
const setBudget = useSetBudget(period.id)
const {
control,
@@ -122,7 +124,7 @@ function BudgetStepForm({
return (
<form onSubmit={onSubmit} noValidate>
<StepIntro title={s.title} hint={s.hint} />
<SummaryTiles summary={summary} />
<SummaryTiles summary={summary} debtPayment={(debt.data?.latest?.total ?? 0) > 0} />
<div className="mt-6">
<SuggestionPicker periodId={period.id} onApply={(a) => reset(toForm(a))} />
+6 -1
View File
@@ -4,7 +4,9 @@ import { PageError, PageHeader, PageLoading } from '@/components/page'
import { Button } from '@/components/ui/button'
import { NotesSection } from '@/features/notes/notes-section'
import { CopyPrevious } from '@/features/period-wizard/step-items'
import { PeriodDebtSection } from '@/features/debt/period-debt'
import { PeriodPicker } from '@/features/periods/period-picker'
import { useDebt } from '@/lib/debt'
import { SelectedPeriod } from '@/features/periods/selected-period'
import { isWritable } from '@/lib/period-select'
import { useBudget, usePlanItems, useSummary } from '@/lib/periods'
@@ -32,6 +34,8 @@ function PlanView({ period }: { period: Period }) {
const items = usePlanItems(period.id)
const summary = useSummary(period.id)
const budget = useBudget(period.id)
const debt = useDebt()
const inDebt = (debt.data?.latest?.total ?? 0) > 0
const [editing, setEditing] = useState(false)
const failed = [items, summary, budget].find((q) => q.isError)
@@ -67,7 +71,8 @@ function PlanView({ period }: { period: Period }) {
)
}
/>
<SummaryTiles summary={summary.data} />
<SummaryTiles summary={summary.data} debtPayment={inDebt} />
<PeriodDebtSection period={period} items={items.data} writable={editable} />
<div className="grid gap-4 lg:grid-cols-2">
<ItemsList kind="income" list={items.data.filter((i) => i.kind === 'income')} />
<ItemsList kind="expense" list={items.data.filter((i) => i.kind === 'expense')} />
+3 -1
View File
@@ -23,6 +23,7 @@ import { formatDayMonth, formatMoney, formatNumber, parseMoney } from '@/lib/for
import { group, GROUPS } from '@/lib/groups'
import { useSavePlan } from '@/lib/periods'
import { diffPlan, GROUP_CODES, hasChanges, moneyToText, toDraft } from '@/lib/plan-draft'
import { useDebt } from '@/lib/debt'
import { t } from '@/lib/strings'
import { cn } from '@/lib/utils'
import type { GroupBudget } from '@/types/GroupBudget'
@@ -59,6 +60,7 @@ export function PlanEdit({
summary: PeriodSummary
onClose: () => void
}) {
const debt = useDebt()
const save = useSavePlan(period)
const {
control,
@@ -146,7 +148,7 @@ export function PlanEdit({
</div>
<PeriodHeading period={period} actions={actions} />
<SummaryTiles summary={preview} />
<SummaryTiles summary={preview} debtPayment={(debt.data?.latest?.total ?? 0) > 0} />
<div className="grid gap-4">
<ItemsEdit
@@ -9,11 +9,18 @@ const s = t.summary
* Plan özeti: Gelir − Sabit giderler − Birikim − Devreden borç (+ Devreden artı) = Harcanabilir.
* Mobilde 2 sütun, geniş ekranda tek satır. Devir kutucukları yalnızca tutar varsa görünür.
*/
export function SummaryTiles({ summary }: { summary: PeriodSummary }) {
export function SummaryTiles({
summary,
debtPayment = false,
}: {
summary: PeriodSummary
/** Borç takibinde borç varken birikim hedefi "Borç ödemesi" adıyla (docs/BORC_TAKIBI.md) */
debtPayment?: boolean
}) {
const tiles: { label: string; amount: number; sign?: '−' | '+'; tone?: string }[] = [
{ label: s.income, amount: summary.income_total, tone: 'text-income' },
{ label: s.fixed, amount: summary.fixed_expense_total, sign: '−' },
{ label: s.savings, amount: summary.savings_goal, sign: '−' },
{ label: debtPayment ? s.debtPayment : s.savings, amount: summary.savings_goal, sign: '−' },
]
if (summary.carryover_debt > 0) {
tiles.push({ label: s.debt, amount: summary.carryover_debt, sign: '−', tone: 'text-expense' })
+14
View File
@@ -79,3 +79,17 @@ describe('debt (backend ile aynı, kabul rakamları)', () => {
])
})
})
describe('plan integration', () => {
it('finds the installments expense item by name', async () => {
const { findInstallmentItem } = await import('./debt')
const items = [
{ kind: 'income', name: 'Taksitler' },
{ kind: 'expense', name: 'Kira' },
{ kind: 'expense', name: ' TAKSİTLER ' },
]
expect(findInstallmentItem(items)?.name).toBe(' TAKSİTLER ')
expect(findInstallmentItem([{ kind: 'expense', name: 'Taksit' }])).toBeTruthy()
expect(findInstallmentItem([{ kind: 'expense', name: 'Taksitli alışveriş' }])).toBeUndefined()
})
})
+18
View File
@@ -159,6 +159,24 @@ export function useSetTargets() {
})
}
export function useSetOpeningDebt(periodId: string) {
const invalidate = useInvalidateDebt()
return useMutation({
mutationFn: (amount: number | null) =>
api.put<void>(`/periods/${periodId}/opening-debt`, { amount }),
onSuccess: invalidate,
})
}
/** Plan kalemlerinde "Taksit(ler)" adlı sabit gider (büyük/küçük harf ve Türkçe harf duyarsız) */
export function findInstallmentItem<T extends { kind: string; name: string }>(
items: readonly T[],
): T | undefined {
return items.find(
(i) => i.kind === 'expense' && /^taks[iı]t(ler|lar)?$/.test(i.name.trim().toLocaleLowerCase('tr')),
)
}
export function useDebtImport() {
const invalidate = useInvalidateDebt()
return useMutation({
+33
View File
@@ -253,6 +253,8 @@ export const t = {
income: 'Gelir',
fixed: 'Sabit giderler',
savings: 'Birikim',
/** Borç takibinde borç > 0 iken birikim hedefinin adı */
debtPayment: 'Borç ödemesi',
debt: 'Devreden borç',
surplus: 'Devreden artı',
spendable: 'Harcanabilir',
@@ -525,6 +527,37 @@ export const t = {
removeInstallmentConfirm: (m: string) => `${m} taksit planı silinsin mi?`,
noInstallments: 'Kayıtlı taksit planı yok.',
toInstallments: 'Taksitler',
period: {
title: 'Bu ayın borcu',
hint: 'Banka borcu (KMH, kart, taksitler). Harcanabilir tutarı düşürmez; birikim hedefin borç bitene kadar borç ödemesi sayılır.',
opening: 'Ay başı borç',
fromSnapshot: (date: string) => `${date} ölçümünden`,
manual: 'Elle girildi',
noOpening: 'Ölçüm yok',
editOpening: 'Ay başı borcu düzelt',
resetOpening: 'Son ölçüme dön',
openingSaved: 'Ay başı borç kaydedildi',
carried: 'Devreden banka borcu',
carriedHint: 'Ay başı borç − ekstreye gelmemiş taksitler',
payment: 'Borç ödemesi',
load: 'Bu ayın taksiti',
target: 'Hedef ay sonu borç',
targetHint: 'Ay başı − borç ödemesi − bu ayın taksiti',
roadmap: (amount: string) => `Yol haritası: ${amount}`,
vsRoadmapAbove: (amount: string) => `yol haritasının ${amount} üstünde`,
vsRoadmapBelow: (amount: string) => `yol haritasının ${amount} altında`,
progressTitle: (date: string) => `${date} ölçümüne göre`,
paidDown: (amount: string) => `Borç ${amount} azaldı`,
grew: (amount: string) => `Borç ${amount} arttı`,
needed: (amount: string) => `ayın hedefi: ${amount} azalma (borç ödemesi + taksit)`,
onTrack: 'Hedefe ulaşıldı',
addInstallments: (amount: string) => `"Taksitler" sabit giderini ekle (${amount})`,
installmentsAdded: 'Taksitler sabit giderlere eklendi',
installmentsMismatch: (plan: string, load: string) =>
`Plandaki "Taksitler" ${plan}; bu ayın taksit yükü ${load}. Düzenle'den güncelleyebilirsin.`,
installmentsName: 'Taksitler',
toDebt: 'Borç takibi',
},
},
monthlyReport: {
title: 'Aylık Rapor',