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 num_rational::Rational64;
9
use serde::Deserialize;
10
use server::command::{
11
    CmdResult, ReportData, ReportNode, commodity::ListCommodities, report::IncomeExpenseReport,
12
};
13
use sqlx::types::Uuid;
14
use sqlx::types::chrono::NaiveDate;
15

            
16
use crate::{AppState, jwt_auth::JWTAuthMiddleware, pages::HtmlTemplate};
17

            
18
use super::balance::{self, CommodityOption};
19
use super::{build_report_filter, empty_string_as_none};
20

            
21
#[derive(Template)]
22
#[template(path = "pages/report/income_expense.html")]
23
struct IncomeExpenseReportPage;
24

            
25
pub async fn income_expense_report_page() -> impl IntoResponse {
26
    HtmlTemplate(IncomeExpenseReportPage)
27
}
28

            
29
struct ReportRowView {
30
    account_name: String,
31
    depth: usize,
32
    amounts: Vec<AmountView>,
33
}
34

            
35
struct AmountView {
36
    commodity_symbol: String,
37
    amount: Rational64,
38
}
39

            
40
fn flatten_nodes(nodes: &[ReportNode]) -> Vec<ReportRowView> {
41
    let mut rows = Vec::new();
42
    for node in nodes {
43
        rows.push(ReportRowView {
44
            account_name: node.account_name.clone(),
45
            depth: node.depth,
46
            amounts: node
47
                .amounts
48
                .iter()
49
                .map(|a| AmountView {
50
                    commodity_symbol: a.commodity_symbol.clone(),
51
                    amount: a.amount,
52
                })
53
                .collect(),
54
        });
55
        rows.extend(flatten_nodes(&node.children));
56
    }
57
    rows
58
}
59

            
60
struct PeriodView {
61
    label: String,
62
    rows: Vec<ReportRowView>,
63
}
64

            
65
#[derive(Template)]
66
#[template(path = "components/report/income_expense_table.html")]
67
struct IncomeExpenseTableTemplate {
68
    commodities: Vec<CommodityOption>,
69
    periods: Vec<PeriodView>,
70
    date_from: Option<String>,
71
    date_to: Option<String>,
72
    target_commodity_id: Option<String>,
73
    period_grouping: Option<String>,
74
    tag_filters: String,
75
    tag_filter_mode: String,
76
    scripting_enabled: bool,
77
}
78

            
79
#[derive(Deserialize)]
80
pub struct IncomeExpenseParams {
81
    #[serde(default, deserialize_with = "empty_string_as_none")]
82
    date_from: Option<String>,
83
    #[serde(default, deserialize_with = "empty_string_as_none")]
84
    date_to: Option<String>,
85
    #[serde(default, deserialize_with = "empty_string_as_none")]
86
    target_commodity_id: Option<String>,
87
    #[serde(default, deserialize_with = "empty_string_as_none")]
88
    period_grouping: Option<String>,
89
    #[serde(default, deserialize_with = "empty_string_as_none")]
90
    tag_filters: Option<String>,
91
    #[serde(default, deserialize_with = "empty_string_as_none")]
92
    tag_filter_mode: Option<String>,
93
}
94

            
95
pub async fn income_expense_report_table(
96
    Query(params): Query<IncomeExpenseParams>,
97
    State(_data): State<Arc<AppState>>,
98
    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
99
    headers: HeaderMap,
100
) -> Result<impl IntoResponse, StatusCode> {
101
    let commodity_entities = ListCommodities::new()
102
        .user_id(jwt_auth.user.id)
103
        .run()
104
        .await
105
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
106

            
107
    let commodities = commodity_entities
108
        .and_then(|r| {
109
            if let CmdResult::TaggedEntities { entities, .. } = r {
110
                Some(balance::fetch_commodity_list(entities))
111
            } else {
112
                None
113
            }
114
        })
115
        .unwrap_or_default();
116

            
117
    let date_from = params
118
        .date_from
119
        .as_deref()
120
        .and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok())
121
        .map(|d| d.and_hms_opt(0, 0, 0).unwrap().and_utc());
122

            
123
    let date_to = params
124
        .date_to
125
        .as_deref()
126
        .and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok())
127
        .map(|d| d.and_hms_opt(23, 59, 59).unwrap().and_utc());
128

            
129
    let tag_filter_mode = params
130
        .tag_filter_mode
131
        .clone()
132
        .unwrap_or_else(|| "visual".to_string());
133

            
134
    let (Some(df), Some(dt)) = (date_from, date_to) else {
135
        return Ok(HtmlTemplate(IncomeExpenseTableTemplate {
136
            commodities,
137
            periods: vec![],
138
            date_from: params.date_from,
139
            date_to: params.date_to,
140
            target_commodity_id: params.target_commodity_id,
141
            period_grouping: params.period_grouping,
142
            tag_filters: params.tag_filters.unwrap_or_default(),
143
            tag_filter_mode,
144
            scripting_enabled: cfg!(feature = "scripting"),
145
        })
146
        .into_response());
147
    };
148

            
149
    let mut cmd = IncomeExpenseReport::new()
150
        .user_id(jwt_auth.user.id)
151
        .date_from(df)
152
        .date_to(dt);
153

            
154
    if let Some(ref tid_str) = params.target_commodity_id
155
        && let Ok(tid) = tid_str.parse::<Uuid>()
156
    {
157
        cmd = cmd.target_commodity_id(tid);
158
    }
159

            
160
    if let Some(ref pg) = params.period_grouping {
161
        cmd = cmd.period_grouping(pg.clone());
162
    }
163

            
164
    if let Some(filter) = build_report_filter(
165
        params.tag_filters.as_deref(),
166
        Some(tag_filter_mode.as_str()),
167
    ) {
168
        cmd = cmd.report_filter(filter);
169
    }
170

            
171
    let result = cmd
172
        .run()
173
        .await
174
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
175

            
176
    let report_data = match result {
177
        Some(CmdResult::Report(data)) => data,
178
        _ => ReportData {
179
            meta: server::command::ReportMeta {
180
                date_from: None,
181
                date_to: None,
182
                target_commodity_id: None,
183
            },
184
            periods: vec![],
185
        },
186
    };
187

            
188
    if headers
189
        .get("accept")
190
        .and_then(|v| v.to_str().ok())
191
        .is_some_and(|v| v.contains("application/json"))
192
    {
193
        return Ok(Json(report_data).into_response());
194
    }
195

            
196
    let periods: Vec<PeriodView> = report_data
197
        .periods
198
        .iter()
199
        .map(|p| PeriodView {
200
            label: p.label.clone().unwrap_or_default(),
201
            rows: flatten_nodes(&p.roots),
202
        })
203
        .collect();
204

            
205
    Ok(HtmlTemplate(IncomeExpenseTableTemplate {
206
        commodities,
207
        periods,
208
        date_from: params.date_from,
209
        date_to: params.date_to,
210
        target_commodity_id: params.target_commodity_id,
211
        period_grouping: params.period_grouping,
212
        tag_filters: params.tag_filters.unwrap_or_default(),
213
        tag_filter_mode,
214
        scripting_enabled: cfg!(feature = "scripting"),
215
    })
216
    .into_response())
217
}