feat: Faz 16 - Kakeibo K1, dört harcama grubu ve banka maliyeti
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ next-env.d.ts
|
|||||||
.env*
|
.env*
|
||||||
!.env.example
|
!.env.example
|
||||||
uploads
|
uploads
|
||||||
|
import
|
||||||
|
|
||||||
# Geliştirme araçları
|
# Geliştirme araçları
|
||||||
.git
|
.git
|
||||||
|
|||||||
@@ -47,3 +47,6 @@ next-env.d.ts
|
|||||||
# local tooling
|
# local tooling
|
||||||
/scratch/
|
/scratch/
|
||||||
/dist
|
/dist
|
||||||
|
|
||||||
|
# Kişisel ekstre dosyaları (içe aktarma için) — asla repoya girmez
|
||||||
|
/import/
|
||||||
|
|||||||
@@ -6,6 +6,23 @@ Biçim [Keep a Changelog](https://keepachangelog.com/tr-TR/1.1.0/) esas alınır
|
|||||||
|
|
||||||
## [Yayınlanmamış]
|
## [Yayınlanmamış]
|
||||||
|
|
||||||
|
## [1.1.0] - 2026-11-07
|
||||||
|
|
||||||
|
Faz 16: Kakeibo K1 — dört harcama grubu.
|
||||||
|
|
||||||
|
### Eklendi
|
||||||
|
|
||||||
|
- Kakeibo grupları: gider kategorilerine **Zorunlu / İstek / Kültür / Beklenmeyen** grubu; alt kategori grubu üst kategoriden devralır. Kategori formunda grup seçimi ve açıklaması; kategori listesinde grup rozeti (devralınan soluk) ve grupsuz kategori uyarısı.
|
||||||
|
- **Banka maliyeti:** KMH faizi, KKDF/BSMV, kart ve hesap ücretleri Kakeibo harcamasına ve dört gruba girmez, ayrı gösterilir (kategori düzeyinde işaret, alt ağaç devralır).
|
||||||
|
- İşlem formunda **Kakeibo grubu**: varsayılan kategoriden ("Kategoriden: Zorunlu"), bu kayıt için değiştirilebilir; bölünmüş işlemde kalem başına.
|
||||||
|
- Kakeibo raporu (`/raporlar/kakeibo`): Kakeibo harcaması ve grup payları (yığılmış çubuk), grup başına tutar, pay, önceki aya göre değişim ve kategori kırılımı; banka maliyeti ve grupsuz harcamalar ayrı; CSV. Bütçe sayfasında seçili ayın Kakeibo özeti.
|
||||||
|
- Migration `016-kakeibo-groups`: varsayılan kategoriler beceri eşlemesine göre doldurulur (kullanıcı kategorileri grupsuz kalır, mevcut seçimlere dokunulmaz, kategori silinmez). Migration'lar geri alınabilir: `npm run migrate -- --down <ad>`.
|
||||||
|
- Testler: grup devralma, banka maliyeti, kayıt bazında grup, grup özeti ve sıfıra bölme (birim); varsayılan gruplar, rapor toplamı = bütçenin gerçekleşeni, grup değiştirme, kategori güncelleme, migration geri alma/yeniden uygulama (entegrasyon).
|
||||||
|
|
||||||
|
### Güvenlik
|
||||||
|
|
||||||
|
- `import/` klasörü (kişisel ekstre dosyaları) git'e ve Docker imajına girmez.
|
||||||
|
|
||||||
## [1.0.0] - 2026-11-07
|
## [1.0.0] - 2026-11-07
|
||||||
|
|
||||||
Faz 15: canlıya alma (Dokploy + Gitea, `butcem.ta1sph.website`).
|
Faz 15: canlıya alma (Dokploy + Gitea, `butcem.ta1sph.website`).
|
||||||
|
|||||||
+9
-2
@@ -295,8 +295,13 @@ Skill fazları (0–4) uygulanır, ardından alan fazları gelir. Her faz kendi
|
|||||||
| 13 | Alışveriş listeleri, akıllı mağaza önerisi, alışveriş modu, **alışveriş sonrası giriş ve karşılaştırma**, "alışverişi tamamla" transaction'ı | 0.13.0 |
|
| 13 | Alışveriş listeleri, akıllı mağaza önerisi, alışveriş modu, **alışveriş sonrası giriş ve karşılaştırma**, "alışverişi tamamla" transaction'ı | 0.13.0 |
|
||||||
| 14 | Dashboard (çizgi grafik + periyotlar) ve raporlar, CSV dışa aktarma | 0.14.0 |
|
| 14 | Dashboard (çizgi grafik + periyotlar) ve raporlar, CSV dışa aktarma | 0.14.0 |
|
||||||
| 15 | Canlıya alma: `docs/DEPLOY.md`, Dokploy + Gitea, domain, volume, yedekleme, smoke test | **1.0.0** |
|
| 15 | Canlıya alma: `docs/DEPLOY.md`, Dokploy + Gitea, domain, volume, yedekleme, smoke test | **1.0.0** |
|
||||||
| 16 | `/api/v1` mobil API ve mobil kimlik doğrulama | 1.1.0 |
|
| 16 | **Kakeibo K1:** dört grup (Zorunlu/İstek/Kültür/Beklenmeyen), banka maliyeti ayrımı, kayıt bazında grup, Kakeibo raporu | 1.1.0 |
|
||||||
| 17 | Otomatik fiyat sağlayıcıları (market ve altın/döviz) araştırması ve izinliyse entegrasyonu | 1.2.0 |
|
| 17 | **Kakeibo K2:** ekstre içe aktarma (Yapı Kredi kart/hesap, Kakeibo çalışma kitabı; önizleme, tekrar engeli, ekstre toplamı kontrolü, geri alma) | 1.2.0 |
|
||||||
|
| 18 | **Kakeibo K3:** ay planı ve dört soru (ay başı sihirbazı, harcanabilir, grup bütçeleri, haftalık nakit limiti), birikim hedefleri | 1.3.0 |
|
||||||
|
| 19 | **Kakeibo K4:** haftalık kontrol, ay sonu değerlendirmesi (pişmanlık, iyileştirme hedefleri), Kakeibo panosu (KMH faizi/gün, taksit yükü, kayıtsız nakit) | 1.4.0 |
|
||||||
|
| 20 | **Kakeibo K5:** satın almadan önce kontrol ve 24 saat bekleme listesi | 1.5.0 |
|
||||||
|
| 21 | `/api/v1` mobil API ve mobil kimlik doğrulama | 1.6.0 |
|
||||||
|
| 22 | Otomatik fiyat sağlayıcıları (market ve altın/döviz) araştırması ve izinliyse entegrasyonu | 1.7.0 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -327,6 +332,8 @@ Aşağıdakileri varsayılan olarak kabul ettim. Faz 0'da yalnızca bunlardan em
|
|||||||
- **Faz 13 kararları (2026-11-06):** 13a alışveriş listeleri, stok eşiği önerileri (onayla/reddet), akıllı mağaza önerisi ve alışveriş modu; 13b alışveriş sonrası giriş, karşılaştırma raporu ve "Alışverişi tamamla"; `v0.13.0` 13b sonunda. Birden çok mağazadan alışverişte mağaza başına ayrı bölünmüş gider (her mağaza için ayrı hesap ve fiş). Tamamlanınca liste kapanır ve rapor sabitlenir; alınmayanlar başka aktif listeye taşınır, yeni listeye alınır ya da bırakılır. Reddedilen öneri yeni alım girilene kadar gösterilmez. Markası belirtilmeyen ürün tipi kalemi (ör. pazardan domates) tipin biriminde stoğa işlenir, fiyat kaydı oluşmaz.
|
- **Faz 13 kararları (2026-11-06):** 13a alışveriş listeleri, stok eşiği önerileri (onayla/reddet), akıllı mağaza önerisi ve alışveriş modu; 13b alışveriş sonrası giriş, karşılaştırma raporu ve "Alışverişi tamamla"; `v0.13.0` 13b sonunda. Birden çok mağazadan alışverişte mağaza başına ayrı bölünmüş gider (her mağaza için ayrı hesap ve fiş). Tamamlanınca liste kapanır ve rapor sabitlenir; alınmayanlar başka aktif listeye taşınır, yeni listeye alınır ya da bırakılır. Reddedilen öneri yeni alım girilene kadar gösterilmez. Markası belirtilmeyen ürün tipi kalemi (ör. pazardan domates) tipin biriminde stoğa işlenir, fiyat kaydı oluşmaz.
|
||||||
- **Faz 14 kararları (2026-11-07):** 14a dashboard ana grafiği (Haftalık/Aylık/Altı aylık/Yıllık, kümülatif/dönemsel) ve finans raporları (kategori dağılımı, aylık trend, kişi ve hesap bazlı, bütçe vs. gerçekleşen); 14b cari ve aynî borçlar, birikim kâr/zarar, alışveriş (liste vs. gerçekleşen, mağaza bazlı harcama), birim fiyat geçmişi, fire/zarar; `v0.14.0` 14b sonunda. Raporlar `/raporlar` altında ayrı sayfalar; dönem ve filtreler URL'de, her raporda CSV. "Ay" bütçe ayıdır; gider kuralları bütçeyle aynı (taksit modu, gizli hesap ayarı). Kişi bazlı harcama gideri kaydeden üyeye göre; mağaza bazlı harcama gider alıcısının mağaza adıyla başlamasına göre (şubeler birlikte).
|
- **Faz 14 kararları (2026-11-07):** 14a dashboard ana grafiği (Haftalık/Aylık/Altı aylık/Yıllık, kümülatif/dönemsel) ve finans raporları (kategori dağılımı, aylık trend, kişi ve hesap bazlı, bütçe vs. gerçekleşen); 14b cari ve aynî borçlar, birikim kâr/zarar, alışveriş (liste vs. gerçekleşen, mağaza bazlı harcama), birim fiyat geçmişi, fire/zarar; `v0.14.0` 14b sonunda. Raporlar `/raporlar` altında ayrı sayfalar; dönem ve filtreler URL'de, her raporda CSV. "Ay" bütçe ayıdır; gider kuralları bütçeyle aynı (taksit modu, gizli hesap ayarı). Kişi bazlı harcama gideri kaydeden üyeye göre; mağaza bazlı harcama gider alıcısının mağaza adıyla başlamasına göre (şubeler birlikte).
|
||||||
- **Faz 15 kararları (2026-11-07):** Canlı MongoDB Dokploy servisi **mongo:7** (mongo:8 sunucu çekirdeğiyle uyumsuz); uygulama mongo:7.0'a karşı tüm entegrasyon testleriyle doğrulandı. Replica set kimlik doğrulamalı tek düğüm (`rs0`, keyfile), uygulama `directConnection=true` ile bağlanır (`docs/MONGODB-KURULUM.md`). Migration'lar ve ilk admin konteyner başlarken otomatik (`scripts/bootstrap.mjs`; replica set yoksa uygulama açılmaz). Gitea `main` push → Dokploy webhook ile otomatik deploy. Yedek: günlük `mongodump` + uploads arşivi, 7 gün, şimdilik yalnızca sunucuda (sunucu dışı kopya sonraki adım).
|
- **Faz 15 kararları (2026-11-07):** Canlı MongoDB Dokploy servisi **mongo:7** (mongo:8 sunucu çekirdeğiyle uyumsuz); uygulama mongo:7.0'a karşı tüm entegrasyon testleriyle doğrulandı. Replica set kimlik doğrulamalı tek düğüm (`rs0`, keyfile), uygulama `directConnection=true` ile bağlanır (`docs/MONGODB-KURULUM.md`). Migration'lar ve ilk admin konteyner başlarken otomatik (`scripts/bootstrap.mjs`; replica set yoksa uygulama açılmaz). Gitea `main` push → Dokploy webhook ile otomatik deploy. Yedek: günlük `mongodump` + uploads arşivi, 7 gün, şimdilik yalnızca sunucuda (sunucu dışı kopya sonraki adım).
|
||||||
|
- **Kakeibo kararları (2026-11-07):** Uygulama `.claude/skills/kakeibo` becerisine göre Kakeibo yöntemine uyarlanır (Faz 16–20); mobil API ve fiyat sağlayıcıları öncelik değil, 21–22'ye kaydı. KMH faizi, KKDF/BSMV ve banka ücretleri "banka maliyeti"dir: dört gruba ve Kakeibo harcama toplamına girmez, ayrı gösterilir. Günlük plan korunur, Kakeibo haftalık kontrolü yanına eklenir. Kişilere giden havale/EFT gider sayılır; kampanya iadeleri "İade" geliri. Ekstre dosyaları `import/` (git ve Docker dışı); gerçek veri önce dev veritabanında denenir, canlıya aktarmadan önce sorulur.
|
||||||
|
- **Faz 16 kararları (2026-11-07):** Kakeibo grubu gider kategorisinde tutulur ve alt kategoriye devredilir; işlem ya da bölünmüş kalem bazında değiştirilebilir ("kategoriyi ürün değil amaç belirler"). Banka maliyeti işareti kategori düzeyinde (varsayılan "Banka ve kart giderleri" ağacı); grup değiştirilse bile banka maliyeti kalır. Varsayılan kategoriler beceri eşlemesine göre doldurulur, kullanıcı kategorileri grupsuz kalır ve uyarı gösterilir. Migration'lar artık geri alınabilir (`npm run migrate -- --down <ad>`).
|
||||||
- **Lokal MongoDB portu:** host'ta `27019` (27017 ve 27018 geliştirme makinesinde dolu). `BUTCEM_MONGO_PORT` ile değiştirilebilir.
|
- **Lokal MongoDB portu:** host'ta `27019` (27017 ve 27018 geliştirme makinesinde dolu). `BUTCEM_MONGO_PORT` ile değiştirilebilir.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "butcem",
|
"name": "butcem",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "butcem",
|
"name": "butcem",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^7.0.2",
|
"@hookform/resolvers": "^7.0.2",
|
||||||
"@tanstack/react-table": "^9.3.1",
|
"@tanstack/react-table": "^9.3.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "butcem",
|
"name": "butcem",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması",
|
"description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
+10
-2
@@ -4,9 +4,17 @@
|
|||||||
*/
|
*/
|
||||||
import mongoose from "mongoose";
|
import mongoose from "mongoose";
|
||||||
import { getMongoClient } from "@/lib/db/mongo";
|
import { getMongoClient } from "@/lib/db/mongo";
|
||||||
import { runMigrations } from "./migrations";
|
import { revertMigration, runMigrations } from "./migrations";
|
||||||
|
|
||||||
runMigrations()
|
// npm run migrate → bekleyen migration'lar
|
||||||
|
// npm run migrate -- --down <ad> → bir migration'ı geri al
|
||||||
|
const downIndex = process.argv.indexOf("--down");
|
||||||
|
const task =
|
||||||
|
downIndex >= 0
|
||||||
|
? revertMigration(process.argv[downIndex + 1] ?? "")
|
||||||
|
: runMigrations().then(() => {});
|
||||||
|
|
||||||
|
task
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
console.error(err instanceof Error ? err.message : err);
|
console.error(err instanceof Error ? err.message : err);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { defaultKakeibo } from "@/features/categories/service";
|
||||||
|
import { CategoryModel } from "@/models/category";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import type { Migration } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kakeibo dört grup (Faz 16): gider kategorilerine `kakeiboGroup` ve `bankCost`. Varsayılan
|
||||||
|
* kategoriler beceri eşlemesine göre doldurulur; kullanıcının kategorileri grupsuz kalır (arayüz
|
||||||
|
* "grup seç" uyarısı gösterir). Alanı zaten olan kategoriye dokunulmaz. Hiçbir kategori silinmez.
|
||||||
|
* Geri alma: alanlar kaldırılır (npm run migrate -- --down 016-kakeibo-groups).
|
||||||
|
*/
|
||||||
|
export const migration: Migration = {
|
||||||
|
name: "016-kakeibo-groups",
|
||||||
|
description: "gider kategorilerine Kakeibo grubu ve banka maliyeti; varsayılanlar dolduruldu",
|
||||||
|
async up() {
|
||||||
|
const defaults = await CategoryModel.find({
|
||||||
|
kind: "expense",
|
||||||
|
defaultKey: { $type: "string" },
|
||||||
|
kakeiboGroup: { $exists: false },
|
||||||
|
})
|
||||||
|
.select({ defaultKey: 1 })
|
||||||
|
.lean<{ _id: unknown; defaultKey: string }[]>();
|
||||||
|
const ops = defaults.map((c) => ({
|
||||||
|
updateOne: { filter: { _id: c._id }, update: { $set: defaultKakeibo(c.defaultKey) } },
|
||||||
|
}));
|
||||||
|
if (ops.length) await CategoryModel.bulkWrite(ops);
|
||||||
|
await CategoryModel.updateMany(
|
||||||
|
{ kakeiboGroup: { $exists: false } },
|
||||||
|
{ $set: { kakeiboGroup: null, bankCost: false } },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
async down() {
|
||||||
|
await CategoryModel.updateMany({}, { $unset: { kakeiboGroup: "", bankCost: "" } });
|
||||||
|
await TransactionModel.updateMany(
|
||||||
|
{ kakeiboGroup: { $exists: true } },
|
||||||
|
{ $unset: { kakeiboGroup: "" } },
|
||||||
|
);
|
||||||
|
await TransactionModel.updateMany(
|
||||||
|
{ "splits.kakeiboGroup": { $exists: true } },
|
||||||
|
{ $unset: { "splits.$[].kakeiboGroup": "" } },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -15,6 +15,7 @@ import { migration as m012 } from "./012-stock-counts";
|
|||||||
import { migration as m013 } from "./013-stores-products";
|
import { migration as m013 } from "./013-stores-products";
|
||||||
import { migration as m014 } from "./014-product-prices";
|
import { migration as m014 } from "./014-product-prices";
|
||||||
import { migration as m015 } from "./015-shopping-lists";
|
import { migration as m015 } from "./015-shopping-lists";
|
||||||
|
import { migration as m016 } from "./016-kakeibo-groups";
|
||||||
import type { Migration } from "./types";
|
import type { Migration } from "./types";
|
||||||
|
|
||||||
/** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */
|
/** Sıralı migration listesi. Yeni migration: dosyayı ekle ve buraya sırayla ekle. */
|
||||||
@@ -34,6 +35,7 @@ export const MIGRATIONS: Migration[] = [
|
|||||||
m013,
|
m013,
|
||||||
m014,
|
m014,
|
||||||
m015,
|
m015,
|
||||||
|
m016,
|
||||||
];
|
];
|
||||||
|
|
||||||
const COLLECTION = "migrations";
|
const COLLECTION = "migrations";
|
||||||
@@ -63,3 +65,23 @@ export async function runMigrations(log: (line: string) => void = console.log):
|
|||||||
if (!ran.length) log("• Bekleyen migration yok.");
|
if (!ran.length) log("• Bekleyen migration yok.");
|
||||||
return ran;
|
return ran;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bir migration'ı geri alır (yalnızca `down` tanımlıysa) ve kaydını siler; bir sonraki
|
||||||
|
* `runMigrations` onu yeniden uygular. Kullanım: npm run migrate -- --down 016-kakeibo-groups
|
||||||
|
*/
|
||||||
|
export async function revertMigration(
|
||||||
|
name: string,
|
||||||
|
log: (line: string) => void = console.log,
|
||||||
|
): Promise<void> {
|
||||||
|
const m = MIGRATIONS.find((x) => x.name === name);
|
||||||
|
if (!m) throw new Error(`Migration bulunamadı: ${name}`);
|
||||||
|
if (!m.down) throw new Error(`${name} geri alınamaz (down tanımlı değil).`);
|
||||||
|
const db = getDb();
|
||||||
|
await connectMongoose();
|
||||||
|
const col = db.collection<{ name: string }>(COLLECTION);
|
||||||
|
if (!(await col.findOne({ name }))) throw new Error(`${name} uygulanmamış.`);
|
||||||
|
await m.down(db);
|
||||||
|
await col.deleteOne({ name });
|
||||||
|
log(`↩ Geri alındı: ${name}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import type { Db } from "mongodb";
|
import type { Db } from "mongodb";
|
||||||
|
|
||||||
/** Bir migration: adı dosya adıyla aynı olmalı (ör. "001-initial-indexes"). Geri alma yok; ileri yönlü. */
|
/**
|
||||||
|
* Bir migration: adı dosya adıyla aynı olmalı (ör. "001-initial-indexes"). İleri yönlü; Faz 16'dan
|
||||||
|
* itibaren veri değiştirenler `down` ile geri alınabilir (npm run migrate -- --down <ad>).
|
||||||
|
*/
|
||||||
export type Migration = {
|
export type Migration = {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
up: (db: Db) => Promise<void>;
|
up: (db: Db) => Promise<void>;
|
||||||
|
down?: (db: Db) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { BudgetView } from "@/features/budgets/components/budget-view";
|
|||||||
import { IncomePlanSection } from "@/features/budgets/components/income-plan";
|
import { IncomePlanSection } from "@/features/budgets/components/income-plan";
|
||||||
import { getIncomePlan } from "@/features/budgets/plan";
|
import { getIncomePlan } from "@/features/budgets/plan";
|
||||||
import { getBudget } from "@/features/budgets/service";
|
import { getBudget } from "@/features/budgets/service";
|
||||||
|
import { KakeiboBudgetCard } from "@/features/reports/components/kakeibo-card";
|
||||||
import { tr } from "@/i18n/tr";
|
import { tr } from "@/i18n/tr";
|
||||||
import { requireActiveHousehold } from "@/lib/dal";
|
import { requireActiveHousehold } from "@/lib/dal";
|
||||||
import { budgetMonthSchema } from "@/shared/schemas/budget";
|
import { budgetMonthSchema } from "@/shared/schemas/budget";
|
||||||
@@ -27,7 +28,13 @@ export default async function BudgetPage({ searchParams }: PageProps<"/butce">)
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title={tr.budget.title} description={tr.budget.description} />
|
<PageHeader title={tr.budget.title} description={tr.budget.description} />
|
||||||
<BudgetView key={data.month} data={data} />
|
<BudgetView
|
||||||
|
key={data.month}
|
||||||
|
data={data}
|
||||||
|
summary={
|
||||||
|
<KakeiboBudgetCard userId={user.id} householdId={household.id} month={data.month} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
<IncomePlanSection
|
<IncomePlanSection
|
||||||
key={`income-${data.month}`}
|
key={`income-${data.month}`}
|
||||||
month={data.month}
|
month={data.month}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { csvContext, csvResponse, forbidden, unauthorized } from "@/features/reports/csv";
|
||||||
|
import { getKakeiboReport } from "@/features/reports/kakeibo";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { csvMoney, toCsv } from "@/shared/csv";
|
||||||
|
import { KAKEIBO_GROUPS, type SpendingClass } from "@/shared/kakeibo";
|
||||||
|
|
||||||
|
/** GET /raporlar/kakeibo/csv?ay=YYYY-MM — sınıf × kategori tutarları. */
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const c = await csvContext(request);
|
||||||
|
if (!c) return unauthorized();
|
||||||
|
const r = await getKakeiboReport(c.userId, c.householdId, c.month);
|
||||||
|
if (!r) return forbidden();
|
||||||
|
const k = tr.kakeibo;
|
||||||
|
const label = (cls: SpendingClass) =>
|
||||||
|
cls === "BANK_COST" ? k.bankCost : cls === "UNGROUPED" ? k.ungrouped : k.groups[cls];
|
||||||
|
const order: SpendingClass[] = [...KAKEIBO_GROUPS, "UNGROUPED", "BANK_COST"];
|
||||||
|
const rows = order.flatMap((cls) =>
|
||||||
|
r.now.byClass[cls].map((x) => [label(cls), x.path, csvMoney(x.amountMinor)]),
|
||||||
|
);
|
||||||
|
return csvResponse(
|
||||||
|
toCsv(tr.reports.kakeiboReport.csvHeader, rows),
|
||||||
|
tr.reports.kakeiboReport.csvFile(r.month),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { KakeiboReportView } from "@/features/reports/components/kakeibo-view";
|
||||||
|
import {
|
||||||
|
CsvButton,
|
||||||
|
MonthNav,
|
||||||
|
ReportFrame,
|
||||||
|
ToolsEnd,
|
||||||
|
monthLabel,
|
||||||
|
} from "@/features/reports/components/report-frame";
|
||||||
|
import { getKakeiboReport } from "@/features/reports/kakeibo";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { requireActiveHousehold } from "@/lib/dal";
|
||||||
|
import { budgetMonthSchema } from "@/shared/schemas/budget";
|
||||||
|
|
||||||
|
const t = tr.reports.kakeiboReport;
|
||||||
|
export const metadata: Metadata = { title: t.title };
|
||||||
|
|
||||||
|
/** /raporlar/kakeibo?ay=YYYY-MM — Kakeibo dört grup, banka maliyeti, grupsuzlar. */
|
||||||
|
export default async function KakeiboReportPage({ searchParams }: PageProps<"/raporlar/kakeibo">) {
|
||||||
|
const { user, household } = await requireActiveHousehold("viewer");
|
||||||
|
const ay = budgetMonthSchema.safeParse((await searchParams).ay);
|
||||||
|
const data = await getKakeiboReport(user.id, household.id, ay.success ? ay.data : undefined);
|
||||||
|
if (!data) notFound();
|
||||||
|
return (
|
||||||
|
<ReportFrame
|
||||||
|
title={t.title}
|
||||||
|
description={t.description(monthLabel(data.month))}
|
||||||
|
tools={
|
||||||
|
<>
|
||||||
|
<MonthNav month={data.month} currentMonth={data.currentMonth} path="/raporlar/kakeibo" />
|
||||||
|
<ToolsEnd>
|
||||||
|
<CsvButton href={`/raporlar/kakeibo/csv?ay=${data.month}`} />
|
||||||
|
</ToolsEnd>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{data.includesHidden ? (
|
||||||
|
<p className="text-xs text-muted-foreground">{tr.reports.includesHidden}</p>
|
||||||
|
) : null}
|
||||||
|
<KakeiboReportView data={data} />
|
||||||
|
</ReportFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
BookOpenCheck,
|
||||||
ChartPie,
|
ChartPie,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Coins,
|
Coins,
|
||||||
@@ -25,6 +26,7 @@ const t = tr.reports;
|
|||||||
export const metadata: Metadata = { title: t.title };
|
export const metadata: Metadata = { title: t.title };
|
||||||
|
|
||||||
const REPORTS = [
|
const REPORTS = [
|
||||||
|
{ href: "/raporlar/kakeibo", key: "kakeibo", icon: BookOpenCheck },
|
||||||
{ href: "/raporlar/kategoriler", key: "categories", icon: ChartPie },
|
{ href: "/raporlar/kategoriler", key: "categories", icon: ChartPie },
|
||||||
{ href: "/raporlar/trend", key: "trend", icon: TrendingUp },
|
{ href: "/raporlar/trend", key: "trend", icon: TrendingUp },
|
||||||
{ href: "/raporlar/kisi-hesap", key: "people", icon: Users },
|
{ href: "/raporlar/kisi-hesap", key: "people", icon: Users },
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export type NavItem = {
|
|||||||
|
|
||||||
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
|
/** Şu an tamamlanmış en yüksek faz — sayfalar eklendikçe artar. */
|
||||||
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
|
/** Alt fazlar ondalıkla: 6.1 = Faz 6a. */
|
||||||
export const CURRENT_PHASE = 15;
|
export const CURRENT_PHASE = 16;
|
||||||
|
|
||||||
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
export const isAvailable = (item: NavItem) => item.phase <= CURRENT_PHASE;
|
||||||
|
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ function Stat({ label, value, tone }: { label: string; value: string; tone?: str
|
|||||||
|
|
||||||
type Pending = { kind: "edit" } | { kind: "replace"; source: "copy" | "actuals" } | null;
|
type Pending = { kind: "edit" } | { kind: "replace"; source: "copy" | "actuals" } | null;
|
||||||
|
|
||||||
export function BudgetView({ data }: { data: Data }) {
|
export function BudgetView({ data, summary }: { data: Data; summary?: React.ReactNode }) {
|
||||||
const [pending, setPending] = useState<Pending>(null);
|
const [pending, setPending] = useState<Pending>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const close = () => setPending(null);
|
const close = () => setPending(null);
|
||||||
@@ -306,6 +306,8 @@ export function BudgetView({ data }: { data: Data }) {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{summary}
|
||||||
|
|
||||||
{data.lines.length ? (
|
{data.lines.length ? (
|
||||||
<ul className="flex flex-col divide-y rounded-xl border bg-card">
|
<ul className="flex flex-col divide-y rounded-xl border bg-card">
|
||||||
{data.lines.map((l) => (
|
{data.lines.map((l) => (
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { useMemo, useState, useTransition } from "react";
|
import { useMemo, useState, useTransition } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { IconBadge } from "@/components/appearance/swatch";
|
import { IconBadge } from "@/components/appearance/swatch";
|
||||||
|
import { KakeiboBadge } from "./kakeibo-badge";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogCancel,
|
AlertDialogCancel,
|
||||||
@@ -61,6 +62,9 @@ export function CategoriesManager({
|
|||||||
[categories, kind, showArchived],
|
[categories, kind, showArchived],
|
||||||
);
|
);
|
||||||
const archivedCount = categories.filter((c) => c.kind === kind && c.archived).length;
|
const archivedCount = categories.filter((c) => c.kind === kind && c.archived).length;
|
||||||
|
const ungrouped = categories.filter(
|
||||||
|
(c) => c.kind === "expense" && !c.archived && !c.kakeibo.group && !c.kakeibo.bankCost,
|
||||||
|
).length;
|
||||||
|
|
||||||
function run(promise: Promise<{ ok: boolean; error?: string }>, success?: string) {
|
function run(promise: Promise<{ ok: boolean; error?: string }>, success?: string) {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
@@ -90,6 +94,9 @@ export function CategoriesManager({
|
|||||||
<span className={cn("min-w-0 flex-1 truncate", node.depth === 0 && "font-medium")}>
|
<span className={cn("min-w-0 flex-1 truncate", node.depth === 0 && "font-medium")}>
|
||||||
{node.name}
|
{node.name}
|
||||||
</span>
|
</span>
|
||||||
|
{node.kind === "expense" && !node.archived ? (
|
||||||
|
<KakeiboBadge kakeibo={node.kakeibo} />
|
||||||
|
) : null}
|
||||||
{node.archived ? <Badge variant="outline">{t.archived}</Badge> : null}
|
{node.archived ? <Badge variant="outline">{t.archived}</Badge> : null}
|
||||||
{canEdit ? (
|
{canEdit ? (
|
||||||
<DropdownMenu modal={false}>
|
<DropdownMenu modal={false}>
|
||||||
@@ -191,6 +198,11 @@ export function CategoriesManager({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{!canEdit ? <p className="text-sm text-muted-foreground">{t.readOnly}</p> : null}
|
{!canEdit ? <p className="text-sm text-muted-foreground">{t.readOnly}</p> : null}
|
||||||
|
{kind === "expense" && ungrouped ? (
|
||||||
|
<p className="rounded-lg border border-dashed p-3 text-sm text-muted-foreground">
|
||||||
|
{tr.kakeibo.ungroupedCount(ungrouped)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{tree.length ? (
|
{tree.length ? (
|
||||||
<ul className="divide-y rounded-xl border bg-card" aria-busy={pending}>
|
<ul className="divide-y rounded-xl border bg-card" aria-busy={pending}>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { toast } from "sonner";
|
|||||||
import { IconPicker, SwatchPicker } from "@/components/appearance/pickers";
|
import { IconPicker, SwatchPicker } from "@/components/appearance/pickers";
|
||||||
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
import { ResponsiveDialog } from "@/components/responsive-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { tr } from "@/i18n/tr";
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { KAKEIBO_GROUPS } from "@/shared/kakeibo";
|
||||||
import {
|
import {
|
||||||
categoryFormSchema,
|
categoryFormSchema,
|
||||||
type CategoryForm,
|
type CategoryForm,
|
||||||
@@ -60,6 +62,8 @@ export function CategoryFormDialog({
|
|||||||
parentId: c.parentId ?? "",
|
parentId: c.parentId ?? "",
|
||||||
icon: c.icon,
|
icon: c.icon,
|
||||||
color: c.color,
|
color: c.color,
|
||||||
|
kakeiboGroup: c.kakeiboGroup ?? "",
|
||||||
|
bankCost: c.bankCost,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const parent = categories.find((c) => c.id === state.parentId);
|
const parent = categories.find((c) => c.id === state.parentId);
|
||||||
@@ -69,6 +73,9 @@ export function CategoryFormDialog({
|
|||||||
parentId: state.parentId ?? "",
|
parentId: state.parentId ?? "",
|
||||||
icon: parent?.icon ?? "tag",
|
icon: parent?.icon ?? "tag",
|
||||||
color: parent?.color ?? "blue",
|
color: parent?.color ?? "blue",
|
||||||
|
// Yeni alt kategori grubu üstten devralır.
|
||||||
|
kakeiboGroup: "",
|
||||||
|
bankCost: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [state, categories, form]);
|
}, [state, categories, form]);
|
||||||
@@ -105,6 +112,10 @@ export function CategoryFormDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const color = useWatch({ control: form.control, name: "color" }) ?? "blue";
|
const color = useWatch({ control: form.control, name: "color" }) ?? "blue";
|
||||||
|
const k = tr.kakeibo;
|
||||||
|
const parentId = useWatch({ control: form.control, name: "parentId" });
|
||||||
|
const parentGroup = categories.find((c) => c.id === parentId)?.kakeibo.group ?? null;
|
||||||
|
const parentBank = categories.find((c) => c.id === parentId)?.kakeibo.bankCost ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResponsiveDialog
|
<ResponsiveDialog
|
||||||
@@ -177,6 +188,60 @@ export function CategoryFormDialog({
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{kind === "expense" ? (
|
||||||
|
<>
|
||||||
|
<Controller
|
||||||
|
name="kakeiboGroup"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="cat-kakeibo">{k.field}</FieldLabel>
|
||||||
|
<Select
|
||||||
|
value={field.value || ROOT}
|
||||||
|
onValueChange={(v) => field.onChange(v === ROOT ? "" : v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="cat-kakeibo" className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={ROOT}>
|
||||||
|
{parentGroup ? k.inheritWith(k.groups[parentGroup]) : k.inherit}
|
||||||
|
</SelectItem>
|
||||||
|
{KAKEIBO_GROUPS.map((g) => (
|
||||||
|
<SelectItem key={g} value={g}>
|
||||||
|
{k.groups[g]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FieldDescription>
|
||||||
|
{field.value
|
||||||
|
? k.groupHints[field.value as keyof typeof k.groupHints]
|
||||||
|
: k.ungroupedHint}
|
||||||
|
</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="bankCost"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Field orientation="horizontal">
|
||||||
|
<Checkbox
|
||||||
|
id="cat-bank"
|
||||||
|
checked={Boolean(field.value) || parentBank}
|
||||||
|
disabled={parentBank}
|
||||||
|
onCheckedChange={(x) => field.onChange(x === true)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<FieldLabel htmlFor="cat-bank">{k.bankCost}</FieldLabel>
|
||||||
|
<FieldDescription>{k.bankCostHint}</FieldDescription>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<Controller
|
<Controller
|
||||||
name="color"
|
name="color"
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { KakeiboGroup, ResolvedKakeibo } from "@/shared/kakeibo";
|
||||||
|
|
||||||
|
const k = tr.kakeibo;
|
||||||
|
|
||||||
|
/** Kakeibo grup renkleri: doğrulanmış kategorik palet (series-1…4); etiket her zaman yanında. */
|
||||||
|
export const KAKEIBO_COLOR: Record<KakeiboGroup, string> = {
|
||||||
|
ZORUNLU: "var(--series-1)",
|
||||||
|
ISTEK: "var(--series-2)",
|
||||||
|
KULTUR: "var(--series-3)",
|
||||||
|
BEKLENMEYEN: "var(--series-4)",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function KakeiboDot({
|
||||||
|
group,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
group: KakeiboGroup | null;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
"inline-block size-2.5 shrink-0 rounded-full",
|
||||||
|
!group && "border border-dashed border-muted-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={group ? { backgroundColor: KAKEIBO_COLOR[group] } : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kategorinin etkin Kakeibo grubu; devralınan grup soluk, banka maliyeti ve grupsuz ayrı. */
|
||||||
|
export function KakeiboBadge({ kakeibo }: { kakeibo: ResolvedKakeibo }) {
|
||||||
|
if (kakeibo.bankCost)
|
||||||
|
return (
|
||||||
|
<Badge variant="secondary" title={k.bankCostHint}>
|
||||||
|
{k.bankCost}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
if (!kakeibo.group)
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="gap-1.5 border-dashed text-muted-foreground"
|
||||||
|
title={k.ungroupedHint}
|
||||||
|
>
|
||||||
|
<KakeiboDot group={null} />
|
||||||
|
{k.ungrouped}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn("gap-1.5", kakeibo.inherited && "text-muted-foreground")}
|
||||||
|
title={
|
||||||
|
kakeibo.inherited
|
||||||
|
? `${k.groups[kakeibo.group]} (${k.inherited})`
|
||||||
|
: k.groupHints[kakeibo.group]
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<KakeiboDot group={kakeibo.group} />
|
||||||
|
{k.groups[kakeibo.group]}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,6 +31,8 @@ const form = (over: Partial<Parameters<typeof createCategory>[2]> = {}) => ({
|
|||||||
parentId: null,
|
parentId: null,
|
||||||
icon: "tag" as const,
|
icon: "tag" as const,
|
||||||
color: "blue" as const,
|
color: "blue" as const,
|
||||||
|
kakeiboGroup: null,
|
||||||
|
bankCost: false,
|
||||||
...over,
|
...over,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ import { toObjectId } from "@/lib/object-id";
|
|||||||
import { CategoryModel } from "@/models/category";
|
import { CategoryModel } from "@/models/category";
|
||||||
import type { IconKey, Swatch } from "@/shared/appearance";
|
import type { IconKey, Swatch } from "@/shared/appearance";
|
||||||
import type { CategoryForm, CategoryKind } from "@/shared/schemas/category";
|
import type { CategoryForm, CategoryKind } from "@/shared/schemas/category";
|
||||||
|
import {
|
||||||
|
DEFAULT_BANK_COST_KEYS,
|
||||||
|
DEFAULT_KAKEIBO_GROUPS,
|
||||||
|
resolveKakeibo,
|
||||||
|
type KakeiboGroup,
|
||||||
|
type ResolvedKakeibo,
|
||||||
|
} from "@/shared/kakeibo";
|
||||||
import { foldTr } from "@/shared/search";
|
import { foldTr } from "@/shared/search";
|
||||||
import { DEFAULT_CATEGORIES } from "./defaults";
|
import { DEFAULT_CATEGORIES } from "./defaults";
|
||||||
import {
|
import {
|
||||||
@@ -26,7 +33,16 @@ import {
|
|||||||
|
|
||||||
const t = tr.categories;
|
const t = tr.categories;
|
||||||
|
|
||||||
export type CategoryView = FlatCategory & { icon: IconKey; color: Swatch; isDefault: boolean };
|
export type CategoryView = FlatCategory & {
|
||||||
|
icon: IconKey;
|
||||||
|
color: Swatch;
|
||||||
|
isDefault: boolean;
|
||||||
|
/** Kendi Kakeibo grubu (null = üstten devral). */
|
||||||
|
kakeiboGroup: KakeiboGroup | null;
|
||||||
|
bankCost: boolean;
|
||||||
|
/** Etkin grup ve banka maliyeti (devralma dahil). */
|
||||||
|
kakeibo: ResolvedKakeibo;
|
||||||
|
};
|
||||||
|
|
||||||
type Lean = {
|
type Lean = {
|
||||||
_id: Types.ObjectId;
|
_id: Types.ObjectId;
|
||||||
@@ -39,9 +55,26 @@ type Lean = {
|
|||||||
color: string;
|
color: string;
|
||||||
defaultKey: string | null;
|
defaultKey: string | null;
|
||||||
ancestors: Types.ObjectId[];
|
ancestors: Types.ObjectId[];
|
||||||
|
kakeiboGroup?: KakeiboGroup | null;
|
||||||
|
bankCost?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const toView = (c: Lean): CategoryView => ({
|
const NONE: ResolvedKakeibo = { group: null, inherited: false, bankCost: false };
|
||||||
|
|
||||||
|
/** Görünümler: etkin Kakeibo grubu tüm kategoriler (arşivliler dahil) üzerinden çözülür. */
|
||||||
|
function toViews(rows: Lean[], all: Lean[] = rows): CategoryView[] {
|
||||||
|
const resolved = resolveKakeibo(
|
||||||
|
all.map((c) => ({
|
||||||
|
id: String(c._id),
|
||||||
|
parentId: c.parentId ? String(c.parentId) : null,
|
||||||
|
kakeiboGroup: c.kakeiboGroup ?? null,
|
||||||
|
bankCost: Boolean(c.bankCost),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
return rows.map((c) => ({ ...toView(c), kakeibo: resolved.get(String(c._id)) ?? NONE }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const toView = (c: Lean): Omit<CategoryView, "kakeibo"> & { kakeibo?: ResolvedKakeibo } => ({
|
||||||
id: String(c._id),
|
id: String(c._id),
|
||||||
kind: c.kind,
|
kind: c.kind,
|
||||||
name: c.name,
|
name: c.name,
|
||||||
@@ -51,8 +84,16 @@ const toView = (c: Lean): CategoryView => ({
|
|||||||
icon: c.icon as IconKey,
|
icon: c.icon as IconKey,
|
||||||
color: c.color as Swatch,
|
color: c.color as Swatch,
|
||||||
isDefault: Boolean(c.defaultKey),
|
isDefault: Boolean(c.defaultKey),
|
||||||
|
kakeiboGroup: c.kakeiboGroup ?? null,
|
||||||
|
bankCost: Boolean(c.bankCost),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Kakeibo alanları yalnızca gider kategorisinde; gelirde temizlenir. */
|
||||||
|
const kakeiboFields = (input: CategoryForm) =>
|
||||||
|
input.kind === "expense"
|
||||||
|
? { kakeiboGroup: input.kakeiboGroup, bankCost: input.bankCost }
|
||||||
|
: { kakeiboGroup: null, bankCost: false };
|
||||||
|
|
||||||
const violationMessage = (v: MoveViolation) => t.violations[v];
|
const violationMessage = (v: MoveViolation) => t.violations[v];
|
||||||
|
|
||||||
export async function listCategories(
|
export async function listCategories(
|
||||||
@@ -64,9 +105,9 @@ export async function listCategories(
|
|||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
const filter: Record<string, unknown> = { householdId: m.householdId };
|
const filter: Record<string, unknown> = { householdId: m.householdId };
|
||||||
if (opts.kind) filter.kind = opts.kind;
|
if (opts.kind) filter.kind = opts.kind;
|
||||||
if (!opts.includeArchived) filter.archivedAt = null;
|
const all = await CategoryModel.find(filter).lean<Lean[]>();
|
||||||
const rows = await CategoryModel.find(filter).lean<Lean[]>();
|
const rows = opts.includeArchived ? all : all.filter((c) => !c.archivedAt);
|
||||||
return rows.map(toView);
|
return toViews(rows, all);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadKind(householdId: Types.ObjectId, kind: CategoryKind, session?: ClientSession) {
|
async function loadKind(householdId: Types.ObjectId, kind: CategoryKind, session?: ClientSession) {
|
||||||
@@ -86,6 +127,17 @@ function hasSiblingNamed(all: Lean[], parentId: string | null, name: string, exc
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Varsayılan kategorinin Kakeibo grubu ve banka maliyeti işareti (Faz 16). */
|
||||||
|
export function defaultKakeibo(key: string): {
|
||||||
|
kakeiboGroup: KakeiboGroup | null;
|
||||||
|
bankCost: boolean;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
kakeiboGroup: DEFAULT_KAKEIBO_GROUPS[key] ?? null,
|
||||||
|
bankCost: (DEFAULT_BANK_COST_KEYS as readonly string[]).includes(key),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Varsayılan seti yükler (idempotent: defaultKey'e göre). Yeni hane oluşturulurken aynı
|
* Varsayılan seti yükler (idempotent: defaultKey'e göre). Yeni hane oluşturulurken aynı
|
||||||
* transaction içinde (session) ya da migration/seed'den çağrılır. Eklenen kategori sayısını döner.
|
* transaction içinde (session) ya da migration/seed'den çağrılır. Eklenen kategori sayısını döner.
|
||||||
@@ -117,6 +169,7 @@ export async function installDefaultCategories(
|
|||||||
color: def.color,
|
color: def.color,
|
||||||
order,
|
order,
|
||||||
defaultKey: def.key,
|
defaultKey: def.key,
|
||||||
|
...(kind === "expense" ? defaultKakeibo(def.key) : {}),
|
||||||
createdBy: actor,
|
createdBy: actor,
|
||||||
updatedBy: actor,
|
updatedBy: actor,
|
||||||
},
|
},
|
||||||
@@ -164,6 +217,7 @@ export async function createCategory(
|
|||||||
ancestors: parent ? [...parent.ancestors, parent._id] : [],
|
ancestors: parent ? [...parent.ancestors, parent._id] : [],
|
||||||
icon: input.icon,
|
icon: input.icon,
|
||||||
color: input.color,
|
color: input.color,
|
||||||
|
...kakeiboFields(input),
|
||||||
order: siblings.length ? Math.max(...siblings.map((s) => s.order)) + 1 : 0,
|
order: siblings.length ? Math.max(...siblings.map((s) => s.order)) + 1 : 0,
|
||||||
createdBy: actor,
|
createdBy: actor,
|
||||||
updatedBy: actor,
|
updatedBy: actor,
|
||||||
@@ -209,6 +263,7 @@ export async function updateCategory(
|
|||||||
nameKey: foldTr(input.name),
|
nameKey: foldTr(input.name),
|
||||||
icon: input.icon,
|
icon: input.icon,
|
||||||
color: input.color,
|
color: input.color,
|
||||||
|
...kakeiboFields(input),
|
||||||
updatedBy: actor,
|
updatedBy: actor,
|
||||||
};
|
};
|
||||||
if (moving) {
|
if (moving) {
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { ChevronRight } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { KakeiboDot } from "@/features/categories/components/kakeibo-badge";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { formatMoney } from "@/shared/format";
|
||||||
|
import { KAKEIBO_GROUPS } from "@/shared/kakeibo";
|
||||||
|
import { getKakeiboReport } from "../kakeibo";
|
||||||
|
import { formatPct } from "./report-frame";
|
||||||
|
|
||||||
|
const k = tr.kakeibo;
|
||||||
|
|
||||||
|
/** Bütçe sayfası: seçili bütçe ayının Kakeibo grup özeti (rapora bağlantılı). */
|
||||||
|
export async function KakeiboBudgetCard({
|
||||||
|
userId,
|
||||||
|
householdId,
|
||||||
|
month,
|
||||||
|
}: {
|
||||||
|
userId: string;
|
||||||
|
householdId: string;
|
||||||
|
month: string;
|
||||||
|
}) {
|
||||||
|
const data = await getKakeiboReport(userId, householdId, month);
|
||||||
|
if (!data) return null;
|
||||||
|
const s = data.now.summary;
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-labelledby="kb-card"
|
||||||
|
className="flex flex-col gap-3 rounded-xl border bg-card p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 id="kb-card" className="font-medium">
|
||||||
|
{tr.reports.kakeiboReport.title}
|
||||||
|
</h2>
|
||||||
|
<Link
|
||||||
|
href={`/raporlar/kakeibo?ay=${month}`}
|
||||||
|
className="ml-auto flex items-center gap-0.5 text-sm text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{tr.reports.hub.open}
|
||||||
|
<ChevronRight className="size-3.5" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<dl className="grid grid-cols-2 gap-3 md:grid-cols-6">
|
||||||
|
{KAKEIBO_GROUPS.map((g) => (
|
||||||
|
<div key={g}>
|
||||||
|
<dt className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<KakeiboDot group={g} />
|
||||||
|
{k.groups[g]}
|
||||||
|
</dt>
|
||||||
|
<dd className="font-semibold tabular-nums">
|
||||||
|
{formatMoney(s.groups[g])}{" "}
|
||||||
|
<span className="text-xs font-normal text-muted-foreground">
|
||||||
|
{formatPct(s.shares[g])}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-muted-foreground">{k.ungrouped}</dt>
|
||||||
|
<dd className="font-semibold tabular-nums">{formatMoney(s.ungroupedMinor)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs text-muted-foreground">{k.bankCost}</dt>
|
||||||
|
<dd className="font-semibold tabular-nums">{formatMoney(s.bankCostMinor)}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { AlertTriangle, Landmark } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { KAKEIBO_COLOR, KakeiboDot } from "@/features/categories/components/kakeibo-badge";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatMoney } from "@/shared/format";
|
||||||
|
import { KAKEIBO_GROUPS, type KakeiboGroup } from "@/shared/kakeibo";
|
||||||
|
import type { KakeiboReport } from "../kakeibo";
|
||||||
|
import { Change, formatPct } from "./report-frame";
|
||||||
|
|
||||||
|
const k = tr.kakeibo;
|
||||||
|
const t = tr.reports.kakeiboReport;
|
||||||
|
|
||||||
|
/** Dört grubun (ve grupsuzun) harcamadaki payı: tek yığılmış çubuk + lejant + tablo. */
|
||||||
|
function Composition({ data }: { data: KakeiboReport }) {
|
||||||
|
const s = data.now.summary;
|
||||||
|
const parts = [
|
||||||
|
...KAKEIBO_GROUPS.map((g) => ({
|
||||||
|
key: g,
|
||||||
|
label: k.groups[g],
|
||||||
|
share: s.shares[g],
|
||||||
|
color: KAKEIBO_COLOR[g],
|
||||||
|
})),
|
||||||
|
...(s.ungroupedMinor
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: "UNGROUPED",
|
||||||
|
label: k.ungrouped,
|
||||||
|
share: s.ungroupedShare,
|
||||||
|
color: "var(--muted-foreground)",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
].filter((p) => p.share > 0);
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div
|
||||||
|
className="flex h-4 w-full gap-0.5 overflow-hidden rounded-md bg-muted"
|
||||||
|
role="img"
|
||||||
|
aria-label={t.chartLabel}
|
||||||
|
>
|
||||||
|
{parts.map((p) => (
|
||||||
|
<div
|
||||||
|
key={p.key}
|
||||||
|
className="h-full first:rounded-l-md last:rounded-r-md"
|
||||||
|
style={{ width: `${p.share}%`, backgroundColor: p.color }}
|
||||||
|
title={`${p.label}: ${formatPct(p.share)}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
{parts.map((p) => (
|
||||||
|
<li key={p.key} className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="size-2.5 rounded-sm"
|
||||||
|
style={{ backgroundColor: p.color }}
|
||||||
|
/>
|
||||||
|
{p.label} <span className="tabular-nums">{formatPct(p.share)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroupCard({ data, group }: { data: KakeiboReport; group: KakeiboGroup }) {
|
||||||
|
const s = data.now.summary;
|
||||||
|
const rows = data.now.byClass[group];
|
||||||
|
const ch = data.change[group];
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-labelledby={`kg-${group}`}
|
||||||
|
className="flex flex-col gap-2 rounded-xl border bg-card p-4"
|
||||||
|
>
|
||||||
|
<header className="flex items-start gap-2">
|
||||||
|
<KakeiboDot group={group} className="mt-1.5" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h2 id={`kg-${group}`} className="font-medium">
|
||||||
|
{k.groups[group]}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-muted-foreground">{k.groupHints[group]}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-lg font-semibold tabular-nums">{formatMoney(s.groups[group])}</p>
|
||||||
|
<p className="text-xs text-muted-foreground tabular-nums">
|
||||||
|
{formatPct(s.shares[group])} ·{" "}
|
||||||
|
<Change minor={ch.minor} pct={ch.pct} format={formatMoney} />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{rows.length ? (
|
||||||
|
<ul className="divide-y text-sm">
|
||||||
|
{rows.map((r) => (
|
||||||
|
<li key={r.id} className="flex justify-between gap-3 py-1.5">
|
||||||
|
<span className="min-w-0 truncate">{r.path}</span>
|
||||||
|
<span className="shrink-0 tabular-nums">{formatMoney(r.amountMinor)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">{t.noSpending}</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /raporlar/kakeibo — dört grup, banka maliyeti ve grupsuz harcamalar. */
|
||||||
|
export function KakeiboReportView({ data }: { data: KakeiboReport }) {
|
||||||
|
const s = data.now.summary;
|
||||||
|
const bank = data.now.byClass.BANK_COST;
|
||||||
|
const ungrouped = data.now.byClass.UNGROUPED;
|
||||||
|
const bankChange = data.change.BANK_COST;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto]">
|
||||||
|
<div className="flex flex-col gap-3 rounded-xl border bg-card p-4">
|
||||||
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
|
<span className="text-sm text-muted-foreground">{t.spending}</span>
|
||||||
|
<span className="text-xl font-semibold tabular-nums">
|
||||||
|
{formatMoney(s.spendingMinor)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Composition data={data} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col justify-between gap-1 rounded-xl border bg-card p-4 md:w-64">
|
||||||
|
<span className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
<Landmark className="size-4" aria-hidden />
|
||||||
|
{k.bankCost}
|
||||||
|
</span>
|
||||||
|
<span className="text-xl font-semibold tabular-nums">{formatMoney(s.bankCostMinor)}</span>
|
||||||
|
<span className="text-xs">
|
||||||
|
<Change minor={bankChange.minor} pct={bankChange.pct} format={formatMoney} />
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] leading-tight text-muted-foreground">{t.bankCostNote}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ungrouped.length ? (
|
||||||
|
<section
|
||||||
|
aria-labelledby="kg-ungrouped"
|
||||||
|
className="flex flex-col gap-2 rounded-xl border border-dashed border-amber-500/50 bg-amber-500/5 p-4"
|
||||||
|
>
|
||||||
|
<h2 id="kg-ungrouped" className="flex items-center gap-1.5 font-medium">
|
||||||
|
<AlertTriangle className="size-4 text-amber-600" aria-hidden />
|
||||||
|
{k.ungrouped}: {formatMoney(s.ungroupedMinor)}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t.ungroupedHelp}{" "}
|
||||||
|
<Link href="/kategoriler" className="text-primary hover:underline">
|
||||||
|
{t.goCategories}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
<ul className="divide-y text-sm">
|
||||||
|
{ungrouped.map((r) => (
|
||||||
|
<li key={r.id} className="flex justify-between gap-3 py-1.5">
|
||||||
|
<span className="min-w-0 truncate">{r.path}</span>
|
||||||
|
<span className="tabular-nums">{formatMoney(r.amountMinor)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid gap-3 lg:grid-cols-2">
|
||||||
|
{KAKEIBO_GROUPS.map((g) => (
|
||||||
|
<GroupCard key={g} data={data} group={g} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bank.length ? (
|
||||||
|
<section
|
||||||
|
aria-labelledby="kg-bank"
|
||||||
|
className={cn("flex flex-col gap-2 rounded-xl border bg-card p-4")}
|
||||||
|
>
|
||||||
|
<h2 id="kg-bank" className="font-medium">
|
||||||
|
{k.bankCost}
|
||||||
|
</h2>
|
||||||
|
<ul className="divide-y text-sm">
|
||||||
|
{bank.map((r) => (
|
||||||
|
<li key={r.id} className="flex justify-between gap-3 py-1.5">
|
||||||
|
<span className="min-w-0 truncate">{r.path}</span>
|
||||||
|
<span className="tabular-nums">{formatMoney(r.amountMinor)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { getBudget } from "@/features/budgets/service";
|
||||||
|
import { listCategories, updateCategory } from "@/features/categories/service";
|
||||||
|
import {
|
||||||
|
createTransaction,
|
||||||
|
getTransactionFormOptions,
|
||||||
|
setTransactionDeleted,
|
||||||
|
} from "@/features/transactions/service";
|
||||||
|
import { AccountModel } from "@/models/account";
|
||||||
|
import { CategoryModel } from "@/models/category";
|
||||||
|
import { todayIstanbul } from "@/shared/dates";
|
||||||
|
import { transactionFormSchema } from "@/shared/schemas/transaction";
|
||||||
|
import { setupSeededDb, teardownDb } from "@/test/seeded-db";
|
||||||
|
import { getKakeiboReport } from "./kakeibo";
|
||||||
|
|
||||||
|
let ids: Awaited<ReturnType<typeof setupSeededDb>>;
|
||||||
|
let cakir = "";
|
||||||
|
let ayse = "";
|
||||||
|
let fatma = "";
|
||||||
|
|
||||||
|
const catId = (defaultKey: string) =>
|
||||||
|
CategoryModel.findOne({ householdId: cakir, defaultKey })
|
||||||
|
.lean()
|
||||||
|
.then((c) => String(c!._id));
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ids = await setupSeededDb();
|
||||||
|
cakir = ids.household("Çakır Ailesi");
|
||||||
|
ayse = ids.user("ayse");
|
||||||
|
fatma = ids.user("fatma");
|
||||||
|
});
|
||||||
|
afterAll(teardownDb);
|
||||||
|
|
||||||
|
describe("varsayılan gruplar", () => {
|
||||||
|
it("varsayılan kategoriler gruplu; alt kategoriler devralır; banka maliyeti işaretli", async () => {
|
||||||
|
const cats = (await listCategories(ayse, cakir, { kind: "expense" }))!;
|
||||||
|
const by = (name: string) => cats.find((c) => c.name === name)!;
|
||||||
|
expect(by("Market").kakeibo).toEqual({ group: "ZORUNLU", inherited: false, bankCost: false });
|
||||||
|
expect(by("Gıda").kakeibo).toEqual({ group: "ZORUNLU", inherited: true, bankCost: false });
|
||||||
|
expect(by("Kişisel bakım").kakeibo.group).toBe("ISTEK");
|
||||||
|
expect(by("Dışarıda yemek").kakeibo.group).toBe("ISTEK");
|
||||||
|
expect(by("Eğitim").kakeibo.group).toBe("KULTUR");
|
||||||
|
expect(by("Faiz").kakeibo.bankCost).toBe(true);
|
||||||
|
expect(by("Diğer").kakeibo.group).toBeNull();
|
||||||
|
const opts = (await getTransactionFormOptions(ayse, cakir))!;
|
||||||
|
expect(opts.categoryKakeibo[by("Gıda").id]).toEqual({ group: "ZORUNLU", bankCost: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Kakeibo raporu", () => {
|
||||||
|
it("harcama + banka maliyeti = bütçenin gerçekleşeni; başka hane göremez", async () => {
|
||||||
|
const r = (await getKakeiboReport(ayse, cakir))!;
|
||||||
|
const budget = (await getBudget(ayse, cakir))!;
|
||||||
|
const s = r.now.summary;
|
||||||
|
expect(s.spendingMinor + s.bankCostMinor).toBe(budget.totals.actualMinor);
|
||||||
|
const classSum = Object.values(r.now.byClass)
|
||||||
|
.flat()
|
||||||
|
.reduce((a, x) => a + x.amountMinor, 0);
|
||||||
|
expect(classSum).toBe(budget.totals.actualMinor);
|
||||||
|
expect(await getKakeiboReport(fatma, cakir)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("işlem bazında grup değiştirme tutarı taşır; banka maliyeti harcamaya girmez", async () => {
|
||||||
|
const before = (await getKakeiboReport(ayse, cakir))!.now.summary;
|
||||||
|
const account = String((await AccountModel.findOne({ name: "Ziraat Maaş" }).lean())!._id);
|
||||||
|
const form = (over: Record<string, unknown>) =>
|
||||||
|
transactionFormSchema.parse({
|
||||||
|
type: "expense",
|
||||||
|
amount: "100",
|
||||||
|
date: todayIstanbul(),
|
||||||
|
accountId: account,
|
||||||
|
installments: 1,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
const gida = await catId("market.gida");
|
||||||
|
const faiz = await catId("banka.faiz");
|
||||||
|
// Market alışverişi ama amaç "istek" (ör. doğum günü pastası).
|
||||||
|
const a = await createTransaction(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
form({ categoryId: gida, kakeiboGroup: "ISTEK" }),
|
||||||
|
);
|
||||||
|
// Bölünmüş: 60 ZORUNLU (kategoriden) + 40 KÜLTÜR (değiştirilmiş).
|
||||||
|
const b = await createTransaction(
|
||||||
|
ayse,
|
||||||
|
cakir,
|
||||||
|
form({
|
||||||
|
categoryId: "",
|
||||||
|
splits: [
|
||||||
|
{ categoryId: gida, amount: "60", note: "" },
|
||||||
|
{ categoryId: gida, amount: "40", note: "", kakeiboGroup: "KULTUR" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const c = await createTransaction(ayse, cakir, form({ categoryId: faiz, amount: "25" }));
|
||||||
|
expect([a.ok, b.ok, c.ok]).toEqual([true, true, true]);
|
||||||
|
|
||||||
|
const after = (await getKakeiboReport(ayse, cakir))!.now.summary;
|
||||||
|
expect(after.groups.ISTEK - before.groups.ISTEK).toBe(100_00);
|
||||||
|
expect(after.groups.ZORUNLU - before.groups.ZORUNLU).toBe(60_00);
|
||||||
|
expect(after.groups.KULTUR - before.groups.KULTUR).toBe(40_00);
|
||||||
|
expect(after.bankCostMinor - before.bankCostMinor).toBe(25_00);
|
||||||
|
expect(after.spendingMinor - before.spendingMinor).toBe(200_00);
|
||||||
|
|
||||||
|
for (const r of [a, b, c]) if (r.ok) await setTransactionDeleted(ayse, cakir, r.data.id, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kategoriye grup atanınca grupsuzdan çıkar", async () => {
|
||||||
|
const diger = await catId("diger-gider");
|
||||||
|
const cats = (await listCategories(ayse, cakir, { kind: "expense" }))!;
|
||||||
|
const d = cats.find((c) => c.id === diger)!;
|
||||||
|
const r = await updateCategory(ayse, cakir, diger, {
|
||||||
|
kind: "expense",
|
||||||
|
name: d.name,
|
||||||
|
parentId: null,
|
||||||
|
icon: d.icon,
|
||||||
|
color: d.color,
|
||||||
|
kakeiboGroup: "BEKLENMEYEN",
|
||||||
|
bankCost: false,
|
||||||
|
});
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
const after = (await listCategories(ayse, cakir, { kind: "expense" }))!.find(
|
||||||
|
(c) => c.id === diger,
|
||||||
|
)!;
|
||||||
|
expect(after.kakeibo.group).toBe("BEKLENMEYEN");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("migration 016", () => {
|
||||||
|
it("geri alınır (alanlar kalkar) ve yeniden uygulanınca varsayılanlar dolar", async () => {
|
||||||
|
const { revertMigration, runMigrations } = await import("../../../scripts/migrations");
|
||||||
|
const log = () => {};
|
||||||
|
await revertMigration("016-kakeibo-groups", log);
|
||||||
|
expect(await CategoryModel.countDocuments({ kakeiboGroup: { $exists: true } })).toBe(0);
|
||||||
|
expect(await runMigrations(log)).toEqual(["016-kakeibo-groups"]);
|
||||||
|
const gida = await CategoryModel.findOne({ householdId: cakir, defaultKey: "market" }).lean();
|
||||||
|
expect(gida!.kakeiboGroup).toBe("ZORUNLU");
|
||||||
|
const banka = await CategoryModel.findOne({ householdId: cakir, defaultKey: "banka" }).lean();
|
||||||
|
expect(banka!.bankCost).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import "server-only";
|
||||||
|
import type { Types } from "mongoose";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { TransactionModel } from "@/models/transaction";
|
||||||
|
import { budgetPeriod, shiftMonth } from "@/shared/budget";
|
||||||
|
import { istanbulDayStart } from "@/shared/dates";
|
||||||
|
import {
|
||||||
|
classifyAmounts,
|
||||||
|
isKakeiboGroup,
|
||||||
|
KAKEIBO_GROUPS,
|
||||||
|
summarizeGroups,
|
||||||
|
type GroupSummary,
|
||||||
|
type KakeiboGroup,
|
||||||
|
type SpendingClass,
|
||||||
|
} from "@/shared/kakeibo";
|
||||||
|
import { changePct } from "@/shared/reports";
|
||||||
|
import { actualByCategory, base, monthInfo, ORPHAN, type Base, type MonthInfo } from "./service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kakeibo grup raporu (Faz 16). Kategori tutarları bütçeyle aynı kaynaktan (gerçekleşmiş
|
||||||
|
* giderler, bölünmüş kalemler, taksit modu, gizli hesap ayarı); işlem/kalem bazında grubu
|
||||||
|
* değiştirilen tutarlar seçilen gruba taşınır. Banka maliyeti ayrı, grupsuzlar listelenir.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DAY_MS = 86_400_000;
|
||||||
|
|
||||||
|
export type KakeiboCategoryRow = { id: string; path: string; amountMinor: number };
|
||||||
|
|
||||||
|
export type KakeiboMonth = {
|
||||||
|
summary: GroupSummary;
|
||||||
|
/** Sınıf başına kategori kırılımı (tutara göre büyükten küçüğe). */
|
||||||
|
byClass: Record<SpendingClass, KakeiboCategoryRow[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type KakeiboReport = MonthInfo & {
|
||||||
|
current: boolean;
|
||||||
|
previous: string;
|
||||||
|
now: KakeiboMonth;
|
||||||
|
prev: KakeiboMonth;
|
||||||
|
/** Grup başına önceki aya göre değişim (tutar ve yüzde). */
|
||||||
|
change: Record<KakeiboGroup | "BANK_COST", { minor: number; pct: number | null }>;
|
||||||
|
includesHidden: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Dönemde grubu işlem/kalem bazında değiştirilmiş gider tutarları (kategori × grup). */
|
||||||
|
async function overrides(b: Base, month: string) {
|
||||||
|
const { from, to } = budgetPeriod(month, b.s.startDay);
|
||||||
|
const match: Record<string, unknown> = {
|
||||||
|
householdId: b.ctx.m.householdId,
|
||||||
|
type: "expense",
|
||||||
|
status: "posted",
|
||||||
|
deletedAt: null,
|
||||||
|
date: {
|
||||||
|
$gte: istanbulDayStart(from)!,
|
||||||
|
$lt: new Date(istanbulDayStart(to)!.getTime() + DAY_MS),
|
||||||
|
},
|
||||||
|
$or: [{ kakeiboGroup: { $type: "string" } }, { "splits.kakeiboGroup": { $type: "string" } }],
|
||||||
|
};
|
||||||
|
if (!b.s.includePrivate && b.ctx.hiddenAccountIds.length)
|
||||||
|
match.accountId = { $nin: b.ctx.hiddenAccountIds };
|
||||||
|
// Tamamı alış ayına modunda taksitler bütçede alış ayına toplanır; değiştirme yalnızca
|
||||||
|
// taksitsiz işlemlere uygulanır (tutarlar kategori toplamıyla tutarlı kalsın).
|
||||||
|
if (b.s.installmentMode === "purchase_month") match["installment.planId"] = { $in: [null] };
|
||||||
|
const docs = await TransactionModel.find(match)
|
||||||
|
.select({ categoryId: 1, amountMinor: 1, kakeiboGroup: 1, splits: 1 })
|
||||||
|
.lean<
|
||||||
|
{
|
||||||
|
categoryId: Types.ObjectId | null;
|
||||||
|
amountMinor: number;
|
||||||
|
kakeiboGroup: string | null;
|
||||||
|
splits: { categoryId: Types.ObjectId; amountMinor: number; kakeiboGroup?: string | null }[];
|
||||||
|
}[]
|
||||||
|
>();
|
||||||
|
const sums = new Map<string, { categoryId: string; group: KakeiboGroup; amountMinor: number }>();
|
||||||
|
const add = (categoryId: string, group: unknown, amount: number) => {
|
||||||
|
if (!isKakeiboGroup(group)) return;
|
||||||
|
const key = `${categoryId}:${group}`;
|
||||||
|
const cur = sums.get(key) ?? { categoryId, group, amountMinor: 0 };
|
||||||
|
cur.amountMinor += amount;
|
||||||
|
sums.set(key, cur);
|
||||||
|
};
|
||||||
|
for (const d of docs) {
|
||||||
|
if (d.splits?.length)
|
||||||
|
for (const s of d.splits) add(String(s.categoryId), s.kakeiboGroup, s.amountMinor);
|
||||||
|
else if (d.categoryId) add(String(d.categoryId), d.kakeiboGroup, d.amountMinor);
|
||||||
|
}
|
||||||
|
return [...sums.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function kakeiboMonth(b: Base, month: string): Promise<KakeiboMonth> {
|
||||||
|
const [amounts, over] = await Promise.all([actualByCategory(b, month), overrides(b, month)]);
|
||||||
|
const items = classifyAmounts(amounts, over, b.ctx.kakeibo);
|
||||||
|
const byClass = Object.fromEntries(
|
||||||
|
[...KAKEIBO_GROUPS, "BANK_COST", "UNGROUPED"].map((c) => [c, [] as KakeiboCategoryRow[]]),
|
||||||
|
) as Record<SpendingClass, KakeiboCategoryRow[]>;
|
||||||
|
const merged = new Map<string, KakeiboCategoryRow & { cls: SpendingClass }>();
|
||||||
|
for (const it of items) {
|
||||||
|
const key = `${it.cls}:${it.categoryId}`;
|
||||||
|
const path =
|
||||||
|
it.categoryId === ORPHAN
|
||||||
|
? tr.reports.uncategorized
|
||||||
|
: (b.ctx.categoryMap.get(it.categoryId)?.path ?? "—");
|
||||||
|
const cur = merged.get(key) ?? { id: it.categoryId, path, amountMinor: 0, cls: it.cls };
|
||||||
|
cur.amountMinor += it.amountMinor;
|
||||||
|
merged.set(key, cur);
|
||||||
|
}
|
||||||
|
for (const r of merged.values())
|
||||||
|
byClass[r.cls].push({ id: r.id, path: r.path, amountMinor: r.amountMinor });
|
||||||
|
for (const list of Object.values(byClass)) list.sort((a, c) => c.amountMinor - a.amountMinor);
|
||||||
|
return { summary: summarizeGroups(items), byClass };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getKakeiboReport(
|
||||||
|
userId: string,
|
||||||
|
householdId: string,
|
||||||
|
month?: string,
|
||||||
|
): Promise<KakeiboReport | null> {
|
||||||
|
const b = await base(userId, householdId);
|
||||||
|
if (!b) return null;
|
||||||
|
const m = month ?? b.currentMonth;
|
||||||
|
const previous = shiftMonth(m, -1);
|
||||||
|
const [now, prev] = await Promise.all([kakeiboMonth(b, m), kakeiboMonth(b, previous)]);
|
||||||
|
const change = Object.fromEntries(
|
||||||
|
[...KAKEIBO_GROUPS, "BANK_COST" as const].map((g) => {
|
||||||
|
const cur = g === "BANK_COST" ? now.summary.bankCostMinor : now.summary.groups[g];
|
||||||
|
const old = g === "BANK_COST" ? prev.summary.bankCostMinor : prev.summary.groups[g];
|
||||||
|
return [g, { minor: cur - old, pct: changePct(cur, old) }];
|
||||||
|
}),
|
||||||
|
) as KakeiboReport["change"];
|
||||||
|
return {
|
||||||
|
...monthInfo(b, m),
|
||||||
|
previous,
|
||||||
|
now,
|
||||||
|
prev,
|
||||||
|
change,
|
||||||
|
includesHidden: b.s.includePrivate && b.ctx.hiddenAccountIds.length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ import { categoryBreakdown, savingsRate, sharePct, type CategoryBreakdown } from
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const DAY_MS = 86_400_000;
|
const DAY_MS = 86_400_000;
|
||||||
const ORPHAN = "__none__";
|
export const ORPHAN = "__none__";
|
||||||
|
|
||||||
export type Base = { ctx: Ctx; s: Settings; today: string; currentMonth: string };
|
export type Base = { ctx: Ctx; s: Settings; today: string; currentMonth: string };
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ export async function base(userId: string, householdId: string): Promise<Base |
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Kategori bazında gerçekleşen gider (kendi tutarı); kategorisiz/bilinmeyen tek satırda. */
|
/** Kategori bazında gerçekleşen gider (kendi tutarı); kategorisiz/bilinmeyen tek satırda. */
|
||||||
async function actualByCategory(b: Base, month: string) {
|
export async function actualByCategory(b: Base, month: string) {
|
||||||
const { from, to } = budgetPeriod(month, b.s.startDay);
|
const { from, to } = budgetPeriod(month, b.s.startDay);
|
||||||
const amounts = await expenseAmounts(b.ctx, b.s, from, to);
|
const amounts = await expenseAmounts(b.ctx, b.s, from, to);
|
||||||
const out = new Map<string, number>();
|
const out = new Map<string, number>();
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { KakeiboDot } from "@/features/categories/components/kakeibo-badge";
|
||||||
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { KAKEIBO_GROUPS, type KakeiboGroup } from "@/shared/kakeibo";
|
||||||
|
|
||||||
|
const k = tr.kakeibo;
|
||||||
|
const FROM_CATEGORY = "__category__";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kakeibo grubu seçimi: varsayılan "kategoriden" (önerilen grup gösterilir); kullanıcı bu kayıt
|
||||||
|
* için değiştirebilir. Banka maliyeti kategorisinde seçim kapalıdır (harcama değildir).
|
||||||
|
*/
|
||||||
|
export function KakeiboSelect({
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
suggested,
|
||||||
|
bankCost,
|
||||||
|
compact = false,
|
||||||
|
ariaLabel,
|
||||||
|
}: {
|
||||||
|
id?: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
/** Kategorinin etkin grubu (öneri). */
|
||||||
|
suggested: KakeiboGroup | null;
|
||||||
|
bankCost: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
ariaLabel?: string;
|
||||||
|
}) {
|
||||||
|
if (bankCost)
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
className={cn("text-xs text-muted-foreground", compact ? "self-center" : "")}
|
||||||
|
title={k.bankCostHint}
|
||||||
|
>
|
||||||
|
{k.bankCost}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
const effective = (value || suggested) as KakeiboGroup | null;
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={value || FROM_CATEGORY}
|
||||||
|
onValueChange={(v) => onChange(v === FROM_CATEGORY ? "" : v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
id={id}
|
||||||
|
aria-label={ariaLabel ?? k.field}
|
||||||
|
className={cn("w-full", compact && "h-8 text-sm")}
|
||||||
|
>
|
||||||
|
<SelectValue>
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
<KakeiboDot group={effective} />
|
||||||
|
<span className="truncate">
|
||||||
|
{value
|
||||||
|
? k.groups[value as KakeiboGroup]
|
||||||
|
: suggested
|
||||||
|
? compact
|
||||||
|
? k.groups[suggested]
|
||||||
|
: k.fromCategory(k.groups[suggested])
|
||||||
|
: k.fromCategoryNone}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={FROM_CATEGORY}>
|
||||||
|
{suggested ? k.fromCategory(k.groups[suggested]) : k.fromCategoryNone}
|
||||||
|
</SelectItem>
|
||||||
|
{KAKEIBO_GROUPS.map((g) => (
|
||||||
|
<SelectItem key={g} value={g}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<KakeiboDot group={g} />
|
||||||
|
{k.groups[g]}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { tr } from "@/i18n/tr";
|
import { tr } from "@/i18n/tr";
|
||||||
|
import { KakeiboSelect } from "./kakeibo-select";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { todayIstanbul } from "@/shared/dates";
|
import { todayIstanbul } from "@/shared/dates";
|
||||||
import { formatMoney } from "@/shared/format";
|
import { formatMoney } from "@/shared/format";
|
||||||
@@ -61,7 +62,7 @@ export type TransactionFormMode =
|
|||||||
| { kind: "edit"; transaction: TransactionDetail }
|
| { kind: "edit"; transaction: TransactionDetail }
|
||||||
| { kind: "copy"; transaction: TransactionDetail };
|
| { kind: "copy"; transaction: TransactionDetail };
|
||||||
|
|
||||||
type SplitValue = { categoryId: string; amount: string; note: string };
|
type SplitValue = { categoryId: string; amount: string; note: string; kakeiboGroup: string };
|
||||||
type FormValues = {
|
type FormValues = {
|
||||||
type: TransactionType;
|
type: TransactionType;
|
||||||
amount: string;
|
amount: string;
|
||||||
@@ -70,6 +71,8 @@ type FormValues = {
|
|||||||
toAccountId: string;
|
toAccountId: string;
|
||||||
categoryId: string;
|
categoryId: string;
|
||||||
splits: SplitValue[];
|
splits: SplitValue[];
|
||||||
|
/** Kakeibo grubu bu işlem için değiştirildiyse ("" = kategoriden). */
|
||||||
|
kakeiboGroup: string;
|
||||||
payee: string;
|
payee: string;
|
||||||
description: string;
|
description: string;
|
||||||
tags: string;
|
tags: string;
|
||||||
@@ -90,6 +93,7 @@ function toValues(mode: TransactionFormMode, options: TransactionFormOptions | n
|
|||||||
toAccountId: p.toAccountId ?? "",
|
toAccountId: p.toAccountId ?? "",
|
||||||
categoryId: p.categoryId ?? "",
|
categoryId: p.categoryId ?? "",
|
||||||
splits: [],
|
splits: [],
|
||||||
|
kakeiboGroup: "",
|
||||||
payee: "",
|
payee: "",
|
||||||
description: p.description ?? "",
|
description: p.description ?? "",
|
||||||
tags: "",
|
tags: "",
|
||||||
@@ -109,7 +113,9 @@ function toValues(mode: TransactionFormMode, options: TransactionFormOptions | n
|
|||||||
categoryId: s.categoryId,
|
categoryId: s.categoryId,
|
||||||
amount: formatMoneyInput(s.amountMinor),
|
amount: formatMoneyInput(s.amountMinor),
|
||||||
note: s.note,
|
note: s.note,
|
||||||
|
kakeiboGroup: s.kakeiboGroup ?? "",
|
||||||
})),
|
})),
|
||||||
|
kakeiboGroup: x.kakeiboGroup ?? "",
|
||||||
payee: x.payeeName,
|
payee: x.payeeName,
|
||||||
description: x.description,
|
description: x.description,
|
||||||
tags: x.tags.join(", "),
|
tags: x.tags.join(", "),
|
||||||
@@ -359,6 +365,7 @@ export function TransactionFormDialog({
|
|||||||
const amount = useWatch({ control: form.control, name: "amount" });
|
const amount = useWatch({ control: form.control, name: "amount" });
|
||||||
const accountId = useWatch({ control: form.control, name: "accountId" });
|
const accountId = useWatch({ control: form.control, name: "accountId" });
|
||||||
const splitValues = useWatch({ control: form.control, name: "splits" });
|
const splitValues = useWatch({ control: form.control, name: "splits" });
|
||||||
|
const categoryIdValue = useWatch({ control: form.control, name: "categoryId" });
|
||||||
const attachmentCount = useWatch({ control: form.control, name: "attachments" })?.length ?? 0;
|
const attachmentCount = useWatch({ control: form.control, name: "attachments" })?.length ?? 0;
|
||||||
const splitMode = splits.fields.length > 0;
|
const splitMode = splits.fields.length > 0;
|
||||||
|
|
||||||
@@ -426,9 +433,15 @@ export function TransactionFormDialog({
|
|||||||
}
|
}
|
||||||
const first = form.getValues("categoryId");
|
const first = form.getValues("categoryId");
|
||||||
splits.replace([
|
splits.replace([
|
||||||
{ categoryId: first, amount: form.getValues("amount"), note: "" },
|
{
|
||||||
{ categoryId: "", amount: "", note: "" },
|
categoryId: first,
|
||||||
|
amount: form.getValues("amount"),
|
||||||
|
note: "",
|
||||||
|
kakeiboGroup: form.getValues("kakeiboGroup"),
|
||||||
|
},
|
||||||
|
{ categoryId: "", amount: "", note: "", kakeiboGroup: "" },
|
||||||
]);
|
]);
|
||||||
|
form.setValue("kakeiboGroup", "");
|
||||||
form.setValue("categoryId", "");
|
form.setValue("categoryId", "");
|
||||||
setAdvancedOpen(true);
|
setAdvancedOpen(true);
|
||||||
}
|
}
|
||||||
@@ -711,6 +724,30 @@ export function TransactionFormDialog({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{type === "expense" && !splitMode && categoryIdValue ? (
|
||||||
|
<Controller
|
||||||
|
name="kakeiboGroup"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => {
|
||||||
|
const kk = options?.categoryKakeibo[categoryIdValue];
|
||||||
|
return (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="tx-kakeibo">{tr.kakeibo.field}</FieldLabel>
|
||||||
|
<KakeiboSelect
|
||||||
|
id="tx-kakeibo"
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
suggested={kk?.group ?? null}
|
||||||
|
bankCost={kk?.bankCost ?? false}
|
||||||
|
/>
|
||||||
|
{field.value ? (
|
||||||
|
<FieldDescription>{tr.kakeibo.overrideHint}</FieldDescription>
|
||||||
|
) : null}
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Controller
|
<Controller
|
||||||
name="payee"
|
name="payee"
|
||||||
@@ -862,10 +899,30 @@ export function TransactionFormDialog({
|
|||||||
{...field}
|
{...field}
|
||||||
placeholder={f.splitNote}
|
placeholder={f.splitNote}
|
||||||
aria-label={`${f.splitNote} ${i + 1}`}
|
aria-label={`${f.splitNote} ${i + 1}`}
|
||||||
className="col-span-2 h-8 text-sm"
|
className={cn("h-8 text-sm", type !== "expense" && "col-span-2")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
{type === "expense" ? (
|
||||||
|
<Controller
|
||||||
|
name={`splits.${i}.kakeiboGroup`}
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => {
|
||||||
|
const kk =
|
||||||
|
options?.categoryKakeibo[splitValues?.[i]?.categoryId ?? ""];
|
||||||
|
return (
|
||||||
|
<KakeiboSelect
|
||||||
|
compact
|
||||||
|
value={field.value ?? ""}
|
||||||
|
onChange={field.onChange}
|
||||||
|
suggested={kk?.group ?? null}
|
||||||
|
bankCost={kk?.bankCost ?? false}
|
||||||
|
ariaLabel={`${tr.kakeibo.field} ${i + 1}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{splitMode ? (
|
{splitMode ? (
|
||||||
@@ -874,7 +931,14 @@ export function TransactionFormDialog({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => splits.append({ categoryId: "", amount: "", note: "" })}
|
onClick={() =>
|
||||||
|
splits.append({
|
||||||
|
categoryId: "",
|
||||||
|
amount: "",
|
||||||
|
note: "",
|
||||||
|
kakeiboGroup: "",
|
||||||
|
})
|
||||||
|
}
|
||||||
disabled={splits.fields.length >= MAX_SPLITS}
|
disabled={splits.fields.length >= MAX_SPLITS}
|
||||||
>
|
>
|
||||||
<Plus />
|
<Plus />
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { allowedAttachmentKinds } from "@/shared/attachments";
|
|||||||
import type { IconKey, Swatch } from "@/shared/appearance";
|
import type { IconKey, Swatch } from "@/shared/appearance";
|
||||||
import { installmentSchedule } from "@/shared/card-periods";
|
import { installmentSchedule } from "@/shared/card-periods";
|
||||||
import { istanbulDayStart, todayIstanbul, toIstanbulYmd } from "@/shared/dates";
|
import { istanbulDayStart, todayIstanbul, toIstanbulYmd } from "@/shared/dates";
|
||||||
|
import { classify, resolveKakeibo, type KakeiboGroup, type SpendingClass } from "@/shared/kakeibo";
|
||||||
import type { CategoryKind } from "@/shared/schemas/category";
|
import type { CategoryKind } from "@/shared/schemas/category";
|
||||||
import type { TransactionFilters, TransactionForm } from "@/shared/schemas/transaction";
|
import type { TransactionFilters, TransactionForm } from "@/shared/schemas/transaction";
|
||||||
import { compareTr, foldTr } from "@/shared/search";
|
import { compareTr, foldTr } from "@/shared/search";
|
||||||
@@ -85,6 +86,8 @@ export async function loadContext(
|
|||||||
icon: 1,
|
icon: 1,
|
||||||
color: 1,
|
color: 1,
|
||||||
ancestors: 1,
|
ancestors: 1,
|
||||||
|
kakeiboGroup: 1,
|
||||||
|
bankCost: 1,
|
||||||
})
|
})
|
||||||
.lean(),
|
.lean(),
|
||||||
ContactModel.find({ householdId: m.householdId })
|
ContactModel.find({ householdId: m.householdId })
|
||||||
@@ -134,6 +137,15 @@ export async function loadContext(
|
|||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
// Kakeibo: kategori başına etkin grup ve banka maliyeti (devralma dahil; Faz 16).
|
||||||
|
const kakeibo = resolveKakeibo(
|
||||||
|
categories.map((c) => ({
|
||||||
|
id: String(c._id),
|
||||||
|
parentId: c.parentId ? String(c.parentId) : null,
|
||||||
|
kakeiboGroup: (c.kakeiboGroup ?? null) as KakeiboGroup | null,
|
||||||
|
bankCost: Boolean(c.bankCost),
|
||||||
|
})),
|
||||||
|
);
|
||||||
const hiddenAccountIds = [...accountMap.values()].filter((a) => a.hidden).map((a) => a.id);
|
const hiddenAccountIds = [...accountMap.values()].filter((a) => a.hidden).map((a) => a.id);
|
||||||
const contactMap = new Map(
|
const contactMap = new Map(
|
||||||
contacts.map((c) => [
|
contacts.map((c) => [
|
||||||
@@ -170,6 +182,7 @@ export async function loadContext(
|
|||||||
assetMap,
|
assetMap,
|
||||||
hiddenAssetIds,
|
hiddenAssetIds,
|
||||||
flatCategories: flat,
|
flatCategories: flat,
|
||||||
|
kakeibo,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,7 +304,12 @@ export function buildDoc(
|
|||||||
|
|
||||||
let toAccountId: Types.ObjectId | null = null;
|
let toAccountId: Types.ObjectId | null = null;
|
||||||
let categoryId: Types.ObjectId | null = null;
|
let categoryId: Types.ObjectId | null = null;
|
||||||
let splits: { categoryId: Types.ObjectId; amountMinor: number; note: string }[] = [];
|
let splits: {
|
||||||
|
categoryId: Types.ObjectId;
|
||||||
|
amountMinor: number;
|
||||||
|
note: string;
|
||||||
|
kakeiboGroup: string | null;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
if (input.type === "transfer") {
|
if (input.type === "transfer") {
|
||||||
toAccountId = checkAccount(
|
toAccountId = checkAccount(
|
||||||
@@ -316,7 +334,13 @@ export function buildDoc(
|
|||||||
return fail(t.validation.categoryInvalid, {
|
return fail(t.validation.categoryInvalid, {
|
||||||
[`splits.${i}.categoryId`]: t.validation.categoryInvalid,
|
[`splits.${i}.categoryId`]: t.validation.categoryInvalid,
|
||||||
});
|
});
|
||||||
splits.push({ categoryId: cid, amountMinor: s.amount, note: s.note });
|
splits.push({
|
||||||
|
categoryId: cid,
|
||||||
|
amountMinor: s.amount,
|
||||||
|
note: s.note,
|
||||||
|
// Kakeibo grubu yalnızca giderde anlamlı.
|
||||||
|
kakeiboGroup: input.type === "expense" ? (s.kakeiboGroup ?? null) : null,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
categoryId = input.categoryId ? checkCategory(ctx, input.categoryId, kind, keep) : null;
|
categoryId = input.categoryId ? checkCategory(ctx, input.categoryId, kind, keep) : null;
|
||||||
@@ -338,6 +362,8 @@ export function buildDoc(
|
|||||||
amountMinor: input.amount,
|
amountMinor: input.amount,
|
||||||
categoryId,
|
categoryId,
|
||||||
splits,
|
splits,
|
||||||
|
kakeiboGroup:
|
||||||
|
input.type === "expense" && splits.length === 0 ? (input.kakeiboGroup ?? null) : null,
|
||||||
effectiveCategoryIds: effective,
|
effectiveCategoryIds: effective,
|
||||||
payeeName,
|
payeeName,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
@@ -650,7 +676,14 @@ export type TransactionDetail = TransactionListItem & {
|
|||||||
color: Swatch;
|
color: Swatch;
|
||||||
amountMinor: number;
|
amountMinor: number;
|
||||||
note: string;
|
note: string;
|
||||||
|
/** Bu kalem için değiştirilmiş Kakeibo grubu (null = kategoriden). */
|
||||||
|
kakeiboGroup: KakeiboGroup | null;
|
||||||
|
/** Etkin sınıf (grup, banka maliyeti ya da grupsuz). */
|
||||||
|
kakeiboClass: SpendingClass;
|
||||||
}[];
|
}[];
|
||||||
|
/** Bölünmemiş giderde değiştirilmiş Kakeibo grubu ve etkin sınıf (gider değilse null). */
|
||||||
|
kakeiboGroup: KakeiboGroup | null;
|
||||||
|
kakeiboClass: SpendingClass | null;
|
||||||
updatedBy: { id: string; name: string };
|
updatedBy: { id: string; name: string };
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -830,8 +863,17 @@ export async function getTransaction(
|
|||||||
attachments,
|
attachments,
|
||||||
...toItem(ctx, tx, names),
|
...toItem(ctx, tx, names),
|
||||||
categoryId: tx.categoryId ? String(tx.categoryId) : null,
|
categoryId: tx.categoryId ? String(tx.categoryId) : null,
|
||||||
|
kakeiboGroup: (tx.kakeiboGroup ?? null) as KakeiboGroup | null,
|
||||||
|
kakeiboClass:
|
||||||
|
tx.type === "expense" && tx.categoryId
|
||||||
|
? classify(
|
||||||
|
ctx.kakeibo.get(String(tx.categoryId)),
|
||||||
|
(tx.kakeiboGroup ?? null) as KakeiboGroup | null,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
splits: (tx.splits ?? []).map((s) => {
|
splits: (tx.splits ?? []).map((s) => {
|
||||||
const c = ctx.categoryMap.get(String(s.categoryId));
|
const c = ctx.categoryMap.get(String(s.categoryId));
|
||||||
|
const override = (s.kakeiboGroup ?? null) as KakeiboGroup | null;
|
||||||
return {
|
return {
|
||||||
categoryId: String(s.categoryId),
|
categoryId: String(s.categoryId),
|
||||||
path: c?.path ?? "—",
|
path: c?.path ?? "—",
|
||||||
@@ -839,6 +881,8 @@ export async function getTransaction(
|
|||||||
color: c?.color ?? "slate",
|
color: c?.color ?? "slate",
|
||||||
amountMinor: s.amountMinor,
|
amountMinor: s.amountMinor,
|
||||||
note: s.note ?? "",
|
note: s.note ?? "",
|
||||||
|
kakeiboGroup: override,
|
||||||
|
kakeiboClass: classify(ctx.kakeibo.get(String(s.categoryId)), override),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
updatedBy: { id: String(tx.updatedBy), name: names.get(String(tx.updatedBy)) ?? "—" },
|
updatedBy: { id: String(tx.updatedBy), name: names.get(String(tx.updatedBy)) ?? "—" },
|
||||||
@@ -924,6 +968,8 @@ export type TransactionFormOptions = {
|
|||||||
accounts: { id: string; name: string; type: AccountType; icon: IconKey; color: Swatch }[];
|
accounts: { id: string; name: string; type: AccountType; icon: IconKey; color: Swatch }[];
|
||||||
categories: CategoryOption[];
|
categories: CategoryOption[];
|
||||||
categoryAppearance: Record<string, { icon: IconKey; color: Swatch }>;
|
categoryAppearance: Record<string, { icon: IconKey; color: Swatch }>;
|
||||||
|
/** Gider kategorisi başına etkin Kakeibo grubu (formda öneri) ve banka maliyeti. */
|
||||||
|
categoryKakeibo: Record<string, { group: KakeiboGroup | null; bankCost: boolean }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getTransactionFormOptions(
|
export async function getTransactionFormOptions(
|
||||||
@@ -950,7 +996,10 @@ export async function getTransactionFormOptions(
|
|||||||
const categoryAppearance = Object.fromEntries(
|
const categoryAppearance = Object.fromEntries(
|
||||||
[...ctx.categoryMap.values()].map((c) => [String(c.id), { icon: c.icon, color: c.color }]),
|
[...ctx.categoryMap.values()].map((c) => [String(c.id), { icon: c.icon, color: c.color }]),
|
||||||
);
|
);
|
||||||
return { accounts, categories, categoryAppearance };
|
const categoryKakeibo = Object.fromEntries(
|
||||||
|
[...ctx.kakeibo.entries()].map(([id, k]) => [id, { group: k.group, bankCost: k.bankCost }]),
|
||||||
|
);
|
||||||
|
return { accounts, categories, categoryAppearance, categoryKakeibo };
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PayeeSuggestion = {
|
export type PayeeSuggestion = {
|
||||||
|
|||||||
@@ -1862,6 +1862,35 @@ export const tr = {
|
|||||||
identifierRequired: "Kullanıcı adı veya e-posta girin",
|
identifierRequired: "Kullanıcı adı veya e-posta girin",
|
||||||
passwordRequired: "Şifre girin",
|
passwordRequired: "Şifre girin",
|
||||||
},
|
},
|
||||||
|
kakeibo: {
|
||||||
|
groups: {
|
||||||
|
ZORUNLU: "Zorunlu",
|
||||||
|
ISTEK: "İstek",
|
||||||
|
KULTUR: "Kültür",
|
||||||
|
BEKLENMEYEN: "Beklenmeyen",
|
||||||
|
},
|
||||||
|
groupHints: {
|
||||||
|
ZORUNLU: "İhtiyaç: market, faturalar, ulaşım, sağlık, sigorta, resmi ödemeler.",
|
||||||
|
ISTEK:
|
||||||
|
"Opsiyonel: dışarıda yemek, kahve-börek, giyim, kozmetik, hobi, online alışveriş, abonelik, tatil.",
|
||||||
|
KULTUR: "Kitap, kurs, eğitim, çocuk etkinlikleri, müze/konser, bağış.",
|
||||||
|
BEKLENMEYEN: "Plansız tamir/tadilat, büyük sağlık gideri, arıza, hediye/düğün.",
|
||||||
|
},
|
||||||
|
bankCost: "Banka maliyeti",
|
||||||
|
bankCostHint:
|
||||||
|
"KMH faizi, KKDF/BSMV, kart ve hesap ücretleri. Harcama değildir; dört gruba ve harcama toplamına girmez.",
|
||||||
|
ungrouped: "Grupsuz",
|
||||||
|
ungroupedHint: "Kakeibo grubu seçilmemiş. Kategoriye grup atayın.",
|
||||||
|
field: "Kakeibo grubu",
|
||||||
|
inherit: "Üst kategoriden devral",
|
||||||
|
inheritWith: (group: string) => `Üst kategoriden: ${group}`,
|
||||||
|
fromCategory: (group: string) => `Kategoriden: ${group}`,
|
||||||
|
fromCategoryNone: "Kategoriden (grupsuz)",
|
||||||
|
overrideHint: "Kategoriyi ürün değil amaç belirler; bu kayıt için grubu değiştirebilirsiniz.",
|
||||||
|
inherited: "devralınan",
|
||||||
|
ungroupedCount: (n: number) =>
|
||||||
|
`${n} gider kategorisinin Kakeibo grubu yok; kategoriyi düzenleyip grup seçin.`,
|
||||||
|
},
|
||||||
reports: {
|
reports: {
|
||||||
title: "Raporlar",
|
title: "Raporlar",
|
||||||
description:
|
description:
|
||||||
@@ -1904,6 +1933,10 @@ export const tr = {
|
|||||||
title: "Cari ve aynî borçlar",
|
title: "Cari ve aynî borçlar",
|
||||||
description: "Kim kime ne kadar borçlu; açık altın/döviz borç ve alacakları.",
|
description: "Kim kime ne kadar borçlu; açık altın/döviz borç ve alacakları.",
|
||||||
},
|
},
|
||||||
|
kakeibo: {
|
||||||
|
title: "Kakeibo grupları",
|
||||||
|
description: "Zorunlu, İstek, Kültür, Beklenmeyen; banka maliyeti ve grupsuz harcamalar.",
|
||||||
|
},
|
||||||
assets: {
|
assets: {
|
||||||
title: "Birikim kâr/zarar",
|
title: "Birikim kâr/zarar",
|
||||||
description: "Varlık başına maliyet, değer ve kâr/zarar; dönem özeti.",
|
description: "Varlık başına maliyet, değer ve kâr/zarar; dönem özeti.",
|
||||||
@@ -1992,6 +2025,19 @@ export const tr = {
|
|||||||
periods: { 3: "Son 3 ay", 6: "Son 6 ay", 12: "Son 12 ay" } as Record<number, string>,
|
periods: { 3: "Son 3 ay", 6: "Son 6 ay", 12: "Son 12 ay" } as Record<number, string>,
|
||||||
periodLabel: "Dönem",
|
periodLabel: "Dönem",
|
||||||
rangeText: (from: string, to: string) => `${from} – ${to}`,
|
rangeText: (from: string, to: string) => `${from} – ${to}`,
|
||||||
|
kakeiboReport: {
|
||||||
|
title: "Kakeibo grupları",
|
||||||
|
description: (month: string) =>
|
||||||
|
`${month} bütçe ayı; harcamanın Zorunlu, İstek, Kültür ve Beklenmeyen gruplarına dağılımı.`,
|
||||||
|
spending: "Kakeibo harcaması",
|
||||||
|
chartLabel: "Harcamanın dört Kakeibo grubuna dağılımı, yığılmış çubuk",
|
||||||
|
bankCostNote: "Harcamaya ve gruplara dahil değil.",
|
||||||
|
noSpending: "Bu ay harcama yok.",
|
||||||
|
ungroupedHelp: "Bu kategorilerin Kakeibo grubu seçilmemiş.",
|
||||||
|
goCategories: "Kategorilere grup ata",
|
||||||
|
csvHeader: ["Grup", "Kategori", "Tutar"],
|
||||||
|
csvFile: (month: string) => `butcem-kakeibo-${month}.csv`,
|
||||||
|
},
|
||||||
contacts: {
|
contacts: {
|
||||||
title: "Cari ve aynî borçlar",
|
title: "Cari ve aynî borçlar",
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||||
import { ICON_KEYS, SWATCHES } from "@/shared/appearance";
|
import { ICON_KEYS, SWATCHES } from "@/shared/appearance";
|
||||||
|
import { KAKEIBO_GROUPS } from "@/shared/kakeibo";
|
||||||
import { CATEGORY_KINDS } from "@/shared/schemas/category";
|
import { CATEGORY_KINDS } from "@/shared/schemas/category";
|
||||||
|
|
||||||
/** Gelir/gider kategori ağacı (Bölüm 6.2). Silme yok; arşivleme (alt ağaçla birlikte). */
|
/** Gelir/gider kategori ağacı (Bölüm 6.2). Silme yok; arşivleme (alt ağaçla birlikte). */
|
||||||
@@ -16,6 +17,13 @@ const categorySchema = new Schema(
|
|||||||
icon: { type: String, enum: ICON_KEYS, default: "tag" },
|
icon: { type: String, enum: ICON_KEYS, default: "tag" },
|
||||||
color: { type: String, enum: SWATCHES, default: "slate" },
|
color: { type: String, enum: SWATCHES, default: "slate" },
|
||||||
order: { type: Number, default: 0 },
|
order: { type: Number, default: 0 },
|
||||||
|
/**
|
||||||
|
* Kakeibo grubu (yalnızca gider; Faz 16). null ise üst kategoriden devralınır; zincirde yoksa
|
||||||
|
* grupsuz.
|
||||||
|
*/
|
||||||
|
kakeiboGroup: { type: String, enum: [...KAKEIBO_GROUPS, null], default: null },
|
||||||
|
/** Banka maliyeti (KMH faizi, KKDF/BSMV, ücret): Kakeibo harcamasına girmez; alt ağaç devralır. */
|
||||||
|
bankCost: { type: Boolean, default: false },
|
||||||
archivedAt: { type: Date, default: null },
|
archivedAt: { type: Date, default: null },
|
||||||
/** Varsayılan setten geldiyse sabit anahtar (tekrar yüklemede kopya olmaz). */
|
/** Varsayılan setten geldiyse sabit anahtar (tekrar yüklemede kopya olmaz). */
|
||||||
defaultKey: { type: String, default: null },
|
defaultKey: { type: String, default: null },
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
import { model, models, Schema, type InferSchemaType, type Model } from "mongoose";
|
||||||
import { CONTACT_ENTRY_KINDS } from "@/shared/contacts";
|
import { CONTACT_ENTRY_KINDS } from "@/shared/contacts";
|
||||||
|
import { KAKEIBO_GROUPS } from "@/shared/kakeibo";
|
||||||
import { TRANSACTION_STATUSES, TRANSACTION_TYPES } from "@/shared/transactions";
|
import { TRANSACTION_STATUSES, TRANSACTION_TYPES } from "@/shared/transactions";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -11,6 +12,8 @@ const splitSchema = new Schema(
|
|||||||
categoryId: { type: Schema.Types.ObjectId, required: true },
|
categoryId: { type: Schema.Types.ObjectId, required: true },
|
||||||
amountMinor: { type: Number, required: true, min: 1 },
|
amountMinor: { type: Number, required: true, min: 1 },
|
||||||
note: { type: String, default: "", maxlength: 120 },
|
note: { type: String, default: "", maxlength: 120 },
|
||||||
|
/** Kakeibo grubu bu kalem için değiştirildiyse; null = kategoriden (Faz 16). */
|
||||||
|
kakeiboGroup: { type: String, enum: [...KAKEIBO_GROUPS, null], default: null },
|
||||||
},
|
},
|
||||||
{ _id: true },
|
{ _id: true },
|
||||||
);
|
);
|
||||||
@@ -29,6 +32,8 @@ const transactionSchema = new Schema(
|
|||||||
/** Bölünmemiş gelir/giderde kategori; bölünmüşte null ve kalemler `splits`te. */
|
/** Bölünmemiş gelir/giderde kategori; bölünmüşte null ve kalemler `splits`te. */
|
||||||
categoryId: { type: Schema.Types.ObjectId, default: null },
|
categoryId: { type: Schema.Types.ObjectId, default: null },
|
||||||
splits: { type: [splitSchema], default: [] },
|
splits: { type: [splitSchema], default: [] },
|
||||||
|
/** Bölünmemiş giderde Kakeibo grubu değiştirildiyse; null = kategoriden (Faz 16). */
|
||||||
|
kakeiboGroup: { type: String, enum: [...KAKEIBO_GROUPS, null], default: null },
|
||||||
/** Kategori filtresi/raporlar için: categoryId veya kalem kategorileri (tekilleştirilmiş). */
|
/** Kategori filtresi/raporlar için: categoryId veya kalem kategorileri (tekilleştirilmiş). */
|
||||||
effectiveCategoryIds: { type: [Schema.Types.ObjectId], default: [] },
|
effectiveCategoryIds: { type: [Schema.Types.ObjectId], default: [] },
|
||||||
payeeId: { type: Schema.Types.ObjectId, default: null },
|
payeeId: { type: Schema.Types.ObjectId, default: null },
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
classify,
|
||||||
|
classifyAmounts,
|
||||||
|
resolveKakeibo,
|
||||||
|
summarizeGroups,
|
||||||
|
type KakeiboCategory,
|
||||||
|
} from "./kakeibo";
|
||||||
|
|
||||||
|
const cat = (
|
||||||
|
id: string,
|
||||||
|
parentId: string | null,
|
||||||
|
kakeiboGroup: KakeiboCategory["kakeiboGroup"] = null,
|
||||||
|
bankCost = false,
|
||||||
|
): KakeiboCategory => ({ id, parentId, kakeiboGroup, bankCost });
|
||||||
|
|
||||||
|
describe("resolveKakeibo", () => {
|
||||||
|
const map = resolveKakeibo([
|
||||||
|
cat("market", null, "ZORUNLU"),
|
||||||
|
cat("gida", "market"),
|
||||||
|
cat("bakim", "market", "ISTEK"),
|
||||||
|
cat("banka", null, null, true),
|
||||||
|
cat("faiz", "banka"),
|
||||||
|
cat("diger", null),
|
||||||
|
// Döngü: kendine üst (bozuk veri) sonsuz döngüye girmemeli.
|
||||||
|
cat("dongu", "dongu"),
|
||||||
|
]);
|
||||||
|
it("alt kategori grubu üstten devralır; kendi değeri önceliklidir", () => {
|
||||||
|
expect(map.get("gida")).toEqual({ group: "ZORUNLU", inherited: true, bankCost: false });
|
||||||
|
expect(map.get("bakim")).toEqual({ group: "ISTEK", inherited: false, bankCost: false });
|
||||||
|
expect(map.get("market")!.inherited).toBe(false);
|
||||||
|
});
|
||||||
|
it("banka maliyeti alt kategorilere geçer; grupsuz ve döngülü kategori güvenli", () => {
|
||||||
|
expect(map.get("faiz")!.bankCost).toBe(true);
|
||||||
|
expect(map.get("diger")).toEqual({ group: null, inherited: false, bankCost: false });
|
||||||
|
expect(map.get("dongu")!.group).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("classify", () => {
|
||||||
|
const map = resolveKakeibo([cat("market", null, "ZORUNLU"), cat("faiz", null, null, true)]);
|
||||||
|
it("işlem bazında grup kategoriyi ezer; banka maliyeti her zaman banka maliyeti", () => {
|
||||||
|
expect(classify(map.get("market"), null)).toBe("ZORUNLU");
|
||||||
|
expect(classify(map.get("market"), "ISTEK")).toBe("ISTEK");
|
||||||
|
expect(classify(map.get("faiz"), "ISTEK")).toBe("BANK_COST");
|
||||||
|
expect(classify(undefined, null)).toBe("UNGROUPED");
|
||||||
|
expect(classify(undefined, "KULTUR")).toBe("KULTUR");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("summarizeGroups", () => {
|
||||||
|
it("banka maliyeti harcamaya ve paylara girmez; grupsuz harcamaya girer", () => {
|
||||||
|
const s = summarizeGroups([
|
||||||
|
{ cls: "ZORUNLU", amountMinor: 4_600 },
|
||||||
|
{ cls: "ISTEK", amountMinor: 3_900 },
|
||||||
|
{ cls: "KULTUR", amountMinor: 150 },
|
||||||
|
{ cls: "BEKLENMEYEN", amountMinor: 1_250 },
|
||||||
|
{ cls: "UNGROUPED", amountMinor: 100 },
|
||||||
|
{ cls: "BANK_COST", amountMinor: 999 },
|
||||||
|
]);
|
||||||
|
expect(s.spendingMinor).toBe(10_000);
|
||||||
|
expect(s.bankCostMinor).toBe(999);
|
||||||
|
expect(s.shares).toEqual({ ZORUNLU: 46, ISTEK: 39, KULTUR: 1.5, BEKLENMEYEN: 12.5 });
|
||||||
|
expect(s.ungroupedShare).toBe(1);
|
||||||
|
});
|
||||||
|
it("harcama yoksa paylar 0 (sıfıra bölme yok)", () => {
|
||||||
|
const s = summarizeGroups([{ cls: "BANK_COST", amountMinor: 50 }]);
|
||||||
|
expect(s.spendingMinor).toBe(0);
|
||||||
|
expect(s.shares.ZORUNLU).toBe(0);
|
||||||
|
expect(s.ungroupedShare).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("classifyAmounts", () => {
|
||||||
|
const resolved = resolveKakeibo([
|
||||||
|
cat("market", null, "ZORUNLU"),
|
||||||
|
cat("faiz", null, null, true),
|
||||||
|
cat("diger", null),
|
||||||
|
]);
|
||||||
|
it("değiştirilen tutar seçilen gruba taşınır, kategori toplamı korunur", () => {
|
||||||
|
const out = classifyAmounts(
|
||||||
|
new Map([
|
||||||
|
["market", 10_000],
|
||||||
|
["faiz", 500],
|
||||||
|
["diger", 300],
|
||||||
|
]),
|
||||||
|
[
|
||||||
|
{ categoryId: "market", group: "ISTEK", amountMinor: 2_500 },
|
||||||
|
// Banka maliyetinde grup değiştirme etkisiz.
|
||||||
|
{ categoryId: "faiz", group: "ISTEK", amountMinor: 500 },
|
||||||
|
// Kategori toplamını aşan değiştirme kırpılır.
|
||||||
|
{ categoryId: "diger", group: "KULTUR", amountMinor: 900 },
|
||||||
|
],
|
||||||
|
resolved,
|
||||||
|
);
|
||||||
|
expect(out).toEqual([
|
||||||
|
{ categoryId: "market", cls: "ISTEK", amountMinor: 2_500 },
|
||||||
|
{ categoryId: "market", cls: "ZORUNLU", amountMinor: 7_500 },
|
||||||
|
{ categoryId: "faiz", cls: "BANK_COST", amountMinor: 500 },
|
||||||
|
{ categoryId: "diger", cls: "KULTUR", amountMinor: 300 },
|
||||||
|
]);
|
||||||
|
const s = summarizeGroups(out);
|
||||||
|
expect(s.spendingMinor + s.bankCostMinor).toBe(10_800);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* Kakeibo (家計簿) dört harcama grubu. Saf fonksiyonlar.
|
||||||
|
*
|
||||||
|
* Her gider tam olarak bir gruba girer: ZORUNLU (ihtiyaç), ISTEK (opsiyonel), KULTUR,
|
||||||
|
* BEKLENMEYEN. Grup kategoriden gelir (kategori → yoksa üst kategoriden devralınır); kayıt
|
||||||
|
* sırasında işlem ya da bölünmüş işlem kalemi bazında değiştirilebilir ("kategoriyi ürün değil
|
||||||
|
* amaç belirler").
|
||||||
|
*
|
||||||
|
* Banka maliyeti (KMH faizi, KKDF/BSMV, kart/hesap ücretleri) harcama değildir: dört gruba ve
|
||||||
|
* Kakeibo harcama toplamına girmez, ayrı gösterilir. Kategori ya da üst kategorisi "banka
|
||||||
|
* maliyeti" işaretliyse kalem grup değiştirilse bile banka maliyetidir.
|
||||||
|
*
|
||||||
|
* Grubu belirlenemeyen gider "grupsuz"dur: harcama toplamına girer, kullanıcıdan grup seçmesi
|
||||||
|
* istenir.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const KAKEIBO_GROUPS = ["ZORUNLU", "ISTEK", "KULTUR", "BEKLENMEYEN"] as const;
|
||||||
|
export type KakeiboGroup = (typeof KAKEIBO_GROUPS)[number];
|
||||||
|
|
||||||
|
export const isKakeiboGroup = (x: unknown): x is KakeiboGroup =>
|
||||||
|
KAKEIBO_GROUPS.includes(x as KakeiboGroup);
|
||||||
|
|
||||||
|
/** Harcama sınıfı: dört grup, banka maliyeti ya da grupsuz. */
|
||||||
|
export type SpendingClass = KakeiboGroup | "BANK_COST" | "UNGROUPED";
|
||||||
|
|
||||||
|
export type KakeiboCategory = {
|
||||||
|
id: string;
|
||||||
|
parentId: string | null;
|
||||||
|
kakeiboGroup: KakeiboGroup | null;
|
||||||
|
bankCost: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResolvedKakeibo = {
|
||||||
|
/** Etkin grup (kendisi ya da en yakın üst kategori); yoksa null. */
|
||||||
|
group: KakeiboGroup | null;
|
||||||
|
/** Grup üst kategoriden mi geliyor? */
|
||||||
|
inherited: boolean;
|
||||||
|
/** Kendisi ya da bir üst kategorisi banka maliyeti. */
|
||||||
|
bankCost: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Kategori başına etkin grup ve banka maliyeti (döngüye karşı korumalı). */
|
||||||
|
export function resolveKakeibo(
|
||||||
|
categories: readonly KakeiboCategory[],
|
||||||
|
): Map<string, ResolvedKakeibo> {
|
||||||
|
const byId = new Map(categories.map((c) => [c.id, c]));
|
||||||
|
const out = new Map<string, ResolvedKakeibo>();
|
||||||
|
for (const c of categories) {
|
||||||
|
let group: KakeiboGroup | null = null;
|
||||||
|
let inherited = false;
|
||||||
|
let bankCost = false;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
let cur: KakeiboCategory | undefined = c;
|
||||||
|
while (cur && !seen.has(cur.id)) {
|
||||||
|
seen.add(cur.id);
|
||||||
|
if (cur.bankCost) bankCost = true;
|
||||||
|
if (!group && cur.kakeiboGroup) {
|
||||||
|
group = cur.kakeiboGroup;
|
||||||
|
inherited = cur !== c;
|
||||||
|
}
|
||||||
|
cur = cur.parentId ? byId.get(cur.parentId) : undefined;
|
||||||
|
}
|
||||||
|
out.set(c.id, { group, inherited, bankCost });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bir gider kaleminin sınıfı: banka maliyeti > işlem bazında grup > kategorinin grubu. */
|
||||||
|
export function classify(
|
||||||
|
category: ResolvedKakeibo | undefined,
|
||||||
|
override: KakeiboGroup | null,
|
||||||
|
): SpendingClass {
|
||||||
|
if (category?.bankCost) return "BANK_COST";
|
||||||
|
return override ?? category?.group ?? "UNGROUPED";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GroupSummary = {
|
||||||
|
/** Grup başına tutar. */
|
||||||
|
groups: Record<KakeiboGroup, number>;
|
||||||
|
ungroupedMinor: number;
|
||||||
|
bankCostMinor: number;
|
||||||
|
/** Kakeibo harcaması: dört grup + grupsuz (banka maliyeti hariç). */
|
||||||
|
spendingMinor: number;
|
||||||
|
/** Grubun harcamadaki payı (bir ondalık yüzde; harcama 0 ise 0). */
|
||||||
|
shares: Record<KakeiboGroup, number>;
|
||||||
|
ungroupedShare: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pct = (part: number, total: number) =>
|
||||||
|
total > 0 ? Math.round((part * 1000) / total) / 10 : 0;
|
||||||
|
|
||||||
|
export function summarizeGroups(
|
||||||
|
items: readonly { cls: SpendingClass; amountMinor: number }[],
|
||||||
|
): GroupSummary {
|
||||||
|
const groups = Object.fromEntries(KAKEIBO_GROUPS.map((g) => [g, 0])) as Record<
|
||||||
|
KakeiboGroup,
|
||||||
|
number
|
||||||
|
>;
|
||||||
|
let ungrouped = 0;
|
||||||
|
let bank = 0;
|
||||||
|
for (const it of items) {
|
||||||
|
if (it.cls === "BANK_COST") bank += it.amountMinor;
|
||||||
|
else if (it.cls === "UNGROUPED") ungrouped += it.amountMinor;
|
||||||
|
else groups[it.cls] += it.amountMinor;
|
||||||
|
}
|
||||||
|
const spending = KAKEIBO_GROUPS.reduce((a, g) => a + groups[g], 0) + ungrouped;
|
||||||
|
return {
|
||||||
|
groups,
|
||||||
|
ungroupedMinor: ungrouped,
|
||||||
|
bankCostMinor: bank,
|
||||||
|
spendingMinor: spending,
|
||||||
|
shares: Object.fromEntries(KAKEIBO_GROUPS.map((g) => [g, pct(groups[g], spending)])) as Record<
|
||||||
|
KakeiboGroup,
|
||||||
|
number
|
||||||
|
>,
|
||||||
|
ungroupedShare: pct(ungrouped, spending),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Varsayılan kategori setinin Kakeibo eşlemesi (anahtar → grup). Alt kategori kendi değeri
|
||||||
|
* yoksa üstünden devralır. Listede olmayan varsayılanlar (ör. "Diğer") grupsuz kalır.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_KAKEIBO_GROUPS: Record<string, KakeiboGroup> = {
|
||||||
|
market: "ZORUNLU",
|
||||||
|
"market.kisisel-bakim": "ISTEK",
|
||||||
|
faturalar: "ZORUNLU",
|
||||||
|
kira: "ZORUNLU",
|
||||||
|
aidat: "ZORUNLU",
|
||||||
|
ulasim: "ZORUNLU",
|
||||||
|
saglik: "ZORUNLU",
|
||||||
|
egitim: "KULTUR",
|
||||||
|
giyim: "ISTEK",
|
||||||
|
eglence: "ISTEK",
|
||||||
|
abonelikler: "ISTEK",
|
||||||
|
ev: "BEKLENMEYEN",
|
||||||
|
cocuk: "KULTUR",
|
||||||
|
"evcil-hayvan": "ZORUNLU",
|
||||||
|
hediye: "BEKLENMEYEN",
|
||||||
|
vergi: "ZORUNLU",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Banka maliyeti işaretli varsayılan kategoriler (alt kategoriler devralır). */
|
||||||
|
export const DEFAULT_BANK_COST_KEYS = ["banka"] as const;
|
||||||
|
|
||||||
|
export type ClassifiedAmount = { categoryId: string; cls: SpendingClass; amountMinor: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kategori tutarlarını sınıflara dağıtır. `overrides`: işlem/kalem bazında grubu değiştirilmiş
|
||||||
|
* tutarlar (kategori × grup); bunlar kategorinin varsayılan payından düşülüp seçilen gruba
|
||||||
|
* yazılır. Kategori toplamı her zaman korunur (taşma olursa varsayılan pay 0'a çekilir).
|
||||||
|
*/
|
||||||
|
export function classifyAmounts(
|
||||||
|
categoryAmounts: ReadonlyMap<string, number>,
|
||||||
|
overrides: readonly { categoryId: string; group: KakeiboGroup; amountMinor: number }[],
|
||||||
|
resolved: ReadonlyMap<string, ResolvedKakeibo>,
|
||||||
|
): ClassifiedAmount[] {
|
||||||
|
const out: ClassifiedAmount[] = [];
|
||||||
|
const byCategory = new Map<string, { group: KakeiboGroup; amountMinor: number }[]>();
|
||||||
|
for (const o of overrides)
|
||||||
|
byCategory.set(o.categoryId, [...(byCategory.get(o.categoryId) ?? []), o]);
|
||||||
|
for (const [categoryId, total] of categoryAmounts) {
|
||||||
|
if (!total) continue;
|
||||||
|
const info = resolved.get(categoryId);
|
||||||
|
let rest = total;
|
||||||
|
for (const o of byCategory.get(categoryId) ?? []) {
|
||||||
|
const part = Math.min(o.amountMinor, Math.max(0, rest));
|
||||||
|
if (!part) continue;
|
||||||
|
rest -= part;
|
||||||
|
out.push({ categoryId, cls: classify(info, o.group), amountMinor: part });
|
||||||
|
}
|
||||||
|
if (rest) out.push({ categoryId, cls: classify(info, null), amountMinor: rest });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { tr } from "../../i18n/tr";
|
import { tr } from "../../i18n/tr";
|
||||||
import { ICON_KEYS, SWATCHES } from "../appearance";
|
import { ICON_KEYS, SWATCHES } from "../appearance";
|
||||||
|
import { KAKEIBO_GROUPS } from "../kakeibo";
|
||||||
|
|
||||||
export const CATEGORY_KINDS = ["expense", "income"] as const;
|
export const CATEGORY_KINDS = ["expense", "income"] as const;
|
||||||
export type CategoryKind = (typeof CATEGORY_KINDS)[number];
|
export type CategoryKind = (typeof CATEGORY_KINDS)[number];
|
||||||
@@ -14,6 +15,13 @@ export const categoryFormSchema = z.object({
|
|||||||
.transform((s) => (s === "" ? null : s)),
|
.transform((s) => (s === "" ? null : s)),
|
||||||
icon: z.enum(ICON_KEYS),
|
icon: z.enum(ICON_KEYS),
|
||||||
color: z.enum(SWATCHES),
|
color: z.enum(SWATCHES),
|
||||||
|
/** Kakeibo grubu ("" = üst kategoriden devral); yalnızca gider. */
|
||||||
|
kakeiboGroup: z
|
||||||
|
.union([z.enum(KAKEIBO_GROUPS), z.literal("")])
|
||||||
|
.default("")
|
||||||
|
.transform((x) => (x === "" ? null : x)),
|
||||||
|
/** Banka maliyeti (KMH faizi, ücret): Kakeibo harcamasına girmez. */
|
||||||
|
bankCost: z.boolean().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type CategoryFormInput = z.input<typeof categoryFormSchema>;
|
export type CategoryFormInput = z.input<typeof categoryFormSchema>;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { tr } from "../../i18n/tr";
|
|||||||
import { parseMoney } from "../money";
|
import { parseMoney } from "../money";
|
||||||
import { MAX_ATTACHMENTS_PER_RECORD } from "../attachments";
|
import { MAX_ATTACHMENTS_PER_RECORD } from "../attachments";
|
||||||
import { MAX_INSTALLMENTS } from "../card-periods";
|
import { MAX_INSTALLMENTS } from "../card-periods";
|
||||||
|
import { KAKEIBO_GROUPS } from "../kakeibo";
|
||||||
import { MAX_SPLITS, normalizeTags, TRANSACTION_TYPES } from "../transactions";
|
import { MAX_SPLITS, normalizeTags, TRANSACTION_TYPES } from "../transactions";
|
||||||
|
|
||||||
const v = tr.validation;
|
const v = tr.validation;
|
||||||
@@ -28,10 +29,17 @@ const positiveMoney = z
|
|||||||
return minor;
|
return minor;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Kakeibo grubu bu kayıt için değiştirildiyse; "" / yok = kategoriden (Faz 16). */
|
||||||
|
const kakeiboOverride = z
|
||||||
|
.union([z.enum(KAKEIBO_GROUPS), z.literal(""), z.null()])
|
||||||
|
.transform((x) => (x ? x : null))
|
||||||
|
.optional();
|
||||||
|
|
||||||
const splitSchema = z.object({
|
const splitSchema = z.object({
|
||||||
categoryId: objectId.refine(Boolean, t.selectCategory),
|
categoryId: objectId.refine(Boolean, t.selectCategory),
|
||||||
amount: positiveMoney,
|
amount: positiveMoney,
|
||||||
note: z.string().trim().max(120).default(""),
|
note: z.string().trim().max(120).default(""),
|
||||||
|
kakeiboGroup: kakeiboOverride,
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,6 +56,7 @@ export const transactionFormSchema = z
|
|||||||
toAccountId: optionalId,
|
toAccountId: optionalId,
|
||||||
categoryId: optionalId,
|
categoryId: optionalId,
|
||||||
splits: z.array(splitSchema).max(MAX_SPLITS).default([]),
|
splits: z.array(splitSchema).max(MAX_SPLITS).default([]),
|
||||||
|
kakeiboGroup: kakeiboOverride,
|
||||||
payee: z.string().trim().max(80).default(""),
|
payee: z.string().trim().max(80).default(""),
|
||||||
description: z.string().trim().max(500).default(""),
|
description: z.string().trim().max(500).default(""),
|
||||||
tags: z
|
tags: z
|
||||||
|
|||||||
Reference in New Issue
Block a user