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
    ActivityData, ActivityGroup, CmdResult, PeriodGrouping, ReportMeta, report::ActivityReport,
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::{ActivityChartOpts, activity_chart};
22
use server::command::report::view::{AmountView, PeriodActivityView, flatten_activity_data};
23

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

            
28
pub async fn activity_report_page() -> impl IntoResponse {
29
    HtmlTemplate(ActivityReportPage)
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
#[derive(Template)]
42
#[template(path = "components/report/activity_table.html")]
43
struct ActivityTableTemplate {
44
    commodities: Vec<CommodityOption>,
45
    summary: Vec<SummaryCard>,
46
    periods: Vec<PeriodActivityView>,
47
    commodity_symbols: Vec<String>,
48
    commodity_columns: bool,
49
    sortable: bool,
50
    collapsible: bool,
51
    date_from: Option<String>,
52
    date_to: Option<String>,
53
    target_commodity_id: Option<String>,
54
    period_grouping: Option<String>,
55
    collapsed_depth: Option<String>,
56
    groups_json: String,
57
    groups_mode: String,
58
    tag_filters: String,
59
    tag_filter_mode: String,
60
    scripting_enabled: bool,
61
    chart_kind: String,
62
    chart_query: String,
63
    renderer: String,
64
}
65

            
66
#[derive(Deserialize)]
67
pub struct ActivityParams {
68
    #[serde(default, deserialize_with = "empty_string_as_none")]
69
    date_from: Option<String>,
70
    #[serde(default, deserialize_with = "empty_string_as_none")]
71
    date_to: Option<String>,
72
    #[serde(default, deserialize_with = "empty_string_as_none")]
73
    target_commodity_id: Option<String>,
74
    #[serde(default, deserialize_with = "empty_string_as_none")]
75
    period_grouping: Option<String>,
76
    #[serde(default, deserialize_with = "empty_string_as_none")]
77
    groups: Option<String>,
78
    #[serde(default, deserialize_with = "empty_string_as_none")]
79
    groups_mode: Option<String>,
80
    #[serde(default, deserialize_with = "empty_string_as_none")]
81
    tag_filters: Option<String>,
82
    #[serde(default, deserialize_with = "empty_string_as_none")]
83
    tag_filter_mode: Option<String>,
84
    #[serde(flatten)]
85
    table: TableControlParams,
86
    #[serde(flatten)]
87
    chart: ChartParams,
88
}
89

            
90
/// Collect the set of commodity symbols referenced anywhere in the
91
/// activity result, in insertion order. Used to drive the
92
/// "Commodities as columns" pivot.
93
fn collect_commodity_symbols(periods: &[PeriodActivityView]) -> Vec<String> {
94
    let mut seen: Vec<String> = Vec::new();
95
    let mut push = |sym: &str| {
96
        if !seen.iter().any(|s| s == sym) {
97
            seen.push(sym.to_string());
98
        }
99
    };
100
    for period in periods {
101
        for group in &period.groups {
102
            for row in &group.rows {
103
                for a in &row.amounts {
104
                    push(&a.commodity_symbol);
105
                }
106
            }
107
        }
108
    }
109
    seen
110
}
111

            
112
fn add_amounts(dest: &mut Vec<AmountView>, src: &[AmountView]) {
113
    for a in src {
114
        match dest
115
            .iter_mut()
116
            .find(|d| d.commodity_symbol == a.commodity_symbol)
117
        {
118
            Some(existing) => existing.amount += a.amount,
119
            None => dest.push(AmountView {
120
                commodity_symbol: a.commodity_symbol.clone(),
121
                amount: a.amount,
122
            }),
123
        }
124
    }
125
}
126

            
127
/// Build summary cards for Activity: one card per group label summing its
128
/// group-total across every period, plus a final net card combining per-
129
/// period nets. Server values are already `flip_sign`-applied by the view
130
/// flattener, so this is a straightforward cross-period sum.
131
fn activity_summary(periods: &[PeriodActivityView]) -> Vec<SummaryCard> {
132
    let mut labeled: Vec<(String, bool, Vec<AmountView>)> = Vec::new();
133
    let mut net: Vec<AmountView> = Vec::new();
134

            
135
    for period in periods {
136
        for group in &period.groups {
137
            match labeled
138
                .iter_mut()
139
                .find(|(label, _, _)| label == &group.label)
140
            {
141
                Some((_, _, totals)) => add_amounts(totals, &group.total),
142
                None => labeled.push((group.label.clone(), group.flip_sign, group.total.clone())),
143
            }
144
        }
145
        add_amounts(&mut net, &period.net);
146
    }
147

            
148
    let mut cards: Vec<SummaryCard> = labeled
149
        .into_iter()
150
        .map(|(label, highlight, amounts)| SummaryCard {
151
            label,
152
            amounts,
153
            is_net: false,
154
            highlight,
155
        })
156
        .collect();
157

            
158
    if !net.is_empty() {
159
        cards.push(SummaryCard {
160
            label: "Net".to_string(),
161
            amounts: net,
162
            is_net: true,
163
            highlight: false,
164
        });
165
    }
166
    cards
167
}
168

            
169
pub async fn activity_report_table(
170
    Query(params): Query<ActivityParams>,
171
    State(_data): State<Arc<AppState>>,
172
    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
173
    headers: HeaderMap,
174
) -> Result<impl IntoResponse, StatusCode> {
175
    let commodities = load_commodities(jwt_auth.user.id).await;
176

            
177
    let date_from_str = params.date_from.clone().unwrap_or_else(month_start_string);
178
    let date_to_str = params.date_to.clone().unwrap_or_else(today_string);
179

            
180
    let date_from = parse_date_bound(&date_from_str, false);
181
    let date_to = parse_date_bound(&date_to_str, true);
182

            
183
    let tag_filter_mode = params
184
        .tag_filter_mode
185
        .clone()
186
        .unwrap_or_else(|| "visual".to_string());
187

            
188
    let groups_mode = params
189
        .groups_mode
190
        .clone()
191
        .unwrap_or_else(|| "visual".to_string());
192

            
193
    let groups_json_raw = params.groups.clone().unwrap_or_default();
194
    let commodity_columns = params.table.commodity_columns_enabled();
195
    let chart_kind = params.chart.chart_kind_str();
196
    let renderer = params.chart.renderer_str();
197
    let chart_query = super::encode_query(&[
198
        ("date_from", date_from_str.as_str()),
199
        ("date_to", date_to_str.as_str()),
200
        (
201
            "target_commodity_id",
202
            params.target_commodity_id.as_deref().unwrap_or_default(),
203
        ),
204
        (
205
            "period_grouping",
206
            params.period_grouping.as_deref().unwrap_or_default(),
207
        ),
208
        ("groups", groups_json_raw.as_str()),
209
        (
210
            "tag_filters",
211
            params.tag_filters.as_deref().unwrap_or_default(),
212
        ),
213
        ("tag_filter_mode", tag_filter_mode.as_str()),
214
        ("chart_kind", chart_kind.as_str()),
215
        ("renderer", renderer.as_str()),
216
    ]);
217

            
218
    let (Some(df), Some(dt)) = (date_from, date_to) else {
219
        return Ok(HtmlTemplate(ActivityTableTemplate {
220
            commodities,
221
            summary: vec![],
222
            periods: vec![],
223
            commodity_symbols: vec![],
224
            commodity_columns,
225
            sortable: true,
226
            collapsible: true,
227
            date_from: Some(date_from_str),
228
            date_to: Some(date_to_str),
229
            target_commodity_id: params.target_commodity_id,
230
            period_grouping: params.period_grouping,
231
            collapsed_depth: params.table.collapsed_depth,
232
            groups_json: groups_json_raw,
233
            groups_mode,
234
            tag_filters: params.tag_filters.unwrap_or_default(),
235
            tag_filter_mode,
236
            scripting_enabled: cfg!(feature = "scripting"),
237
            chart_kind: chart_kind.clone(),
238
            chart_query: chart_query.clone(),
239
            renderer: renderer.clone(),
240
        })
241
        .into_response());
242
    };
243

            
244
    let parsed_groups: Option<Vec<ActivityGroup>> = params
245
        .groups
246
        .as_deref()
247
        .and_then(|s| serde_json::from_str::<Vec<ActivityGroup>>(s).ok());
248

            
249
    let mut cmd = ActivityReport::new()
250
        .user_id(jwt_auth.user.id)
251
        .date_from(df)
252
        .date_to(dt);
253

            
254
    if let Some(ref tid_str) = params.target_commodity_id
255
        && let Ok(tid) = tid_str.parse::<Uuid>()
256
    {
257
        cmd = cmd.target_commodity_id(tid);
258
    }
259

            
260
    if let Some(pg) = params
261
        .period_grouping
262
        .as_deref()
263
        .and_then(parse_period_grouping)
264
    {
265
        cmd = cmd.period_grouping(pg);
266
    }
267

            
268
    if let Some(g) = parsed_groups {
269
        cmd = cmd.groups(g);
270
    }
271

            
272
    if let Some(filter) = build_report_filter(
273
        params.tag_filters.as_deref(),
274
        Some(tag_filter_mode.as_str()),
275
    ) {
276
        cmd = cmd.report_filter(filter);
277
    }
278

            
279
    let result = cmd
280
        .run()
281
        .await
282
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
283

            
284
    let activity_data = match result {
285
        Some(CmdResult::Activity(data)) => data,
286
        _ => ActivityData {
287
            meta: ReportMeta {
288
                date_from: None,
289
                date_to: None,
290
                target_commodity_id: None,
291
            },
292
            periods: vec![],
293
        },
294
    };
295

            
296
    if wants_json(&headers) {
297
        return Ok(Json(activity_data).into_response());
298
    }
299

            
300
    let periods = flatten_activity_data(&activity_data);
301
    let summary = activity_summary(&periods);
302
    let commodity_symbols = collect_commodity_symbols(&periods);
303

            
304
    Ok(HtmlTemplate(ActivityTableTemplate {
305
        commodities,
306
        summary,
307
        periods,
308
        commodity_symbols,
309
        commodity_columns,
310
        sortable: true,
311
        collapsible: true,
312
        date_from: Some(date_from_str),
313
        date_to: Some(date_to_str),
314
        target_commodity_id: params.target_commodity_id,
315
        period_grouping: params.period_grouping,
316
        collapsed_depth: params.table.collapsed_depth,
317
        groups_json: groups_json_raw,
318
        groups_mode,
319
        tag_filters: params.tag_filters.unwrap_or_default(),
320
        tag_filter_mode,
321
        scripting_enabled: cfg!(feature = "scripting"),
322
        chart_kind,
323
        chart_query,
324
        renderer,
325
    })
326
    .into_response())
327
}
328

            
329
#[derive(Deserialize)]
330
pub struct ActivityChartQuery {
331
    #[serde(default, deserialize_with = "empty_string_as_none")]
332
    date_from: Option<String>,
333
    #[serde(default, deserialize_with = "empty_string_as_none")]
334
    date_to: Option<String>,
335
    #[serde(default, deserialize_with = "empty_string_as_none")]
336
    target_commodity_id: Option<String>,
337
    #[serde(default, deserialize_with = "empty_string_as_none")]
338
    period_grouping: Option<String>,
339
    #[serde(default, deserialize_with = "empty_string_as_none")]
340
    groups: Option<String>,
341
    #[serde(default, deserialize_with = "empty_string_as_none")]
342
    tag_filters: Option<String>,
343
    #[serde(default, deserialize_with = "empty_string_as_none")]
344
    tag_filter_mode: Option<String>,
345
    #[serde(flatten)]
346
    chart: ChartParams,
347
}
348

            
349
/// Run the activity command with the chart params and flatten.
350
/// Returns an empty `ActivityData` when the date range is invalid —
351
/// consistent with how the table handler fails soft.
352
async fn activity_periods_for_chart(
353
    user_id: Uuid,
354
    params: &ActivityChartQuery,
355
) -> Result<Vec<PeriodActivityView>, StatusCode> {
356
    let date_from_str = params.date_from.clone().unwrap_or_else(month_start_string);
357
    let date_to_str = params.date_to.clone().unwrap_or_else(today_string);
358
    let (Some(df), Some(dt)) = (
359
        parse_date_bound(&date_from_str, false),
360
        parse_date_bound(&date_to_str, true),
361
    ) else {
362
        return Ok(Vec::new());
363
    };
364

            
365
    let mut cmd = ActivityReport::new()
366
        .user_id(user_id)
367
        .date_from(df)
368
        .date_to(dt);
369

            
370
    if let Some(ref tid_str) = params.target_commodity_id
371
        && let Ok(tid) = tid_str.parse::<Uuid>()
372
    {
373
        cmd = cmd.target_commodity_id(tid);
374
    }
375

            
376
    if let Some(pg) = params
377
        .period_grouping
378
        .as_deref()
379
        .and_then(parse_period_grouping)
380
    {
381
        cmd = cmd.period_grouping(pg);
382
    }
383

            
384
    if let Some(g) = params
385
        .groups
386
        .as_deref()
387
        .and_then(|s| serde_json::from_str::<Vec<ActivityGroup>>(s).ok())
388
    {
389
        cmd = cmd.groups(g);
390
    }
391

            
392
    if let Some(filter) = build_report_filter(
393
        params.tag_filters.as_deref(),
394
        params.tag_filter_mode.as_deref(),
395
    ) {
396
        cmd = cmd.report_filter(filter);
397
    }
398

            
399
    let result = cmd
400
        .run()
401
        .await
402
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
403

            
404
    let activity_data = match result {
405
        Some(CmdResult::Activity(data)) => data,
406
        _ => ActivityData {
407
            meta: ReportMeta {
408
                date_from: None,
409
                date_to: None,
410
                target_commodity_id: None,
411
            },
412
            periods: vec![],
413
        },
414
    };
415

            
416
    Ok(flatten_activity_data(&activity_data))
417
}
418

            
419
fn chart_series_includes_net(raw: Option<&str>) -> bool {
420
    // `chart_series=groups` suppresses the Net line; anything else
421
    // (including the default) includes it.
422
    !matches!(raw.map(str::to_ascii_lowercase), Some(ref s) if s == "groups")
423
}
424

            
425
async fn activity_chart_spec(
426
    user_id: Uuid,
427
    params: &ActivityChartQuery,
428
) -> Result<plotting::ChartSpec, StatusCode> {
429
    let periods = activity_periods_for_chart(user_id, params).await?;
430
    Ok(activity_chart(
431
        &periods,
432
        ActivityChartOpts {
433
            kind: params.chart.chart_kind_or_default(),
434
            include_net: chart_series_includes_net(params.chart.chart_series.as_deref()),
435
        },
436
    ))
437
}
438

            
439
pub async fn activity_report_chart_svg(
440
    Query(params): Query<ActivityChartQuery>,
441
    State(_data): State<Arc<AppState>>,
442
    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
443
) -> Result<impl IntoResponse, StatusCode> {
444
    let spec = activity_chart_spec(jwt_auth.user.id, &params).await?;
445
    let svg = plotting::svg::render_svg(&spec, 720, 360);
446
    Ok((
447
        StatusCode::OK,
448
        [(axum::http::header::CONTENT_TYPE, "image/svg+xml")],
449
        svg,
450
    ))
451
}
452

            
453
pub async fn activity_report_chart_json(
454
    Query(params): Query<ActivityChartQuery>,
455
    State(_data): State<Arc<AppState>>,
456
    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
457
) -> Result<impl IntoResponse, StatusCode> {
458
    let spec = activity_chart_spec(jwt_auth.user.id, &params).await?;
459
    Ok(Json(spec))
460
}