1
use std::sync::Arc;
2

            
3
use askama::Template;
4
use axum::Json;
5
use axum::extract::Query;
6
use axum::http::HeaderMap;
7
use axum::{Extension, extract::State, http::StatusCode, response::IntoResponse};
8
use serde::Deserialize;
9
use server::command::{
10
    BreakdownData, BreakdownSort, CmdResult, PeriodGrouping, ReportMeta, report::CategoryBreakdown,
11
};
12
use sqlx::types::Uuid;
13

            
14
use crate::{AppState, jwt_auth::JWTAuthMiddleware, pages::HtmlTemplate};
15

            
16
use super::{
17
    ChartParams, CommodityOption, SummaryCard, TableControlParams, build_report_filter,
18
    empty_string_as_none, load_commodities, month_start_string, parse_date_bound, today_string,
19
    wants_json,
20
};
21
use plotting::adapters::{BreakdownChartOpts, breakdown_chart};
22
use server::command::report::view::{AmountView, BreakdownPeriodView, flatten_breakdown_data};
23

            
24
#[derive(Template)]
25
#[template(path = "pages/report/category_breakdown.html")]
26
struct CategoryBreakdownReportPage;
27

            
28
pub async fn category_breakdown_report_page() -> impl IntoResponse {
29
    HtmlTemplate(CategoryBreakdownReportPage)
30
}
31

            
32
fn parse_period_grouping(s: &str) -> Option<PeriodGrouping> {
33
    match s {
34
        "month" => Some(PeriodGrouping::Month),
35
        "quarter" => Some(PeriodGrouping::Quarter),
36
        "year" => Some(PeriodGrouping::Year),
37
        _ => None,
38
    }
39
}
40

            
41
fn parse_sort_order(s: &str) -> Option<BreakdownSort> {
42
    match s {
43
        "amount_desc" => Some(BreakdownSort::AmountDesc),
44
        "amount_asc" => Some(BreakdownSort::AmountAsc),
45
        "name_asc" => Some(BreakdownSort::NameAsc),
46
        "name_desc" => Some(BreakdownSort::NameDesc),
47
        _ => None,
48
    }
49
}
50

            
51
#[derive(Template)]
52
#[template(path = "components/report/category_breakdown_table.html")]
53
struct CategoryBreakdownTableTemplate {
54
    commodities: Vec<CommodityOption>,
55
    summary: Vec<SummaryCard>,
56
    periods: Vec<BreakdownPeriodView>,
57
    commodity_symbols: Vec<String>,
58
    commodity_columns: bool,
59
    tag_name: String,
60
    date_from: Option<String>,
61
    date_to: Option<String>,
62
    target_commodity_id: Option<String>,
63
    period_grouping: Option<String>,
64
    sort_order: String,
65
    include_uncategorized: bool,
66
    tag_filters: String,
67
    tag_filter_mode: String,
68
    scripting_enabled: bool,
69
    chart_kind: String,
70
    chart_query: String,
71
    renderer: String,
72
}
73

            
74
#[derive(Deserialize)]
75
pub struct CategoryBreakdownParams {
76
    #[serde(default, deserialize_with = "empty_string_as_none")]
77
    date_from: Option<String>,
78
    #[serde(default, deserialize_with = "empty_string_as_none")]
79
    date_to: Option<String>,
80
    #[serde(default, deserialize_with = "empty_string_as_none")]
81
    target_commodity_id: Option<String>,
82
    #[serde(default, deserialize_with = "empty_string_as_none")]
83
    period_grouping: Option<String>,
84
    #[serde(default, deserialize_with = "empty_string_as_none")]
85
    tag_name: Option<String>,
86
    #[serde(default, deserialize_with = "empty_string_as_none")]
87
    sort_order: Option<String>,
88
    #[serde(default)]
89
    exclude_uncategorized: Option<String>,
90
    #[serde(default, deserialize_with = "empty_string_as_none")]
91
    tag_filters: Option<String>,
92
    #[serde(default, deserialize_with = "empty_string_as_none")]
93
    tag_filter_mode: Option<String>,
94
    #[serde(flatten)]
95
    table: TableControlParams,
96
    #[serde(flatten)]
97
    chart: ChartParams,
98
}
99

            
100
/// HTML checkboxes only submit a value when checked. `exclude_uncategorized`
101
/// defaults to false so an absent parameter naturally reads as "include the
102
/// bucket" and a checked box as "exclude it".
103
fn params_include_uncategorized(exclude_raw: Option<&str>) -> bool {
104
    let exclude = exclude_raw.is_some_and(|v| {
105
        let v = v.trim();
106
        v.eq_ignore_ascii_case("on") || v.eq_ignore_ascii_case("true")
107
    });
108
    !exclude
109
}
110

            
111
fn push_amount(dest: &mut Vec<AmountView>, a: &AmountView) {
112
    match dest
113
        .iter_mut()
114
        .find(|d| d.commodity_symbol == a.commodity_symbol)
115
    {
116
        Some(existing) => existing.amount += a.amount,
117
        None => dest.push(a.clone()),
118
    }
119
}
120

            
121
/// Build summary cards for Category Breakdown: one grand-total card per
122
/// commodity plus up to `top_n` cards for the largest categories by
123
/// absolute amount.
124
fn breakdown_summary(periods: &[BreakdownPeriodView], top_n: usize) -> Vec<SummaryCard> {
125
    let mut totals: Vec<AmountView> = Vec::new();
126
    let mut per_category: Vec<(String, bool, Vec<AmountView>)> = Vec::new();
127

            
128
    for period in periods {
129
        for row in &period.rows {
130
            for a in &row.amounts {
131
                push_amount(&mut totals, a);
132
            }
133
            match per_category
134
                .iter_mut()
135
                .find(|(name, _, _)| name == &row.tag_value)
136
            {
137
                Some((_, _, amts)) => {
138
                    for a in &row.amounts {
139
                        push_amount(amts, a);
140
                    }
141
                }
142
                None => per_category.push((
143
                    row.tag_value.clone(),
144
                    row.is_uncategorized,
145
                    row.amounts.clone(),
146
                )),
147
            }
148
        }
149
    }
150

            
151
    let mut cards: Vec<SummaryCard> = totals
152
        .into_iter()
153
        .map(|a| SummaryCard {
154
            label: format!("Total ({})", a.commodity_symbol),
155
            amounts: vec![a],
156
            is_net: false,
157
            highlight: true,
158
        })
159
        .collect();
160

            
161
    per_category.sort_by(|a, b| {
162
        let key = |row: &(String, bool, Vec<AmountView>)| -> num_rational::Rational64 {
163
            row.2
164
                .iter()
165
                .map(|x| {
166
                    if x.amount < num_rational::Rational64::new(0, 1) {
167
                        -x.amount
168
                    } else {
169
                        x.amount
170
                    }
171
                })
172
                .sum()
173
        };
174
        key(b).cmp(&key(a))
175
    });
176

            
177
    for (name, is_uncat, amounts) in per_category.into_iter().take(top_n) {
178
        let label = if is_uncat {
179
            "(uncategorized)".to_string()
180
        } else {
181
            name
182
        };
183
        cards.push(SummaryCard {
184
            label,
185
            amounts,
186
            is_net: false,
187
            highlight: false,
188
        });
189
    }
190
    cards
191
}
192

            
193
fn collect_commodity_symbols(periods: &[BreakdownPeriodView]) -> Vec<String> {
194
    let mut seen: Vec<String> = Vec::new();
195
    for period in periods {
196
        for row in &period.rows {
197
            for a in &row.amounts {
198
                if !seen.iter().any(|s| s == &a.commodity_symbol) {
199
                    seen.push(a.commodity_symbol.clone());
200
                }
201
            }
202
        }
203
    }
204
    seen
205
}
206

            
207
pub async fn category_breakdown_report_table(
208
    Query(params): Query<CategoryBreakdownParams>,
209
    State(_data): State<Arc<AppState>>,
210
    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
211
    headers: HeaderMap,
212
) -> Result<impl IntoResponse, StatusCode> {
213
    let commodities = load_commodities(jwt_auth.user.id).await;
214

            
215
    let date_from_str = params.date_from.clone().unwrap_or_else(month_start_string);
216
    let date_to_str = params.date_to.clone().unwrap_or_else(today_string);
217

            
218
    let date_from = parse_date_bound(&date_from_str, false);
219
    let date_to = parse_date_bound(&date_to_str, true);
220

            
221
    let tag_filter_mode = params
222
        .tag_filter_mode
223
        .clone()
224
        .unwrap_or_else(|| "visual".to_string());
225

            
226
    let tag_name_display = params
227
        .tag_name
228
        .clone()
229
        .unwrap_or_else(|| "category".to_string());
230

            
231
    let sort_order_raw = params
232
        .sort_order
233
        .clone()
234
        .unwrap_or_else(|| "amount_desc".to_string());
235

            
236
    let include_uncategorized =
237
        params_include_uncategorized(params.exclude_uncategorized.as_deref());
238

            
239
    let commodity_columns = params.table.commodity_columns_enabled();
240
    let chart_kind = params.chart.chart_kind_str();
241
    let renderer = params.chart.renderer_str();
242
    let chart_query = super::encode_query(&[
243
        ("date_from", date_from_str.as_str()),
244
        ("date_to", date_to_str.as_str()),
245
        (
246
            "target_commodity_id",
247
            params.target_commodity_id.as_deref().unwrap_or_default(),
248
        ),
249
        (
250
            "period_grouping",
251
            params.period_grouping.as_deref().unwrap_or_default(),
252
        ),
253
        ("tag_name", params.tag_name.as_deref().unwrap_or_default()),
254
        ("sort_order", sort_order_raw.as_str()),
255
        (
256
            "exclude_uncategorized",
257
            if include_uncategorized { "" } else { "on" },
258
        ),
259
        (
260
            "tag_filters",
261
            params.tag_filters.as_deref().unwrap_or_default(),
262
        ),
263
        ("tag_filter_mode", tag_filter_mode.as_str()),
264
        ("chart_kind", chart_kind.as_str()),
265
        ("renderer", renderer.as_str()),
266
    ]);
267

            
268
    let (Some(df), Some(dt)) = (date_from, date_to) else {
269
        return Ok(HtmlTemplate(CategoryBreakdownTableTemplate {
270
            commodities,
271
            summary: vec![],
272
            periods: vec![],
273
            commodity_symbols: vec![],
274
            commodity_columns,
275
            tag_name: tag_name_display,
276
            date_from: Some(date_from_str),
277
            date_to: Some(date_to_str),
278
            target_commodity_id: params.target_commodity_id,
279
            period_grouping: params.period_grouping,
280
            sort_order: sort_order_raw,
281
            include_uncategorized,
282
            tag_filters: params.tag_filters.unwrap_or_default(),
283
            tag_filter_mode,
284
            scripting_enabled: cfg!(feature = "scripting"),
285
            chart_kind: chart_kind.clone(),
286
            chart_query: chart_query.clone(),
287
            renderer: renderer.clone(),
288
        })
289
        .into_response());
290
    };
291

            
292
    let mut cmd = CategoryBreakdown::new()
293
        .user_id(jwt_auth.user.id)
294
        .date_from(df)
295
        .date_to(dt)
296
        .include_uncategorized(include_uncategorized);
297

            
298
    if let Some(ref name) = params.tag_name {
299
        cmd = cmd.tag_name(name.clone());
300
    }
301

            
302
    if let Some(ref tid_str) = params.target_commodity_id
303
        && let Ok(tid) = tid_str.parse::<Uuid>()
304
    {
305
        cmd = cmd.target_commodity_id(tid);
306
    }
307

            
308
    if let Some(pg) = params
309
        .period_grouping
310
        .as_deref()
311
        .and_then(parse_period_grouping)
312
    {
313
        cmd = cmd.period_grouping(pg);
314
    }
315

            
316
    if let Some(order) = parse_sort_order(&sort_order_raw) {
317
        cmd = cmd.sort_order(order);
318
    }
319

            
320
    if let Some(filter) = build_report_filter(
321
        params.tag_filters.as_deref(),
322
        Some(tag_filter_mode.as_str()),
323
    ) {
324
        cmd = cmd.report_filter(filter);
325
    }
326

            
327
    let result = cmd
328
        .run()
329
        .await
330
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
331

            
332
    let breakdown_data = match result {
333
        Some(CmdResult::Breakdown(data)) => data,
334
        _ => BreakdownData {
335
            meta: ReportMeta {
336
                date_from: None,
337
                date_to: None,
338
                target_commodity_id: None,
339
            },
340
            tag_name: tag_name_display.clone(),
341
            periods: vec![],
342
        },
343
    };
344

            
345
    if wants_json(&headers) {
346
        return Ok(Json(breakdown_data).into_response());
347
    }
348

            
349
    let periods = flatten_breakdown_data(&breakdown_data);
350
    let summary = breakdown_summary(&periods, 5);
351
    let commodity_symbols = collect_commodity_symbols(&periods);
352

            
353
    Ok(HtmlTemplate(CategoryBreakdownTableTemplate {
354
        commodities,
355
        summary,
356
        periods,
357
        commodity_symbols,
358
        commodity_columns,
359
        tag_name: breakdown_data.tag_name,
360
        date_from: Some(date_from_str),
361
        date_to: Some(date_to_str),
362
        target_commodity_id: params.target_commodity_id,
363
        period_grouping: params.period_grouping,
364
        sort_order: sort_order_raw,
365
        include_uncategorized,
366
        tag_filters: params.tag_filters.unwrap_or_default(),
367
        tag_filter_mode,
368
        scripting_enabled: cfg!(feature = "scripting"),
369
        chart_kind,
370
        chart_query,
371
        renderer,
372
    })
373
    .into_response())
374
}
375

            
376
#[derive(Deserialize)]
377
pub struct CategoryBreakdownChartQuery {
378
    #[serde(default, deserialize_with = "empty_string_as_none")]
379
    date_from: Option<String>,
380
    #[serde(default, deserialize_with = "empty_string_as_none")]
381
    date_to: Option<String>,
382
    #[serde(default, deserialize_with = "empty_string_as_none")]
383
    target_commodity_id: Option<String>,
384
    #[serde(default, deserialize_with = "empty_string_as_none")]
385
    period_grouping: Option<String>,
386
    #[serde(default, deserialize_with = "empty_string_as_none")]
387
    tag_name: Option<String>,
388
    #[serde(default, deserialize_with = "empty_string_as_none")]
389
    sort_order: Option<String>,
390
    #[serde(default)]
391
    exclude_uncategorized: Option<String>,
392
    #[serde(default, deserialize_with = "empty_string_as_none")]
393
    tag_filters: Option<String>,
394
    #[serde(default, deserialize_with = "empty_string_as_none")]
395
    tag_filter_mode: Option<String>,
396
    #[serde(flatten)]
397
    chart: ChartParams,
398
}
399

            
400
async fn breakdown_periods_for_chart(
401
    user_id: Uuid,
402
    params: &CategoryBreakdownChartQuery,
403
) -> Result<Vec<BreakdownPeriodView>, StatusCode> {
404
    let date_from_str = params.date_from.clone().unwrap_or_else(month_start_string);
405
    let date_to_str = params.date_to.clone().unwrap_or_else(today_string);
406
    let (Some(df), Some(dt)) = (
407
        parse_date_bound(&date_from_str, false),
408
        parse_date_bound(&date_to_str, true),
409
    ) else {
410
        return Ok(Vec::new());
411
    };
412

            
413
    let include_uncategorized =
414
        params_include_uncategorized(params.exclude_uncategorized.as_deref());
415

            
416
    let mut cmd = CategoryBreakdown::new()
417
        .user_id(user_id)
418
        .date_from(df)
419
        .date_to(dt)
420
        .include_uncategorized(include_uncategorized);
421

            
422
    if let Some(ref name) = params.tag_name {
423
        cmd = cmd.tag_name(name.clone());
424
    }
425

            
426
    if let Some(ref tid_str) = params.target_commodity_id
427
        && let Ok(tid) = tid_str.parse::<Uuid>()
428
    {
429
        cmd = cmd.target_commodity_id(tid);
430
    }
431

            
432
    if let Some(pg) = params
433
        .period_grouping
434
        .as_deref()
435
        .and_then(parse_period_grouping)
436
    {
437
        cmd = cmd.period_grouping(pg);
438
    }
439

            
440
    if let Some(order) = params.sort_order.as_deref().and_then(parse_sort_order) {
441
        cmd = cmd.sort_order(order);
442
    }
443

            
444
    if let Some(filter) = build_report_filter(
445
        params.tag_filters.as_deref(),
446
        params.tag_filter_mode.as_deref(),
447
    ) {
448
        cmd = cmd.report_filter(filter);
449
    }
450

            
451
    let result = cmd
452
        .run()
453
        .await
454
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
455

            
456
    let breakdown_data = match result {
457
        Some(CmdResult::Breakdown(data)) => data,
458
        _ => BreakdownData {
459
            meta: ReportMeta {
460
                date_from: None,
461
                date_to: None,
462
                target_commodity_id: None,
463
            },
464
            tag_name: params
465
                .tag_name
466
                .clone()
467
                .unwrap_or_else(|| "category".to_string()),
468
            periods: vec![],
469
        },
470
    };
471

            
472
    Ok(flatten_breakdown_data(&breakdown_data))
473
}
474

            
475
async fn breakdown_chart_spec(
476
    user_id: Uuid,
477
    params: &CategoryBreakdownChartQuery,
478
) -> Result<plotting::ChartSpec, StatusCode> {
479
    let periods = breakdown_periods_for_chart(user_id, params).await?;
480
    Ok(breakdown_chart(
481
        &periods,
482
        BreakdownChartOpts {
483
            kind: params.chart.chart_kind_or_default(),
484
            top_n: 10,
485
        },
486
    ))
487
}
488

            
489
pub async fn category_breakdown_report_chart_svg(
490
    Query(params): Query<CategoryBreakdownChartQuery>,
491
    State(_data): State<Arc<AppState>>,
492
    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
493
) -> Result<impl IntoResponse, StatusCode> {
494
    let spec = breakdown_chart_spec(jwt_auth.user.id, &params).await?;
495
    let svg = plotting::svg::render_svg(&spec, 720, 360);
496
    Ok((
497
        StatusCode::OK,
498
        [(axum::http::header::CONTENT_TYPE, "image/svg+xml")],
499
        svg,
500
    ))
501
}
502

            
503
pub async fn category_breakdown_report_chart_json(
504
    Query(params): Query<CategoryBreakdownChartQuery>,
505
    State(_data): State<Arc<AppState>>,
506
    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
507
) -> Result<impl IntoResponse, StatusCode> {
508
    let spec = breakdown_chart_spec(jwt_auth.user.id, &params).await?;
509
    Ok(Json(spec))
510
}