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::{CmdResult, ReportData, ReportMeta, report::BalanceReport};
10
use sqlx::types::Uuid;
11

            
12
use crate::{AppState, jwt_auth::JWTAuthMiddleware, pages::HtmlTemplate};
13

            
14
use super::{
15
    ChartParams, CommodityOption, SummaryCard, TableControlParams, build_report_filter,
16
    commodity_symbols_in_rows, empty_string_as_none, load_commodities, parse_date_bound,
17
    parse_sort_order_shared, sort_top_level_rows, sum_top_level_amounts, today_string, wants_json,
18
};
19
use plotting::adapters::{BalanceChartOpts, balance_chart};
20
use server::command::report::view::{AmountView, ReportRowView, flatten_report_data};
21

            
22
#[derive(Template)]
23
#[template(path = "pages/report/balance.html")]
24
struct BalanceReportPage;
25

            
26
pub async fn balance_report_page() -> impl IntoResponse {
27
    HtmlTemplate(BalanceReportPage)
28
}
29

            
30
#[derive(Template)]
31
#[template(path = "components/report/balance_table.html")]
32
struct BalanceTableTemplate {
33
    commodities: Vec<CommodityOption>,
34
    summary: Vec<SummaryCard>,
35
    rows: Vec<ReportRowView>,
36
    commodity_symbols: Vec<String>,
37
    commodity_columns: bool,
38
    sortable: bool,
39
    collapsible: bool,
40
    period_mode: bool,
41
    date_from: Option<String>,
42
    as_of: Option<String>,
43
    target_commodity_id: Option<String>,
44
    collapsed_depth: Option<String>,
45
    sort_order: String,
46
    tag_filters: String,
47
    tag_filter_mode: String,
48
    scripting_enabled: bool,
49
    chart_kind: String,
50
    chart_query: String,
51
    renderer: String,
52
}
53

            
54
#[derive(Deserialize)]
55
pub struct BalanceParams {
56
    #[serde(default, deserialize_with = "empty_string_as_none")]
57
    target_commodity_id: Option<String>,
58
    #[serde(default, deserialize_with = "empty_string_as_none")]
59
    date_from: Option<String>,
60
    #[serde(default, deserialize_with = "empty_string_as_none")]
61
    as_of: Option<String>,
62
    #[serde(default, deserialize_with = "empty_string_as_none")]
63
    tag_filters: Option<String>,
64
    #[serde(default, deserialize_with = "empty_string_as_none")]
65
    tag_filter_mode: Option<String>,
66
    #[serde(default, deserialize_with = "empty_string_as_none")]
67
    sort_order: Option<String>,
68
    #[serde(flatten)]
69
    table: TableControlParams,
70
    #[serde(flatten)]
71
    chart: ChartParams,
72
}
73

            
74
fn balance_summary(rows: &[ReportRowView]) -> Vec<SummaryCard> {
75
    sum_top_level_amounts(rows)
76
        .into_iter()
77
        .map(|a| SummaryCard {
78
            label: format!("Total ({})", a.commodity_symbol),
79
            amounts: vec![AmountView {
80
                commodity_symbol: a.commodity_symbol,
81
                amount: a.amount,
82
            }],
83
            is_net: false,
84
            highlight: false,
85
        })
86
        .collect()
87
}
88

            
89
/// Build the `BalanceReport` command from shared filter params, run it,
90
/// and flatten to `ReportRowView`. Both the table handler and the chart
91
/// handlers reuse this so the chart is always consistent with the table.
92
async fn run_balance(
93
    user_id: Uuid,
94
    target_commodity_id: Option<&str>,
95
    date_from: Option<&str>,
96
    as_of_str: &str,
97
    tag_filters: Option<&str>,
98
    tag_filter_mode: Option<&str>,
99
) -> Result<ReportData, StatusCode> {
100
    let mut cmd = BalanceReport::new().user_id(user_id);
101

            
102
    if let Some(tid_str) = target_commodity_id
103
        && let Ok(tid) = tid_str.parse::<Uuid>()
104
    {
105
        cmd = cmd.target_commodity_id(tid);
106
    }
107

            
108
    if let Some(df_str) = date_from
109
        && let Some(df) = parse_date_bound(df_str, false)
110
    {
111
        cmd = cmd.date_from(df);
112
    }
113

            
114
    if let Some(as_of) = parse_date_bound(as_of_str, true) {
115
        cmd = cmd.as_of(as_of);
116
    }
117

            
118
    if let Some(filter) = build_report_filter(tag_filters, tag_filter_mode) {
119
        cmd = cmd.report_filter(filter);
120
    }
121

            
122
    let result = cmd
123
        .run()
124
        .await
125
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
126

            
127
    Ok(match result {
128
        Some(CmdResult::Report(data)) => data,
129
        _ => ReportData {
130
            meta: ReportMeta {
131
                date_from: None,
132
                date_to: None,
133
                target_commodity_id: None,
134
            },
135
            periods: vec![],
136
        },
137
    })
138
}
139

            
140
pub async fn balance_report_table(
141
    Query(params): Query<BalanceParams>,
142
    State(_data): State<Arc<AppState>>,
143
    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
144
    headers: HeaderMap,
145
) -> Result<impl IntoResponse, StatusCode> {
146
    let commodities = load_commodities(jwt_auth.user.id).await;
147

            
148
    let as_of_str = params.as_of.clone().unwrap_or_else(today_string);
149
    let period_mode = params.date_from.is_some();
150

            
151
    let report_data = run_balance(
152
        jwt_auth.user.id,
153
        params.target_commodity_id.as_deref(),
154
        params.date_from.as_deref(),
155
        &as_of_str,
156
        params.tag_filters.as_deref(),
157
        params.tag_filter_mode.as_deref(),
158
    )
159
    .await?;
160

            
161
    if wants_json(&headers) {
162
        return Ok(Json(report_data).into_response());
163
    }
164

            
165
    let mut rows = flatten_report_data(&report_data);
166
    let sort_order = parse_sort_order_shared(params.sort_order.as_deref());
167
    sort_top_level_rows(&mut rows, sort_order);
168
    let summary = balance_summary(&rows);
169
    let commodity_symbols = commodity_symbols_in_rows(&rows);
170
    let commodity_columns = params.table.commodity_columns_enabled();
171

            
172
    let tag_filter_mode = params
173
        .tag_filter_mode
174
        .clone()
175
        .unwrap_or_else(|| "visual".to_string());
176

            
177
    let chart_kind = params.chart.chart_kind_str();
178
    let renderer = params.chart.renderer_str();
179
    let sort_order_str = sort_order.to_str().to_string();
180
    let chart_query = super::encode_query(&[
181
        (
182
            "target_commodity_id",
183
            params.target_commodity_id.as_deref().unwrap_or_default(),
184
        ),
185
        ("date_from", params.date_from.as_deref().unwrap_or_default()),
186
        ("as_of", as_of_str.as_str()),
187
        (
188
            "tag_filters",
189
            params.tag_filters.as_deref().unwrap_or_default(),
190
        ),
191
        ("tag_filter_mode", tag_filter_mode.as_str()),
192
        ("chart_kind", chart_kind.as_str()),
193
        ("renderer", renderer.as_str()),
194
        ("sort_order", sort_order_str.as_str()),
195
    ]);
196

            
197
    Ok(HtmlTemplate(BalanceTableTemplate {
198
        commodities,
199
        summary,
200
        rows,
201
        commodity_symbols,
202
        commodity_columns,
203
        sortable: true,
204
        collapsible: true,
205
        period_mode,
206
        date_from: params.date_from,
207
        as_of: Some(as_of_str),
208
        target_commodity_id: params.target_commodity_id,
209
        collapsed_depth: params.table.collapsed_depth,
210
        sort_order: sort_order_str,
211
        tag_filters: params.tag_filters.unwrap_or_default(),
212
        tag_filter_mode,
213
        scripting_enabled: cfg!(feature = "scripting"),
214
        chart_kind,
215
        chart_query,
216
        renderer,
217
    })
218
    .into_response())
219
}
220

            
221
/// The balance chart shows top-level accounts by magnitude. Both the
222
/// SVG and JSON chart endpoints take the same params as the table
223
/// endpoint plus `ChartParams`.
224
#[derive(Deserialize)]
225
pub struct BalanceChartQuery {
226
    #[serde(default, deserialize_with = "empty_string_as_none")]
227
    target_commodity_id: Option<String>,
228
    #[serde(default, deserialize_with = "empty_string_as_none")]
229
    date_from: Option<String>,
230
    #[serde(default, deserialize_with = "empty_string_as_none")]
231
    as_of: Option<String>,
232
    #[serde(default, deserialize_with = "empty_string_as_none")]
233
    tag_filters: Option<String>,
234
    #[serde(default, deserialize_with = "empty_string_as_none")]
235
    tag_filter_mode: Option<String>,
236
    #[serde(default, deserialize_with = "empty_string_as_none")]
237
    sort_order: Option<String>,
238
    #[serde(flatten)]
239
    chart: ChartParams,
240
}
241

            
242
async fn balance_chart_spec(
243
    user_id: Uuid,
244
    params: &BalanceChartQuery,
245
) -> Result<plotting::ChartSpec, StatusCode> {
246
    let as_of_str = params.as_of.clone().unwrap_or_else(today_string);
247
    let report_data = run_balance(
248
        user_id,
249
        params.target_commodity_id.as_deref(),
250
        params.date_from.as_deref(),
251
        &as_of_str,
252
        params.tag_filters.as_deref(),
253
        params.tag_filter_mode.as_deref(),
254
    )
255
    .await?;
256
    let rows = flatten_report_data(&report_data);
257
    let sort_order = parse_sort_order_shared(params.sort_order.as_deref());
258
    Ok(balance_chart(
259
        &rows,
260
        BalanceChartOpts {
261
            kind: params.chart.chart_kind_or_default(),
262
            top_n: 10,
263
            sort_order: sort_order.into_plotting_balance(),
264
        },
265
    ))
266
}
267

            
268
pub async fn balance_report_chart_svg(
269
    Query(params): Query<BalanceChartQuery>,
270
    State(_data): State<Arc<AppState>>,
271
    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
272
) -> Result<impl IntoResponse, StatusCode> {
273
    let spec = balance_chart_spec(jwt_auth.user.id, &params).await?;
274
    let svg = plotting::svg::render_svg(&spec, 720, 360);
275
    Ok((
276
        StatusCode::OK,
277
        [(axum::http::header::CONTENT_TYPE, "image/svg+xml")],
278
        svg,
279
    ))
280
}
281

            
282
pub async fn balance_report_chart_json(
283
    Query(params): Query<BalanceChartQuery>,
284
    State(_data): State<Arc<AppState>>,
285
    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
286
) -> Result<impl IntoResponse, StatusCode> {
287
    let spec = balance_chart_spec(jwt_auth.user.id, &params).await?;
288
    Ok(Json(spec))
289
}