feat(plan): devreden borç planın parçası, borçlu ayda %45 kuralı
- Devreden borç: elle girilen ya da dönem başından önceki son ay sonu ölçümü - Borçlu ay: harcanabilir = gelir × oran (vars. %45), kalan borca, artan birikime - Birikim hedefi ve Kakeibo devri borçlu ayda uygulanmaz; kapanış devir üretmez - Plan düzenleme ve sihirbazda devreden borç alanı + oran; canlı önizleme - Plan'daki eski borç bölümü ve taksit önerisi kaldırıldı (veri yerinde) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
# Değişiklik günlüğü
|
||||
|
||||
Biçim: [Keep a Changelog](https://keepachangelog.com/tr/1.1.0/). Tarihler GG.AA.YYYY.
|
||||
|
||||
## Yayınlanmamış
|
||||
|
||||
### Eklendi
|
||||
- Aylık planda **Devreden borç (KMH + kart)**: son ay sonu ölçümünden otomatik gelir, planda ve yeni dönem sihirbazında elle düzeltilebilir ("elle düzeltildi", "Ölçüme dön"). Ölçüm yoksa elle yazılır.
|
||||
- Borçlu ay düzeni: harcanabilir = gelirin %45'i (dönem başına %10–%90 ayarlanabilir); kalan önce borca gider, borcu aşan kısım birikime. Özet: Gelir − Sabit giderler − Bu ay borca giden = Harcanabilir; altında "Devreden borç · ay sonu hedef". Sabitler çok yüksekse kırmızı uyarı.
|
||||
|
||||
### Değişti
|
||||
- Borç takibi aylık planın parçası oldu (docs/KAKEIBO_PANEL_ISTEKLERI.md). Borçlu ayda birikim hedefi ve Kakeibo'nun kendi devri (önceki ay aşımı/artısı) uygulanmaz; dönem kapanışında devir ve birikim aktarımı üretilmez. Devreden borç 0 olunca normal Kakeibo düzeni döner.
|
||||
- Kakeibo'nun bütçe aşımı devri artık "Önceki ay aşımı" adıyla gösterilir (banka borcuyla karışmasın).
|
||||
|
||||
### Kaldırıldı
|
||||
- Plan'daki "Bu ayın borcu" bölümü ve "Taksitler" sabit gideri önerisi (taksitler sabit giderlere elle girilir). Veriler yerinde.
|
||||
@@ -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). 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".
|
||||
- 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 (docs/KAKEIBO_PANEL_ISTEKLERI.md A): devreden borç = `periods.opening_debt` (elle) yoksa dönem başından önceki son ölçüm (`debt::carried_for`); > 0 → borçlu ay: `budget::debt_plan` (harcanabilir = gelir × `spendable_rate_bp` [vars. 4500], borca giden = min(kalan, borç), artan → birikim; birikim hedefi ve Kakeibo devirleri yok sayılır, kapanış devir/birikim üretmez). Frontend aynası `lib/debt-plan.ts` (`debtPlan`, `previewSummary`), alan `features/planning/carried-debt-field.tsx` (plan düzenleme + sihirbaz 4. adım), `SummaryTiles` `summary.debt_mode`. Kakeibo devri "Önceki ay aşımı".
|
||||
- Hane izolasyonu: hane kimliği yalnızca `AuthUser.household_id`'den; sorgular `scope(hid)` ile başlar; başka hane → 404.
|
||||
|
||||
## Ortam
|
||||
|
||||
@@ -61,5 +61,8 @@ pub struct Period {
|
||||
/// Elle girilen ay başı toplam banka borcu (kuruş); yoksa son ölçümden (borç takibi)
|
||||
#[serde(default)]
|
||||
pub opening_debt: Option<i64>,
|
||||
/// Borçlu ayda harcanabilir oranı (baz puan, 4500 = %45); boşsa varsayılan
|
||||
#[serde(default)]
|
||||
pub spendable_rate_bp: Option<i32>,
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
@@ -72,9 +72,34 @@ pub struct SummaryDto {
|
||||
/// Önceki dönemden devreden artı bakiye
|
||||
#[ts(type = "number")]
|
||||
carryover_surplus: i64,
|
||||
/// gelir − sabit gider − birikim − borç + artı
|
||||
/// Normal ay: gelir − sabit gider − birikim − borç + artı · Borçlu ay: gelir × oran
|
||||
#[ts(type = "number")]
|
||||
spendable: i64,
|
||||
/// Devreden (banka) borcu; yoksa 0. > 0 → borçlu ay (birikim hedefi plana girmez)
|
||||
#[ts(type = "number")]
|
||||
carried_debt: i64,
|
||||
/// `auto` (ay sonu ölçümü) | `manual` (planda elle) | `null` (hiç girilmedi)
|
||||
#[ts(type = "\"auto\" | \"manual\" | null")]
|
||||
carried_debt_source: Option<String>,
|
||||
/// Son ay sonu ölçümünün tarihi (elle girilmiş olsa da); ölçüm yoksa `null`
|
||||
carried_debt_date: Option<String>,
|
||||
/// Son ay sonu ölçümünden gelen tutar (elle düzeltme kaldırılınca kullanılacak); yoksa `null`
|
||||
#[ts(type = "number | null")]
|
||||
carried_debt_auto: Option<i64>,
|
||||
debt_mode: bool,
|
||||
/// Etkin harcanabilir oranı (baz puan)
|
||||
spendable_rate_bp: i32,
|
||||
/// Bu ay borca giden
|
||||
#[ts(type = "number")]
|
||||
debt_payment: i64,
|
||||
/// Borca giden devreden borcu aşarsa birikime kalan
|
||||
#[ts(type = "number")]
|
||||
debt_to_savings: i64,
|
||||
/// Sabit giderler çok yüksek: bu ay borç azalmıyor
|
||||
debt_fixed_too_high: bool,
|
||||
/// Ay sonu hedef borç (borçlu ayda)
|
||||
#[ts(type = "number | null")]
|
||||
closing_debt_target: Option<i64>,
|
||||
/// 4 grubun aylık bütçe toplamı
|
||||
#[ts(type = "number")]
|
||||
budget_total: i64,
|
||||
@@ -194,6 +219,18 @@ pub async fn summary(
|
||||
carryover_debt: x.carryover_debt,
|
||||
carryover_surplus: x.carryover_surplus,
|
||||
spendable: x.spendable,
|
||||
carried_debt: x.debt.map_or(0, |d| d.carried),
|
||||
carried_debt_source: x.carried_source.map(str::to_string),
|
||||
carried_debt_date: x.carried_auto.as_ref().map(|a| a.1.clone()),
|
||||
carried_debt_auto: x.carried_auto.as_ref().map(|a| a.0),
|
||||
debt_mode: x.debt.is_some(),
|
||||
spendable_rate_bp: x
|
||||
.debt
|
||||
.map_or(budget::DEFAULT_SPENDABLE_RATE_BP, |d| d.rate_bp),
|
||||
debt_payment: x.debt.map_or(0, |d| d.payment),
|
||||
debt_to_savings: x.debt.map_or(0, |d| d.to_savings),
|
||||
debt_fixed_too_high: x.debt.is_some_and(|d| d.fixed_too_high),
|
||||
closing_debt_target: x.debt.map(|d| d.closing_target),
|
||||
budget_total: x.budget_total,
|
||||
unallocated: x.spendable - x.budget_total,
|
||||
spent_total: x.spent_total,
|
||||
|
||||
@@ -58,6 +58,8 @@ pub struct UpdateRequest {
|
||||
end_date: Option<String>,
|
||||
#[ts(type = "number", optional)]
|
||||
savings_goal: Option<i64>,
|
||||
/// Borçlu ayda harcanabilir oranı (baz puan; 4500 = %45)
|
||||
spendable_rate_bp: Option<i32>,
|
||||
/// "Nasıl daha iyi yapabilirim?" (Markdown); boş metin → kaldır
|
||||
reflection_md: Option<String>,
|
||||
version: Option<i32>,
|
||||
@@ -79,6 +81,8 @@ pub struct PeriodDto {
|
||||
/// Kuruş
|
||||
#[ts(type = "number")]
|
||||
savings_goal: i64,
|
||||
/// Borçlu ayda harcanabilir oranı (baz puan); `null` → varsayılan %45
|
||||
spendable_rate_bp: Option<i32>,
|
||||
/// "Nasıl daha iyi yapabilirim?" (Markdown)
|
||||
reflection_md: Option<String>,
|
||||
/// Kapanışta birikime aktarılan (kuruş); kapanmamışsa `null`
|
||||
@@ -115,6 +119,7 @@ impl From<Period> for PeriodDto {
|
||||
end_date: p.end_date,
|
||||
status: p.status,
|
||||
savings_goal: p.savings_goal,
|
||||
spendable_rate_bp: p.spendable_rate_bp,
|
||||
reflection_md: p.reflection_md,
|
||||
to_savings: p.to_savings,
|
||||
closed_at: p.closed_at.and_then(|d| d.try_to_rfc3339_string().ok()),
|
||||
@@ -194,6 +199,7 @@ pub async fn update(
|
||||
start_date: req.start_date,
|
||||
end_date: req.end_date,
|
||||
savings_goal: req.savings_goal,
|
||||
spendable_rate_bp: req.spendable_rate_bp,
|
||||
reflection_md: req.reflection_md,
|
||||
version: req.version,
|
||||
},
|
||||
|
||||
@@ -34,7 +34,58 @@ use crate::{
|
||||
/// Gruplara göre tutarlar (kuruş), `Group::ALL` sırasıyla.
|
||||
pub type GroupAmounts = [i64; 4];
|
||||
|
||||
/// Borçlu ayda varsayılan harcanabilir oranı: %45 (docs/KAKEIBO_PANEL_ISTEKLERI.md A5)
|
||||
pub const DEFAULT_SPENDABLE_RATE_BP: i32 = 4500;
|
||||
|
||||
/// Borçlu ayın planı (devreden borç > 0)
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct DebtPlan {
|
||||
/// Devreden borç (toplam)
|
||||
pub carried: i64,
|
||||
pub rate_bp: i32,
|
||||
/// Harcanabilir = gelir × oran (sabitler çok yüksekse gelir − sabit)
|
||||
pub spendable: i64,
|
||||
/// Bu ay borca giden = gelir − sabit − harcanabilir (en fazla devreden borç kadar)
|
||||
pub payment: i64,
|
||||
/// Borca giden devreden borçtan büyükse artan kısım birikime
|
||||
pub to_savings: i64,
|
||||
/// Gelir − sabit < gelir × oran → bu ay borç azalmıyor
|
||||
pub fixed_too_high: bool,
|
||||
/// Ay sonu hedef borç = devreden − borca giden
|
||||
pub closing_target: i64,
|
||||
}
|
||||
|
||||
/// A5: Harcanabilir = gelir × oran; borca giden = gelir − sabit − harcanabilir.
|
||||
pub fn debt_plan(income: i64, fixed: i64, carried: i64, rate_bp: i32) -> DebtPlan {
|
||||
// Kuruşa yarım yukarı yuvarlama (gelir ≥ 0)
|
||||
let by_rate = (i128::from(income.max(0)) * i128::from(rate_bp) + 5_000) / 10_000;
|
||||
let by_rate = by_rate as i64;
|
||||
let after_fixed = income - fixed;
|
||||
let (spendable, raw, fixed_too_high) = if after_fixed < by_rate {
|
||||
(after_fixed, 0, true)
|
||||
} else {
|
||||
(by_rate, after_fixed - by_rate, false)
|
||||
};
|
||||
let payment = raw.min(carried);
|
||||
DebtPlan {
|
||||
carried,
|
||||
rate_bp,
|
||||
spendable,
|
||||
payment,
|
||||
to_savings: raw - payment,
|
||||
fixed_too_high,
|
||||
closing_target: carried - payment,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Summary {
|
||||
/// Borçlu ay (devreden borç > 0): harcanabilir %45 kuralıyla; birikim hedefi ve
|
||||
/// Kakeibo devirleri hesaba girmez. `None` → normal Kakeibo düzeni.
|
||||
pub debt: Option<DebtPlan>,
|
||||
/// Devreden borcun kaynağı: `auto` (ay sonu ölçümü) | `manual` (planda elle); yoksa `None`
|
||||
pub carried_source: Option<&'static str>,
|
||||
/// Son ay sonu ölçümü (toplam ≥ 0, tarih) — elle girilmiş olsa da
|
||||
pub carried_auto: Option<(i64, String)>,
|
||||
pub income_total: i64,
|
||||
pub fixed_expense_total: i64,
|
||||
pub savings_goal: i64,
|
||||
@@ -139,8 +190,21 @@ pub async fn summary(db: &Database, household_id: Uuid, period_id: Uuid) -> AppR
|
||||
.await?
|
||||
.iter()
|
||||
.sum();
|
||||
let spendable =
|
||||
income_total - fixed_expense_total - p.savings_goal - carryover_debt + carryover_surplus;
|
||||
let carried = crate::services::debt::carried_for(db, household_id, &p).await?;
|
||||
let debt = carried.as_ref().filter(|c| c.amount > 0).map(|c| {
|
||||
debt_plan(
|
||||
income_total,
|
||||
fixed_expense_total,
|
||||
c.amount,
|
||||
p.spendable_rate_bp.unwrap_or(DEFAULT_SPENDABLE_RATE_BP),
|
||||
)
|
||||
});
|
||||
let spendable = match &debt {
|
||||
Some(d) => d.spendable,
|
||||
None => {
|
||||
income_total - fixed_expense_total - p.savings_goal - carryover_debt + carryover_surplus
|
||||
}
|
||||
};
|
||||
let by_group = sum_by(
|
||||
db,
|
||||
col::ENTRIES,
|
||||
@@ -153,6 +217,9 @@ pub async fn summary(db: &Database, household_id: Uuid, period_id: Uuid) -> AppR
|
||||
let spent_total = spent_by_group.iter().sum();
|
||||
|
||||
Ok(Summary {
|
||||
debt,
|
||||
carried_source: carried.as_ref().map(|c| c.source),
|
||||
carried_auto: carried.and_then(|c| c.auto),
|
||||
income_total,
|
||||
fixed_expense_total,
|
||||
savings_goal: p.savings_goal,
|
||||
@@ -579,3 +646,14 @@ async fn previous_closed(
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod debt_plan_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn debt_month_matches_the_request_document() {
|
||||
let d = debt_plan(7_950_964, 2_300_690, 5_710_000, 4500);
|
||||
assert_eq!(d.spendable, 3_577_934);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,10 +101,15 @@ pub async fn close(
|
||||
if version.is_some_and(|v| v != p.version) {
|
||||
return Err(period::stale());
|
||||
}
|
||||
let realized = review::review(db, household_id, period_id)
|
||||
.await?
|
||||
.realized_savings;
|
||||
let (to_savings, carry) = settle(realized, to_savings)?;
|
||||
let r = review::review(db, household_id, period_id).await?;
|
||||
let realized = r.realized_savings;
|
||||
// Borçlu ay: Kakeibo'nun kendi devri üretilmez (açık zaten banka borcunda; çifte sayım
|
||||
// olmasın) ve birikim aktarılmaz — borcun ay sonu hali bir sonraki dönemin devreden borcu olur.
|
||||
let (to_savings, carry) = if r.summary.debt.is_some() {
|
||||
(0, None)
|
||||
} else {
|
||||
settle(realized, to_savings)?
|
||||
};
|
||||
let next = next_period(db, household_id, &p).await?;
|
||||
// Yeniden kapatma: birikime eklenmiş kayıt varsa yeni tutara uyarlanır (önce denetlenir)
|
||||
let savings_sync =
|
||||
|
||||
@@ -573,6 +573,48 @@ pub async fn summary(db: &Database, hid: Uuid, today: &str) -> AppResult<Summary
|
||||
))
|
||||
}
|
||||
|
||||
// ---------- Devreden borç (aylık plan, docs/KAKEIBO_PANEL_ISTEKLERI.md A) ----------
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Carried {
|
||||
/// ≥ 0 (ay sonu net artıdaysa borç 0)
|
||||
pub amount: i64,
|
||||
/// `manual` (planda elle) | `auto` (ay sonu ölçümü)
|
||||
pub source: &'static str,
|
||||
/// Son ay sonu ölçümünün tarihi ve toplamı (≥ 0) — elle girilmiş olsa da (arayüz "otomatiğe dön" için)
|
||||
pub auto: Option<(i64, String)>,
|
||||
}
|
||||
|
||||
/// Dönemin devreden borcu: elle girilmişse o (0 dahil → borç bitti), değilse dönem başından
|
||||
/// önceki (≤ başlangıç günü) son ay sonu ölçümünün toplamı. İkisi de yoksa `None`.
|
||||
pub async fn carried_for(
|
||||
db: &Database,
|
||||
hid: Uuid,
|
||||
p: &crate::models::period::Period,
|
||||
) -> AppResult<Option<Carried>> {
|
||||
let mut f = scope(hid);
|
||||
f.insert("date", doc! { "$lte": &p.start_date });
|
||||
let s = db
|
||||
.collection::<Snapshot>(col::DEBT_SNAPSHOTS)
|
||||
.find_one(f)
|
||||
.sort(doc! { "date": -1, "_id": -1 })
|
||||
.await?;
|
||||
let auto = s.map(|s| (s.total().max(0), s.date.clone()));
|
||||
Ok(match (p.opening_debt, auto) {
|
||||
(Some(v), auto) => Some(Carried {
|
||||
amount: v.max(0),
|
||||
source: "manual",
|
||||
auto,
|
||||
}),
|
||||
(None, Some(a)) => Some(Carried {
|
||||
amount: a.0,
|
||||
source: "auto",
|
||||
auto: Some(a),
|
||||
}),
|
||||
(None, None) => None,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- Dönem (aylık plan) ----------
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -1006,6 +1048,7 @@ mod tests {
|
||||
to_savings: None,
|
||||
closed_at: None,
|
||||
opening_debt: None,
|
||||
spendable_rate_bp: None,
|
||||
version: 1,
|
||||
};
|
||||
let snaps = vec![
|
||||
|
||||
@@ -41,6 +41,8 @@ pub struct PeriodChanges {
|
||||
pub start_date: Option<String>,
|
||||
pub end_date: Option<String>,
|
||||
pub savings_goal: Option<i64>,
|
||||
/// Borçlu ayda harcanabilir oranı (baz puan, 1000–9000)
|
||||
pub spendable_rate_bp: Option<i32>,
|
||||
/// `Some("")` → metni kaldır
|
||||
pub reflection_md: Option<String>,
|
||||
pub version: Option<i32>,
|
||||
@@ -208,6 +210,14 @@ pub async fn update(
|
||||
if let Some(name) = ch.name {
|
||||
set.insert("name", clean_name(Some(&name), start, end)?);
|
||||
}
|
||||
if let Some(bp) = ch.spendable_rate_bp {
|
||||
if !(1000..=9000).contains(&bp) {
|
||||
return Err(AppError::BadRequest(
|
||||
"Harcanabilir oranı %10 ile %90 arasında olmalı".into(),
|
||||
));
|
||||
}
|
||||
set.insert("spendable_rate_bp", bp);
|
||||
}
|
||||
if let Some(goal) = ch.savings_goal {
|
||||
set.insert("savings_goal", validate_amount(goal)?);
|
||||
}
|
||||
|
||||
@@ -87,6 +87,17 @@ async fn summary_computes_spendable_with_carryovers() {
|
||||
"carryover_surplus": 20_000,
|
||||
// 4.800.000 − 1.545.000 − 500.000 − 100.000 + 20.000
|
||||
"spendable": 2_675_000,
|
||||
// Banka borcu yok → normal Kakeibo (docs/KAKEIBO_PANEL_ISTEKLERI.md A)
|
||||
"carried_debt": 0,
|
||||
"carried_debt_source": null,
|
||||
"carried_debt_date": null,
|
||||
"carried_debt_auto": null,
|
||||
"debt_mode": false,
|
||||
"spendable_rate_bp": 4500,
|
||||
"debt_payment": 0,
|
||||
"debt_to_savings": 0,
|
||||
"debt_fixed_too_high": false,
|
||||
"closing_debt_target": null,
|
||||
"budget_total": 0,
|
||||
"unallocated": 2_675_000,
|
||||
"spent_total": 0,
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Devreden borç planın parçası (docs/KAKEIBO_PANEL_ISTEKLERI.md A): %45 kuralı, kaynak
|
||||
//! (ölçüm / elle), borç bitince normal düzen, borçlu ayda Kakeibo devri yok.
|
||||
|
||||
mod common;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use common::{Session, TestApp};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
async fn december(app: &TestApp, s: &Session) -> String {
|
||||
let (_, p) = app
|
||||
.post(
|
||||
"/periods",
|
||||
s,
|
||||
json!({ "start_date": "2026-12-01", "end_date": "2027-01-01", "savings_goal": 500_000 }),
|
||||
)
|
||||
.await;
|
||||
let id = p["id"].as_str().unwrap().to_string();
|
||||
// A5 kontrol: gelir 79.509,64 · sabit 23.006,90
|
||||
for (kind, name, amount) in [
|
||||
("income", "Maaş", 7_950_964),
|
||||
("expense", "Kira", 2_000_000),
|
||||
("expense", "Taksitler", 300_690),
|
||||
] {
|
||||
app.post(
|
||||
&format!("/periods/{id}/plan-items"),
|
||||
s,
|
||||
json!({ "kind": kind, "name": name, "amount": amount }),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
async fn summary(app: &TestApp, s: &Session, pid: &str) -> Value {
|
||||
let (st, b) = app.get(&format!("/periods/{pid}/summary"), s).await;
|
||||
assert_eq!(st, StatusCode::OK, "{b}");
|
||||
b
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn debt_month_uses_45_percent_rule() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Sahip", None).await;
|
||||
let pid = december(&app, &s).await;
|
||||
|
||||
// Borç yokken normal Kakeibo: gelir − sabit − birikim
|
||||
let x = summary(&app, &s, &pid).await;
|
||||
assert_eq!(x["debt_mode"], false);
|
||||
assert_eq!(x["carried_debt_source"], Value::Null);
|
||||
assert_eq!(x["spendable"], 7_950_964 - 2_300_690 - 500_000);
|
||||
|
||||
// Kasım sonu ölçümü (toplam 57.100) → otomatik devreden borç
|
||||
app.post(
|
||||
"/debt/snapshots",
|
||||
&s,
|
||||
json!({ "date": "2026-11-30", "kmh": 3_000_000, "card_balance": 2_710_000, "note": "maaş sonrası" }),
|
||||
)
|
||||
.await;
|
||||
let x = summary(&app, &s, &pid).await;
|
||||
assert_eq!(x["debt_mode"], true);
|
||||
assert_eq!(
|
||||
(
|
||||
x["carried_debt"].as_i64(),
|
||||
x["carried_debt_source"].as_str()
|
||||
),
|
||||
(Some(5_710_000), Some("auto"))
|
||||
);
|
||||
assert_eq!(x["carried_debt_date"], "2026-11-30");
|
||||
assert_eq!(x["spendable_rate_bp"], 4500);
|
||||
assert_eq!(
|
||||
x["spendable"], 3_577_934,
|
||||
"35.779,34 — birikim hedefi girmez"
|
||||
);
|
||||
assert_eq!(x["debt_payment"], 2_072_340, "20.723,40");
|
||||
assert_eq!(x["closing_debt_target"], 3_637_660, "36.376,60");
|
||||
assert_eq!(x["debt_fixed_too_high"], false);
|
||||
assert_eq!(
|
||||
x["unallocated"], 3_577_934,
|
||||
"bütçe dağıtımı yeni harcanabilire göre"
|
||||
);
|
||||
|
||||
// Elle düzeltme → manual; 10.000 → borca 10.000, birikime 10.723,40
|
||||
let (st, _) = app
|
||||
.put(
|
||||
&format!("/periods/{pid}/opening-debt"),
|
||||
&s,
|
||||
json!({ "amount": 1_000_000 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::NO_CONTENT);
|
||||
let x = summary(&app, &s, &pid).await;
|
||||
assert_eq!(x["carried_debt_source"], "manual");
|
||||
assert_eq!(
|
||||
x["carried_debt_auto"], 5_710_000,
|
||||
"ölçüm değeri elle düzeltmede de görünür"
|
||||
);
|
||||
assert_eq!(x["carried_debt_date"], "2026-11-30");
|
||||
assert_eq!(
|
||||
(x["debt_payment"].as_i64(), x["debt_to_savings"].as_i64()),
|
||||
(Some(1_000_000), Some(1_072_340))
|
||||
);
|
||||
|
||||
// Elle 0 → borç bitti, normal düzen (A6)
|
||||
app.put(
|
||||
&format!("/periods/{pid}/opening-debt"),
|
||||
&s,
|
||||
json!({ "amount": 0 }),
|
||||
)
|
||||
.await;
|
||||
let x = summary(&app, &s, &pid).await;
|
||||
assert_eq!(x["debt_mode"], false);
|
||||
assert_eq!(x["spendable"], 7_950_964 - 2_300_690 - 500_000);
|
||||
|
||||
// Oran ayarlanabilir (%10–%90)
|
||||
app.put(
|
||||
&format!("/periods/{pid}/opening-debt"),
|
||||
&s,
|
||||
json!({ "amount": null }),
|
||||
)
|
||||
.await;
|
||||
let (_, p) = app.get(&format!("/periods/{pid}"), &s).await;
|
||||
let (st, p2) = app
|
||||
.patch(
|
||||
&format!("/periods/{pid}"),
|
||||
&s,
|
||||
json!({ "spendable_rate_bp": 5000, "version": p["version"] }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "{p2}");
|
||||
assert_eq!(p2["spendable_rate_bp"], 5000);
|
||||
assert_eq!(summary(&app, &s, &pid).await["spendable"], 3_975_482);
|
||||
let (st, _) = app
|
||||
.patch(
|
||||
&format!("/periods/{pid}"),
|
||||
&s,
|
||||
json!({ "spendable_rate_bp": 9500 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::BAD_REQUEST);
|
||||
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn net_positive_measurement_means_no_debt_and_fixed_too_high_warns() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Sahip", None).await;
|
||||
let pid = december(&app, &s).await;
|
||||
// Artı bakiye borçtan büyük → borç 0 → normal düzen
|
||||
app.post(
|
||||
"/debt/snapshots",
|
||||
&s,
|
||||
json!({ "date": "2026-11-30", "card_balance": 100_000, "positive_balance": 300_000 }),
|
||||
)
|
||||
.await;
|
||||
let x = summary(&app, &s, &pid).await;
|
||||
assert_eq!(
|
||||
(x["debt_mode"].as_bool(), x["carried_debt"].as_i64()),
|
||||
(Some(false), Some(0))
|
||||
);
|
||||
|
||||
// Sabitler çok yüksek: harcanabilir = gelir − sabit, borca 0, uyarı
|
||||
app.put(
|
||||
&format!("/periods/{pid}/opening-debt"),
|
||||
&s,
|
||||
json!({ "amount": 5_710_000 }),
|
||||
)
|
||||
.await;
|
||||
app.post(
|
||||
&format!("/periods/{pid}/plan-items"),
|
||||
&s,
|
||||
json!({ "kind": "expense", "name": "Büyük ödeme", "amount": 3_000_000 }),
|
||||
)
|
||||
.await;
|
||||
let x = summary(&app, &s, &pid).await;
|
||||
assert_eq!(x["debt_fixed_too_high"], true);
|
||||
assert_eq!(x["debt_payment"], 0);
|
||||
assert_eq!(x["spendable"], 7_950_964 - 5_300_690);
|
||||
assert_eq!(x["closing_debt_target"], 5_710_000);
|
||||
app.cleanup().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn closing_a_debt_month_creates_no_kakeibo_carryover() {
|
||||
let app = TestApp::new().await;
|
||||
let s = app.register("Sahip", None).await;
|
||||
let (_, p) = app
|
||||
.post(
|
||||
"/periods",
|
||||
&s,
|
||||
json!({ "start_date": "2026-11-01", "end_date": "2026-12-01", "savings_goal": 0 }),
|
||||
)
|
||||
.await;
|
||||
let nov = p["id"].as_str().unwrap().to_string();
|
||||
app.post(
|
||||
&format!("/periods/{nov}/plan-items"),
|
||||
&s,
|
||||
json!({ "kind": "income", "name": "Maaş", "amount": 1_000_000 }),
|
||||
)
|
||||
.await;
|
||||
app.put(
|
||||
&format!("/periods/{nov}/opening-debt"),
|
||||
&s,
|
||||
json!({ "amount": 5_000_000 }),
|
||||
)
|
||||
.await;
|
||||
let (st, c) = app
|
||||
.post(
|
||||
&format!("/periods/{nov}/close"),
|
||||
&s,
|
||||
json!({ "to_savings": 200_000 }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(st, StatusCode::OK, "{c}");
|
||||
assert_eq!(c["to_savings"], 0, "borçlu ayda birikim aktarılmaz");
|
||||
assert!(c["carryover"].is_null(), "Kakeibo devri yok: {c}");
|
||||
let (_, p) = app
|
||||
.post(
|
||||
"/periods",
|
||||
&s,
|
||||
json!({ "start_date": "2026-12-01", "end_date": "2027-01-01" }),
|
||||
)
|
||||
.await;
|
||||
let x = summary(&app, &s, p["id"].as_str().unwrap()).await;
|
||||
assert_eq!(
|
||||
(
|
||||
x["carryover_debt"].as_i64(),
|
||||
x["carryover_surplus"].as_i64()
|
||||
),
|
||||
(Some(0), Some(0))
|
||||
);
|
||||
app.cleanup().await;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
> **Bu yaklaşım `docs/KAKEIBO_PANEL_ISTEKLERI.md` ile değiştirildi (14 Kasım 2026):** borç ayrı sayfada değil, aylık planın ve panelin içinde "Devreden borç" olarak izlenir. Koleksiyonlar ve veri yerinde kalır.
|
||||
|
||||
# Borç takibi (Kakeibo uyarlaması)
|
||||
|
||||
Kaynak: `butcem/docs/BORC_TAKIBI.md` (kullanıcının özellik tanımı). Kişisel rakamlar (seed) bu repoya
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
# Kakeibo panel istekleri (güncel: 14 Kasım 2026, 2. tur)
|
||||
|
||||
## VS Code'daki Claude'a verilecek prompt
|
||||
|
||||
Aşağıdaki metni olduğu gibi kopyalayıp yapıştırın:
|
||||
|
||||
```text
|
||||
docs/KAKEIBO_PANEL_ISTEKLERI.md dosyasını baştan sona oku. Bu dosya, docs/BORC_TAKIBI.md'deki
|
||||
"Borç sayfası" yaklaşımının yerine geçiyor; borç artık ayrı sayfada değil, aylık planın ve panelin
|
||||
içinde "Devreden borç" olarak takip edilecek.
|
||||
|
||||
Kurallar:
|
||||
1. Kod yazmadan önce: ilgili dosyaları bul (Planlama, Defter, Ay sonu değerlendirmesi, Panel,
|
||||
Borç sayfası, nav menüsü) ve bana dosya listesiyle birlikte adım adım bir plan göster.
|
||||
Onayımı bekle.
|
||||
2. Mevcut verilere dokunma: veritabanı yedeği al, yalnızca alan ekle, eski kayıtları toplu
|
||||
değiştirme, koleksiyon silme. Borç sayfasını kaldırırken verisini silme.
|
||||
3. Bölüm sırasıyla ilerle (A → H). Her bölümden sonra test et, kısa özet ver.
|
||||
4. Hesapları dosyadaki "Kontrol" rakamlarıyla birim testine dönüştür.
|
||||
5. Türkçe büyük harf için toLocaleUpperCase("tr-TR") kullan; CSS text-transform kullanma.
|
||||
6. Bitince npm run lint, typecheck ve testler geçsin; CHANGELOG "Yayınlanmamış" altına yaz.
|
||||
```
|
||||
|
||||
> **Not (Claude, sohbetten):** Bu bilgisayardaki çalışma kopyasında (dal `faz/17-ice-aktarma`, sürüm 1.2.0, 9 Kasım) Planlama, Defter, Ay sonu ve Borç ekranları yok. Bu ekranların bulunduğu güncel sürüm üzerinde çalışılmalı. Dosyalar bulunamazsa Claude tahminle yeni ekran yazmasın, sorsun.
|
||||
|
||||
---
|
||||
|
||||
## A · Devreden borç: planın parçası olsun
|
||||
|
||||
### A1 · Planlama sayfasında yeni kutu
|
||||
- "Birikim hedefi" kutusunun **üzerine** aynı görünümde **"Devreden borç"** kutusu eklenir.
|
||||
- Aylık plana alanlar eklenir (eski alanlar değişmez, varsayılan `null`):
|
||||
- `carriedDebtMinor`: devreden borç (pozitif sayı)
|
||||
- `carriedDebtSource`: `"auto"` ya da `"manual"`
|
||||
- `debtPaymentMinor`: bu ay borca giden tutar (hesaplanır)
|
||||
- `spendableRateBp`: borçlu ayda harcanabilir oranı (varsayılan **4500** = %45)
|
||||
|
||||
### A2 · İlk dönem: elle giriş
|
||||
- Önceki tanımlı dönem yoksa "Devreden borç" boş ve düzenlenebilir gelir. Kullanıcı elle girer (`source = "manual"`).
|
||||
- Yardım metni: "Maaş hesabının eksi bakiyesi + kartın tüm borcu (ekstre + dönem içi harcama) + kalan taksitler − hesaptaki artı bakiye."
|
||||
|
||||
### A3 · Sonraki dönemler: otomatik
|
||||
- Yeni dönem planı oluşturulurken devreden borç, **önceki dönemin ay sonu borcundan** otomatik dolar (`source = "auto"`). Ay sonu borcunun tanımı için bkz. **C**.
|
||||
- Kullanıcı isterse elle düzeltebilir. Düzeltince `source = "manual"` olur ve kutuda "elle düzeltildi" rozeti görünür.
|
||||
|
||||
### A4 · Borç varsa birikim yok, borç odaklı plan
|
||||
- `carriedDebtMinor > 0` iken **birikim hedefi plana dahil edilmez** (0 sayılır).
|
||||
- Planlama sayfasında "Birikim hedefi" kutusu yerine **"Devreden borç"** ve altında **"Bu ay borca giden"** gösterilir. Birikim kutusu gizlenir ya da soluk "Borç bitince açılır" yazısıyla durur.
|
||||
- Panelde de aynı kutu "Devreden borç" yazar (bkz. D).
|
||||
|
||||
### A5 · Harcanabilir hesabı (borçlu ay)
|
||||
Devreden borcun tamamını tek ayda düşmek harcanabiliri eksiye sokar. Bu yüzden:
|
||||
|
||||
```
|
||||
Harcanabilir = Toplam gelir × %45 (spendableRateBp ile ayarlanabilir)
|
||||
Bu ay borca giden = Toplam gelir − Sabit giderler − Harcanabilir
|
||||
```
|
||||
|
||||
Kenar durumları:
|
||||
- `Bu ay borca giden` **devreden borçtan büyükse**: borca yalnızca devreden borç kadar gider, kalan tutar birikime yazılır (borç bu ay biter).
|
||||
- `Gelir − Sabit < Gelir × %45` ise (sabitler çok yüksek): Harcanabilir = Gelir − Sabit, borca giden 0, kırmızı uyarı: "Sabit giderler çok yüksek, bu ay borç azalmıyor."
|
||||
- Ekranda formül açıkça yazılsın: `Gelir − Sabit − Bu ay borca giden = Harcanabilir`. Altında küçük satır: "Devreden borç 57.100 · ay sonu hedef 36.376,60."
|
||||
|
||||
**Kontrol (Aralık 2026 rakamlarıyla):**
|
||||
- Gelir 79.509,64 · Sabit 23.006,90 · Devreden borç 57.100
|
||||
- Harcanabilir = 79.509,64 × 0,45 = **35.779,34**
|
||||
- Bu ay borca giden = 79.509,64 − 23.006,90 − 35.779,34 = **20.723,40**
|
||||
- Ay sonu hedef borç = 57.100 − 20.723,40 = **36.376,60**
|
||||
- Kenar durumu testi: devreden borç 10.000 → borca giden 10.000, birikime 10.723,40.
|
||||
|
||||
### A6 · Borç bitince
|
||||
- Devreden borç 0 olduğunda (otomatik ya da elle) plan normal Kakeibo düzenine döner: `Gelir − Sabit − Birikim hedefi = Harcanabilir`. Birikim kutusu tekrar görünür, %45 kuralı devre dışı kalır.
|
||||
|
||||
---
|
||||
|
||||
## B · Borçlar sayfası kaldırılsın
|
||||
- Borç sayfası rotası (`/borc` ya da benzeri), menü bağlantısı ve sayfaya özel bileşenler kaldırılır.
|
||||
- **Veri silinmez:** `debtSnapshots` / `installmentPlans` koleksiyonları ve modelleri yerinde kalır. Kullanılmayan servis kodu silinebilir, migration ile koleksiyon düşürülmez.
|
||||
- Taksit yükü bilgisi gerekiyorsa Planlama → Sabit giderler → "Taksitler" satırında öneri olarak kalabilir.
|
||||
- `docs/BORC_TAKIBI.md` dosyasının en üstüne "Bu yaklaşım KAKEIBO_PANEL_ISTEKLERI.md ile değiştirildi" notu eklenir.
|
||||
|
||||
---
|
||||
|
||||
## C · Ay sonu: "Ne kadar biriktirdim?" → "Ne kadar borç devretti?"
|
||||
|
||||
### Tanım (tek doğru kaynak)
|
||||
```
|
||||
Ay sonu borcu = banka hesaplarının eksi bakiyeleri (KMH)
|
||||
+ kredi kartı tüm borcu (ekstre + dönem içi + provizyon)
|
||||
+ kalan taksitler
|
||||
− hesaplardaki artı bakiyeler (nakit, banka, yemek kartı, toplu taşıma)
|
||||
(sonuç eksiyse borç 0, aradaki fark birikimdir)
|
||||
```
|
||||
Hesap, dönem bitiş tarihindeki hesap bakiyelerinden yapılır. Transferler (kart ödemesi, ATM, kendi hesapları arası havale) borcu değiştirmez.
|
||||
|
||||
### Ekran
|
||||
- **Borç varken** başlık: **"4 · Ne kadar borç devretti?"**
|
||||
- Ana rakam: ay sonu borcu (bir sonraki dönemin devreden borcu olur).
|
||||
- Altında: "Ay başı 78.530,48 → ay sonu X" ve fark. Azaldıysa yeşil "borç Y TL azaldı", arttıysa kırmızı "borç Y TL arttı".
|
||||
- Plan karşılaştırması: "Hedef: Z TL borca gidecekti, gerçekleşen: Y TL".
|
||||
- **Borç yokken** başlık: **"4 · Ne kadar biriktirdim?"**. Değer = ay sonu net artı − ay başı net artı. Eksiyse birikim yazılmaz, "Bu ay birikim yok" denir.
|
||||
- **Plan hedefi asla gerçekleşen olarak gösterilmez.**
|
||||
|
||||
### Mevcut hatanın kontrolü
|
||||
Kullanıcı hiç birikim girmedi, hesap sürekli ekside, ama ekranda birikim çıkıyor. Şu dört olasılığı kontrol et:
|
||||
1. Plan hedefi (C) gerçekleşen yerine yazılıyor olabilir.
|
||||
2. Hesap yalnızca `gelir − gider` olarak yapılıp sabit giderler, kart borcu ya da KMH faizi düşülmüyor olabilir.
|
||||
3. Kart ödemesi veya hesaplar arası havale birikim sayılıyor olabilir.
|
||||
4. Eksi sonuç `Math.max(0, …)` ile sıfıra çekiliyor ya da işareti kayboluyor olabilir.
|
||||
|
||||
**Kontrol (bankayla doğrulanmış):** 31 Ekim ay sonu borcu **78.530,48**, 13 Kasım **79.078,85** → "borç **548,37 TL arttı**". Birikim gösterilmez.
|
||||
|
||||
---
|
||||
|
||||
## D · Panel (dashboard)
|
||||
|
||||
### D1 · Plan satırı
|
||||
```
|
||||
[ Toplam gelir ] − [ Sabit giderler ] − [ Devreden borç ] − [ Toplam birikim ] = [ Harcanabilir ]
|
||||
```
|
||||
- "Devreden borç" kutusunun ana rakamı **bu ay borca giden** tutardır. Küçük satırda devreden borcun tamamı yazar: "toplam 57.100". Formül böylece eksiye düşmez.
|
||||
- Borç varken birikim kutusu 0 ve soluk ("borç bitince"). Borç yokken devreden borç kutusu soluk/gizli.
|
||||
|
||||
### D2 · Ödeme türleri satırı (anlık bakiye)
|
||||
`[ Nakit ] [ Kredi kartı ] [ Banka kartı + Havale/EFT ] [ Yemek kartı ] [ Toplu taşıma kartı ]`
|
||||
|
||||
| Kutu | Kaynak (hesap türü) | Ana rakam | Alt satır |
|
||||
|---|---|---|---|
|
||||
| Nakit | `cash` | bakiye | bu ay nakit harcama (minibüs, taksi dahil) |
|
||||
| Kredi kartı | `credit_card` | **dönem içi borç** (ör. −29.713,54) | toplam borç (taksit dahil) · son ödeme · kalan limit |
|
||||
| Banka kartı + Havale/EFT | `bank` | bakiye (ör. **−39.264,44**, kırmızı) | KMH kullanılan / limit · bu ay çıkış |
|
||||
| Yemek kartı | `meal_card` | bakiye | bu ay harcanan |
|
||||
| Toplu taşıma kartı | **yeni** `transit_card` | bakiye | bu ay yükleme / harcama |
|
||||
|
||||
Kurallar:
|
||||
- **Ödeme türü = işlemin hesabının türü.** Defter kodları: N `cash`, KK `credit_card`, BK `bank`, YK `meal_card`, TT `transit_card`.
|
||||
- **Havale/EFT** banka hesabından çıktığı için "Banka kartı" kutusunda gider sayılır. Kendi hesapları arası havale (maaş → Enpara, kızın ek hesabı) transferdir.
|
||||
- **Kart ödemesi ve ATM çekimi transferdir**, hiçbir kutuda gider değildir.
|
||||
- **İstanbulkart:** yükleme, banka/nakit hesabından `transit_card` hesabına transfer. Binişler ulaşım gideri (ya da ay sonunda tek düzeltme kaydı). İçe aktarmada "İSTANBULKART YUKLEME" yeni aktarmalarda bu hesaba transfer olarak eşlensin; eski kayıtlar değişmesin.
|
||||
- `transit_card` için: `shared/accounts.ts` → `ACCOUNT_TYPES`, hesap formu, `tr.ts` ("Toplu taşıma kartı" / "Toplu taşıma kartları"), ikon. Enum genişlediği için migration gerekmez.
|
||||
- Her kutuda **son içe aktarma / mutabakat tarihi** küçük yazılsın. Bankayla farklıysa mevcut mutabakat akışına bağlantı.
|
||||
- **Kontrol (13 Kasım):** Banka kartı **−39.264,44**, Kredi kartı **−29.713,54**. 235 TL provizyon "onay bekleyen" olarak ayrı.
|
||||
|
||||
### D3 · Yerleşim
|
||||
- Orta alan çok dar. Ana kapsayıcının dar `max-w-*` sınırı kaldırılsın ya da genişletilsin (ör. `max-w-screen-2xl`, yanlarda `px-4 lg:px-8`).
|
||||
- Kutu satırları ekran genişliğine yayılsın:
|
||||
- Plan satırı: mobilde 2, tablette 3, masaüstünde 5 sütun.
|
||||
- Ödeme türleri: mobilde 2, masaüstünde 5 sütun.
|
||||
- Altındaki kartlar iki sütun (`lg:grid-cols-2`).
|
||||
- Kutular arasındaki "−" ve "=" işaretleri masaüstünde görünsün, mobilde gizlensin.
|
||||
|
||||
---
|
||||
|
||||
## E · Defter: Açıklama varsayılan büyük harf
|
||||
- İşlem formu (`transaction-form.tsx`, `name="description"`) ve defter satırlarındaki açıklama alanları.
|
||||
- `toLocaleUpperCase("tr-TR")`: hem yazarken (onChange) hem sunucuda (zod `.transform`).
|
||||
- Mevcut kayıtlar toplu değiştirilmez.
|
||||
|
||||
## F · Planlama: Sabit gelirler / Sabit giderler kutuları büyük harf
|
||||
- Bu iki bölümdeki metin kutuları (kalem adı) aynı kuralla.
|
||||
- Ortak yardımcı: `shared/format.ts` → `upperTr(s)`. Birim testi: `"elma market"` → `"ELMA MARKET"`, `"istanbul"` → `"İSTANBUL"`, `"ılık"` → `"ILIK"`.
|
||||
|
||||
## G · Metin değişiklikleri (`tr.ts`)
|
||||
- "Birikim hedefi" → borçlu ayda "Devreden borç" / "Bu ay borca giden".
|
||||
- Ay sonu: "Ne kadar biriktirdim?" → borçlu ayda "Ne kadar borç devretti?".
|
||||
- Panel kutusu: "Devreden borç".
|
||||
|
||||
## H · Kabul kriterleri
|
||||
- [ ] Planlamada "Devreden borç" kutusu birikim hedefinin üstünde. İlk dönemde elle, sonraki dönemlerde otomatik dolar, elle düzeltilebilir.
|
||||
- [ ] Borç varken birikim hedefi plana girmez. Harcanabilir = gelir × %45. A5 kontrol rakamları testte birebir.
|
||||
- [ ] Borç 0 olunca plan birikim düzenine döner.
|
||||
- [ ] Borçlar sayfası ve menü bağlantısı yok. Verisi duruyor.
|
||||
- [ ] Ay sonu "Ne kadar borç devretti?" gösteriyor. Test: 78.530,48 → 79.078,85 = "borç 548,37 TL arttı".
|
||||
- [ ] Panelde plan satırı `Gelir − Sabit − Devreden borç − Birikim = Harcanabilir` ve ödeme türleri satırı var. 13 Kasım: −39.264,44 ve −29.713,54.
|
||||
- [ ] Kart ödemesi, ATM ve kendi hesaplar arası havale hiçbir kutuda gider değil.
|
||||
- [ ] Panel orta alanı geniş ekranda yayılıyor, mobilde taşma yok.
|
||||
- [ ] Açıklama ve sabit gelir/gider adları Türkçe büyük harfle kaydediliyor. Eski kayıtlar değişmedi.
|
||||
- [ ] Lint, typecheck ve testler geçiyor. CHANGELOG güncel.
|
||||
|
||||
---
|
||||
|
||||
## Kakeibo uyarlaması ve kararlar (kullanıcı, 14 Kasım 2026)
|
||||
|
||||
Belge butcem için yazıldı; Kakeibo karşılıkları ve kullanıcı kararları:
|
||||
|
||||
1. **Panel bakiyeleri (D2):** son ay sonu ölçümünden (`debt_snapshots`) gelir; canlı banka eşleşmesi yok.
|
||||
Önceki aydan devreden yoksa planlamada elle yazılır.
|
||||
2. **Borçlu aylarda Kakeibo'nun kendi aşım devri (`carryovers`) üretilmez** (çifte sayım olmasın).
|
||||
3. **Taksit planları için ayrı ekran yok.** Taksitler sabit giderlere elle eklenir; taksit önerileri kaldırılır,
|
||||
`installment_plans` verisi yerinde kalır.
|
||||
4. **%45 kuralı varsayılan:** açık dönem dahil, borç > 0 olan her dönemde.
|
||||
5. Mevcut veriye dokunulmaz: yalnızca alan eklenir (`periods.spendable_rate_bp`, `payment_methods.type`),
|
||||
eski kayıtlar toplu değiştirilmez; `periods.opening_debt` (B1'den) devreden borcun elle değeridir.
|
||||
|
||||
@@ -161,3 +161,4 @@
|
||||
- **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.
|
||||
- **Dev sunucusu düzeltmesi (2026-11-14):** Kullanıcı /borc'ta "Kayıt bulunamadı" gördü: 8080'de 00:40'tan kalma eski backend (borç kodu yok) çalışıyordu; yeniden başlatılan `npm run dev`'in cargo watch'ı portu alamıyordu (ikinci kez). Kök neden: `concurrently -k` cargo watch'ı kapatır, `cargo run`'ın çocuğu kakeibo bazen yetim kalır. Çözüm: `scripts/stop-stale-api.sh` (`dev:api` başında; 8080'i tutan süreç adı `kakeibo` ise kapatır, değilse uyarır). 8091'de denendi.
|
||||
|
||||
- **Panel istekleri A (2026-11-14):** docs/KAKEIBO_PANEL_ISTEKLERI.md + kararlar. `periods.spendable_rate_bp` (yalnız alan eklendi; eski kayıt `None` → %45). `debt::carried_for` (elle > son ölçüm ≤ dönem başı; elle olsa da `auto` değeri döner → "Ölçüme dön"). `budget::debt_plan` A5 rakamları birim + `tests/debt_plan.rs` (3: %45 kuralı/elle/0 → normal/oran 50 & 95 red; net artı ölçüm → borç yok; sabit yüksek uyarı; kapanışta devir ve birikim yok). Frontend `lib/debt-plan.ts` (+12 test), `CarriedDebtField`, plan taslağına `carried`/`rate` (`diffPlan` → PUT opening-debt, PATCH oran). `PeriodDebtSection` + taksit önerisi silindi (veri duruyor). E2E 16 kontrol (8091, DB silindi); bulunan: telefonda "Devreden borç · hedef" satırı `money` (nowrap) yüzünden 393 px taşıyordu → iki parça. Backend tümü, frontend 155 test.
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -42,7 +42,10 @@ export function itemSchema(start: string, last: string) {
|
||||
export type ItemForm = z.infer<ReturnType<typeof itemSchema>>
|
||||
|
||||
/** 4. adım */
|
||||
export const savingsSchema = z.object({ goal: moneyText('optional') })
|
||||
export const savingsSchema = z.object({
|
||||
goal: moneyText('optional'),
|
||||
carried: moneyText('optional'),
|
||||
})
|
||||
export type SavingsForm = z.infer<typeof savingsSchema>
|
||||
|
||||
/** 5. adım: 4 grubun aylık bütçesi */
|
||||
|
||||
@@ -13,7 +13,6 @@ 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'
|
||||
@@ -88,7 +87,6 @@ function BudgetStepForm({
|
||||
onBack: () => void
|
||||
onDone: () => void
|
||||
}) {
|
||||
const debt = useDebt()
|
||||
const setBudget = useSetBudget(period.id)
|
||||
const {
|
||||
control,
|
||||
@@ -124,7 +122,7 @@ function BudgetStepForm({
|
||||
return (
|
||||
<form onSubmit={onSubmit} noValidate>
|
||||
<StepIntro title={s.title} hint={s.hint} />
|
||||
<SummaryTiles summary={summary} debtPayment={(debt.data?.latest?.total ?? 0) > 0} />
|
||||
<SummaryTiles summary={summary} />
|
||||
|
||||
<div className="mt-6">
|
||||
<SuggestionPicker periodId={period.id} onApply={(a) => reset(toForm(a))} />
|
||||
|
||||
@@ -3,19 +3,33 @@ import { Controller, useForm, useWatch } from 'react-hook-form'
|
||||
import { FormError, FormField } from '@/components/form-field'
|
||||
import { MoneyInput } from '@/components/money-input'
|
||||
import { PageError, PageLoading } from '@/components/page'
|
||||
import { CarriedDebtField } from '@/features/planning/carried-debt-field'
|
||||
import { ApiError } from '@/lib/api'
|
||||
import {
|
||||
carriedText,
|
||||
DEFAULT_SPENDABLE_RATE_BP,
|
||||
effectiveCarried,
|
||||
formatRate,
|
||||
parseCarried,
|
||||
previewSummary,
|
||||
} from '@/lib/debt-plan'
|
||||
import { useSetOpeningDebt } from '@/lib/debt'
|
||||
import { formatMoney, formatNumber, parseMoney } from '@/lib/format'
|
||||
import { toKurus } from '@/lib/money-schema'
|
||||
import { useSummary, useUpdatePeriod } from '@/lib/periods'
|
||||
import { t } from '@/lib/strings'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Period } from '@/types/Period'
|
||||
import type { PeriodSummary } from '@/types/PeriodSummary'
|
||||
import { savingsSchema, type SavingsForm } from './schemas'
|
||||
import { StepActions, StepIntro } from './wizard-ui'
|
||||
|
||||
const s = t.wizard.savings
|
||||
|
||||
/** 4. adım: birikim hedefi (harcamadan önce ayrılır). */
|
||||
/**
|
||||
* 4. adım: devreden borç + birikim hedefi (harcamadan önce ayrılır).
|
||||
* Devreden borç > 0 → borçlu ay: harcanabilir gelirin %45'i, kalan borca; birikim kapalı.
|
||||
*/
|
||||
export function StepSavings({
|
||||
period,
|
||||
onBack,
|
||||
@@ -26,7 +40,25 @@ export function StepSavings({
|
||||
onNext: () => void
|
||||
}) {
|
||||
const summary = useSummary(period.id)
|
||||
if (summary.isPending) return <PageLoading />
|
||||
if (summary.isError)
|
||||
return <PageError message={summary.error.message} onRetry={summary.refetch} />
|
||||
return <SavingsForm period={period} summary={summary.data} onBack={onBack} onNext={onNext} />
|
||||
}
|
||||
|
||||
function SavingsForm({
|
||||
period,
|
||||
summary,
|
||||
onBack,
|
||||
onNext,
|
||||
}: {
|
||||
period: Period
|
||||
summary: PeriodSummary
|
||||
onBack: () => void
|
||||
onNext: () => void
|
||||
}) {
|
||||
const update = useUpdatePeriod()
|
||||
const setCarried = useSetOpeningDebt(period.id)
|
||||
|
||||
const {
|
||||
control,
|
||||
@@ -36,23 +68,32 @@ export function StepSavings({
|
||||
} = useForm<SavingsForm>({
|
||||
resolver: zodResolver(savingsSchema),
|
||||
mode: 'onTouched',
|
||||
defaultValues: { goal: period.savings_goal ? formatNumber(period.savings_goal / 100) : '' },
|
||||
defaultValues: {
|
||||
goal: period.savings_goal ? formatNumber(period.savings_goal / 100) : '',
|
||||
carried: carriedText(summary),
|
||||
},
|
||||
})
|
||||
const goalText = useWatch({ control, name: 'goal' })
|
||||
const carriedValue = useWatch({ control, name: 'carried' })
|
||||
|
||||
if (summary.isPending) return <PageLoading />
|
||||
if (summary.isError)
|
||||
return <PageError message={summary.error.message} onRetry={summary.refetch} />
|
||||
|
||||
// Birikimden önceki kalan = harcanabilir + (kayıtlı) birikim hedefi → formül yalnızca backend'de
|
||||
const available = summary.data.spendable + summary.data.savings_goal
|
||||
const goal = parseMoney(goalText) ?? 0
|
||||
const after = available - Math.max(goal, 0)
|
||||
const preview = previewSummary(summary, {
|
||||
income: summary.income_total,
|
||||
fixed: summary.fixed_expense_total,
|
||||
savings: Math.max(parseMoney(goalText) ?? 0, 0),
|
||||
carried: effectiveCarried(carriedValue, summary),
|
||||
rateBp: period.spendable_rate_bp ?? DEFAULT_SPENDABLE_RATE_BP,
|
||||
budget: summary.budget_total,
|
||||
})
|
||||
const available = summary.income_total - summary.fixed_expense_total
|
||||
const after = preview.spendable
|
||||
|
||||
const onSubmit = handleSubmit(async (v) => {
|
||||
const kurus = toKurus(v.goal)
|
||||
const carried = parseCarried(v.carried)
|
||||
const manual = summary.carried_debt_source === 'manual' ? summary.carried_debt : null
|
||||
try {
|
||||
if (kurus !== period.savings_goal) {
|
||||
if (carried !== manual) await setCarried.mutateAsync(carried)
|
||||
if (!preview.debt_mode && kurus !== period.savings_goal) {
|
||||
await update.mutateAsync({ id: period.id, version: period.version, savings_goal: kurus })
|
||||
}
|
||||
onNext()
|
||||
@@ -67,18 +108,54 @@ export function StepSavings({
|
||||
|
||||
<div className="space-y-5">
|
||||
<Row label={s.available} amount={available} />
|
||||
<FormField label={s.goal} error={errors.goal}>
|
||||
{(f) => (
|
||||
<Controller
|
||||
control={control}
|
||||
name="goal"
|
||||
render={({ field }) => (
|
||||
<MoneyInput {...f} {...field} placeholder="0,00" className="h-12 text-lg" />
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
name="carried"
|
||||
render={({ field }) => (
|
||||
<CarriedDebtField
|
||||
summary={summary}
|
||||
name={field.name}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
error={errors.carried}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
<Row label={s.after} amount={after} strong />
|
||||
/>
|
||||
{preview.debt_mode ? (
|
||||
<>
|
||||
<Row label={t.summary.debtPayment} amount={preview.debt_payment} />
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
{s.goal}: {t.carried.savingsLocked}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<FormField label={s.goal} error={errors.goal}>
|
||||
{(f) => (
|
||||
<Controller
|
||||
control={control}
|
||||
name="goal"
|
||||
render={({ field }) => (
|
||||
<MoneyInput {...f} {...field} placeholder="0,00" className="h-12 text-lg" />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
)}
|
||||
<Row
|
||||
label={
|
||||
preview.debt_mode
|
||||
? `${s.after} (${t.summary.rateShort(formatRate(preview.spendable_rate_bp))})`
|
||||
: s.after
|
||||
}
|
||||
amount={after}
|
||||
strong
|
||||
/>
|
||||
{preview.debt_fixed_too_high && (
|
||||
<p role="alert" className="text-sm font-medium text-expense">
|
||||
{t.summary.fixedTooHigh}
|
||||
</p>
|
||||
)}
|
||||
{after < 0 && (
|
||||
<p role="status" className="text-sm text-warning">
|
||||
{s.negative}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { FieldError } from 'react-hook-form'
|
||||
import { FormField } from '@/components/form-field'
|
||||
import { MoneyInput } from '@/components/money-input'
|
||||
import { formatDate, formatMoney, formatNumber } from '@/lib/format'
|
||||
import { parseCarried } from '@/lib/debt-plan'
|
||||
import { t } from '@/lib/strings'
|
||||
import type { PeriodSummary } from '@/types/PeriodSummary'
|
||||
|
||||
const s = t.carried
|
||||
|
||||
/**
|
||||
* Devreden banka borcu (KMH + kart). Boş → son ay sonu ölçümünden otomatik; yazılırsa elle
|
||||
* düzeltme ("elle düzeltildi"), `0` → borç bitti. Kaydetme çağıranın formunda.
|
||||
*/
|
||||
export function CarriedDebtField({
|
||||
summary,
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
name,
|
||||
error,
|
||||
}: {
|
||||
summary: PeriodSummary
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
onBlur?: () => void
|
||||
name?: string
|
||||
error?: FieldError
|
||||
}) {
|
||||
const manual = parseCarried(value)
|
||||
const auto = summary.carried_debt_auto
|
||||
const info =
|
||||
manual === 0
|
||||
? s.zero
|
||||
: manual === null
|
||||
? auto !== null && summary.carried_debt_date
|
||||
? s.fromSnapshot(formatDate(summary.carried_debt_date), formatMoney(auto))
|
||||
: s.noSnapshot
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<FormField label={s.label} error={error} className="max-w-xs">
|
||||
{(f) => (
|
||||
<MoneyInput
|
||||
{...f}
|
||||
name={name}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
placeholder={auto !== null ? formatNumber(auto / 100) : '0,00'}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
{manual !== null && (
|
||||
<span className="inline-block rounded-full bg-warning/15 px-2 py-0.5 text-xs font-medium text-warning">
|
||||
{s.manual}
|
||||
</span>
|
||||
)}
|
||||
{info && <p className="money text-xs text-muted-foreground">{info}</p>}
|
||||
{manual !== null && auto !== null && (
|
||||
<button
|
||||
type="button"
|
||||
className="min-h-11 text-sm font-medium text-primary underline-offset-4 hover:underline"
|
||||
onClick={() => onChange('')}
|
||||
>
|
||||
{s.backToAuto} ({formatMoney(auto)})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,9 +4,7 @@ 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'
|
||||
@@ -34,8 +32,6 @@ 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)
|
||||
@@ -71,8 +67,7 @@ function PlanView({ period }: { period: Period }) {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<SummaryTiles summary={summary.data} debtPayment={inDebt} />
|
||||
<PeriodDebtSection period={period} items={items.data} writable={editable} />
|
||||
<SummaryTiles summary={summary.data} />
|
||||
<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')} />
|
||||
|
||||
@@ -23,7 +23,12 @@ 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 {
|
||||
DEFAULT_SPENDABLE_RATE_BP,
|
||||
effectiveCarried,
|
||||
parseRate,
|
||||
previewSummary,
|
||||
} from '@/lib/debt-plan'
|
||||
import { t } from '@/lib/strings'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { GroupBudget } from '@/types/GroupBudget'
|
||||
@@ -35,6 +40,7 @@ import { DistributionBar } from './distribution-bar'
|
||||
import { PeriodHeading, Section } from './plan-read'
|
||||
import { planSchema, type PlanForm } from './schema'
|
||||
import { SuggestionPicker } from './suggestion-picker'
|
||||
import { CarriedDebtField } from './carried-debt-field'
|
||||
import { SummaryTiles } from './summary-tiles'
|
||||
|
||||
const s = t.plan
|
||||
@@ -60,7 +66,6 @@ export function PlanEdit({
|
||||
summary: PeriodSummary
|
||||
onClose: () => void
|
||||
}) {
|
||||
const debt = useDebt()
|
||||
const save = useSavePlan(period)
|
||||
const {
|
||||
control,
|
||||
@@ -71,29 +76,27 @@ export function PlanEdit({
|
||||
} = useForm<PlanForm>({
|
||||
resolver: zodResolver(planSchema(period.start_date, period.last_date)),
|
||||
mode: 'onTouched',
|
||||
defaultValues: toDraft(period, items, budget),
|
||||
defaultValues: toDraft(period, items, budget, summary),
|
||||
})
|
||||
const v = useWatch({ control })
|
||||
|
||||
// ---- Canlı önizleme: kaydedilmiş özet + taslaktaki farklar (formül backend'de kalır)
|
||||
const incomeTotal = (v.incomes ?? []).reduce((a, r) => a + k(r?.amount), 0)
|
||||
const fixedTotal = (v.expenses ?? []).reduce((a, r) => a + k(r?.amount), 0)
|
||||
const savings = k(v.savings)
|
||||
const spendable =
|
||||
summary.spendable +
|
||||
(incomeTotal - summary.income_total) -
|
||||
(fixedTotal - summary.fixed_expense_total) -
|
||||
(savings - summary.savings_goal)
|
||||
const budgetTotal = GROUP_CODES.reduce((a, g) => a + k(v.budget?.[g]), 0)
|
||||
const preview: PeriodSummary = {
|
||||
...summary,
|
||||
income_total: incomeTotal,
|
||||
fixed_expense_total: fixedTotal,
|
||||
savings_goal: savings,
|
||||
spendable,
|
||||
budget_total: budgetTotal,
|
||||
unallocated: spendable - budgetTotal,
|
||||
}
|
||||
const rateBp = parseRate(v.rate)
|
||||
const preview = previewSummary(summary, {
|
||||
income: incomeTotal,
|
||||
fixed: fixedTotal,
|
||||
savings: k(v.savings),
|
||||
carried: effectiveCarried(v.carried, summary),
|
||||
rateBp:
|
||||
rateBp !== null && rateBp >= 1000 && rateBp <= 9000
|
||||
? rateBp
|
||||
: (period.spendable_rate_bp ?? DEFAULT_SPENDABLE_RATE_BP),
|
||||
budget: budgetTotal,
|
||||
})
|
||||
const spendable = preview.spendable
|
||||
|
||||
// ---- Kaydedilmemiş değişiklik koruması
|
||||
const blocker = useBlocker(
|
||||
@@ -113,7 +116,7 @@ export function PlanEdit({
|
||||
}, [isDirty])
|
||||
|
||||
const onSubmit = handleSubmit(async (draft) => {
|
||||
const ops = diffPlan(period, items, budget, draft)
|
||||
const ops = diffPlan(period, items, budget, draft, summary)
|
||||
if (!hasChanges(ops)) {
|
||||
toast(s.noChanges)
|
||||
return onClose()
|
||||
@@ -148,7 +151,7 @@ export function PlanEdit({
|
||||
</div>
|
||||
|
||||
<PeriodHeading period={period} actions={actions} />
|
||||
<SummaryTiles summary={preview} debtPayment={(debt.data?.latest?.total ?? 0) > 0} />
|
||||
<SummaryTiles summary={preview} />
|
||||
|
||||
<div className="grid gap-4">
|
||||
<ItemsEdit
|
||||
@@ -178,15 +181,54 @@ export function PlanEdit({
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<FormField label={s.savings} error={errors.savings} className="max-w-xs">
|
||||
{(f) => (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-3 rounded-xl border p-3">
|
||||
<h4 className="font-medium">{t.carried.title}</h4>
|
||||
<p className="text-sm text-muted-foreground">{t.carried.hint}</p>
|
||||
<Controller
|
||||
control={control}
|
||||
name="savings"
|
||||
render={({ field }) => <MoneyInput {...f} {...field} placeholder="0,00" />}
|
||||
name="carried"
|
||||
render={({ field }) => (
|
||||
<CarriedDebtField
|
||||
summary={summary}
|
||||
name={field.name}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
error={errors.carried}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{preview.debt_mode && (
|
||||
<FormField
|
||||
label={t.carried.rate}
|
||||
error={errors.rate}
|
||||
hint={t.carried.rateHint}
|
||||
className="max-w-40"
|
||||
>
|
||||
{(f) => (
|
||||
<Input {...f} inputMode="decimal" autoComplete="off" {...register('rate')} />
|
||||
)}
|
||||
</FormField>
|
||||
)}
|
||||
</div>
|
||||
{preview.debt_mode ? (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">{s.savings}</p>
|
||||
<p className="text-sm text-muted-foreground italic">{t.carried.savingsLocked}</p>
|
||||
</div>
|
||||
) : (
|
||||
<FormField label={s.savings} error={errors.savings} className="max-w-xs">
|
||||
{(f) => (
|
||||
<Controller
|
||||
control={control}
|
||||
name="savings"
|
||||
render={({ field }) => <MoneyInput {...f} {...field} placeholder="0,00" />}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
)}
|
||||
</FormField>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<BudgetEdit
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { z } from 'zod'
|
||||
import { itemSchema } from '@/features/period-wizard/schemas'
|
||||
import { parseRate } from '@/lib/debt-plan'
|
||||
import { moneyText } from '@/lib/money-schema'
|
||||
import { t } from '@/lib/strings'
|
||||
|
||||
/** Plan ekranı düzenleme formu (`lib/plan-draft.ts` → `PlanDraft` ile aynı biçim). */
|
||||
export function planSchema(start: string, last: string) {
|
||||
@@ -13,6 +15,11 @@ export function planSchema(start: string, last: string) {
|
||||
incomes: z.array(item),
|
||||
expenses: z.array(item),
|
||||
savings: money,
|
||||
carried: money,
|
||||
rate: z.string().refine((v) => {
|
||||
const bp = parseRate(v)
|
||||
return bp !== null && bp >= 1000 && bp <= 9000
|
||||
}, t.carried.rateError),
|
||||
budget: z.object({ needs: money, wants: money, culture: money, unexpected: money }),
|
||||
weeks: z.object({
|
||||
needs: z.array(money),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { TriangleAlert } from 'lucide-react'
|
||||
import { formatRate } from '@/lib/debt-plan'
|
||||
import { formatMoney } from '@/lib/format'
|
||||
import { t } from '@/lib/strings'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -6,61 +8,96 @@ import type { PeriodSummary } from '@/types/PeriodSummary'
|
||||
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.
|
||||
* Plan özeti.
|
||||
* - Normal ay: Gelir − Sabit giderler − Birikim − Önceki ay aşımı (+ Devreden artı) = Harcanabilir.
|
||||
* - Borçlu ay (devreden banka borcu > 0, docs/KAKEIBO_PANEL_ISTEKLERI.md A):
|
||||
* Gelir − Sabit giderler − Bu ay borca giden = Harcanabilir (gelirin %45'i); birikim kapalı.
|
||||
* Mobilde 2 sütun, geniş ekranda tek satır.
|
||||
*/
|
||||
export function SummaryTiles({
|
||||
summary,
|
||||
debtPayment = false,
|
||||
}: {
|
||||
summary: PeriodSummary
|
||||
/** Borç takibinde borç varken birikim hedefi "Borç ödemesi" adıyla (docs/BORC_TAKIBI.md) */
|
||||
debtPayment?: boolean
|
||||
}) {
|
||||
export function SummaryTiles({ summary }: { summary: PeriodSummary }) {
|
||||
const debt = summary.debt_mode
|
||||
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: 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' })
|
||||
}
|
||||
if (summary.carryover_surplus > 0) {
|
||||
tiles.push({ label: s.surplus, amount: summary.carryover_surplus, sign: '+' })
|
||||
if (debt) {
|
||||
tiles.push({ label: s.debtPayment, amount: summary.debt_payment, sign: '−' })
|
||||
} else {
|
||||
tiles.push({ label: 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' })
|
||||
}
|
||||
if (summary.carryover_surplus > 0) {
|
||||
tiles.push({ label: s.surplus, amount: summary.carryover_surplus, sign: '+' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<dl className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
|
||||
{tiles.map((tile) => (
|
||||
<div key={tile.label} className="rounded-xl border bg-card p-3 sm:min-w-32 sm:flex-1">
|
||||
<dt className="text-xs text-muted-foreground">
|
||||
{tile.sign && <span aria-hidden>{tile.sign} </span>}
|
||||
{tile.label}
|
||||
</dt>
|
||||
<dd className={cn('money mt-1 font-semibold', tile.tone)}>{formatMoney(tile.amount)}</dd>
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
className={cn(
|
||||
'col-span-2 rounded-xl border-2 p-3 sm:min-w-40 sm:flex-[1.4]',
|
||||
summary.spendable < 0
|
||||
? 'border-expense/50 bg-expense/5'
|
||||
: 'border-primary/40 bg-primary/5',
|
||||
)}
|
||||
>
|
||||
<dt className="text-xs font-medium text-muted-foreground">
|
||||
<span aria-hidden>= </span>
|
||||
{s.spendable}
|
||||
</dt>
|
||||
<dd
|
||||
<div className="space-y-2">
|
||||
<dl className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
|
||||
{tiles.map((tile) => (
|
||||
<div key={tile.label} className="rounded-xl border bg-card p-3 sm:min-w-32 sm:flex-1">
|
||||
<dt className="text-xs text-muted-foreground">
|
||||
{tile.sign && <span aria-hidden>{tile.sign} </span>}
|
||||
{tile.label}
|
||||
</dt>
|
||||
<dd className={cn('money mt-1 font-semibold', tile.tone)}>
|
||||
{formatMoney(tile.amount)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
className={cn(
|
||||
'money mt-1 text-xl font-bold',
|
||||
summary.spendable < 0 ? 'text-expense' : 'text-foreground',
|
||||
'col-span-2 rounded-xl border-2 p-3 sm:min-w-40 sm:flex-[1.4]',
|
||||
summary.spendable < 0
|
||||
? 'border-expense/50 bg-expense/5'
|
||||
: 'border-primary/40 bg-primary/5',
|
||||
)}
|
||||
>
|
||||
{formatMoney(summary.spendable)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<dt className="text-xs font-medium text-muted-foreground">
|
||||
<span aria-hidden>= </span>
|
||||
{s.spendable}
|
||||
</dt>
|
||||
<dd
|
||||
className={cn(
|
||||
'money mt-1 text-xl font-bold',
|
||||
summary.spendable < 0 ? 'text-expense' : 'text-foreground',
|
||||
)}
|
||||
>
|
||||
{formatMoney(summary.spendable)}
|
||||
</dd>
|
||||
{debt && (
|
||||
<p className="mt-0.5 text-[11px] text-muted-foreground">
|
||||
{s.rateLabel(formatRate(summary.spendable_rate_bp))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</dl>
|
||||
{debt && (
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="flex flex-wrap gap-x-1.5 text-muted-foreground">
|
||||
<span className="money">{s.carriedPart(formatMoney(summary.carried_debt))}</span>
|
||||
<span aria-hidden>·</span>
|
||||
<span className="money">
|
||||
{s.targetPart(formatMoney(summary.closing_debt_target ?? summary.carried_debt))}
|
||||
</span>
|
||||
</p>
|
||||
{summary.debt_to_savings > 0 && (
|
||||
<p className="money text-income">
|
||||
{s.toSavingsLine(formatMoney(summary.debt_to_savings))}
|
||||
</p>
|
||||
)}
|
||||
{summary.debt_to_savings === 0 && (
|
||||
<p className="text-muted-foreground italic">{s.savingsLocked}</p>
|
||||
)}
|
||||
{summary.debt_fixed_too_high && (
|
||||
<p role="alert" className="flex items-start gap-1.5 font-medium text-expense">
|
||||
<TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||||
{s.fixedTooHigh}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
carriedText,
|
||||
debtPlan,
|
||||
effectiveCarried,
|
||||
parseCarried,
|
||||
parseRate,
|
||||
previewSummary,
|
||||
} from './debt-plan'
|
||||
|
||||
// docs/KAKEIBO_PANEL_ISTEKLERI.md A5 "Kontrol" rakamları (backend testiyle aynı)
|
||||
describe('debtPlan', () => {
|
||||
it('A5: gelir 79.509,64 · sabit 23.006,90 · devreden 57.100', () => {
|
||||
const p = debtPlan(7_950_964, 2_300_690, 5_710_000, 4500)
|
||||
expect(p.spendable).toBe(3_577_934)
|
||||
expect(p.payment).toBe(2_072_340)
|
||||
expect(p.toSavings).toBe(0)
|
||||
expect(p.closingTarget).toBe(3_637_660)
|
||||
expect(p.fixedTooHigh).toBe(false)
|
||||
})
|
||||
|
||||
it('borç küçükse artan birikime gider, hedef 0', () => {
|
||||
const p = debtPlan(7_950_964, 2_300_690, 1_000_000, 4500)
|
||||
expect([p.payment, p.toSavings, p.closingTarget]).toEqual([1_000_000, 1_072_340, 0])
|
||||
})
|
||||
|
||||
it('sabitler çok yüksek: harcanabilir = gelir − sabit, borca 0', () => {
|
||||
const p = debtPlan(7_950_964, 5_000_000, 5_710_000, 4500)
|
||||
expect([p.spendable, p.payment, p.fixedTooHigh]).toEqual([2_950_964, 0, true])
|
||||
expect(p.closingTarget).toBe(5_710_000)
|
||||
})
|
||||
|
||||
it('oran değişebilir; yarım kuruş yukarı yuvarlanır', () => {
|
||||
expect(debtPlan(1_000_000, 0, 1, 5000).spendable).toBe(500_000)
|
||||
expect(debtPlan(3, 0, 1, 5000).spendable).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('devreden borç alanı ve önizleme', () => {
|
||||
const auto = {
|
||||
carried_debt_source: 'auto' as const,
|
||||
carried_debt: 5_710_000,
|
||||
carried_debt_auto: 5_710_000,
|
||||
}
|
||||
const manual0 = {
|
||||
carried_debt_source: 'manual' as const,
|
||||
carried_debt: 0,
|
||||
carried_debt_auto: 5_710_000,
|
||||
}
|
||||
|
||||
it('form metni: otomatik → boş, elle 0 → "0"', () => {
|
||||
expect(carriedText(auto)).toBe('')
|
||||
expect(carriedText(manual0)).toBe('0')
|
||||
expect(parseCarried('')).toBeNull()
|
||||
expect(parseCarried('0')).toBe(0)
|
||||
expect(parseCarried('10.000')).toBe(1_000_000)
|
||||
})
|
||||
|
||||
it('boş alan son ölçüme döner; elle değer önce gelir', () => {
|
||||
expect(effectiveCarried('', manual0)).toBe(5_710_000)
|
||||
expect(effectiveCarried('0', auto)).toBe(0)
|
||||
expect(
|
||||
effectiveCarried('', { ...auto, carried_debt_source: null, carried_debt_auto: null }),
|
||||
).toBe(0)
|
||||
})
|
||||
|
||||
it('oran metni', () => {
|
||||
expect(parseRate('45')).toBe(4500)
|
||||
expect(parseRate('45,5')).toBe(4550)
|
||||
expect(parseRate('')).toBeNull()
|
||||
expect(parseRate('abc')).toBeNull()
|
||||
})
|
||||
|
||||
it('önizleme: borçlu ay %45, borç bitince normal Kakeibo', () => {
|
||||
const saved = {
|
||||
income_total: 0,
|
||||
fixed_expense_total: 0,
|
||||
savings_goal: 500_000,
|
||||
carryover_debt: 100_000,
|
||||
carryover_surplus: 0,
|
||||
spendable: 0,
|
||||
carried_debt: 0,
|
||||
carried_debt_source: null,
|
||||
carried_debt_date: null,
|
||||
carried_debt_auto: null,
|
||||
debt_mode: false,
|
||||
spendable_rate_bp: 4500,
|
||||
debt_payment: 0,
|
||||
debt_to_savings: 0,
|
||||
debt_fixed_too_high: false,
|
||||
closing_debt_target: null,
|
||||
budget_total: 0,
|
||||
unallocated: 0,
|
||||
spent_total: 1_000,
|
||||
remaining: 0,
|
||||
}
|
||||
const d = {
|
||||
income: 7_950_964,
|
||||
fixed: 2_300_690,
|
||||
savings: 500_000,
|
||||
rateBp: 4500,
|
||||
budget: 3_000_000,
|
||||
}
|
||||
const debt = previewSummary(saved, { ...d, carried: 5_710_000 })
|
||||
expect(debt.debt_mode).toBe(true)
|
||||
expect([debt.spendable, debt.debt_payment, debt.closing_debt_target]).toEqual([
|
||||
3_577_934, 2_072_340, 3_637_660,
|
||||
])
|
||||
expect(debt.unallocated).toBe(577_934)
|
||||
expect(debt.remaining).toBe(3_576_934)
|
||||
const normal = previewSummary(saved, { ...d, carried: 0 })
|
||||
expect(normal.debt_mode).toBe(false)
|
||||
// gelir − sabit − birikim − önceki ay aşımı
|
||||
expect(normal.spendable).toBe(7_950_964 - 2_300_690 - 500_000 - 100_000)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Borçlu ayın planı (docs/KAKEIBO_PANEL_ISTEKLERI.md A). Backend `services/budget.rs`
|
||||
* `debt_plan` ile birebir aynı; asıl değer backend'den gelir, bu yalnızca düzenleme önizlemesi.
|
||||
*
|
||||
* harcanabilir = gelir × oran (varsayılan %45, yarım yukarı yuvarlanır)
|
||||
* borca giden = min(gelir − sabit − harcanabilir, devreden borç); artan → birikim
|
||||
* gelir − sabit < gelir × oran → harcanabilir = gelir − sabit, borca 0, uyarı
|
||||
*/
|
||||
import type { PeriodSummary } from '@/types/PeriodSummary'
|
||||
import { formatNumber } from './format'
|
||||
import { toKurus } from './money-schema'
|
||||
|
||||
export const DEFAULT_SPENDABLE_RATE_BP = 4500
|
||||
|
||||
export type DebtPlan = {
|
||||
carried: number
|
||||
rateBp: number
|
||||
spendable: number
|
||||
payment: number
|
||||
toSavings: number
|
||||
fixedTooHigh: boolean
|
||||
closingTarget: number
|
||||
}
|
||||
|
||||
export function debtPlan(income: number, fixed: number, carried: number, rateBp: number): DebtPlan {
|
||||
const byRate = Number((BigInt(Math.max(income, 0)) * BigInt(rateBp) + 5_000n) / 10_000n)
|
||||
const afterFixed = income - fixed
|
||||
const fixedTooHigh = afterFixed < byRate
|
||||
const spendable = fixedTooHigh ? afterFixed : byRate
|
||||
const raw = fixedTooHigh ? 0 : afterFixed - byRate
|
||||
const payment = Math.min(raw, carried)
|
||||
return {
|
||||
carried,
|
||||
rateBp,
|
||||
spendable,
|
||||
payment,
|
||||
toSavings: raw - payment,
|
||||
fixedTooHigh,
|
||||
closingTarget: carried - payment,
|
||||
}
|
||||
}
|
||||
|
||||
/** Baz puan → `45` / `45,5` */
|
||||
export function formatRate(bp: number): string {
|
||||
return formatNumber(bp / 100, bp % 100 === 0 ? 0 : 2)
|
||||
}
|
||||
|
||||
// ---------- Plan düzenleme: devreden borç alanı + canlı önizleme ----------
|
||||
|
||||
type CarriedSource = Pick<
|
||||
PeriodSummary,
|
||||
'carried_debt_source' | 'carried_debt' | 'carried_debt_auto'
|
||||
>
|
||||
|
||||
/** Form metni: elle girilmişse tutar (`0` dahil — borç bitti), otomatikse boş */
|
||||
export function carriedText(s: CarriedSource): string {
|
||||
if (s.carried_debt_source !== 'manual') return ''
|
||||
return s.carried_debt ? formatNumber(s.carried_debt / 100) : '0'
|
||||
}
|
||||
|
||||
/** Form metni → istek: boş → `null` (son ölçümden), aksi halde kuruş */
|
||||
export function parseCarried(text: string | undefined): number | null {
|
||||
return (text ?? '').trim() === '' ? null : Math.max(toKurus(text ?? ''), 0)
|
||||
}
|
||||
|
||||
/** Formdaki metne göre geçerli devreden borç */
|
||||
export function effectiveCarried(text: string | undefined, s: CarriedSource): number {
|
||||
return parseCarried(text) ?? s.carried_debt_auto ?? 0
|
||||
}
|
||||
|
||||
/** Oran metni (`45`, `45,5`) → baz puan; geçersizse `null` */
|
||||
export function parseRate(text: string | undefined): number | null {
|
||||
const v = Number((text ?? '').trim().replace(',', '.'))
|
||||
if (!(text ?? '').trim() || !Number.isFinite(v)) return null
|
||||
return Math.round(v * 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* Düzenleme taslağından özet önizlemesi (backend `budget::summary` ile aynı kural).
|
||||
* Borçlu ay: harcanabilir = `debtPlan`; değilse gelir − sabit − birikim − devir borç + devir artı.
|
||||
*/
|
||||
export function previewSummary(
|
||||
saved: PeriodSummary,
|
||||
d: {
|
||||
income: number
|
||||
fixed: number
|
||||
savings: number
|
||||
carried: number
|
||||
rateBp: number
|
||||
budget: number
|
||||
},
|
||||
): PeriodSummary {
|
||||
const base = {
|
||||
...saved,
|
||||
income_total: d.income,
|
||||
fixed_expense_total: d.fixed,
|
||||
savings_goal: d.savings,
|
||||
}
|
||||
let next: PeriodSummary
|
||||
if (d.carried > 0) {
|
||||
const p = debtPlan(d.income, d.fixed, d.carried, d.rateBp)
|
||||
next = {
|
||||
...base,
|
||||
spendable: p.spendable,
|
||||
carried_debt: d.carried,
|
||||
debt_mode: true,
|
||||
spendable_rate_bp: d.rateBp,
|
||||
debt_payment: p.payment,
|
||||
debt_to_savings: p.toSavings,
|
||||
debt_fixed_too_high: p.fixedTooHigh,
|
||||
closing_debt_target: p.closingTarget,
|
||||
}
|
||||
} else {
|
||||
next = {
|
||||
...base,
|
||||
spendable: d.income - d.fixed - d.savings - saved.carryover_debt + saved.carryover_surplus,
|
||||
carried_debt: 0,
|
||||
debt_mode: false,
|
||||
spendable_rate_bp: d.rateBp,
|
||||
debt_payment: 0,
|
||||
debt_to_savings: 0,
|
||||
debt_fixed_too_high: false,
|
||||
closing_debt_target: null,
|
||||
}
|
||||
}
|
||||
next.budget_total = d.budget
|
||||
next.unallocated = next.spendable - d.budget
|
||||
next.remaining = next.spendable - saved.spent_total
|
||||
return next
|
||||
}
|
||||
@@ -79,17 +79,3 @@ 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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,7 +10,6 @@ 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'
|
||||
|
||||
@@ -96,13 +95,6 @@ export function useDebt() {
|
||||
return useQuery({ queryKey: queryKeys.debt, queryFn: () => api.get<DebtSummary>('/debt') })
|
||||
}
|
||||
|
||||
export function usePeriodDebt(periodId: string) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.periodDebt(periodId),
|
||||
queryFn: () => api.get<PeriodDebt>(`/periods/${periodId}/debt`),
|
||||
})
|
||||
}
|
||||
|
||||
/** Borç verisi değişince özet ve dönem borçları yenilenir */
|
||||
function useInvalidateDebt() {
|
||||
const qc = useQueryClient()
|
||||
@@ -168,16 +160,6 @@ export function useSetOpeningDebt(periodId: string) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 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({
|
||||
|
||||
@@ -214,12 +214,14 @@ export function useSavePlan(period: Period) {
|
||||
return useMutation({
|
||||
mutationFn: async (ops: PlanOps) => {
|
||||
const pid = period.id
|
||||
if (ops.savings !== null) {
|
||||
if (ops.savings !== null || ops.rateBp !== null) {
|
||||
await api.patch<Period>(`/periods/${pid}`, {
|
||||
savings_goal: ops.savings,
|
||||
savings_goal: ops.savings ?? undefined,
|
||||
spendable_rate_bp: ops.rateBp ?? undefined,
|
||||
version: period.version,
|
||||
})
|
||||
}
|
||||
if (ops.carried) await api.put(`/periods/${pid}/opening-debt`, ops.carried)
|
||||
for (const id of ops.deletes) await api.delete(`/plan-items/${id}`)
|
||||
for (const { id, ...body } of ops.updates) await api.patch(`/plan-items/${id}`, body)
|
||||
for (const body of ops.creates) await api.post(`/periods/${pid}/plan-items`, body)
|
||||
|
||||
@@ -13,6 +13,7 @@ const period: Period = {
|
||||
days: 31,
|
||||
status: 'open',
|
||||
savings_goal: 500_000,
|
||||
spendable_rate_bp: null,
|
||||
reflection_md: null,
|
||||
to_savings: null,
|
||||
closed_at: null,
|
||||
@@ -118,4 +119,26 @@ describe('plan draft', () => {
|
||||
const ops = diffPlan(period, items, budget, draft)
|
||||
expect(ops.weeks).toEqual([{ group: 'wants', week_start: '2026-12-01', amount: 0 }])
|
||||
})
|
||||
|
||||
it('devreden borç ve oran yalnızca değişince gönderilir', () => {
|
||||
const auto = {
|
||||
carried_debt_source: 'auto' as const,
|
||||
carried_debt: 5_710_000,
|
||||
carried_debt_auto: 5_710_000,
|
||||
}
|
||||
const draft = toDraft(period, items, budget, auto)
|
||||
expect([draft.carried, draft.rate]).toEqual(['', '45'])
|
||||
expect(hasChanges(diffPlan(period, items, budget, draft, auto))).toBe(false)
|
||||
draft.carried = '0' // borç bitti
|
||||
draft.rate = '50'
|
||||
const ops = diffPlan(period, items, budget, draft, auto)
|
||||
expect(ops.carried).toEqual({ amount: 0 })
|
||||
expect(ops.rateBp).toBe(5000)
|
||||
// Elle → boş: otomatiğe dön
|
||||
const manual = { ...auto, carried_debt_source: 'manual' as const, carried_debt: 1_000_000 }
|
||||
const d2 = toDraft(period, items, budget, manual)
|
||||
expect(d2.carried).toBe('10.000,00')
|
||||
d2.carried = ''
|
||||
expect(diffPlan(period, items, budget, d2, manual).carried).toEqual({ amount: null })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,14 @@ import type { Period } from '@/types/Period'
|
||||
import type { PlanItem } from '@/types/PlanItem'
|
||||
import type { PlanItemCreateRequest } from '@/types/PlanItemCreateRequest'
|
||||
import type { PlanItemUpdateRequest } from '@/types/PlanItemUpdateRequest'
|
||||
import type { PeriodSummary } from '@/types/PeriodSummary'
|
||||
import {
|
||||
carriedText,
|
||||
DEFAULT_SPENDABLE_RATE_BP,
|
||||
formatRate,
|
||||
parseCarried,
|
||||
parseRate,
|
||||
} from './debt-plan'
|
||||
import { formatNumber } from './format'
|
||||
import { toKurus } from './money-schema'
|
||||
|
||||
@@ -32,13 +40,32 @@ export type PlanDraft = {
|
||||
incomes: ItemDraft[]
|
||||
expenses: ItemDraft[]
|
||||
savings: string
|
||||
/** Devreden borç: `""` → son ay sonu ölçümünden, aksi halde elle (`0` → borç bitti) */
|
||||
carried: string
|
||||
/** Borçlu ayda harcanabilir oranı, yüzde (`45`) */
|
||||
rate: string
|
||||
budget: Record<Group, string>
|
||||
/** Her grup için dönemin haftaları sırasıyla; `""` → otomatik */
|
||||
weeks: Record<Group, string[]>
|
||||
}
|
||||
|
||||
/** Dönemin kayıtlı devreden borç durumu (özetten) */
|
||||
export type CarriedState = Pick<
|
||||
PeriodSummary,
|
||||
'carried_debt_source' | 'carried_debt' | 'carried_debt_auto'
|
||||
>
|
||||
const NO_CARRIED: CarriedState = {
|
||||
carried_debt_source: null,
|
||||
carried_debt: 0,
|
||||
carried_debt_auto: null,
|
||||
}
|
||||
|
||||
export type PlanOps = {
|
||||
savings: number | null
|
||||
/** Değişmediyse `null`; `{ amount: null }` → otomatiğe dön */
|
||||
carried: { amount: number | null } | null
|
||||
/** Değişmediyse `null` */
|
||||
rateBp: number | null
|
||||
deletes: string[]
|
||||
updates: (PlanItemUpdateRequest & { id: string })[]
|
||||
creates: PlanItemCreateRequest[]
|
||||
@@ -65,13 +92,20 @@ function budgetFor(budget: GroupBudget[], g: Group): GroupBudget | undefined {
|
||||
return budget.find((b) => b.group === g)
|
||||
}
|
||||
|
||||
export function toDraft(period: Period, items: PlanItem[], budget: GroupBudget[]): PlanDraft {
|
||||
export function toDraft(
|
||||
period: Period,
|
||||
items: PlanItem[],
|
||||
budget: GroupBudget[],
|
||||
carried: CarriedState = NO_CARRIED,
|
||||
): PlanDraft {
|
||||
const rec = <T>(f: (g: Group) => T) =>
|
||||
Object.fromEntries(GROUP_CODES.map((g) => [g, f(g)])) as Record<Group, T>
|
||||
return {
|
||||
incomes: items.filter((i) => i.kind === 'income').map(itemToDraft),
|
||||
expenses: items.filter((i) => i.kind === 'expense').map(itemToDraft),
|
||||
savings: moneyToText(period.savings_goal),
|
||||
carried: carriedText(carried),
|
||||
rate: formatRate(period.spendable_rate_bp ?? DEFAULT_SPENDABLE_RATE_BP),
|
||||
budget: rec((g) => moneyToText(budgetFor(budget, g)?.monthly ?? 0)),
|
||||
weeks: rec((g) =>
|
||||
period.weeks.map((w) => {
|
||||
@@ -88,9 +122,12 @@ export function diffPlan(
|
||||
items: PlanItem[],
|
||||
budget: GroupBudget[],
|
||||
draft: PlanDraft,
|
||||
carried: CarriedState = NO_CARRIED,
|
||||
): PlanOps {
|
||||
const ops: PlanOps = {
|
||||
savings: null,
|
||||
carried: null,
|
||||
rateBp: null,
|
||||
deletes: [],
|
||||
updates: [],
|
||||
creates: [],
|
||||
@@ -101,6 +138,14 @@ export function diffPlan(
|
||||
const savings = toKurus(draft.savings)
|
||||
if (savings !== period.savings_goal) ops.savings = savings
|
||||
|
||||
const manual = carried.carried_debt_source === 'manual' ? carried.carried_debt : null
|
||||
const nextCarried = parseCarried(draft.carried)
|
||||
if (nextCarried !== manual) ops.carried = { amount: nextCarried }
|
||||
const rate = parseRate(draft.rate)
|
||||
if (rate !== null && rate !== (period.spendable_rate_bp ?? DEFAULT_SPENDABLE_RATE_BP)) {
|
||||
ops.rateBp = rate
|
||||
}
|
||||
|
||||
const byId = new Map(items.map((i) => [i.id, i]))
|
||||
const kept = new Set<string>()
|
||||
const rows = [
|
||||
@@ -146,6 +191,8 @@ export function diffPlan(
|
||||
export function hasChanges(ops: PlanOps): boolean {
|
||||
return (
|
||||
ops.savings !== null ||
|
||||
ops.carried !== null ||
|
||||
ops.rateBp !== null ||
|
||||
ops.deletes.length + ops.updates.length + ops.creates.length + ops.weeks.length > 0 ||
|
||||
ops.monthly !== null
|
||||
)
|
||||
|
||||
+30
-34
@@ -253,13 +253,40 @@ export const t = {
|
||||
income: 'Gelir',
|
||||
fixed: 'Sabit giderler',
|
||||
savings: 'Birikim',
|
||||
/** Borç takibinde borç > 0 iken birikim hedefinin adı */
|
||||
debtPayment: 'Borç ödemesi',
|
||||
debt: 'Devreden borç',
|
||||
/** Borçlu ay (devreden borç > 0): gelir − sabit − bu ay borca giden = harcanabilir */
|
||||
debtPayment: 'Bu ay borca giden',
|
||||
carriedPart: (amount: string) => `Devreden borç ${amount}`,
|
||||
targetPart: (amount: string) => `ay sonu hedef ${amount}`,
|
||||
rateShort: (pct: string) => `gelirin %${pct}`,
|
||||
toSavingsLine: (amount: string) => `Borç bu ay kapanıyor; artan ${amount} birikime`,
|
||||
savingsLocked: 'Birikim: borç bitince açılır',
|
||||
fixedTooHigh:
|
||||
'Sabit giderler çok yüksek: harcanabilir oranın altında kaldı, bu ay borca para ayrılamıyor.',
|
||||
rateLabel: (pct: string) => `Harcanabilir: gelirin %${pct}`,
|
||||
/** Kakeibo'nun kendi devri (önceki ayın bütçe aşımı); banka borcuyla karışmasın */
|
||||
debt: 'Önceki ay aşımı',
|
||||
surplus: 'Devreden artı',
|
||||
spendable: 'Harcanabilir',
|
||||
},
|
||||
|
||||
/** Planda devreden banka borcu alanı (docs/KAKEIBO_PANEL_ISTEKLERI.md A) */
|
||||
carried: {
|
||||
title: 'Devreden borç',
|
||||
label: 'Devreden borç (KMH + kart)',
|
||||
hint: 'Borç varken harcanabilir gelirin belli bir oranıdır; kalan önce borca gider, birikim borç bitince açılır.',
|
||||
fromSnapshot: (date: string, amount: string) =>
|
||||
`Ay sonu ölçümünden (${date}) otomatik: ${amount}`,
|
||||
noSnapshot:
|
||||
'Ay sonu ölçümü yok. Önceki aydan devreden banka borcu (KMH + kart) varsa yaz; yoksa boş bırak.',
|
||||
manual: 'elle düzeltildi',
|
||||
backToAuto: 'Ölçüme dön',
|
||||
zero: '0 → borç bitti; normal Kakeibo düzeni (birikim hedefi) geçerli.',
|
||||
rate: 'Harcanabilir oranı (%)',
|
||||
rateHint: 'Borçlu ayda harcanabilir = gelir × bu oran. Varsayılan %45.',
|
||||
rateError: '%10 ile %90 arasında bir sayı yazın',
|
||||
savingsLocked: 'Borç bitince açılır. Bu ay gelirin kalanı önce borca gider.',
|
||||
},
|
||||
|
||||
ledger: {
|
||||
refund: 'İade',
|
||||
refundHint: 'İade kaydı: bu tutar kategorinin harcamasından düşülür.',
|
||||
@@ -527,37 +554,6 @@ 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',
|
||||
|
||||
@@ -19,6 +19,10 @@ last_date: string, days: number, status: PeriodStatus,
|
||||
* Kuruş
|
||||
*/
|
||||
savings_goal: number,
|
||||
/**
|
||||
* Borçlu ayda harcanabilir oranı (baz puan); `null` → varsayılan %45
|
||||
*/
|
||||
spendable_rate_bp: number | null,
|
||||
/**
|
||||
* "Nasıl daha iyi yapabilirim?" (Markdown)
|
||||
*/
|
||||
|
||||
@@ -10,9 +10,45 @@ carryover_debt: number,
|
||||
*/
|
||||
carryover_surplus: number,
|
||||
/**
|
||||
* gelir − sabit gider − birikim − borç + artı
|
||||
* Normal ay: gelir − sabit gider − birikim − borç + artı · Borçlu ay: gelir × oran
|
||||
*/
|
||||
spendable: number,
|
||||
/**
|
||||
* Devreden (banka) borcu; yoksa 0. > 0 → borçlu ay (birikim hedefi plana girmez)
|
||||
*/
|
||||
carried_debt: number,
|
||||
/**
|
||||
* `auto` (ay sonu ölçümü) | `manual` (planda elle) | `null` (hiç girilmedi)
|
||||
*/
|
||||
carried_debt_source: "auto" | "manual" | null,
|
||||
/**
|
||||
* Son ay sonu ölçümünün tarihi (elle girilmiş olsa da); ölçüm yoksa `null`
|
||||
*/
|
||||
carried_debt_date: string | null,
|
||||
/**
|
||||
* Son ay sonu ölçümünden gelen tutar (elle düzeltme kaldırılınca kullanılacak); yoksa `null`
|
||||
*/
|
||||
carried_debt_auto: number | null, debt_mode: boolean,
|
||||
/**
|
||||
* Etkin harcanabilir oranı (baz puan)
|
||||
*/
|
||||
spendable_rate_bp: number,
|
||||
/**
|
||||
* Bu ay borca giden
|
||||
*/
|
||||
debt_payment: number,
|
||||
/**
|
||||
* Borca giden devreden borcu aşarsa birikime kalan
|
||||
*/
|
||||
debt_to_savings: number,
|
||||
/**
|
||||
* Sabit giderler çok yüksek: bu ay borç azalmıyor
|
||||
*/
|
||||
debt_fixed_too_high: boolean,
|
||||
/**
|
||||
* Ay sonu hedef borç (borçlu ayda)
|
||||
*/
|
||||
closing_debt_target: number | null,
|
||||
/**
|
||||
* 4 grubun aylık bütçe toplamı
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type PeriodUpdateRequest = { name?: string, start_date?: string, end_date?: string, savings_goal?: number,
|
||||
/**
|
||||
* Borçlu ayda harcanabilir oranı (baz puan; 4500 = %45)
|
||||
*/
|
||||
spendable_rate_bp?: number,
|
||||
/**
|
||||
* "Nasıl daha iyi yapabilirim?" (Markdown); boş metin → kaldır
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user