Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| feb2e1d16c | |||
| 87409590a6 | |||
| 8c9c3c2506 |
@@ -377,3 +377,11 @@ GET {{baseUrl}}/savings/pending
|
|||||||
|
|
||||||
### Dönemin kapanıştaki birikimini TL varlığına ekle (dönem başına bir kez)
|
### Dönemin kapanıştaki birikimini TL varlığına ekle (dönem başına bir kez)
|
||||||
POST {{baseUrl}}/periods/BURAYA-DONEM-ID/to-savings
|
POST {{baseUrl}}/periods/BURAYA-DONEM-ID/to-savings
|
||||||
|
|
||||||
|
# ─────────────────────────── Raporlar ───────────────────────────
|
||||||
|
|
||||||
|
### Harcama kırılımı: grup, tüm kategoriler, ödeme türü, ay (filtreler isteğe bağlı)
|
||||||
|
GET {{baseUrl}}/reports/breakdown?from=2026-01-01&to=2026-12-31
|
||||||
|
|
||||||
|
### Bir dönemde, yalnızca Zorunlu, ödeme türü girilmemiş olanlar
|
||||||
|
GET {{baseUrl}}/reports/breakdown?period_id=BURAYA-DONEM-ID&group=needs&payment_method_id=none
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ mod note;
|
|||||||
mod period;
|
mod period;
|
||||||
mod plan;
|
mod plan;
|
||||||
mod rates;
|
mod rates;
|
||||||
|
mod reports;
|
||||||
mod review;
|
mod review;
|
||||||
mod spa;
|
mod spa;
|
||||||
mod stats;
|
mod stats;
|
||||||
@@ -87,6 +88,7 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.route("/periods/{id}/review", get(review::get))
|
.route("/periods/{id}/review", get(review::get))
|
||||||
.route("/periods/{id}/daily", get(stats::daily))
|
.route("/periods/{id}/daily", get(stats::daily))
|
||||||
.route("/stats/periods", get(stats::periods))
|
.route("/stats/periods", get(stats::periods))
|
||||||
|
.route("/reports/breakdown", get(reports::breakdown))
|
||||||
.route(
|
.route(
|
||||||
"/periods/{id}/budget",
|
"/periods/{id}/budget",
|
||||||
get(budget::get).put(budget::set_monthly),
|
get(budget::get).put(budget::set_monthly),
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
//! Rapor uç noktaları (hanenin tüm üyeleri):
|
||||||
|
//!
|
||||||
|
//! | Yöntem | Yol | İş |
|
||||||
|
//! |---|---|---|
|
||||||
|
//! | GET | `/api/reports/breakdown?period_id=&from=&to=&group=&category_id=&payment_method_id=` | harcama: toplam, grup, tüm kategoriler, ödeme türü, ay |
|
||||||
|
//!
|
||||||
|
//! `payment_method_id=none` → ödeme türü girilmemiş kayıtlar. Dönemler arası özet: `/api/stats/periods`.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Query, State},
|
||||||
|
};
|
||||||
|
use mongodb::bson::Uuid;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use ts_rs::TS;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::AuthUser,
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
models::group::Group,
|
||||||
|
services::reports::{self, Filter},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct BreakdownQuery {
|
||||||
|
period_id: Option<String>,
|
||||||
|
from: Option<String>,
|
||||||
|
to: Option<String>,
|
||||||
|
group: Option<String>,
|
||||||
|
category_id: Option<String>,
|
||||||
|
payment_method_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export, rename = "ReportBreakdown")]
|
||||||
|
pub struct BreakdownDto {
|
||||||
|
/// Kuruş
|
||||||
|
#[ts(type = "number")]
|
||||||
|
total: i64,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
count: i64,
|
||||||
|
/// 4 grup, sabit sırada
|
||||||
|
groups: Vec<GroupRow>,
|
||||||
|
/// Harcama olan **tüm** kategoriler, çoktan aza
|
||||||
|
categories: Vec<CategoryRow>,
|
||||||
|
/// Ödeme türleri; `null` = girilmemiş
|
||||||
|
payment_methods: Vec<MethodRow>,
|
||||||
|
/// Aylar (`"YYYY-MM"`), eskiden yeniye
|
||||||
|
months: Vec<MonthRow>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export, rename = "ReportGroupRow")]
|
||||||
|
struct GroupRow {
|
||||||
|
group: Group,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
total: i64,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export, rename = "ReportCategoryRow")]
|
||||||
|
struct CategoryRow {
|
||||||
|
category_id: String,
|
||||||
|
group: Group,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
total: i64,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export, rename = "ReportMethodRow")]
|
||||||
|
struct MethodRow {
|
||||||
|
payment_method_id: Option<String>,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
total: i64,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, TS)]
|
||||||
|
#[ts(export, rename = "ReportMonthRow")]
|
||||||
|
struct MonthRow {
|
||||||
|
/// `"YYYY-MM"`
|
||||||
|
month: String,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
total: i64,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn opt_id(v: Option<&str>, what: &str) -> AppResult<Option<Uuid>> {
|
||||||
|
match v.map(str::trim).filter(|s| !s.is_empty()) {
|
||||||
|
None => Ok(None),
|
||||||
|
Some(s) => Uuid::parse_str(s)
|
||||||
|
.map(Some)
|
||||||
|
.map_err(|_| AppError::BadRequest(format!("{what} geçersiz"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn breakdown(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Query(q): Query<BreakdownQuery>,
|
||||||
|
) -> AppResult<Json<BreakdownDto>> {
|
||||||
|
let group = match q.group.as_deref().map(str::trim).filter(|g| !g.is_empty()) {
|
||||||
|
None => None,
|
||||||
|
Some(g) => Some(
|
||||||
|
Group::ALL
|
||||||
|
.into_iter()
|
||||||
|
.find(|x| x.as_str() == g)
|
||||||
|
.ok_or_else(|| AppError::BadRequest("Grup geçersiz".into()))?,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let payment_method = match q.payment_method_id.as_deref().map(str::trim) {
|
||||||
|
None | Some("") => None,
|
||||||
|
Some("none") => Some(None),
|
||||||
|
Some(id) => Some(opt_id(Some(id), "Ödeme türü")?),
|
||||||
|
};
|
||||||
|
let filter = Filter {
|
||||||
|
period_id: opt_id(q.period_id.as_deref(), "Dönem")?,
|
||||||
|
from: q.from.filter(|s| !s.trim().is_empty()),
|
||||||
|
to: q.to.filter(|s| !s.trim().is_empty()),
|
||||||
|
group,
|
||||||
|
category_id: opt_id(q.category_id.as_deref(), "Kategori")?,
|
||||||
|
payment_method,
|
||||||
|
};
|
||||||
|
let b = reports::breakdown(&s.db, auth.household_id, filter).await?;
|
||||||
|
Ok(Json(BreakdownDto {
|
||||||
|
total: b.total,
|
||||||
|
count: b.count,
|
||||||
|
groups: b
|
||||||
|
.groups
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| GroupRow {
|
||||||
|
group: r.key,
|
||||||
|
total: r.total,
|
||||||
|
count: r.count,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
categories: b
|
||||||
|
.categories
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| CategoryRow {
|
||||||
|
category_id: r.key.category_id.to_string(),
|
||||||
|
group: r.key.group,
|
||||||
|
total: r.total,
|
||||||
|
count: r.count,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
payment_methods: b
|
||||||
|
.methods
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| MethodRow {
|
||||||
|
payment_method_id: r.key.map(|u| u.to_string()),
|
||||||
|
total: r.total,
|
||||||
|
count: r.count,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
months: b
|
||||||
|
.months
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| MonthRow {
|
||||||
|
month: r.key,
|
||||||
|
total: r.total,
|
||||||
|
count: r.count,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
//! | Yöntem | Yol | İş |
|
//! | Yöntem | Yol | İş |
|
||||||
//! |---|---|---|
|
//! |---|---|---|
|
||||||
//! | GET | `/api/periods/{id}/daily` | dönemin her günü: 4 grubun harcaması |
|
//! | GET | `/api/periods/{id}/daily` | dönemin her günü: 4 grubun harcaması |
|
||||||
//! | GET | `/api/stats/periods?limit=12` | son dönemler (eskiden yeniye): harcanabilir, harcanan, birikime aktarılan + toplam birikim |
|
//! | GET | `/api/stats/periods?limit=12` | son dönemler (eskiden yeniye): gelir, sabit gider, devirler, harcanabilir, harcanan, gerçekleşen birikim, birikime aktarılan + toplam birikim |
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
@@ -76,11 +76,24 @@ struct PeriodStatDto {
|
|||||||
last_date: String,
|
last_date: String,
|
||||||
status: PeriodStatus,
|
status: PeriodStatus,
|
||||||
#[ts(type = "number")]
|
#[ts(type = "number")]
|
||||||
|
income_total: i64,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
fixed_expense_total: i64,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
savings_goal: i64,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
carryover_debt: i64,
|
||||||
|
#[ts(type = "number")]
|
||||||
|
carryover_surplus: i64,
|
||||||
|
#[ts(type = "number")]
|
||||||
spendable: i64,
|
spendable: i64,
|
||||||
#[ts(type = "number")]
|
#[ts(type = "number")]
|
||||||
spent: i64,
|
spent: i64,
|
||||||
/// Harcanan, gruplara göre
|
/// Harcanan, gruplara göre
|
||||||
spent_by_group: GroupAmounts,
|
spent_by_group: GroupAmounts,
|
||||||
|
/// Gelir − sabit − borç + artı − harcanan (negatifse açık). Birikim oranı = bu ÷ gelir
|
||||||
|
#[ts(type = "number")]
|
||||||
|
realized_savings: i64,
|
||||||
/// Kapanmamışsa `null`
|
/// Kapanmamışsa `null`
|
||||||
#[ts(type = "number | null")]
|
#[ts(type = "number | null")]
|
||||||
to_savings: Option<i64>,
|
to_savings: Option<i64>,
|
||||||
@@ -100,14 +113,20 @@ pub async fn periods(
|
|||||||
.map(|x| {
|
.map(|x| {
|
||||||
let end = parse_date(&x.period.end_date).expect("geçerli bitiş tarihi");
|
let end = parse_date(&x.period.end_date).expect("geçerli bitiş tarihi");
|
||||||
PeriodStatDto {
|
PeriodStatDto {
|
||||||
|
realized_savings: x.realized_savings(),
|
||||||
id: x.period.id.to_string(),
|
id: x.period.id.to_string(),
|
||||||
name: x.period.name,
|
name: x.period.name,
|
||||||
start_date: x.period.start_date,
|
start_date: x.period.start_date,
|
||||||
last_date: format_date(end - time::Duration::days(1)),
|
last_date: format_date(end - time::Duration::days(1)),
|
||||||
status: x.period.status,
|
status: x.period.status,
|
||||||
spendable: x.spendable,
|
income_total: x.summary.income_total,
|
||||||
spent: x.spent,
|
fixed_expense_total: x.summary.fixed_expense_total,
|
||||||
spent_by_group: x.spent_by_group.into(),
|
savings_goal: x.summary.savings_goal,
|
||||||
|
carryover_debt: x.summary.carryover_debt,
|
||||||
|
carryover_surplus: x.summary.carryover_surplus,
|
||||||
|
spendable: x.summary.spendable,
|
||||||
|
spent: x.summary.spent_total,
|
||||||
|
spent_by_group: x.summary.spent_by_group.into(),
|
||||||
to_savings: x.period.to_savings,
|
to_savings: x.period.to_savings,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ pub mod period;
|
|||||||
pub mod plan;
|
pub mod plan;
|
||||||
pub mod rate_sources;
|
pub mod rate_sources;
|
||||||
pub mod rates;
|
pub mod rates;
|
||||||
|
pub mod reports;
|
||||||
pub mod review;
|
pub mod review;
|
||||||
pub mod seed;
|
pub mod seed;
|
||||||
pub mod stats;
|
pub mod stats;
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
//! Raporlar (KAKEIBO_PROMPT §8.8). Harcama kırılımı tek aggregation (`$facet`) ile:
|
||||||
|
//! grup, **tüm** kategoriler, ödeme türü, ay. Saklanmaz; her istekte hesaplanır.
|
||||||
|
//!
|
||||||
|
//! Filtre: dönem **ya da** tarih aralığı (ikisi de dahil), grup, kategori, ödeme türü
|
||||||
|
//! (`Some(None)` → ödeme türü girilmemiş kayıtlar). Silinen kayıtlar sayılmaz.
|
||||||
|
|
||||||
|
use mongodb::{
|
||||||
|
Database,
|
||||||
|
bson::{Bson, Document, Uuid, doc},
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
db::col,
|
||||||
|
error::{AppError, AppResult},
|
||||||
|
models::{common::scope, group::Group},
|
||||||
|
services::{calendar::parse_date, period},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Filter {
|
||||||
|
pub period_id: Option<Uuid>,
|
||||||
|
pub from: Option<String>,
|
||||||
|
pub to: Option<String>,
|
||||||
|
pub group: Option<Group>,
|
||||||
|
pub category_id: Option<Uuid>,
|
||||||
|
/// `Some(None)` → ödeme türü boş olanlar
|
||||||
|
pub payment_method: Option<Option<Uuid>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct Row<K> {
|
||||||
|
#[serde(rename = "_id")]
|
||||||
|
pub key: K,
|
||||||
|
pub total: i64,
|
||||||
|
pub count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CategoryKey {
|
||||||
|
pub category_id: Uuid,
|
||||||
|
pub group: Group,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Facets {
|
||||||
|
totals: Vec<Row<Bson>>,
|
||||||
|
groups: Vec<Row<Group>>,
|
||||||
|
categories: Vec<Row<CategoryKey>>,
|
||||||
|
methods: Vec<Row<Option<Uuid>>>,
|
||||||
|
months: Vec<Row<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Breakdown {
|
||||||
|
pub total: i64,
|
||||||
|
pub count: i64,
|
||||||
|
/// `Group::ALL` sırasıyla, harcama olmayan grup 0
|
||||||
|
pub groups: Vec<Row<Group>>,
|
||||||
|
/// Çoktan aza (eşitlikte kategori kimliği)
|
||||||
|
pub categories: Vec<Row<CategoryKey>>,
|
||||||
|
/// Çoktan aza; `None` = ödeme türü girilmemiş
|
||||||
|
pub methods: Vec<Row<Option<Uuid>>>,
|
||||||
|
/// `"YYYY-MM"`, eskiden yeniye
|
||||||
|
pub months: Vec<Row<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn breakdown(db: &Database, household_id: Uuid, f: Filter) -> AppResult<Breakdown> {
|
||||||
|
let mut m = scope(household_id);
|
||||||
|
if let Some(pid) = f.period_id {
|
||||||
|
period::get(db, household_id, pid).await?; // bu haneye ait mi?
|
||||||
|
m.insert("period_id", pid);
|
||||||
|
}
|
||||||
|
let mut range = Document::new();
|
||||||
|
for (op, v) in [("$gte", &f.from), ("$lte", &f.to)] {
|
||||||
|
if let Some(d) = v {
|
||||||
|
let d = d.trim();
|
||||||
|
parse_date(d).ok_or_else(|| AppError::BadRequest("Tarih geçersiz".into()))?;
|
||||||
|
range.insert(op, d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let (Some(a), Some(b)) = (&f.from, &f.to)
|
||||||
|
&& a.trim() > b.trim()
|
||||||
|
{
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"Başlangıç tarihi bitişten sonra olamaz".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !range.is_empty() {
|
||||||
|
m.insert("date", range);
|
||||||
|
}
|
||||||
|
if let Some(g) = f.group {
|
||||||
|
m.insert("group", g.as_str());
|
||||||
|
}
|
||||||
|
if let Some(c) = f.category_id {
|
||||||
|
m.insert("category_id", c);
|
||||||
|
}
|
||||||
|
if let Some(pm) = f.payment_method {
|
||||||
|
m.insert("payment_method_id", pm.map_or(Bson::Null, Bson::from));
|
||||||
|
}
|
||||||
|
|
||||||
|
let sum = || doc! { "total": { "$sum": "$amount" }, "count": { "$sum": Bson::Int64(1) } };
|
||||||
|
let group_by = |key: Bson| {
|
||||||
|
let mut g = doc! { "_id": key };
|
||||||
|
g.extend(sum());
|
||||||
|
doc! { "$group": g }
|
||||||
|
};
|
||||||
|
let pipeline = vec![
|
||||||
|
doc! { "$match": m },
|
||||||
|
doc! { "$facet": {
|
||||||
|
"totals": [ group_by(Bson::Null) ],
|
||||||
|
"groups": [ group_by(Bson::String("$group".into())) ],
|
||||||
|
"categories": [
|
||||||
|
group_by(Bson::Document(doc! { "category_id": "$category_id", "group": "$group" })),
|
||||||
|
{ "$sort": { "total": -1, "_id.category_id": 1 } },
|
||||||
|
],
|
||||||
|
"methods": [
|
||||||
|
// Alanı hiç olmayan eski kayıtlar da "girilmemiş" sayılsın
|
||||||
|
group_by(Bson::Document(doc! { "$ifNull": ["$payment_method_id", null] })),
|
||||||
|
{ "$sort": { "total": -1, "_id": 1 } },
|
||||||
|
],
|
||||||
|
"months": [
|
||||||
|
group_by(Bson::Document(doc! { "$substrBytes": ["$date", 0, 7] })),
|
||||||
|
{ "$sort": { "_id": 1 } },
|
||||||
|
],
|
||||||
|
} },
|
||||||
|
];
|
||||||
|
let mut cursor = db
|
||||||
|
.collection::<Document>(col::ENTRIES)
|
||||||
|
.aggregate(pipeline)
|
||||||
|
.with_type::<Facets>()
|
||||||
|
.await?;
|
||||||
|
let f = if cursor.advance().await? {
|
||||||
|
cursor.deserialize_current()?
|
||||||
|
} else {
|
||||||
|
return Err(AppError::Internal("rapor sorgusu boş döndü".into()));
|
||||||
|
};
|
||||||
|
|
||||||
|
let (total, count) = f.totals.first().map_or((0, 0), |r| (r.total, r.count));
|
||||||
|
let groups = Group::ALL
|
||||||
|
.into_iter()
|
||||||
|
.map(|g| {
|
||||||
|
f.groups.iter().find(|r| r.key == g).map_or(
|
||||||
|
Row {
|
||||||
|
key: g,
|
||||||
|
total: 0,
|
||||||
|
count: 0,
|
||||||
|
},
|
||||||
|
|r| Row {
|
||||||
|
key: g,
|
||||||
|
total: r.total,
|
||||||
|
count: r.count,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(Breakdown {
|
||||||
|
total,
|
||||||
|
count,
|
||||||
|
groups,
|
||||||
|
categories: f.categories,
|
||||||
|
methods: f.methods,
|
||||||
|
months: f.months,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -57,9 +57,17 @@ pub async fn daily(db: &Database, household_id: Uuid, period_id: Uuid) -> AppRes
|
|||||||
|
|
||||||
pub struct PeriodStat {
|
pub struct PeriodStat {
|
||||||
pub period: Period,
|
pub period: Period,
|
||||||
pub spendable: i64,
|
/// Gelir, sabit gider, devirler, harcanabilir, harcanan (gruplara göre dahil)
|
||||||
pub spent: i64,
|
pub summary: budget::Summary,
|
||||||
pub spent_by_group: GroupAmounts,
|
}
|
||||||
|
|
||||||
|
impl PeriodStat {
|
||||||
|
/// Değerlendirmenin 4. sorusu: gelir − sabit − borç + artı − harcanan (`review` ile aynı)
|
||||||
|
pub fn realized_savings(&self) -> i64 {
|
||||||
|
let s = &self.summary;
|
||||||
|
s.income_total - s.fixed_expense_total - s.carryover_debt + s.carryover_surplus
|
||||||
|
- s.spent_total
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Stats {
|
pub struct Stats {
|
||||||
@@ -74,13 +82,8 @@ pub async fn periods(db: &Database, household_id: Uuid, limit: usize) -> AppResu
|
|||||||
let total_saved = list.iter().filter_map(|p| p.to_savings).sum();
|
let total_saved = list.iter().filter_map(|p| p.to_savings).sum();
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for p in list.into_iter().take(limit.clamp(1, MAX_PERIODS)) {
|
for p in list.into_iter().take(limit.clamp(1, MAX_PERIODS)) {
|
||||||
let s = budget::summary(db, household_id, p.id).await?;
|
let summary = budget::summary(db, household_id, p.id).await?;
|
||||||
out.push(PeriodStat {
|
out.push(PeriodStat { period: p, summary });
|
||||||
period: p,
|
|
||||||
spendable: s.spendable,
|
|
||||||
spent: s.spent_total,
|
|
||||||
spent_by_group: s.spent_by_group,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
out.reverse();
|
out.reverse();
|
||||||
Ok(Stats {
|
Ok(Stats {
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
//! Raporlar: harcama kırılımı (grup, tüm kategoriler, ödeme türü, ay) ve filtreler.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use common::{Session, TestApp};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
async fn id_by_name(app: &TestApp, s: &Session, path: &str, name: &str) -> String {
|
||||||
|
let (_, list) = app.get(path, s).await;
|
||||||
|
list.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|c| c["name"] == name)
|
||||||
|
.unwrap()["id"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn spend(
|
||||||
|
app: &TestApp,
|
||||||
|
s: &Session,
|
||||||
|
pid: &str,
|
||||||
|
date: &str,
|
||||||
|
amount: i64,
|
||||||
|
cat: &str,
|
||||||
|
pm: Option<&str>,
|
||||||
|
) -> Value {
|
||||||
|
let mut body = json!({ "date": date, "amount": amount, "category_id": cat });
|
||||||
|
if let Some(pm) = pm {
|
||||||
|
body["payment_method_id"] = json!(pm);
|
||||||
|
}
|
||||||
|
let (st, e) = app.post(&format!("/periods/{pid}/entries"), s, body).await;
|
||||||
|
assert_eq!(st, StatusCode::CREATED, "{e}");
|
||||||
|
e
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn report(app: &TestApp, s: &Session, query: &str) -> Value {
|
||||||
|
let (st, b) = app.get(&format!("/reports/breakdown{query}"), s).await;
|
||||||
|
assert_eq!(st, StatusCode::OK, "{query} → {b}");
|
||||||
|
b
|
||||||
|
}
|
||||||
|
|
||||||
|
fn totals(list: &Value, key: &str) -> Vec<(String, i64)> {
|
||||||
|
list.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|r| {
|
||||||
|
(
|
||||||
|
r[key].as_str().unwrap_or("-").to_string(),
|
||||||
|
r["total"].as_i64().unwrap(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn breakdown_with_filters() {
|
||||||
|
let app = TestApp::new().await;
|
||||||
|
let s = app.register("Test", None).await;
|
||||||
|
let market = id_by_name(&app, &s, "/categories", "Market").await;
|
||||||
|
let giyim = id_by_name(&app, &s, "/categories", "Giyim").await;
|
||||||
|
let kitap = id_by_name(&app, &s, "/categories", "Kitap").await;
|
||||||
|
let nakit = id_by_name(&app, &s, "/payment-methods", "Nakit").await;
|
||||||
|
let kart = id_by_name(&app, &s, "/payment-methods", "Kredi Kartı").await;
|
||||||
|
|
||||||
|
let (_, oct) = app
|
||||||
|
.post(
|
||||||
|
"/periods",
|
||||||
|
&s,
|
||||||
|
json!({ "start_date": "2026-10-01", "end_date": "2026-11-01" }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let oct = oct["id"].as_str().unwrap().to_string();
|
||||||
|
spend(&app, &s, &oct, "2026-10-05", 100_000, &market, Some(&nakit)).await;
|
||||||
|
spend(&app, &s, &oct, "2026-10-20", 50_000, &giyim, None).await;
|
||||||
|
app.post(&format!("/periods/{oct}/close"), &s, json!({}))
|
||||||
|
.await;
|
||||||
|
let (_, nov) = app
|
||||||
|
.post(
|
||||||
|
"/periods",
|
||||||
|
&s,
|
||||||
|
json!({ "start_date": "2026-11-01", "end_date": "2026-12-01" }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let nov = nov["id"].as_str().unwrap().to_string();
|
||||||
|
spend(&app, &s, &nov, "2026-11-02", 30_000, &market, Some(&kart)).await;
|
||||||
|
spend(&app, &s, &nov, "2026-11-03", 20_000, &kitap, Some(&nakit)).await;
|
||||||
|
spend(&app, &s, &nov, "2026-11-04", 10_000, &market, Some(&nakit)).await;
|
||||||
|
let gone = spend(&app, &s, &nov, "2026-11-05", 99_000, &kitap, None).await;
|
||||||
|
app.delete(&format!("/entries/{}", gone["id"].as_str().unwrap()), &s)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Filtresiz: tüm zamanlar
|
||||||
|
let r = report(&app, &s, "").await;
|
||||||
|
assert_eq!(r["total"], 210_000);
|
||||||
|
assert_eq!(r["count"], 5, "silinen sayılmaz");
|
||||||
|
assert_eq!(
|
||||||
|
totals(&r["groups"], "group"),
|
||||||
|
[
|
||||||
|
("needs".into(), 140_000),
|
||||||
|
("wants".into(), 50_000),
|
||||||
|
("culture".into(), 20_000),
|
||||||
|
("unexpected".into(), 0)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
totals(&r["categories"], "category_id"),
|
||||||
|
[
|
||||||
|
(market.clone(), 140_000),
|
||||||
|
(giyim.clone(), 50_000),
|
||||||
|
(kitap.clone(), 20_000)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(r["categories"][0]["count"], 3);
|
||||||
|
assert_eq!(r["categories"][0]["group"], "needs");
|
||||||
|
assert_eq!(
|
||||||
|
totals(&r["payment_methods"], "payment_method_id"),
|
||||||
|
[
|
||||||
|
(nakit.clone(), 130_000),
|
||||||
|
("-".into(), 50_000),
|
||||||
|
(kart.clone(), 30_000)
|
||||||
|
],
|
||||||
|
"ödeme türü girilmemiş → null"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
totals(&r["months"], "month"),
|
||||||
|
[("2026-10".into(), 150_000), ("2026-11".into(), 60_000)]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dönem
|
||||||
|
assert_eq!(
|
||||||
|
report(&app, &s, &format!("?period_id={nov}")).await["total"],
|
||||||
|
60_000
|
||||||
|
);
|
||||||
|
// Tarih aralığı iki dönemi keser (ikisi de dahil)
|
||||||
|
let r = report(&app, &s, "?from=2026-10-15&to=2026-11-03").await;
|
||||||
|
assert_eq!(
|
||||||
|
(r["total"].as_i64(), r["count"].as_i64()),
|
||||||
|
(Some(100_000), Some(3))
|
||||||
|
);
|
||||||
|
// Grup, kategori, ödeme türü (+ girilmemiş)
|
||||||
|
assert_eq!(report(&app, &s, "?group=needs").await["total"], 140_000);
|
||||||
|
assert_eq!(
|
||||||
|
report(&app, &s, &format!("?category_id={kitap}")).await["total"],
|
||||||
|
20_000
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
report(&app, &s, &format!("?payment_method_id={nakit}")).await["total"],
|
||||||
|
130_000
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
report(&app, &s, "?payment_method_id=none").await["total"],
|
||||||
|
50_000
|
||||||
|
);
|
||||||
|
// Birleşik
|
||||||
|
let r = report(
|
||||||
|
&app,
|
||||||
|
&s,
|
||||||
|
&format!("?period_id={nov}&group=needs&payment_method_id={nakit}"),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
(r["total"].as_i64(), r["count"].as_i64()),
|
||||||
|
(Some(10_000), Some(1))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Hatalı filtreler
|
||||||
|
for q in [
|
||||||
|
"?from=2026-11-10&to=2026-11-01",
|
||||||
|
"?from=2026-02-30",
|
||||||
|
"?group=xyz",
|
||||||
|
"?category_id=abc",
|
||||||
|
] {
|
||||||
|
let (st, b) = app.get(&format!("/reports/breakdown{q}"), &s).await;
|
||||||
|
assert_eq!(st, StatusCode::BAD_REQUEST, "{q} → {b}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Başka hane: kendi verisi (boş); bu hanenin dönemi → 404
|
||||||
|
let other = app.register("Diger", None).await;
|
||||||
|
let r = report(&app, &other, "").await;
|
||||||
|
assert_eq!(r["total"], 0);
|
||||||
|
assert_eq!(r["groups"].as_array().unwrap().len(), 4);
|
||||||
|
assert_eq!(r["categories"], json!([]));
|
||||||
|
let (st, _) = app
|
||||||
|
.get(&format!("/reports/breakdown?period_id={nov}"), &other)
|
||||||
|
.await;
|
||||||
|
assert_eq!(st, StatusCode::NOT_FOUND);
|
||||||
|
app.cleanup().await;
|
||||||
|
}
|
||||||
@@ -151,6 +151,17 @@ async fn period_stats_and_total_savings() {
|
|||||||
assert_eq!(list[1]["spent"], 42_500);
|
assert_eq!(list[1]["spent"], 42_500);
|
||||||
assert_eq!(list[1]["spent_by_group"]["needs"], 40_000);
|
assert_eq!(list[1]["spent_by_group"]["needs"], 40_000);
|
||||||
assert_eq!(list[1]["spent_by_group"]["culture"], 2_500);
|
assert_eq!(list[1]["spent_by_group"]["culture"], 2_500);
|
||||||
|
assert_eq!(list[1]["income_total"], 2_000_000);
|
||||||
|
assert_eq!(list[1]["savings_goal"], 100_000);
|
||||||
|
assert_eq!(list[1]["carryover_surplus"], 200_000);
|
||||||
|
assert_eq!(
|
||||||
|
list[0]["realized_savings"], 700_000,
|
||||||
|
"Kasım: 10.000 − 3.000"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
list[1]["realized_savings"], 2_157_500,
|
||||||
|
"20.000 + 2.000 artı − 425 harcanan (hedef düşülmez)"
|
||||||
|
);
|
||||||
assert_eq!(list[1]["to_savings"], Value::Null);
|
assert_eq!(list[1]["to_savings"], Value::Null);
|
||||||
|
|
||||||
// limit: en yeni N dönem
|
// limit: en yeni N dönem
|
||||||
|
|||||||
+6
-1
@@ -1,10 +1,11 @@
|
|||||||
# 🧠 Proje Hafızası
|
# 🧠 Proje Hafızası
|
||||||
|
|
||||||
## ▶️ Sıradaki Adım
|
## ▶️ Sıradaki Adım
|
||||||
**Faz 9 planı:** Raporlama + JSON/CSV içe–dışa aktarma + aylık değerlendirme çıktısı. Önce `KAKEIBO_PROMPT.md` §8.8–8.9 (+ `templates/monthly_report.md`) oku; rapor biçimini kullanıcı verecek (açık konu). Kullanıcı şartı: raporlarda **tüm** kategorilerin dağılımı. Adımları PHASES'e yaz, onaylat. Faz 9'da Dokploy adımları da var (§12.2) — canlı kurulum zaten yapıldı.
|
**Faz 9 – Adım 3:** Uygulamanın kendi CSV'si (harcamalar, plan kalemleri, varlık işlemleri): dışa aktarma (backend ya da frontend; `lib/csv.ts` biçimi: BOM, `;`, `1250,50`, GG.AA.YYYY) + içe aktarma (önizleme → satır satır hata → geçerliler aktarılır; kategori/ödeme türü **adla** eşleşir; dönem tarihten bulunur, yazılamaz dönem → satır hatası). Adım 4 (ekstre) bunun üstüne kurulacak: ortak satır doğrulama/aktarma servisi tasarla.
|
||||||
**Bekleyen (kullanıcıda):** Faz 5 – Adım 5 canlı yedek kurulumu (`docs/DEPLOY.md` §6.1–6.3) → çıktılar gelince Faz 5 ✅.
|
**Bekleyen (kullanıcıda):** Faz 5 – Adım 5 canlı yedek kurulumu (`docs/DEPLOY.md` §6.1–6.3) → çıktılar gelince Faz 5 ✅.
|
||||||
|
|
||||||
## 📌 Alınan Kararlar
|
## 📌 Alınan Kararlar
|
||||||
|
- **Faz 9 kararları (kullanıcı, 2026-11-13):** (1) Aylık rapor biçimini **Claude tasarlar**: müşteri gözüyle ne yaptım / ne yapılabilirdi / ne yapılmalıydı. (2) JSON yedek: Claude best practice analizi yapıp kullanıcıyı üzmeyen çözüm üretir (öneri: sunucuda otomatik geri dönüş noktası + "geri al", önizleme, sahip). (3) **Tüm kredi kartı ve nakit/banka hesabı ekstreleri** içe aktarılabilsin; amaç **yıl başından beri** veriyi toplayıp sonraki aylara referans; grup/kategori eşleştirmesi kullanıcıya bırakılabilir → ayrı adım (Faz 9 Adım 4), geçmiş dönemler gerekir.
|
||||||
- **Yeniden kapatmada birikim (kullanıcı "a", 2026-11-13):** bağlı TL alımı yeni tutara güncellenir (0 → silinir).
|
- **Yeniden kapatmada birikim (kullanıcı "a", 2026-11-13):** bağlı TL alımı yeni tutara güncellenir (0 → silinir).
|
||||||
- **Faz 8 kararları (kullanıcı, 2026-11-13):** kur kaynakları TCMB (döviz) + xaus.com ons×TCMB USD (altın; çeyrek/yarım/tam has gram ile) + manuel; değerleme **alış kuru**; kapanış birikimi onayla TL varlığına alım (tekrar eklenmez), geçmiş kapanışlar "bekleyen birikimler".
|
- **Faz 8 kararları (kullanıcı, 2026-11-13):** kur kaynakları TCMB (döviz) + xaus.com ons×TCMB USD (altın; çeyrek/yarım/tam has gram ile) + manuel; değerleme **alış kuru**; kapanış birikimi onayla TL varlığına alım (tekrar eklenmez), geçmiş kapanışlar "bekleyen birikimler".
|
||||||
- **Kategori yönetimi ekranı (kullanıcı, 2026-11-13): Faz 10'da** (seçenek b). Faz 8 önce.
|
- **Kategori yönetimi ekranı (kullanıcı, 2026-11-13): Faz 10'da** (seçenek b). Faz 8 önce.
|
||||||
@@ -130,3 +131,7 @@
|
|||||||
- **Adım 2:** Kurlar: `reqwest` 0.12 (`rustls-tls` → MongoDB ile aynı `ring`; 0.13 `aws-lc-rs` getiriyordu), `quick-xml` (serialize). `services/rate_sources.rs` (saf: `parse_tcmb` ForexBuying ÷ Unit, boş alış atlanır, `decimal_e6` kayan noktasız; `parse_gold` xaus `per_gram_usd`; `gold_rates` has gram katsayıları; testler örnek metinle), `services/rates.rs` (`refresh`: iki kaynak bağımsız, TCMB düşerse DB'deki son USD; gün başına upsert; `spawn_refresher` main'de, `RATES_REFRESH_MINUTES` varsayılan 60 / 0 kapalı; `current`: aggregation kod×(oto|hane) en yeni → en yeni tarih, eşitlikte manuel; `set_manual`/`delete_manual`; `portfolio` + `value_of` yarım yukarı). `GET /portfolio`, `POST /rates`, `DELETE /rates/{id}`. `calendar::today_tr` (UTC+3). Testler sahte axum sunucusuyla (internet yok): `tests/rates.rs` (2) + birim (4). Mutasyon: öncelik, USD yedeği yakalandı; yuvarlama kaçtı → `value_of` birim testi eklendi, artık yakalıyor. Gerçek kaynak denemesi (test DB): TCMB 22 döviz, altın 5 kod (gram 6.253,82, çeyrek 10.024,88 ₺). Backend 145 test. DEPLOY §2 + `.env.example`: `RATES_REFRESH_MINUTES`, konteynerin internete çıkışı gerekir.
|
- **Adım 2:** Kurlar: `reqwest` 0.12 (`rustls-tls` → MongoDB ile aynı `ring`; 0.13 `aws-lc-rs` getiriyordu), `quick-xml` (serialize). `services/rate_sources.rs` (saf: `parse_tcmb` ForexBuying ÷ Unit, boş alış atlanır, `decimal_e6` kayan noktasız; `parse_gold` xaus `per_gram_usd`; `gold_rates` has gram katsayıları; testler örnek metinle), `services/rates.rs` (`refresh`: iki kaynak bağımsız, TCMB düşerse DB'deki son USD; gün başına upsert; `spawn_refresher` main'de, `RATES_REFRESH_MINUTES` varsayılan 60 / 0 kapalı; `current`: aggregation kod×(oto|hane) en yeni → en yeni tarih, eşitlikte manuel; `set_manual`/`delete_manual`; `portfolio` + `value_of` yarım yukarı). `GET /portfolio`, `POST /rates`, `DELETE /rates/{id}`. `calendar::today_tr` (UTC+3). Testler sahte axum sunucusuyla (internet yok): `tests/rates.rs` (2) + birim (4). Mutasyon: öncelik, USD yedeği yakalandı; yuvarlama kaçtı → `value_of` birim testi eklendi, artık yakalıyor. Gerçek kaynak denemesi (test DB): TCMB 22 döviz, altın 5 kod (gram 6.253,82, çeyrek 10.024,88 ₺). Backend 145 test. DEPLOY §2 + `.env.example`: `RATES_REFRESH_MINUTES`, konteynerin internete çıkışı gerekir.
|
||||||
- **Adım 3:** Birikim sayfası (`features/savings/`): `page.tsx` (özet: toplam değer + son kur zamanı, maliyet, kâr/zarar `stacked`, gerçekleşen; uyarılar: kur hiç yok / 6 saattir çekilemedi / kursuz varlık; dağılım halka grafiği `allocation-chart.tsx` lazy 6 KB, renkli liste = metin özeti; varlık listesi: elde/geçmişi olanlar + `<details>` "Diğer varlıklar"; `add-currency.tsx`), `asset-page.tsx` (`/birikim/:id`: durum kartları, `RateBox` elle kur gir/kaldır, `TxFormView` `tx-form.tsx` alım/satım: miktar × birim fiyat → tutar önerisi, elle değişen tutar korunur, yeni alımda birim fiyat = güncel kur (kuruşa yuvarlı), TL'de yalnız tutar, satışta eldeki sınırı; geçmiş: düzenle + iki adımlı sil; pasife al/etkinleştir). `lib/decimal.ts` (`parseE6`: virgül ondalık, tek nokta + 3 rakam binlik, `formatE6`, `formatRate`, `valueKurus` BigInt = backend), `lib/savings.ts` (`plRatio`, `formatPlPercent` `-%0,6` ve ≈0 gizli, `isFetchStale` 6 saat, `rateAgeDays`, `splitAssets`, `allocation`, `kindEmoji`, `qty`), `lib/assets.ts` (kancalar, hepsi `['portfolio']` yeniler), `components/decimal-input.tsx`, `formatDateTime`. Frontend 114 test; e2e 37 kontrol (kurlar DB'ye elle, `RATES_REFRESH_MINUTES=0`). Düzeltilen: `%-0,0`, TL satırında tekrar, mobil özet taşması; Tailwind keyfi alt seçici (`[&>*]`) `lg:` ile ezilemedi → sınıflar öğenin kendisine.
|
- **Adım 3:** Birikim sayfası (`features/savings/`): `page.tsx` (özet: toplam değer + son kur zamanı, maliyet, kâr/zarar `stacked`, gerçekleşen; uyarılar: kur hiç yok / 6 saattir çekilemedi / kursuz varlık; dağılım halka grafiği `allocation-chart.tsx` lazy 6 KB, renkli liste = metin özeti; varlık listesi: elde/geçmişi olanlar + `<details>` "Diğer varlıklar"; `add-currency.tsx`), `asset-page.tsx` (`/birikim/:id`: durum kartları, `RateBox` elle kur gir/kaldır, `TxFormView` `tx-form.tsx` alım/satım: miktar × birim fiyat → tutar önerisi, elle değişen tutar korunur, yeni alımda birim fiyat = güncel kur (kuruşa yuvarlı), TL'de yalnız tutar, satışta eldeki sınırı; geçmiş: düzenle + iki adımlı sil; pasife al/etkinleştir). `lib/decimal.ts` (`parseE6`: virgül ondalık, tek nokta + 3 rakam binlik, `formatE6`, `formatRate`, `valueKurus` BigInt = backend), `lib/savings.ts` (`plRatio`, `formatPlPercent` `-%0,6` ve ≈0 gizli, `isFetchStale` 6 saat, `rateAgeDays`, `splitAssets`, `allocation`, `kindEmoji`, `qty`), `lib/assets.ts` (kancalar, hepsi `['portfolio']` yeniler), `components/decimal-input.tsx`, `formatDateTime`. Frontend 114 test; e2e 37 kontrol (kurlar DB'ye elle, `RATES_REFRESH_MINUTES=0`). Düzeltilen: `%-0,0`, TL satırında tekrar, mobil özet taşması; Tailwind keyfi alt seçici (`[&>*]`) `lg:` ile ezilemedi → sınıflar öğenin kendisine.
|
||||||
- **Adım 4:** Kapanış → birikim. Backend (`services/asset.rs`): `add_period_savings` (yalnız `closed`, `to_savings > 0`, dönem başına tek → 409; TL varlığı pasif olsa da; not "<ad> kapanışı", tarih son gün), `pending_period_savings`, `plan_savings_sync` (transaction öncesi: bağlı kayıt + TL geçmişi denetimi → `SavingsSync::Update/Delete`) + `apply_savings_sync` (kapatma transaction'ı içinde). `PeriodCloseResult.savings_linked`. `GET /savings/pending`, `POST /periods/{id}/to-savings`. `tests/savings_link.rs` (3), mutasyon 3/3. Frontend: kapatma sonucunda `AddToSavings` (onay düğmesi → "Birikime eklendi" + bağlantı; yeniden kapatmada "kayıt güncellendi"), `features/savings/pending.tsx` (Birikimler üstünde), Panel "Toplam birikim" = `portfolio.value_total` (`stats.total_saved` artık arayüzde kullanılmıyor; backend'de duruyor). `useClosePeriod` portföyü de yeniler. Backend 149, frontend 114 test; e2e 14 kontrol. **Faz 8 ✅.**
|
- **Adım 4:** Kapanış → birikim. Backend (`services/asset.rs`): `add_period_savings` (yalnız `closed`, `to_savings > 0`, dönem başına tek → 409; TL varlığı pasif olsa da; not "<ad> kapanışı", tarih son gün), `pending_period_savings`, `plan_savings_sync` (transaction öncesi: bağlı kayıt + TL geçmişi denetimi → `SavingsSync::Update/Delete`) + `apply_savings_sync` (kapatma transaction'ı içinde). `PeriodCloseResult.savings_linked`. `GET /savings/pending`, `POST /periods/{id}/to-savings`. `tests/savings_link.rs` (3), mutasyon 3/3. Frontend: kapatma sonucunda `AddToSavings` (onay düğmesi → "Birikime eklendi" + bağlantı; yeniden kapatmada "kayıt güncellendi"), `features/savings/pending.tsx` (Birikimler üstünde), Panel "Toplam birikim" = `portfolio.value_total` (`stats.total_saved` artık arayüzde kullanılmıyor; backend'de duruyor). `useClosePeriod` portföyü de yeniler. Backend 149, frontend 114 test; e2e 14 kontrol. **Faz 8 ✅.**
|
||||||
|
|
||||||
|
### 2026-11-13 — Faz 9
|
||||||
|
- **Adım 1:** Rapor verileri. `/stats/periods` → `PeriodStat` artık `summary: budget::Summary` taşır; DTO'ya `income_total`, `fixed_expense_total`, `savings_goal`, `carryover_debt/surplus`, `realized_savings` (= review 4. soru) eklendi. `services/reports.rs` + `GET /reports/breakdown` (tek `$facet`: toplam, 4 grup sabit sırada (0 dahil), **tüm** kategoriler çoktan aza, ödeme türü (`$ifNull` → `null` = girilmemiş), ay `$substrBytes`; filtre dönem/tarih aralığı (dahil, başlangıç>bitiş 400)/grup/kategori/ödeme türü (`none`)). Bütçe sapması için yeni uç nokta yok (mevcut `/budget`). `tests/reports.rs` (1, kapsamlı) + stats testi genişledi. Mutasyon 2/2. Backend 155 test.
|
||||||
|
- **Adım 2:** Raporlar sayfası (`features/reports/`): `page.tsx` (filtre adreste: `donem`/`baslangic`/`bitis`/`grup`/`kategori`/`odeme` (`yok` = girilmemiş), dönem varsa tarih yok sayılır), `filters.tsx` (Kapsam: tüm zamanlar / dönem / tarih aralığı (yıl başı → bugün varsayılan), grup seçilince kategori listesi daralır, mobilde katlanır), `section.tsx` (`ReportSection`: `data-report`, CSV indir + bölüm yazdır; `ShareTable`: satır içi CSS pay çubukları — yazdırmada da görünür), `sections.tsx` (gruplar, ödeme türleri, **tüm** kategoriler, aylar = filtreli; bütçe sapması = seçili/açık/son dönem, grup + hafta; gelir–gider + birikim oranı = son 12 dönem; varlıklar), `charts.tsx` lazy (bar + çizgi). `lib/csv.ts` (BOM, `;`, CRLF, tırnaklama, `csvMoney` binliksiz), `lib/reports.ts` (`parseFilter`/`filterToParams`/`filterToQuery`, `describeFilter` → yazdırmada kapsam satırı, `savingsRate`, `deviation`, `formatMonth`), `lib/print.ts` (`installPrintTheme`: beforeprint'te koyu tema kaldırılır; `printSection`: `data-print-one` + `data-print-target`), index.css `@media print`, layout `print:hidden`. Kayıt değişince `['reports']` yenilenir. Frontend 121 test; e2e 34 kontrol (CSV baytları, yazdırma hedefi, tema, filtreler). Not: `Blob.text()` BOM'u siler → BOM'u baytlardan doğrula.
|
||||||
|
|||||||
+9
-1
@@ -13,7 +13,7 @@
|
|||||||
| **Faz 6** | Ay sonu değerlendirmesi + dönem kapatma/devir sihirbazı | Tam dönem döngüsü | ✅ |
|
| **Faz 6** | Ay sonu değerlendirmesi + dönem kapatma/devir sihirbazı | Tam dönem döngüsü | ✅ |
|
||||||
| **Faz 7** | Dashboard: özet kartları ve grafikler | Ana ekran | ✅ |
|
| **Faz 7** | Dashboard: özet kartları ve grafikler | Ana ekran | ✅ |
|
||||||
| **Faz 8** | Birikimler/varlıklar, kur entegrasyonu, kâr/zarar | Birikim modülü | ✅ |
|
| **Faz 8** | Birikimler/varlıklar, kur entegrasyonu, kâr/zarar | Birikim modülü | ✅ |
|
||||||
| **Faz 9** | Raporlama + JSON/CSV içe–dışa aktarma + aylık değerlendirme çıktısı | Raporlar | ⏳ |
|
| **Faz 9** | Raporlama + JSON/CSV içe–dışa aktarma + aylık değerlendirme çıktısı | Raporlar | 🔄 |
|
||||||
| **Faz 10** | PWA, performans, erişilebilirlik, UI cilası + **Ayarlar: kategori / ödeme türü yönetimi** | Telefona kurulabilir web uygulaması | ⏳ |
|
| **Faz 10** | PWA, performans, erişilebilirlik, UI cilası + **Ayarlar: kategori / ödeme türü yönetimi** | Telefona kurulabilir web uygulaması | ⏳ |
|
||||||
| **Faz 11+** | Tauri 2: Windows, Linux, Android (çevrimdışı + senkronizasyon) | Masaüstü ve mobil uygulamalar | ⏳ |
|
| **Faz 11+** | Tauri 2: Windows, Linux, Android (çevrimdışı + senkronizasyon) | Masaüstü ve mobil uygulamalar | ⏳ |
|
||||||
|
|
||||||
@@ -77,3 +77,11 @@
|
|||||||
- ✅ 2. Backend: kurlar — TCMB `today.xml` (döviz) + xaus.com ons fiyatı × TCMB USD (altın; çeyrek/yarım/tam has gram katsayısıyla), saatlik arka plan görevi + önbellek (`exchange_rates`), kaynak erişilemezse son kur + zamanı; **manuel kur** (hane); portföy değerleme uç noktası (güncel değer, kâr/zarar tutar ve %, toplamlar, dağılım); kaynak ayrıştırıcıları örnek dosyalarla testli
|
- ✅ 2. Backend: kurlar — TCMB `today.xml` (döviz) + xaus.com ons fiyatı × TCMB USD (altın; çeyrek/yarım/tam has gram katsayısıyla), saatlik arka plan görevi + önbellek (`exchange_rates`), kaynak erişilemezse son kur + zamanı; **manuel kur** (hane); portföy değerleme uç noktası (güncel değer, kâr/zarar tutar ve %, toplamlar, dağılım); kaynak ayrıştırıcıları örnek dosyalarla testli
|
||||||
- ✅ 3. Frontend: Birikim sayfası — portföy özeti + halka grafik, varlık listesi (miktar, ortalama maliyet, güncel kur, değer, kâr/zarar), alım/satım formu, işlem geçmişi (düzenle/sil), manuel kur, eski kur uyarısı
|
- ✅ 3. Frontend: Birikim sayfası — portföy özeti + halka grafik, varlık listesi (miktar, ortalama maliyet, güncel kur, değer, kâr/zarar), alım/satım formu, işlem geçmişi (düzenle/sil), manuel kur, eski kur uyarısı
|
||||||
- ✅ 4. Kapanış entegrasyonu: birikime aktarılan tutar onayla TL varlığına eklenir (`source_period_id`, tekrar eklenmez); geçmiş kapanışlar için "bekleyen birikimler" listesi; Panel "Toplam birikim" → portföyün güncel değeri
|
- ✅ 4. Kapanış entegrasyonu: birikime aktarılan tutar onayla TL varlığına eklenir (`source_period_id`, tekrar eklenmez); geçmiş kapanışlar için "bekleyen birikimler" listesi; Panel "Toplam birikim" → portföyün güncel değeri
|
||||||
|
|
||||||
|
## Faz 9 Adımları
|
||||||
|
- ✅ 1. Backend: rapor verileri — dönem istatistiklerine gelir/sabit gider/devir/gerçekleşen birikim (birikim oranı için) + `GET /reports/breakdown` (grup / **tüm kategoriler** / ödeme türü / ay; filtre: dönem ya da tarih aralığı, grup, kategori, ödeme türü). Bütçe sapması mevcut `budget` verisinden
|
||||||
|
- ✅ 2. Frontend: Raporlar sayfası — filtreler (adreste), gelir–gider çubuk grafiği, kategori kırılımı (tablo + grafik), bütçe sapması, ödeme türü dağılımı, birikim oranı trendi, varlık performansı; her rapor CSV (UTF-8 BOM, `;`) + yazdır
|
||||||
|
- ⏳ 3. CSV dışa/içe aktarma (uygulamanın kendi biçimi): harcamalar, plan kalemleri, varlık işlemleri; içe aktarmada önizleme + satır satır hata raporu
|
||||||
|
- ⏳ 4. **Banka / kredi kartı ekstresi içe aktarma** (kullanıcı isteği): CSV/Excel, sütun eşleştirme (banka profili kaydedilir), harcama/gelir yönü, tekrar eden satır tespiti, açıklamadan kategori önerisi (öğrenen kural), grup/kategori eşleştirmesi kullanıcıda; eksik aylar için **geçmiş (arşiv) dönemler** — amaç yıl başından beri veri → sonraki aylara referans (ortalama önerileri)
|
||||||
|
- ⏳ 5. JSON tam hane yedeği: sürümlü dışa aktarma; geri yüklemede önizleme + onay + sunucuda otomatik geri dönüş noktası ("geri yüklemeyi geri al"); başka haneden taşıma
|
||||||
|
- ⏳ 6. Aylık değerlendirme çıktısı: `templates/monthly_report.md` (Claude tasarlar — müşteri gözüyle: ne yaptım, ne iyi/kötü gitti, ne yapmalıydım, gelecek ay önerileri) → Markdown + yazdırılabilir/PDF
|
||||||
|
|||||||
@@ -19,14 +19,14 @@ export function AppLayout() {
|
|||||||
<Sidebar />
|
<Sidebar />
|
||||||
|
|
||||||
{/* Mobil üst çubuk */}
|
{/* Mobil üst çubuk */}
|
||||||
<header className="sticky top-0 z-20 flex h-14 items-center border-b bg-background/90 px-4 backdrop-blur md:hidden">
|
<header className="sticky top-0 z-20 flex h-14 items-center border-b bg-background/90 px-4 backdrop-blur md:hidden print:hidden">
|
||||||
<Logo size={28} withText />
|
<Logo size={28} withText />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* md:pl-64 → kenar çubuğu kadar boşluk; içerik bu alanda ortalanır.
|
{/* md:pl-64 → kenar çubuğu kadar boşluk; içerik bu alanda ortalanır.
|
||||||
pb-28 → mobilde alt menünün altında içerik kalmasın. */}
|
pb-28 → mobilde alt menünün altında içerik kalmasın. */}
|
||||||
<div className="md:pl-64">
|
<div className="md:pl-64 print:pl-0">
|
||||||
<main className="mx-auto w-full max-w-5xl px-4 pt-6 pb-28 md:px-8 md:pt-8 md:pb-10">
|
<main className="mx-auto w-full max-w-5xl px-4 pt-6 pb-28 md:px-8 md:pt-8 md:pb-10 print:max-w-none print:p-0">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@@ -39,7 +39,7 @@ export function AppLayout() {
|
|||||||
|
|
||||||
function Sidebar() {
|
function Sidebar() {
|
||||||
return (
|
return (
|
||||||
<aside className="fixed inset-y-0 left-0 z-30 hidden w-64 flex-col border-r bg-sidebar md:flex">
|
<aside className="fixed inset-y-0 left-0 z-30 hidden w-64 flex-col border-r bg-sidebar md:flex print:hidden">
|
||||||
<div className="flex h-16 items-center px-5">
|
<div className="flex h-16 items-center px-5">
|
||||||
<Logo size={32} withText />
|
<Logo size={32} withText />
|
||||||
</div>
|
</div>
|
||||||
@@ -78,7 +78,7 @@ function BottomNav() {
|
|||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
aria-label={t.nav.main}
|
aria-label={t.nav.main}
|
||||||
className="fixed inset-x-0 bottom-0 z-30 border-t bg-background/95 pb-[env(safe-area-inset-bottom)] backdrop-blur md:hidden"
|
className="fixed inset-x-0 bottom-0 z-30 border-t bg-background/95 pb-[env(safe-area-inset-bottom)] backdrop-blur md:hidden print:hidden"
|
||||||
>
|
>
|
||||||
<ul className="grid grid-cols-5">
|
<ul className="grid grid-cols-5">
|
||||||
{bottomNav.map(({ to, label, icon: Icon }) => (
|
{bottomNav.map(({ to, label, icon: Icon }) => (
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
Legend,
|
||||||
|
Line,
|
||||||
|
LineChart,
|
||||||
|
ReferenceLine,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts'
|
||||||
|
import { formatCompact, formatMoney, formatPercent } from '@/lib/format'
|
||||||
|
import { savingsRate } from '@/lib/reports'
|
||||||
|
import { t } from '@/lib/strings'
|
||||||
|
import type { PeriodStat } from '@/types/PeriodStat'
|
||||||
|
|
||||||
|
const s = t.reports
|
||||||
|
|
||||||
|
/** Ham CSS değişkenleri (`--color-*` `@theme inline` yüzünden derlenmiş CSS'te olmayabilir) */
|
||||||
|
const C = {
|
||||||
|
income: 'var(--income)',
|
||||||
|
expense: 'var(--expense)',
|
||||||
|
primary: 'var(--primary)',
|
||||||
|
grid: 'var(--border)',
|
||||||
|
axis: 'var(--muted-foreground)',
|
||||||
|
}
|
||||||
|
|
||||||
|
const tooltipStyle = {
|
||||||
|
background: 'var(--popover)',
|
||||||
|
color: 'var(--popover-foreground)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 12,
|
||||||
|
fontSize: 13,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dönem adını eksende kısalt: "Kasım 2026" → "Kas 26" */
|
||||||
|
const short = (name: string) => {
|
||||||
|
const m = /^(\S+)\s+(\d{4})$/.exec(name)
|
||||||
|
return m ? `${m[1].slice(0, 3)} ${m[2].slice(2)}` : name
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raporlar sayfasının grafikleri (Recharts → ayrı parça) */
|
||||||
|
export default function ReportChart({
|
||||||
|
kind,
|
||||||
|
periods,
|
||||||
|
}: {
|
||||||
|
kind: 'periods' | 'rate'
|
||||||
|
periods: PeriodStat[]
|
||||||
|
}) {
|
||||||
|
const data = periods.map((p) => ({
|
||||||
|
label: short(p.name),
|
||||||
|
name: p.name,
|
||||||
|
income: p.income_total,
|
||||||
|
expense: p.fixed_expense_total + p.spent,
|
||||||
|
rate: savingsRate(p),
|
||||||
|
}))
|
||||||
|
return (
|
||||||
|
<div className="h-64 sm:h-72" aria-hidden>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
{kind === 'periods' ? (
|
||||||
|
<BarChart data={data} margin={{ top: 5, right: 8, bottom: 0, left: 0 }}>
|
||||||
|
<CartesianGrid stroke={C.grid} strokeDasharray="3 3" vertical={false} />
|
||||||
|
<XAxis dataKey="label" stroke={C.axis} tick={{ fontSize: 12 }} tickLine={false} />
|
||||||
|
<YAxis
|
||||||
|
stroke={C.axis}
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
width={48}
|
||||||
|
tickFormatter={(v: number) => formatCompact(v)}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(v) => (typeof v === 'number' ? formatMoney(v) : '—')}
|
||||||
|
labelFormatter={(_, p) => p?.[0]?.payload?.name ?? ''}
|
||||||
|
contentStyle={tooltipStyle}
|
||||||
|
/>
|
||||||
|
<Legend wrapperStyle={{ fontSize: 12 }} itemSorter={null} />
|
||||||
|
<Bar
|
||||||
|
dataKey="income"
|
||||||
|
name={s.income}
|
||||||
|
fill={C.income}
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="expense"
|
||||||
|
name={s.expense}
|
||||||
|
fill={C.expense}
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
) : (
|
||||||
|
<LineChart data={data} margin={{ top: 5, right: 8, bottom: 0, left: 0 }}>
|
||||||
|
<CartesianGrid stroke={C.grid} strokeDasharray="3 3" vertical={false} />
|
||||||
|
<XAxis dataKey="label" stroke={C.axis} tick={{ fontSize: 12 }} tickLine={false} />
|
||||||
|
<YAxis
|
||||||
|
stroke={C.axis}
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
width={48}
|
||||||
|
tickFormatter={(v: number) => formatPercent(v, 0)}
|
||||||
|
/>
|
||||||
|
<ReferenceLine y={0} stroke={C.axis} />
|
||||||
|
<Tooltip
|
||||||
|
formatter={(v) => (typeof v === 'number' ? formatPercent(v) : '—')}
|
||||||
|
labelFormatter={(_, p) => p?.[0]?.payload?.name ?? ''}
|
||||||
|
contentStyle={tooltipStyle}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
dataKey="rate"
|
||||||
|
name={s.rate}
|
||||||
|
stroke={C.primary}
|
||||||
|
strokeWidth={3}
|
||||||
|
dot={{ r: 3, strokeWidth: 2, fill: 'var(--card)' }}
|
||||||
|
connectNulls
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
)}
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { useState, type ReactNode } from 'react'
|
||||||
|
import { ChevronRight, X } from 'lucide-react'
|
||||||
|
import { Segmented } from '@/components/segmented'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { toIsoDate } from '@/lib/format'
|
||||||
|
import { GROUPS, type GroupCode } from '@/lib/groups'
|
||||||
|
import { activeFilterCount, type ReportFilter } from '@/lib/reports'
|
||||||
|
import { t } from '@/lib/strings'
|
||||||
|
import type { CatalogItem } from '@/types/CatalogItem'
|
||||||
|
import type { Period } from '@/types/Period'
|
||||||
|
|
||||||
|
const s = t.reports
|
||||||
|
const select = 'h-11 w-full rounded-lg border bg-background px-3 text-sm'
|
||||||
|
|
||||||
|
type Scope = 'all' | 'period' | 'range'
|
||||||
|
|
||||||
|
/** Rapor filtreleri. Mobilde katlanır; seçim adrese yazılır (sayfa yenilense de kalır). */
|
||||||
|
export function Filters({
|
||||||
|
filter: f,
|
||||||
|
onChange,
|
||||||
|
periods,
|
||||||
|
categories,
|
||||||
|
methods,
|
||||||
|
}: {
|
||||||
|
filter: ReportFilter
|
||||||
|
onChange: (f: ReportFilter) => void
|
||||||
|
periods: Period[]
|
||||||
|
categories: CatalogItem[]
|
||||||
|
methods: CatalogItem[]
|
||||||
|
}) {
|
||||||
|
const [open] = useState(
|
||||||
|
() => activeFilterCount(f) > 0 || window.matchMedia('(min-width: 768px)').matches,
|
||||||
|
)
|
||||||
|
const scope: Scope = f.period ? 'period' : f.from || f.to ? 'range' : 'all'
|
||||||
|
const count = activeFilterCount(f)
|
||||||
|
const cats = categories.filter((c) => !f.group || c.group === f.group)
|
||||||
|
|
||||||
|
function setScope(next: Scope) {
|
||||||
|
const rest = { ...f, period: undefined, from: undefined, to: undefined }
|
||||||
|
if (next === 'period') onChange({ ...rest, period: periods[0]?.id })
|
||||||
|
else if (next === 'range') {
|
||||||
|
const today = toIsoDate(new Date())
|
||||||
|
onChange({ ...rest, from: `${today.slice(0, 4)}-01-01`, to: today })
|
||||||
|
} else onChange(rest)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<details open={open} className="group rounded-2xl border bg-card p-4 sm:p-5 print:hidden">
|
||||||
|
<summary className="flex min-h-11 cursor-pointer items-center justify-between font-semibold">
|
||||||
|
{s.filterCount(count)}
|
||||||
|
<ChevronRight className="size-4 transition-transform group-open:rotate-90" aria-hidden />
|
||||||
|
</summary>
|
||||||
|
<div className="mt-3 space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium">{s.scope}</p>
|
||||||
|
<Segmented
|
||||||
|
label={s.scope}
|
||||||
|
value={scope}
|
||||||
|
onChange={setScope}
|
||||||
|
options={[
|
||||||
|
{ value: 'all', label: s.allTime },
|
||||||
|
{ value: 'period', label: s.byPeriod },
|
||||||
|
{ value: 'range', label: s.byRange },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{scope === 'period' && (
|
||||||
|
<Field label={s.byPeriod} id="f-period">
|
||||||
|
<select
|
||||||
|
id="f-period"
|
||||||
|
name="period"
|
||||||
|
className={select}
|
||||||
|
value={f.period ?? ''}
|
||||||
|
onChange={(e) => onChange({ ...f, period: e.target.value || undefined })}
|
||||||
|
>
|
||||||
|
{periods.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{scope === 'range' && (
|
||||||
|
<>
|
||||||
|
<Field label={s.from} id="f-from">
|
||||||
|
<Input
|
||||||
|
id="f-from"
|
||||||
|
name="from"
|
||||||
|
type="date"
|
||||||
|
value={f.from ?? ''}
|
||||||
|
max={f.to}
|
||||||
|
onChange={(e) => onChange({ ...f, from: e.target.value || undefined })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={s.to} id="f-to">
|
||||||
|
<Input
|
||||||
|
id="f-to"
|
||||||
|
name="to"
|
||||||
|
type="date"
|
||||||
|
value={f.to ?? ''}
|
||||||
|
min={f.from}
|
||||||
|
onChange={(e) => onChange({ ...f, to: e.target.value || undefined })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Field label={s.group} id="f-group">
|
||||||
|
<select
|
||||||
|
id="f-group"
|
||||||
|
name="group"
|
||||||
|
className={select}
|
||||||
|
value={f.group ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const group = (e.target.value || undefined) as GroupCode | undefined
|
||||||
|
// Seçili kategori başka gruptaysa bırak
|
||||||
|
const keep = categories.find((c) => c.id === f.category)?.group === group
|
||||||
|
onChange({ ...f, group, category: keep ? f.category : undefined })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">{s.any}</option>
|
||||||
|
{GROUPS.map((g) => (
|
||||||
|
<option key={g.code} value={g.code}>
|
||||||
|
{g.emoji} {g.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label={s.category} id="f-category">
|
||||||
|
<select
|
||||||
|
id="f-category"
|
||||||
|
name="category"
|
||||||
|
className={select}
|
||||||
|
value={f.category ?? ''}
|
||||||
|
onChange={(e) => onChange({ ...f, category: e.target.value || undefined })}
|
||||||
|
>
|
||||||
|
<option value="">{s.any}</option>
|
||||||
|
{cats.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.emoji ? `${c.emoji} ` : ''}
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label={s.method} id="f-method">
|
||||||
|
<select
|
||||||
|
id="f-method"
|
||||||
|
name="method"
|
||||||
|
className={select}
|
||||||
|
value={f.method ?? ''}
|
||||||
|
onChange={(e) => onChange({ ...f, method: e.target.value || undefined })}
|
||||||
|
>
|
||||||
|
<option value="">{s.any}</option>
|
||||||
|
<option value="none">{s.noMethod}</option>
|
||||||
|
{methods.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
{count > 0 && (
|
||||||
|
<Button variant="ghost" onClick={() => onChange({})}>
|
||||||
|
<X aria-hidden />
|
||||||
|
{s.clear}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, id, children }: { label: string; id: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor={id} className="text-sm font-medium">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,11 +1,114 @@
|
|||||||
import { ComingSoon, PageHeader } from '@/components/page'
|
import { Printer } from 'lucide-react'
|
||||||
|
import { useSearchParams } from 'react-router'
|
||||||
|
import { PageError, PageHeader, PageLoading } from '@/components/page'
|
||||||
|
import { Skeleton } from '@/components/skeleton'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { usePortfolio } from '@/lib/assets'
|
||||||
|
import { useCategories, usePaymentMethods } from '@/lib/catalog'
|
||||||
|
import { useCurrentPeriod, usePeriods, useStats } from '@/lib/periods'
|
||||||
|
import { printSection } from '@/lib/print'
|
||||||
|
import {
|
||||||
|
describeFilter,
|
||||||
|
filterToParams,
|
||||||
|
parseFilter,
|
||||||
|
useReportBreakdown,
|
||||||
|
type ReportFilter,
|
||||||
|
} from '@/lib/reports'
|
||||||
import { t } from '@/lib/strings'
|
import { t } from '@/lib/strings'
|
||||||
|
import { Filters } from './filters'
|
||||||
|
import {
|
||||||
|
AssetsReport,
|
||||||
|
CategoriesReport,
|
||||||
|
DeviationReport,
|
||||||
|
GroupsReport,
|
||||||
|
MethodsReport,
|
||||||
|
MonthsReport,
|
||||||
|
PeriodsReport,
|
||||||
|
RateReport,
|
||||||
|
} from './sections'
|
||||||
|
|
||||||
|
const s = t.reports
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raporlar: filtreler adreste (`?donem=`, `baslangic`, `bitis`, `grup`, `kategori`, `odeme`).
|
||||||
|
* Kırılımlar filtreye göre; dönemler arası özet ve varlıklar filtrelerden bağımsız;
|
||||||
|
* bütçe sapması seçili dönem (yoksa açık / son dönem). Her bölüm CSV + yazdır.
|
||||||
|
*/
|
||||||
export function ReportsPage() {
|
export function ReportsPage() {
|
||||||
|
const [params, setParams] = useSearchParams()
|
||||||
|
const filter = parseFilter(params)
|
||||||
|
const periods = usePeriods()
|
||||||
|
const current = useCurrentPeriod()
|
||||||
|
const categories = useCategories(true)
|
||||||
|
const methods = usePaymentMethods(true)
|
||||||
|
const stats = useStats(12)
|
||||||
|
const breakdown = useReportBreakdown(filter)
|
||||||
|
const portfolio = usePortfolio()
|
||||||
|
|
||||||
|
const setFilter = (f: ReportFilter) => setParams(filterToParams(f), { replace: true })
|
||||||
|
const failed = [periods, categories, methods].find((q) => q.isError)
|
||||||
|
const devPeriod =
|
||||||
|
periods.data?.find((p) => p.id === filter.period) ?? current.data ?? periods.data?.[0] ?? null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title={t.pages.reports.title} description={t.pages.reports.description} />
|
<PageHeader
|
||||||
<ComingSoon when="Faz 9'da" />
|
title={t.pages.reports.title}
|
||||||
|
description={t.pages.reports.description}
|
||||||
|
actions={
|
||||||
|
<Button variant="outline" className="print:hidden" onClick={() => printSection()}>
|
||||||
|
<Printer aria-hidden />
|
||||||
|
{s.printAll}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{failed?.error ? (
|
||||||
|
<PageError message={failed.error.message} onRetry={failed.refetch} />
|
||||||
|
) : !periods.data || !categories.data || !methods.data ? (
|
||||||
|
<PageLoading />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Yalnız yazdırmada: kağıtta hangi kapsamın basıldığı belli olsun */}
|
||||||
|
<p className="hidden text-sm print:block">
|
||||||
|
{describeFilter(filter, {
|
||||||
|
period: (id) => periods.data.find((p) => p.id === id)?.name,
|
||||||
|
category: (id) => categories.data.find((c) => c.id === id)?.name,
|
||||||
|
method: (id) => methods.data.find((m) => m.id === id)?.name,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<Filters
|
||||||
|
filter={filter}
|
||||||
|
onChange={setFilter}
|
||||||
|
periods={periods.data}
|
||||||
|
categories={categories.data}
|
||||||
|
methods={methods.data}
|
||||||
|
/>
|
||||||
|
{breakdown.isError ? (
|
||||||
|
<PageError message={breakdown.error.message} onRetry={breakdown.refetch} />
|
||||||
|
) : !breakdown.data ? (
|
||||||
|
<Skeleton className="h-64" />
|
||||||
|
) : (
|
||||||
|
<div className="grid items-start gap-4 lg:grid-cols-2 print:block print:space-y-4">
|
||||||
|
<GroupsReport b={breakdown.data} />
|
||||||
|
<MethodsReport b={breakdown.data} methods={methods.data} />
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<CategoriesReport b={breakdown.data} categories={categories.data} />
|
||||||
|
</div>
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<MonthsReport b={breakdown.data} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DeviationReport period={devPeriod} />
|
||||||
|
{stats.data && (
|
||||||
|
<div className="grid items-start gap-4 lg:grid-cols-2 print:block print:space-y-4">
|
||||||
|
<PeriodsReport periods={stats.data.periods} />
|
||||||
|
<RateReport periods={stats.data.periods} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{portfolio.data && <AssetsReport p={portfolio.data} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { useRef, type ReactNode } from 'react'
|
||||||
|
import { Download, Printer } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { downloadCsv, toCsv, type Cell } from '@/lib/csv'
|
||||||
|
import { formatMoney, formatPercent, toIsoDate } from '@/lib/format'
|
||||||
|
import { printSection } from '@/lib/print'
|
||||||
|
import { t } from '@/lib/strings'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const s = t.reports
|
||||||
|
|
||||||
|
export type CsvData = { file: string; headers: string[]; rows: Cell[][] }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rapor bölümü: başlık + açıklama + "CSV indir" / "Yazdır" (yalnızca bu bölüm).
|
||||||
|
* `data-report` → yazdırma stili (lib/print.ts, index.css).
|
||||||
|
*/
|
||||||
|
export function ReportSection({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
hint,
|
||||||
|
note,
|
||||||
|
csv,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
hint?: string
|
||||||
|
/** "Filtrelere göre" / "Filtrelerden bağımsız" */
|
||||||
|
note?: string
|
||||||
|
csv?: CsvData
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLElement>(null)
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
ref={ref}
|
||||||
|
data-report
|
||||||
|
aria-labelledby={`${id}-title`}
|
||||||
|
className="rounded-2xl border bg-card p-4 sm:p-5 print:rounded-none print:border-0 print:p-0 print:pb-6"
|
||||||
|
>
|
||||||
|
<div className="mb-3 flex flex-wrap items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 id={`${id}-title`} className="font-semibold">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{(hint || note) && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{hint}
|
||||||
|
{hint && note && ' · '}
|
||||||
|
{note}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 print:hidden">
|
||||||
|
{csv && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
downloadCsv(
|
||||||
|
`kakeibo-${csv.file}-${toIsoDate(new Date())}`,
|
||||||
|
toCsv(csv.headers, csv.rows),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Download aria-hidden />
|
||||||
|
{s.csv}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => printSection(ref.current)}>
|
||||||
|
<Printer aria-hidden />
|
||||||
|
{s.print}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tablo + satır içi pay çubuğu (yazdırmada da görünür; grafik kütüphanesi gerekmez) */
|
||||||
|
export function ShareTable({
|
||||||
|
rows,
|
||||||
|
total,
|
||||||
|
firstHeader,
|
||||||
|
showCount = true,
|
||||||
|
}: {
|
||||||
|
rows: { key: string; label: ReactNode; amount: number; count?: number; color?: string }[]
|
||||||
|
total: number
|
||||||
|
firstHeader: string
|
||||||
|
showCount?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left text-xs text-muted-foreground">
|
||||||
|
<th className="py-2 pr-2 font-medium">{firstHeader}</th>
|
||||||
|
{showCount && <th className="px-2 py-2 text-right font-medium">{s.count}</th>}
|
||||||
|
<th className="px-2 py-2 text-right font-medium">{s.share}</th>
|
||||||
|
<th className="py-2 pl-2 text-right font-medium">{s.amount}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r) => {
|
||||||
|
const ratio = total > 0 ? r.amount / total : 0
|
||||||
|
return (
|
||||||
|
<tr key={r.key} className="border-b last:border-0">
|
||||||
|
<td className="py-2 pr-2">
|
||||||
|
<div className="min-w-32">{r.label}</div>
|
||||||
|
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-muted" aria-hidden>
|
||||||
|
<div
|
||||||
|
className={cn('h-full rounded-full', r.color ?? 'bg-primary')}
|
||||||
|
style={{ width: `${ratio * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{showCount && <td className="money px-2 py-2 text-right">{r.count ?? ''}</td>}
|
||||||
|
<td className="money px-2 py-2 text-right text-muted-foreground">
|
||||||
|
{formatPercent(ratio)}
|
||||||
|
</td>
|
||||||
|
<td className="money py-2 pl-2 text-right font-medium whitespace-nowrap">
|
||||||
|
{formatMoney(r.amount)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr className="border-t font-semibold">
|
||||||
|
<td className="py-2 pr-2">{s.total}</td>
|
||||||
|
{showCount && (
|
||||||
|
<td className="money px-2 py-2 text-right">
|
||||||
|
{rows.reduce((a, r) => a + (r.count ?? 0), 0)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
<td className="px-2 py-2" />
|
||||||
|
<td className="money py-2 pl-2 text-right whitespace-nowrap">{formatMoney(total)}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,559 @@
|
|||||||
|
import { lazy, Suspense, type ReactNode } from 'react'
|
||||||
|
import { PageError, PageLoading } from '@/components/page'
|
||||||
|
import { Skeleton } from '@/components/skeleton'
|
||||||
|
import { csvMoney, csvPercent } from '@/lib/csv'
|
||||||
|
import { formatE6 } from '@/lib/decimal'
|
||||||
|
import { formatDate, formatDayMonth, formatMoney, formatPercent } from '@/lib/format'
|
||||||
|
import { group } from '@/lib/groups'
|
||||||
|
import { useBudget } from '@/lib/periods'
|
||||||
|
import { deviation, formatMonth, savingsRate } from '@/lib/reports'
|
||||||
|
import { formatPlPercent, plRatio, qty } from '@/lib/savings'
|
||||||
|
import { t } from '@/lib/strings'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import type { CatalogItem } from '@/types/CatalogItem'
|
||||||
|
import type { Period } from '@/types/Period'
|
||||||
|
import type { PeriodStat } from '@/types/PeriodStat'
|
||||||
|
import type { Portfolio } from '@/types/Portfolio'
|
||||||
|
import type { ReportBreakdown } from '@/types/ReportBreakdown'
|
||||||
|
import { ReportSection, ShareTable } from './section'
|
||||||
|
|
||||||
|
const s = t.reports
|
||||||
|
const ReportChart = lazy(() => import('./charts'))
|
||||||
|
const chartFallback = <Skeleton className="h-64 sm:h-72" />
|
||||||
|
|
||||||
|
const signed = (kurus: number) => formatMoney(kurus, 'TRY', { signed: true })
|
||||||
|
const pct = (r: number | null) => (r === null ? '—' : formatPercent(r))
|
||||||
|
|
||||||
|
// ---------- Dönemler (filtrelerden bağımsız) ----------
|
||||||
|
|
||||||
|
export function PeriodsReport({ periods }: { periods: PeriodStat[] }) {
|
||||||
|
return (
|
||||||
|
<ReportSection
|
||||||
|
id="r-periods"
|
||||||
|
title={s.periodsTitle}
|
||||||
|
hint={s.periodsHint}
|
||||||
|
note={s.unfilteredNote}
|
||||||
|
csv={{
|
||||||
|
file: s.file.periods,
|
||||||
|
headers: [s.period, s.from, s.to, s.income, s.fixed, s.spent, s.realized, `${s.rate} (%)`],
|
||||||
|
rows: periods.map((p) => [
|
||||||
|
p.name,
|
||||||
|
formatDate(p.start_date),
|
||||||
|
formatDate(p.last_date),
|
||||||
|
csvMoney(p.income_total),
|
||||||
|
csvMoney(p.fixed_expense_total),
|
||||||
|
csvMoney(p.spent),
|
||||||
|
csvMoney(p.realized_savings),
|
||||||
|
csvPercent(savingsRate(p)),
|
||||||
|
]),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{periods.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{s.noPeriods}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Suspense fallback={chartFallback}>
|
||||||
|
<ReportChart kind="periods" periods={periods} />
|
||||||
|
</Suspense>
|
||||||
|
<p className="sr-only">{s.periodsSummary(periods.length)}</p>
|
||||||
|
<div className="mt-3 overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left text-xs text-muted-foreground">
|
||||||
|
<th className="py-2 pr-2 font-medium">{s.period}</th>
|
||||||
|
<th className="px-2 py-2 text-right font-medium">{s.income}</th>
|
||||||
|
<th className="px-2 py-2 text-right font-medium">{s.expense}</th>
|
||||||
|
<th className="px-2 py-2 text-right font-medium">{s.realized}</th>
|
||||||
|
<th className="py-2 pl-2 text-right font-medium">{s.rate}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{[...periods].reverse().map((p) => (
|
||||||
|
<tr key={p.id} className="border-b last:border-0">
|
||||||
|
<td className="py-2 pr-2 whitespace-nowrap">{p.name}</td>
|
||||||
|
<td className="money px-2 py-2 text-right whitespace-nowrap">
|
||||||
|
{formatMoney(p.income_total)}
|
||||||
|
</td>
|
||||||
|
<td className="money px-2 py-2 text-right whitespace-nowrap">
|
||||||
|
{formatMoney(p.fixed_expense_total + p.spent)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={cn(
|
||||||
|
'money px-2 py-2 text-right whitespace-nowrap',
|
||||||
|
p.realized_savings < 0 && 'text-expense',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatMoney(p.realized_savings)}
|
||||||
|
</td>
|
||||||
|
<td className="money py-2 pl-2 text-right">{pct(savingsRate(p))}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ReportSection>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RateReport({ periods }: { periods: PeriodStat[] }) {
|
||||||
|
const last = periods.at(-1)
|
||||||
|
return (
|
||||||
|
<ReportSection id="r-rate" title={s.rateTitle} hint={s.rateHint} note={s.unfilteredNote}>
|
||||||
|
{periods.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{s.noPeriods}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Suspense fallback={chartFallback}>
|
||||||
|
<ReportChart kind="rate" periods={periods} />
|
||||||
|
</Suspense>
|
||||||
|
{last && (
|
||||||
|
<p className="mt-3 text-sm text-muted-foreground">
|
||||||
|
{s.rateSummary(last.name, pct(savingsRate(last)))}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ReportSection>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Kırılımlar (filtrelere göre) ----------
|
||||||
|
|
||||||
|
export function GroupsReport({ b }: { b: ReportBreakdown }) {
|
||||||
|
return (
|
||||||
|
<ReportSection
|
||||||
|
id="r-groups"
|
||||||
|
title={s.groupsTitle}
|
||||||
|
note={s.filteredNote}
|
||||||
|
csv={{
|
||||||
|
file: s.file.groups,
|
||||||
|
headers: [s.group, s.count, `${s.share} (%)`, s.amount],
|
||||||
|
rows: b.groups.map((r) => [
|
||||||
|
group(r.group).label,
|
||||||
|
r.count,
|
||||||
|
csvPercent(b.total ? r.total / b.total : 0),
|
||||||
|
csvMoney(r.total),
|
||||||
|
]),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ShareTable
|
||||||
|
firstHeader={s.group}
|
||||||
|
total={b.total}
|
||||||
|
rows={b.groups.map((r) => {
|
||||||
|
const g = group(r.group)
|
||||||
|
return {
|
||||||
|
key: r.group,
|
||||||
|
label: (
|
||||||
|
<span className={g.className.text}>
|
||||||
|
{g.emoji} {g.label}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
amount: r.total,
|
||||||
|
count: r.count,
|
||||||
|
color: g.className.fill,
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</ReportSection>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategoriesReport({
|
||||||
|
b,
|
||||||
|
categories,
|
||||||
|
}: {
|
||||||
|
b: ReportBreakdown
|
||||||
|
categories: CatalogItem[]
|
||||||
|
}) {
|
||||||
|
const name = (id: string) => categories.find((c) => c.id === id)
|
||||||
|
return (
|
||||||
|
<ReportSection
|
||||||
|
id="r-categories"
|
||||||
|
title={s.categoriesTitle}
|
||||||
|
hint={s.categoriesHint}
|
||||||
|
note={s.filteredNote}
|
||||||
|
csv={{
|
||||||
|
file: s.file.categories,
|
||||||
|
headers: [s.category, s.group, s.count, `${s.share} (%)`, s.amount],
|
||||||
|
rows: b.categories.map((r) => [
|
||||||
|
name(r.category_id)?.name ?? '—',
|
||||||
|
group(r.group).label,
|
||||||
|
r.count,
|
||||||
|
csvPercent(b.total ? r.total / b.total : 0),
|
||||||
|
csvMoney(r.total),
|
||||||
|
]),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{b.categories.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{s.empty}</p>
|
||||||
|
) : (
|
||||||
|
<ShareTable
|
||||||
|
firstHeader={s.category}
|
||||||
|
total={b.total}
|
||||||
|
rows={b.categories.map((r) => {
|
||||||
|
const c = name(r.category_id)
|
||||||
|
const g = group(r.group)
|
||||||
|
return {
|
||||||
|
key: r.category_id,
|
||||||
|
label: (
|
||||||
|
<>
|
||||||
|
{c?.emoji ? `${c.emoji} ` : ''}
|
||||||
|
{c?.name ?? '—'}{' '}
|
||||||
|
<span className={cn('text-xs', g.className.text)}>{g.label}</span>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
amount: r.total,
|
||||||
|
count: r.count,
|
||||||
|
color: g.className.fill,
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ReportSection>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MethodsReport({ b, methods }: { b: ReportBreakdown; methods: CatalogItem[] }) {
|
||||||
|
const name = (id: string | null) =>
|
||||||
|
id ? (methods.find((m) => m.id === id)?.name ?? '—') : s.noMethod
|
||||||
|
return (
|
||||||
|
<ReportSection
|
||||||
|
id="r-methods"
|
||||||
|
title={s.methodsTitle}
|
||||||
|
note={s.filteredNote}
|
||||||
|
csv={{
|
||||||
|
file: s.file.methods,
|
||||||
|
headers: [s.method, s.count, `${s.share} (%)`, s.amount],
|
||||||
|
rows: b.payment_methods.map((r) => [
|
||||||
|
name(r.payment_method_id),
|
||||||
|
r.count,
|
||||||
|
csvPercent(b.total ? r.total / b.total : 0),
|
||||||
|
csvMoney(r.total),
|
||||||
|
]),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{b.payment_methods.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{s.empty}</p>
|
||||||
|
) : (
|
||||||
|
<ShareTable
|
||||||
|
firstHeader={s.method}
|
||||||
|
total={b.total}
|
||||||
|
rows={b.payment_methods.map((r) => ({
|
||||||
|
key: r.payment_method_id ?? 'none',
|
||||||
|
label: name(r.payment_method_id),
|
||||||
|
amount: r.total,
|
||||||
|
count: r.count,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ReportSection>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MonthsReport({ b }: { b: ReportBreakdown }) {
|
||||||
|
return (
|
||||||
|
<ReportSection
|
||||||
|
id="r-months"
|
||||||
|
title={s.monthsTitle}
|
||||||
|
note={s.filteredNote}
|
||||||
|
csv={{
|
||||||
|
file: s.file.months,
|
||||||
|
headers: [s.period, s.count, s.amount],
|
||||||
|
rows: b.months.map((r) => [formatMonth(r.month), r.count, csvMoney(r.total)]),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{b.months.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{s.empty}</p>
|
||||||
|
) : (
|
||||||
|
<ShareTable
|
||||||
|
firstHeader={s.period}
|
||||||
|
total={b.total}
|
||||||
|
rows={b.months.map((r) => ({
|
||||||
|
key: r.month,
|
||||||
|
label: formatMonth(r.month),
|
||||||
|
amount: r.total,
|
||||||
|
count: r.count,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ReportSection>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Bütçe sapması (tek dönem) ----------
|
||||||
|
|
||||||
|
export function DeviationReport({ period }: { period: Period | null }) {
|
||||||
|
if (!period) {
|
||||||
|
return (
|
||||||
|
<ReportSection id="r-deviation" title={s.deviationTitle}>
|
||||||
|
<p className="text-sm text-muted-foreground">{s.noDeviationPeriod}</p>
|
||||||
|
</ReportSection>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <DeviationView period={period} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeviationView({ period }: { period: Period }) {
|
||||||
|
const budget = useBudget(period.id)
|
||||||
|
const rows = budget.data ? deviation(budget.data) : []
|
||||||
|
const weeks = period.weeks.map((w, i) => {
|
||||||
|
const planned = budget.data?.reduce((a, b) => a + (b.weeks[i]?.amount ?? 0), 0) ?? 0
|
||||||
|
const spent = budget.data?.reduce((a, b) => a + (b.weeks[i]?.spent ?? 0), 0) ?? 0
|
||||||
|
return { w, planned, spent, diff: planned - spent }
|
||||||
|
})
|
||||||
|
const totals = rows.reduce(
|
||||||
|
(a, r) => ({ planned: a.planned + r.planned, spent: a.spent + r.spent }),
|
||||||
|
{
|
||||||
|
planned: 0,
|
||||||
|
spent: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<ReportSection
|
||||||
|
id="r-deviation"
|
||||||
|
title={s.deviationTitle}
|
||||||
|
hint={s.deviationHint(period.name)}
|
||||||
|
csv={{
|
||||||
|
file: s.file.deviation,
|
||||||
|
headers: [s.group, s.planned, s.spent, s.diff, `${s.used} (%)`],
|
||||||
|
rows: [
|
||||||
|
...rows.map((r) => [
|
||||||
|
group(r.group).label,
|
||||||
|
csvMoney(r.planned),
|
||||||
|
csvMoney(r.spent),
|
||||||
|
csvMoney(r.diff),
|
||||||
|
csvPercent(r.used),
|
||||||
|
]),
|
||||||
|
...weeks.map(({ w, planned, spent, diff }) => [
|
||||||
|
s.week(w.index, `${formatDayMonth(w.start_date)}–${formatDayMonth(w.last_date)}`),
|
||||||
|
csvMoney(planned),
|
||||||
|
csvMoney(spent),
|
||||||
|
csvMoney(diff),
|
||||||
|
csvPercent(planned ? spent / planned : null),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{budget.isError ? (
|
||||||
|
<PageError message={budget.error.message} onRetry={budget.refetch} />
|
||||||
|
) : !budget.data ? (
|
||||||
|
<PageLoading />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4 overflow-x-auto">
|
||||||
|
<DevTable
|
||||||
|
first={s.group}
|
||||||
|
rows={[
|
||||||
|
...rows.map((r) => {
|
||||||
|
const g = group(r.group)
|
||||||
|
return {
|
||||||
|
key: r.group,
|
||||||
|
label: (
|
||||||
|
<span className={g.className.text}>
|
||||||
|
{g.emoji} {g.label}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
...r,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
total={{ ...totals, diff: totals.planned - totals.spent }}
|
||||||
|
/>
|
||||||
|
<h3 className="pt-2 text-sm font-medium">{s.weeksTitle}</h3>
|
||||||
|
<DevTable
|
||||||
|
first={s.period}
|
||||||
|
rows={weeks.map(({ w, planned, spent, diff }) => ({
|
||||||
|
key: String(w.index),
|
||||||
|
label: s.week(
|
||||||
|
w.index,
|
||||||
|
`${formatDayMonth(w.start_date)}–${formatDayMonth(w.last_date)}`,
|
||||||
|
),
|
||||||
|
planned,
|
||||||
|
spent,
|
||||||
|
diff,
|
||||||
|
used: planned ? spent / planned : null,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ReportSection>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DevTable({
|
||||||
|
first,
|
||||||
|
rows,
|
||||||
|
total,
|
||||||
|
}: {
|
||||||
|
first: string
|
||||||
|
rows: {
|
||||||
|
key: string
|
||||||
|
label: ReactNode
|
||||||
|
planned: number
|
||||||
|
spent: number
|
||||||
|
diff: number
|
||||||
|
used: number | null
|
||||||
|
}[]
|
||||||
|
total?: { planned: number; spent: number; diff: number }
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left text-xs text-muted-foreground">
|
||||||
|
<th className="py-2 pr-2 font-medium">{first}</th>
|
||||||
|
<th className="px-2 py-2 text-right font-medium">{s.planned}</th>
|
||||||
|
<th className="px-2 py-2 text-right font-medium">{s.spent}</th>
|
||||||
|
<th className="px-2 py-2 text-right font-medium">{s.diff}</th>
|
||||||
|
<th className="py-2 pl-2 text-right font-medium">{s.used}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<tr key={r.key} className="border-b last:border-0">
|
||||||
|
<td className="py-2 pr-2 whitespace-nowrap">{r.label}</td>
|
||||||
|
<td className="money px-2 py-2 text-right whitespace-nowrap">
|
||||||
|
{formatMoney(r.planned)}
|
||||||
|
</td>
|
||||||
|
<td className="money px-2 py-2 text-right whitespace-nowrap">{formatMoney(r.spent)}</td>
|
||||||
|
<td
|
||||||
|
className={cn(
|
||||||
|
'money px-2 py-2 text-right whitespace-nowrap',
|
||||||
|
r.diff < 0 && 'text-expense',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{signed(r.diff)}
|
||||||
|
</td>
|
||||||
|
<td className={cn('money py-2 pl-2 text-right', (r.used ?? 0) > 1 && 'text-expense')}>
|
||||||
|
{pct(r.used)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
{total && (
|
||||||
|
<tfoot>
|
||||||
|
<tr className="border-t font-semibold">
|
||||||
|
<td className="py-2 pr-2">{s.total}</td>
|
||||||
|
<td className="money px-2 py-2 text-right whitespace-nowrap">
|
||||||
|
{formatMoney(total.planned)}
|
||||||
|
</td>
|
||||||
|
<td className="money px-2 py-2 text-right whitespace-nowrap">
|
||||||
|
{formatMoney(total.spent)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={cn(
|
||||||
|
'money px-2 py-2 text-right whitespace-nowrap',
|
||||||
|
total.diff < 0 && 'text-expense',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{signed(total.diff)}
|
||||||
|
</td>
|
||||||
|
<td className="money py-2 pl-2 text-right">
|
||||||
|
{pct(total.planned ? total.spent / total.planned : null)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
)}
|
||||||
|
</table>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Varlıklar ----------
|
||||||
|
|
||||||
|
export function AssetsReport({ p }: { p: Portfolio }) {
|
||||||
|
const items = p.items.filter((i) => i.asset.quantity_e6 > 0 || i.asset.realized_pl !== 0)
|
||||||
|
return (
|
||||||
|
<ReportSection
|
||||||
|
id="r-assets"
|
||||||
|
title={s.assetsTitle}
|
||||||
|
hint={s.assetsHint}
|
||||||
|
note={s.unfilteredNote}
|
||||||
|
csv={{
|
||||||
|
file: s.file.assets,
|
||||||
|
headers: [
|
||||||
|
s.asset,
|
||||||
|
s.quantity,
|
||||||
|
s.cost,
|
||||||
|
s.value,
|
||||||
|
s.unrealized,
|
||||||
|
`${s.unrealized} (%)`,
|
||||||
|
s.realizedPl,
|
||||||
|
],
|
||||||
|
rows: items.map((i) => [
|
||||||
|
i.asset.name,
|
||||||
|
`${formatE6(i.asset.quantity_e6)} ${i.asset.unit_label}`,
|
||||||
|
csvMoney(i.asset.cost),
|
||||||
|
i.value === null ? '' : csvMoney(i.value),
|
||||||
|
i.unrealized_pl === null ? '' : csvMoney(i.unrealized_pl),
|
||||||
|
i.unrealized_pl === null ? '' : csvPercent(plRatio(i.unrealized_pl, i.asset.cost)),
|
||||||
|
csvMoney(i.asset.realized_pl),
|
||||||
|
]),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{s.noAssets}</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left text-xs text-muted-foreground">
|
||||||
|
<th className="py-2 pr-2 font-medium">{s.asset}</th>
|
||||||
|
<th className="px-2 py-2 text-right font-medium">{s.cost}</th>
|
||||||
|
<th className="px-2 py-2 text-right font-medium">{s.value}</th>
|
||||||
|
<th className="py-2 pl-2 text-right font-medium">{s.unrealized}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((i) => {
|
||||||
|
const pl = i.unrealized_pl
|
||||||
|
const r = pl === null ? null : formatPlPercent(plRatio(pl, i.asset.cost))
|
||||||
|
return (
|
||||||
|
<tr key={i.asset.id} className="border-b last:border-0">
|
||||||
|
<td className="py-2 pr-2">
|
||||||
|
<div className="font-medium">{i.asset.name}</div>
|
||||||
|
<div className="money text-xs text-muted-foreground">{qty(i.asset)}</div>
|
||||||
|
</td>
|
||||||
|
<td className="money px-2 py-2 text-right whitespace-nowrap">
|
||||||
|
{formatMoney(i.asset.cost)}
|
||||||
|
</td>
|
||||||
|
<td className="money px-2 py-2 text-right whitespace-nowrap">
|
||||||
|
{i.value === null ? '—' : formatMoney(i.value)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={cn(
|
||||||
|
'money py-2 pl-2 text-right whitespace-nowrap',
|
||||||
|
(pl ?? 0) > 0 ? 'text-income' : (pl ?? 0) < 0 ? 'text-expense' : '',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{pl === null ? '—' : signed(pl)}
|
||||||
|
{r && <div className="text-xs">{r}</div>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr className="border-t font-semibold">
|
||||||
|
<td className="py-2 pr-2">{s.total}</td>
|
||||||
|
<td className="money px-2 py-2 text-right whitespace-nowrap">
|
||||||
|
{formatMoney(p.cost_total)}
|
||||||
|
</td>
|
||||||
|
<td className="money px-2 py-2 text-right whitespace-nowrap">
|
||||||
|
{formatMoney(p.value_total)}
|
||||||
|
</td>
|
||||||
|
<td className="money py-2 pl-2 text-right whitespace-nowrap">
|
||||||
|
{signed(p.unrealized_pl_total)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
{p.realized_pl_total !== 0 && (
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
{s.realizedPl}: <span className="money">{signed(p.realized_pl_total)}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ReportSection>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -265,3 +265,18 @@
|
|||||||
@apply border-border;
|
@apply border-border;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Yazdırma (lib/print.ts): tek bölüm seçildiyse diğer rapor bölümleri gizlenir;
|
||||||
|
kartlar sayfa ortasında bölünmesin; arka plan renkleri (çubuklar) basılsın. */
|
||||||
|
@media print {
|
||||||
|
html[data-print-one] main [data-report]:not([data-print-target]) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
main [data-report] {
|
||||||
|
break-inside: avoid;
|
||||||
|
}
|
||||||
|
* {
|
||||||
|
print-color-adjust: exact;
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { csvMoney, csvPercent, toCsv } from './csv'
|
||||||
|
|
||||||
|
describe('csv', () => {
|
||||||
|
it('Excel-friendly: BOM, semicolon, CRLF, quoting', () => {
|
||||||
|
const csv = toCsv(
|
||||||
|
['Kategori', 'Tutar'],
|
||||||
|
[
|
||||||
|
['Market', '1250,50'],
|
||||||
|
['Dışarıda yemek; kafe', '10,00'],
|
||||||
|
['"Özel" ad', null],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
expect(csv.charCodeAt(0)).toBe(0xfeff)
|
||||||
|
expect(csv.slice(1)).toBe(
|
||||||
|
'Kategori;Tutar\r\nMarket;1250,50\r\n"Dışarıda yemek; kafe";10,00\r\n"""Özel"" ad";\r\n',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('money and percent without thousands separators', () => {
|
||||||
|
expect(csvMoney(125_050)).toBe('1250,50')
|
||||||
|
expect(csvMoney(100_000_000)).toBe('1000000,00')
|
||||||
|
expect(csvMoney(-5)).toBe('-0,05')
|
||||||
|
expect(csvMoney(0)).toBe('0,00')
|
||||||
|
expect(csvPercent(0.125)).toBe('12,5')
|
||||||
|
expect(csvPercent(-0.0058)).toBe('-0,6')
|
||||||
|
expect(csvPercent(null)).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* CSV dışa aktarma — Türkçe Excel uyumlu (KAKEIBO_PROMPT §8.9):
|
||||||
|
* UTF-8 **BOM** (Türkçe karakterler), `;` ayırıcı, satır sonu CRLF.
|
||||||
|
* Tutarlar Excel'in sayı sanması için binlik ayırıcısız ve ondalık virgüllü (`1250,50`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Cell = string | number | null | undefined
|
||||||
|
|
||||||
|
const BOM = ''
|
||||||
|
|
||||||
|
function quote(v: Cell): string {
|
||||||
|
if (v === null || v === undefined) return ''
|
||||||
|
const s = String(v)
|
||||||
|
return /[;"\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toCsv(headers: string[], rows: Cell[][]): string {
|
||||||
|
return BOM + [headers, ...rows].map((r) => r.map(quote).join(';')).join('\r\n') + '\r\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kuruş → `1250,50` (CSV için; binlik ayırıcı yok) */
|
||||||
|
export function csvMoney(kurus: number): string {
|
||||||
|
const neg = kurus < 0
|
||||||
|
const abs = Math.abs(kurus)
|
||||||
|
return `${neg ? '-' : ''}${Math.floor(abs / 100)},${String(abs % 100).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Oran → `12,5` (yüzde, 1 ondalık; CSV için) */
|
||||||
|
export function csvPercent(ratio: number | null): string {
|
||||||
|
return ratio === null ? '' : (Math.round(ratio * 1000) / 10).toString().replace('.', ',')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tarayıcıda dosya indirir */
|
||||||
|
export function downloadCsv(filename: string, csv: string) {
|
||||||
|
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }))
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = filename.endsWith('.csv') ? filename : `${filename}.csv`
|
||||||
|
document.body.append(a)
|
||||||
|
a.click()
|
||||||
|
a.remove()
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ function refresh(qc: QueryClient, periodId: string) {
|
|||||||
return Promise.all([
|
return Promise.all([
|
||||||
qc.invalidateQueries({ queryKey: queryKeys.period(periodId) }),
|
qc.invalidateQueries({ queryKey: queryKeys.period(periodId) }),
|
||||||
qc.invalidateQueries({ queryKey: queryKeys.stats }),
|
qc.invalidateQueries({ queryKey: queryKeys.stats }),
|
||||||
|
qc.invalidateQueries({ queryKey: queryKeys.reports }),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Yazdırma: her zaman açık tema (koyu temada açık renk yazı beyaz kâğıtta okunmaz) ve
|
||||||
|
* istenirse yalnızca tek bir rapor bölümü. Tarayıcının kendi Ctrl+P'si de aynı kuralı izler.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let installed = false
|
||||||
|
|
||||||
|
/** Uygulama açılışında bir kez: yazdırmadan önce açık tema, sonra eski haline */
|
||||||
|
export function installPrintTheme() {
|
||||||
|
if (installed || typeof window === 'undefined') return
|
||||||
|
installed = true
|
||||||
|
const html = document.documentElement
|
||||||
|
let wasDark = false
|
||||||
|
window.addEventListener('beforeprint', () => {
|
||||||
|
wasDark = html.classList.contains('dark')
|
||||||
|
html.classList.remove('dark')
|
||||||
|
})
|
||||||
|
window.addEventListener('afterprint', () => {
|
||||||
|
if (wasDark) html.classList.add('dark')
|
||||||
|
html.removeAttribute('data-print-one')
|
||||||
|
document.querySelector('[data-print-target]')?.removeAttribute('data-print-target')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Yalnızca `el`'i yazdır (diğer rapor bölümleri gizlenir); `el` yoksa tüm sayfa */
|
||||||
|
export function printSection(el?: HTMLElement | null) {
|
||||||
|
if (el) {
|
||||||
|
document.documentElement.setAttribute('data-print-one', '')
|
||||||
|
el.setAttribute('data-print-target', '')
|
||||||
|
}
|
||||||
|
window.print()
|
||||||
|
}
|
||||||
@@ -22,6 +22,9 @@ export const queryKeys = {
|
|||||||
portfolio: ['portfolio'] as const,
|
portfolio: ['portfolio'] as const,
|
||||||
assetTxs: (assetId: string) => ['portfolio', 'txs', assetId] as const,
|
assetTxs: (assetId: string) => ['portfolio', 'txs', assetId] as const,
|
||||||
pendingSavings: ['portfolio', 'pending'] as const,
|
pendingSavings: ['portfolio', 'pending'] as const,
|
||||||
|
/** Rapor kırılımı (`query` = sunucu sorgu metni); kayıt değişince `reports` öneki yenilenir */
|
||||||
|
reportBreakdown: (query: string) => ['reports', 'breakdown', query] as const,
|
||||||
|
reports: ['reports'] as const,
|
||||||
period: (id: string) => ['period', id] as const,
|
period: (id: string) => ['period', id] as const,
|
||||||
planItems: (id: string) => ['period', id, 'plan-items'] as const,
|
planItems: (id: string) => ['period', id, 'plan-items'] as const,
|
||||||
summary: (id: string) => ['period', id, 'summary'] as const,
|
summary: (id: string) => ['period', id, 'summary'] as const,
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import type { GroupBudget } from '@/types/GroupBudget'
|
||||||
|
import {
|
||||||
|
activeFilterCount,
|
||||||
|
describeFilter,
|
||||||
|
deviation,
|
||||||
|
filterToParams,
|
||||||
|
filterToQuery,
|
||||||
|
formatMonth,
|
||||||
|
parseFilter,
|
||||||
|
savingsRate,
|
||||||
|
} from './reports'
|
||||||
|
|
||||||
|
describe('report filter', () => {
|
||||||
|
it('parses Turkish params; period wins over dates', () => {
|
||||||
|
const f = parseFilter(new URLSearchParams('donem=p1&baslangic=2026-01-01&grup=needs&odeme=yok'))
|
||||||
|
expect(f).toEqual({ period: 'p1', group: 'needs', method: 'none' })
|
||||||
|
expect(filterToQuery(f)).toBe('?period_id=p1&group=needs&payment_method_id=none')
|
||||||
|
expect(filterToParams(f)).toEqual({ donem: 'p1', grup: 'needs', odeme: 'yok' })
|
||||||
|
expect(activeFilterCount(f)).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('date range; invalid values ignored', () => {
|
||||||
|
const f = parseFilter(
|
||||||
|
new URLSearchParams('baslangic=2026-01-01&bitis=2026-02-30&grup=xyz&kategori=c1'),
|
||||||
|
)
|
||||||
|
expect(f).toEqual({ from: '2026-01-01', category: 'c1' })
|
||||||
|
expect(filterToQuery(f)).toBe('?from=2026-01-01&category_id=c1')
|
||||||
|
expect(filterToQuery({})).toBe('')
|
||||||
|
expect(activeFilterCount({})).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('filter description (printed reports)', () => {
|
||||||
|
const names = {
|
||||||
|
period: (id: string) => (id === 'p1' ? 'Kasım 2026' : undefined),
|
||||||
|
category: () => 'Market',
|
||||||
|
method: () => 'Nakit',
|
||||||
|
}
|
||||||
|
it('describes scope and filters', () => {
|
||||||
|
expect(describeFilter({}, names)).toBe('Kapsam: Tüm zamanlar')
|
||||||
|
expect(describeFilter({ period: 'p1', group: 'needs', method: 'none' }, names)).toBe(
|
||||||
|
'Kapsam: Kasım 2026 · Grup: Zorunlu · Ödeme türü: Girilmemiş',
|
||||||
|
)
|
||||||
|
expect(describeFilter({ from: '2026-01-01', to: '2026-11-13', category: 'c' }, names)).toBe(
|
||||||
|
'Kapsam: 01.01.2026 – 13.11.2026 · Kategori: Market',
|
||||||
|
)
|
||||||
|
expect(describeFilter({ from: '2026-01-01' }, names)).toBe('Kapsam: 01.01.2026 – …')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('report calculations', () => {
|
||||||
|
it('savings rate', () => {
|
||||||
|
expect(savingsRate({ realized_savings: 250, income_total: 1_000 })).toBe(0.25)
|
||||||
|
expect(savingsRate({ realized_savings: -100, income_total: 1_000 })).toBe(-0.1)
|
||||||
|
expect(savingsRate({ realized_savings: 0, income_total: 0 })).toBeNull()
|
||||||
|
expect(formatMonth('2026-11')).toBe('Kasım 2026')
|
||||||
|
expect(formatMonth('2027-01')).toBe('Ocak 2027')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('budget deviation monthly and weekly', () => {
|
||||||
|
const wk = (amount: number, spent: number) => ({
|
||||||
|
index: 1,
|
||||||
|
start_date: '',
|
||||||
|
last_date: '',
|
||||||
|
days: 7,
|
||||||
|
amount,
|
||||||
|
is_manual: false,
|
||||||
|
spent,
|
||||||
|
})
|
||||||
|
const budget = [
|
||||||
|
{
|
||||||
|
group: 'needs',
|
||||||
|
monthly: 1_000,
|
||||||
|
spent: 1_200,
|
||||||
|
weekly_over: false,
|
||||||
|
weeks: [wk(300, 100), wk(700, 1_100)],
|
||||||
|
},
|
||||||
|
{ group: 'wants', monthly: 0, spent: 50, weekly_over: false, weeks: [wk(0, 50), wk(0, 0)] },
|
||||||
|
] as GroupBudget[]
|
||||||
|
expect(deviation(budget)).toEqual([
|
||||||
|
{ group: 'needs', planned: 1_000, spent: 1_200, diff: -200, used: 1.2 },
|
||||||
|
{ group: 'wants', planned: 0, spent: 50, diff: -50, used: null },
|
||||||
|
])
|
||||||
|
expect(deviation(budget, 2)[0]).toEqual({
|
||||||
|
group: 'needs',
|
||||||
|
planned: 700,
|
||||||
|
spent: 1_100,
|
||||||
|
diff: -400,
|
||||||
|
used: 1_100 / 700,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* Raporlar: filtre (adreste), sunucu kancası ve saf hesaplar.
|
||||||
|
*
|
||||||
|
* Adres parametreleri: `donem`, `baslangic`, `bitis`, `grup`, `kategori`, `odeme`
|
||||||
|
* (`odeme=yok` → ödeme türü girilmemiş). Dönem seçiliyse tarih aralığı yok sayılır.
|
||||||
|
*/
|
||||||
|
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
||||||
|
import type { GroupBudget } from '@/types/GroupBudget'
|
||||||
|
import type { PeriodStat } from '@/types/PeriodStat'
|
||||||
|
import type { ReportBreakdown } from '@/types/ReportBreakdown'
|
||||||
|
import { api } from './api'
|
||||||
|
import { formatDate } from './format'
|
||||||
|
import { group as groupMeta, GROUPS, type GroupCode } from './groups'
|
||||||
|
import { isIsoDate } from './period-dates'
|
||||||
|
import { queryKeys } from './query'
|
||||||
|
|
||||||
|
export type ReportFilter = {
|
||||||
|
period?: string
|
||||||
|
from?: string
|
||||||
|
to?: string
|
||||||
|
group?: GroupCode
|
||||||
|
category?: string
|
||||||
|
/** `'none'` → girilmemiş */
|
||||||
|
method?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const P = {
|
||||||
|
period: 'donem',
|
||||||
|
from: 'baslangic',
|
||||||
|
to: 'bitis',
|
||||||
|
group: 'grup',
|
||||||
|
category: 'kategori',
|
||||||
|
method: 'odeme',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export function parseFilter(params: URLSearchParams): ReportFilter {
|
||||||
|
const get = (k: string) => params.get(k)?.trim() || undefined
|
||||||
|
const f: ReportFilter = {}
|
||||||
|
const period = get(P.period)
|
||||||
|
if (period) f.period = period
|
||||||
|
else {
|
||||||
|
const from = get(P.from)
|
||||||
|
const to = get(P.to)
|
||||||
|
if (from && isIsoDate(from)) f.from = from
|
||||||
|
if (to && isIsoDate(to)) f.to = to
|
||||||
|
}
|
||||||
|
const group = get(P.group)
|
||||||
|
if (group && GROUPS.some((g) => g.code === group)) f.group = group as GroupCode
|
||||||
|
const category = get(P.category)
|
||||||
|
if (category) f.category = category
|
||||||
|
const method = get(P.method)
|
||||||
|
if (method) f.method = method === 'yok' ? 'none' : method
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterToParams(f: ReportFilter): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {}
|
||||||
|
if (f.period) out[P.period] = f.period
|
||||||
|
else {
|
||||||
|
if (f.from) out[P.from] = f.from
|
||||||
|
if (f.to) out[P.to] = f.to
|
||||||
|
}
|
||||||
|
if (f.group) out[P.group] = f.group
|
||||||
|
if (f.category) out[P.category] = f.category
|
||||||
|
if (f.method) out[P.method] = f.method === 'none' ? 'yok' : f.method
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sunucu sorgusu (`/reports/breakdown?...`) */
|
||||||
|
export function filterToQuery(f: ReportFilter): string {
|
||||||
|
const q = new URLSearchParams()
|
||||||
|
if (f.period) q.set('period_id', f.period)
|
||||||
|
else {
|
||||||
|
if (f.from) q.set('from', f.from)
|
||||||
|
if (f.to) q.set('to', f.to)
|
||||||
|
}
|
||||||
|
if (f.group) q.set('group', f.group)
|
||||||
|
if (f.category) q.set('category_id', f.category)
|
||||||
|
if (f.method) q.set('payment_method_id', f.method)
|
||||||
|
const s = q.toString()
|
||||||
|
return s ? `?${s}` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filtrenin okunur özeti (yazdırılan raporun başında: kağıtta hangi kapsamın basıldığı belli olsun).
|
||||||
|
* Örn. `Kapsam: Kasım 2026 · Grup: Zorunlu · Ödeme türü: Girilmemiş`
|
||||||
|
*/
|
||||||
|
export function describeFilter(
|
||||||
|
f: ReportFilter,
|
||||||
|
names: {
|
||||||
|
period: (id: string) => string | undefined
|
||||||
|
category: (id: string) => string | undefined
|
||||||
|
method: (id: string) => string | undefined
|
||||||
|
},
|
||||||
|
): string {
|
||||||
|
const scope = f.period
|
||||||
|
? (names.period(f.period) ?? '—')
|
||||||
|
: f.from || f.to
|
||||||
|
? `${f.from ? formatDate(f.from) : '…'} – ${f.to ? formatDate(f.to) : '…'}`
|
||||||
|
: 'Tüm zamanlar'
|
||||||
|
const parts = [`Kapsam: ${scope}`]
|
||||||
|
if (f.group) parts.push(`Grup: ${groupMeta(f.group).label}`)
|
||||||
|
if (f.category) parts.push(`Kategori: ${names.category(f.category) ?? '—'}`)
|
||||||
|
if (f.method)
|
||||||
|
parts.push(
|
||||||
|
`Ödeme türü: ${f.method === 'none' ? 'Girilmemiş' : (names.method(f.method) ?? '—')}`,
|
||||||
|
)
|
||||||
|
return parts.join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activeFilterCount(f: ReportFilter): number {
|
||||||
|
return [f.period || f.from || f.to, f.group, f.category, f.method].filter(Boolean).length
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReportBreakdown(f: ReportFilter) {
|
||||||
|
const query = filterToQuery(f)
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.reportBreakdown(query),
|
||||||
|
queryFn: () => api.get<ReportBreakdown>(`/reports/breakdown${query}`),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Birikim oranı: gerçekleşen birikim ÷ gelir (gelir yoksa `null`) */
|
||||||
|
export function savingsRate(
|
||||||
|
p: Pick<PeriodStat, 'realized_savings' | 'income_total'>,
|
||||||
|
): number | null {
|
||||||
|
return p.income_total > 0 ? p.realized_savings / p.income_total : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthName = new Intl.DateTimeFormat('tr-TR', {
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
timeZone: 'UTC',
|
||||||
|
})
|
||||||
|
|
||||||
|
/** `"2026-11"` → `Kasım 2026` */
|
||||||
|
export const formatMonth = (ym: string) => monthName.format(new Date(`${ym}-01T00:00:00Z`))
|
||||||
|
|
||||||
|
/** Pay (0–1); toplam 0 ise 0 */
|
||||||
|
export const share = (part: number, total: number) => (total > 0 ? part / total : 0)
|
||||||
|
|
||||||
|
export type DeviationRow = {
|
||||||
|
group: GroupCode
|
||||||
|
planned: number
|
||||||
|
spent: number
|
||||||
|
/** planned − spent (eksi → aşım) */
|
||||||
|
diff: number
|
||||||
|
/** spent ÷ planned (plan yoksa `null`) */
|
||||||
|
used: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bütçe sapması: grup başına aylık (ya da `week` verilirse o hafta) planlanan vs gerçekleşen */
|
||||||
|
export function deviation(budget: GroupBudget[], week?: number): DeviationRow[] {
|
||||||
|
return budget.map((b) => {
|
||||||
|
const w = week ? b.weeks[week - 1] : undefined
|
||||||
|
const planned = week ? (w?.amount ?? 0) : b.monthly
|
||||||
|
const spent = week ? (w?.spent ?? 0) : b.spent
|
||||||
|
return {
|
||||||
|
group: b.group,
|
||||||
|
planned,
|
||||||
|
spent,
|
||||||
|
diff: planned - spent,
|
||||||
|
used: planned > 0 ? spent / planned : null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -494,6 +494,83 @@ export const t = {
|
|||||||
added: (name: string) => `${name} birikimi eklendi`,
|
added: (name: string) => `${name} birikimi eklendi`,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
reports: {
|
||||||
|
printAll: 'Tümünü yazdır',
|
||||||
|
print: 'Yazdır',
|
||||||
|
csv: 'CSV indir',
|
||||||
|
filters: 'Filtreler',
|
||||||
|
filterCount: (n: number) => (n ? `Filtreler (${n})` : 'Filtreler'),
|
||||||
|
scope: 'Kapsam',
|
||||||
|
allTime: 'Tüm zamanlar',
|
||||||
|
byPeriod: 'Dönem',
|
||||||
|
byRange: 'Tarih aralığı',
|
||||||
|
from: 'Başlangıç',
|
||||||
|
to: 'Bitiş',
|
||||||
|
group: 'Grup',
|
||||||
|
category: 'Kategori',
|
||||||
|
method: 'Ödeme türü',
|
||||||
|
any: 'Tümü',
|
||||||
|
noMethod: 'Girilmemiş',
|
||||||
|
clear: 'Filtreleri temizle',
|
||||||
|
filteredNote: 'Filtrelere göre',
|
||||||
|
unfilteredNote: 'Filtrelerden bağımsız',
|
||||||
|
empty: 'Bu filtrelerle harcama yok.',
|
||||||
|
total: 'Toplam',
|
||||||
|
count: 'Kayıt',
|
||||||
|
share: 'Pay',
|
||||||
|
amount: 'Tutar',
|
||||||
|
// Gelir–gider
|
||||||
|
periodsTitle: 'Gelir ve gider (dönemler)',
|
||||||
|
periodsHint: 'Son 12 dönem: gelir ile sabit giderler + harcamaların karşılaştırması.',
|
||||||
|
period: 'Dönem',
|
||||||
|
income: 'Gelir',
|
||||||
|
fixed: 'Sabit gider',
|
||||||
|
spent: 'Harcanan',
|
||||||
|
expense: 'Gider',
|
||||||
|
realized: 'Gerçekleşen birikim',
|
||||||
|
rate: 'Birikim oranı',
|
||||||
|
rateTitle: 'Birikim oranı trendi',
|
||||||
|
rateHint: 'Gelirin yüzde kaçı birikti (gerçekleşen birikim ÷ gelir).',
|
||||||
|
rateSummary: (name: string, rate: string) => `Son dönem (${name}) birikim oranı: ${rate}.`,
|
||||||
|
periodsSummary: (n: number) => `${n} dönemin gelir ve gider karşılaştırması aşağıdaki tabloda.`,
|
||||||
|
noPeriods: 'Henüz dönem yok.',
|
||||||
|
// Kırılımlar
|
||||||
|
groupsTitle: 'Gruplara göre harcama',
|
||||||
|
categoriesTitle: 'Kategorilere göre harcama',
|
||||||
|
categoriesHint: 'Harcama olan tüm kategoriler, çoktan aza.',
|
||||||
|
methodsTitle: 'Ödeme türüne göre harcama',
|
||||||
|
monthsTitle: 'Aylara göre harcama',
|
||||||
|
// Bütçe sapması
|
||||||
|
deviationTitle: 'Bütçe sapması',
|
||||||
|
deviationHint: (name: string) => `${name}: grup bütçeleri ile gerçekleşen harcama.`,
|
||||||
|
planned: 'Planlanan',
|
||||||
|
diff: 'Fark',
|
||||||
|
used: 'Kullanım',
|
||||||
|
over: 'aşıldı',
|
||||||
|
weeksTitle: 'Haftalara göre (tüm gruplar)',
|
||||||
|
week: (n: number, range: string) => `${n}. hafta (${range})`,
|
||||||
|
noDeviationPeriod: 'Bütçe sapması için bir dönem gerekiyor.',
|
||||||
|
// Varlıklar
|
||||||
|
assetsTitle: 'Birikim / varlık performansı',
|
||||||
|
assetsHint: 'Eldeki varlıkların güncel değeri ve kâr/zarar.',
|
||||||
|
asset: 'Varlık',
|
||||||
|
quantity: 'Miktar',
|
||||||
|
cost: 'Maliyet',
|
||||||
|
value: 'Güncel değer',
|
||||||
|
unrealized: 'Kâr / zarar',
|
||||||
|
realizedPl: 'Gerçekleşen kâr / zarar',
|
||||||
|
noAssets: 'Henüz birikim kaydı yok.',
|
||||||
|
file: {
|
||||||
|
periods: 'gelir-gider',
|
||||||
|
groups: 'gruplar',
|
||||||
|
categories: 'kategoriler',
|
||||||
|
methods: 'odeme-turleri',
|
||||||
|
months: 'aylar',
|
||||||
|
deviation: 'butce-sapmasi',
|
||||||
|
assets: 'varliklar',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
periods: {
|
periods: {
|
||||||
picker: 'Dönem',
|
picker: 'Dönem',
|
||||||
status: { open: 'Açık', closed: '🔒 Kapalı', correcting: '✏️ Düzeltmede' },
|
status: { open: 'Açık', closed: '🔒 Kapalı', correcting: '✏️ Düzeltmede' },
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ import { RouterProvider } from 'react-router'
|
|||||||
import './index.css'
|
import './index.css'
|
||||||
import { queryClient } from '@/lib/query'
|
import { queryClient } from '@/lib/query'
|
||||||
import { Toaster } from '@/components/toaster'
|
import { Toaster } from '@/components/toaster'
|
||||||
|
import { installPrintTheme } from '@/lib/print'
|
||||||
import { ThemeProvider } from '@/lib/theme'
|
import { ThemeProvider } from '@/lib/theme'
|
||||||
import { router } from './router'
|
import { router } from './router'
|
||||||
|
|
||||||
|
installPrintTheme()
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
|||||||
@@ -6,11 +6,15 @@ export type PeriodStat = { id: string, name: string, start_date: string,
|
|||||||
/**
|
/**
|
||||||
* Son gün, dahil
|
* Son gün, dahil
|
||||||
*/
|
*/
|
||||||
last_date: string, status: PeriodStatus, spendable: number, spent: number,
|
last_date: string, status: PeriodStatus, income_total: number, fixed_expense_total: number, savings_goal: number, carryover_debt: number, carryover_surplus: number, spendable: number, spent: number,
|
||||||
/**
|
/**
|
||||||
* Harcanan, gruplara göre
|
* Harcanan, gruplara göre
|
||||||
*/
|
*/
|
||||||
spent_by_group: GroupAmounts,
|
spent_by_group: GroupAmounts,
|
||||||
|
/**
|
||||||
|
* Gelir − sabit − borç + artı − harcanan (negatifse açık). Birikim oranı = bu ÷ gelir
|
||||||
|
*/
|
||||||
|
realized_savings: number,
|
||||||
/**
|
/**
|
||||||
* Kapanmamışsa `null`
|
* Kapanmamışsa `null`
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
import type { ReportCategoryRow } from "./ReportCategoryRow";
|
||||||
|
import type { ReportGroupRow } from "./ReportGroupRow";
|
||||||
|
import type { ReportMethodRow } from "./ReportMethodRow";
|
||||||
|
import type { ReportMonthRow } from "./ReportMonthRow";
|
||||||
|
|
||||||
|
export type ReportBreakdown = {
|
||||||
|
/**
|
||||||
|
* Kuruş
|
||||||
|
*/
|
||||||
|
total: number, count: number,
|
||||||
|
/**
|
||||||
|
* 4 grup, sabit sırada
|
||||||
|
*/
|
||||||
|
groups: Array<ReportGroupRow>,
|
||||||
|
/**
|
||||||
|
* Harcama olan **tüm** kategoriler, çoktan aza
|
||||||
|
*/
|
||||||
|
categories: Array<ReportCategoryRow>,
|
||||||
|
/**
|
||||||
|
* Ödeme türleri; `null` = girilmemiş
|
||||||
|
*/
|
||||||
|
payment_methods: Array<ReportMethodRow>,
|
||||||
|
/**
|
||||||
|
* Aylar (`"YYYY-MM"`), eskiden yeniye
|
||||||
|
*/
|
||||||
|
months: Array<ReportMonthRow>, };
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
import type { Group } from "./Group";
|
||||||
|
|
||||||
|
export type ReportCategoryRow = { category_id: string, group: Group, total: number, count: number, };
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
import type { Group } from "./Group";
|
||||||
|
|
||||||
|
export type ReportGroupRow = { group: Group, total: number, count: number, };
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
export type ReportMethodRow = { payment_method_id: string | null, total: number, count: number, };
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
export type ReportMonthRow = {
|
||||||
|
/**
|
||||||
|
* `"YYYY-MM"`
|
||||||
|
*/
|
||||||
|
month: string, total: number, count: number, };
|
||||||
Reference in New Issue
Block a user