Skip to main content

web/pages/account/
manage.rs

1use std::{collections::HashMap, sync::Arc};
2
3use askama::Template;
4use axum::{
5    Extension, Form, Json,
6    extract::{Path, Query, State},
7    http::StatusCode,
8    response::IntoResponse,
9};
10use serde::{Deserialize, Serialize};
11use server::command::{
12    CmdError, CmdResult, FinanceEntity,
13    account::{GetAccountForManage, ListAccountsForManage, SetAccountTag},
14};
15use sqlx::types::Uuid;
16
17use crate::{AppState, jwt_auth::JWTAuthMiddleware, pages::HtmlTemplate};
18
19#[derive(Template)]
20#[template(path = "pages/account/manage.html")]
21struct AccountManagePage;
22
23#[derive(Clone)]
24struct AccountTreeEntry {
25    id: Uuid,
26    parent_id: Option<Uuid>,
27    name: String,
28}
29
30#[derive(Clone)]
31struct AccountTreeNodeView {
32    id: Uuid,
33    name: String,
34    selected: bool,
35    children: Vec<AccountTreeNodeView>,
36}
37
38#[derive(Clone)]
39struct AccountTagView {
40    id: Uuid,
41    tag_name: String,
42    tag_value: String,
43    description: Option<String>,
44}
45
46#[derive(Clone)]
47struct AccountDetailsTemplateView {
48    id: Uuid,
49    parent_id: Option<Uuid>,
50    name: String,
51    tags: Vec<AccountTagView>,
52}
53
54#[derive(Template)]
55#[template(path = "components/account/manage_content.html")]
56struct AccountManageContentTemplate {
57    tree_html: String,
58    details: Option<AccountDetailsTemplateView>,
59    flash_success: Option<String>,
60    flash_error: Option<String>,
61}
62
63pub async fn account_manage_page() -> impl IntoResponse {
64    HtmlTemplate(AccountManagePage {})
65}
66
67#[derive(Serialize)]
68pub struct AccountTreeNode {
69    id: Uuid,
70    parent_id: Option<Uuid>,
71    name: String,
72}
73
74pub async fn account_tree(
75    State(_data): State<Arc<AppState>>,
76    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
77) -> Result<Json<Vec<AccountTreeNode>>, (StatusCode, Json<serde_json::Value>)> {
78    let entries = list_account_entries(jwt_auth.user.id).await.map_err(|e| {
79        let error_response = serde_json::json!({
80            "status": "fail",
81            "message": "Failed to fetch account tree",
82        });
83        log::error!("Failed to fetch account tree: {e:?}");
84        (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
85    })?;
86
87    let tree = entries
88        .into_iter()
89        .map(|entry| AccountTreeNode {
90            id: entry.id,
91            parent_id: entry.parent_id,
92            name: entry.name,
93        })
94        .collect();
95
96    Ok(Json(tree))
97}
98
99#[derive(Deserialize)]
100pub struct AccountDetailsQuery {
101    account: Uuid,
102}
103
104#[derive(Serialize)]
105pub struct AccountDetailsTagView {
106    id: Uuid,
107    name: String,
108    value: String,
109    description: Option<String>,
110}
111
112#[derive(Serialize)]
113pub struct AccountDetailsView {
114    id: Uuid,
115    parent_id: Option<Uuid>,
116    name: String,
117    tags: Vec<AccountDetailsTagView>,
118}
119
120pub async fn account_manage_details(
121    Query(query): Query<AccountDetailsQuery>,
122    State(_data): State<Arc<AppState>>,
123    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
124) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
125    let details = get_account_details(jwt_auth.user.id, query.account)
126        .await
127        .map_err(|e| cmd_error_to_json(e, "Failed to fetch account details", query.account))?;
128
129    Ok(Json(AccountDetailsView {
130        id: details.id,
131        parent_id: details.parent_id,
132        name: details.name,
133        tags: details
134            .tags
135            .into_iter()
136            .map(|tag| AccountDetailsTagView {
137                id: tag.id,
138                name: tag.tag_name,
139                value: tag.tag_value,
140                description: tag.description,
141            })
142            .collect(),
143    })
144    .into_response())
145}
146
147#[derive(Deserialize)]
148pub struct AccountManageViewQuery {
149    account: Option<Uuid>,
150}
151
152pub async fn account_manage_view(
153    Query(query): Query<AccountManageViewQuery>,
154    State(_data): State<Arc<AppState>>,
155    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
156) -> Result<impl IntoResponse, (StatusCode, String)> {
157    let template = build_manage_content(jwt_auth.user.id, query.account, None, None)
158        .await
159        .map_err(|e| {
160            log::error!("Failed to render account manage view: {e:?}");
161            (
162                StatusCode::INTERNAL_SERVER_ERROR,
163                "Failed to load account management view".into(),
164            )
165        })?;
166
167    Ok(HtmlTemplate(template))
168}
169
170#[derive(Deserialize)]
171pub struct SetAccountTagForm {
172    account_id: Uuid,
173    tag_name: String,
174    tag_value: String,
175    description: Option<String>,
176}
177
178#[derive(Serialize)]
179pub struct SetAccountTagResponse {
180    status: &'static str,
181    account_id: Uuid,
182    tag_name: String,
183    tag_value: String,
184}
185
186pub async fn account_manage_set_tag(
187    State(_data): State<Arc<AppState>>,
188    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
189    Json(form): Json<SetAccountTagForm>,
190) -> Result<Json<SetAccountTagResponse>, (StatusCode, Json<serde_json::Value>)> {
191    let normalized = normalize_tag_input(form.tag_name, form.tag_value, form.description)
192        .map_err(|message| json_error(StatusCode::BAD_REQUEST, message))?;
193
194    set_account_tag(
195        jwt_auth.user.id,
196        form.account_id,
197        &normalized.tag_name,
198        &normalized.tag_value,
199        normalized.description,
200    )
201    .await
202    .map_err(|e| cmd_error_to_json(e, "Failed to set account tag", form.account_id))?;
203
204    Ok(Json(SetAccountTagResponse {
205        status: "ok",
206        account_id: form.account_id,
207        tag_name: normalized.tag_name,
208        tag_value: normalized.tag_value,
209    }))
210}
211
212pub async fn account_manage_set_tag_submit(
213    Query(query): Query<AccountManageViewQuery>,
214    State(_data): State<Arc<AppState>>,
215    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
216    Form(form): Form<SetAccountTagForm>,
217) -> Result<impl IntoResponse, (StatusCode, String)> {
218    let selected_account_id = Some(query.account.unwrap_or(form.account_id));
219
220    let render_with = |success: Option<String>, error: Option<String>| async {
221        build_manage_content(jwt_auth.user.id, selected_account_id, success, error)
222            .await
223            .map(HtmlTemplate)
224            .map_err(|e| {
225                log::error!("Failed to render updated account manage view: {e:?}");
226                (
227                    StatusCode::INTERNAL_SERVER_ERROR,
228                    "Failed to render account management view".to_string(),
229                )
230            })
231    };
232
233    let normalized = match normalize_tag_input(form.tag_name, form.tag_value, form.description) {
234        Ok(normalized) => normalized,
235        Err(message) => return render_with(None, Some(message)).await,
236    };
237
238    match set_account_tag(
239        jwt_auth.user.id,
240        form.account_id,
241        &normalized.tag_name,
242        &normalized.tag_value,
243        normalized.description,
244    )
245    .await
246    {
247        Ok(()) => render_with(Some("Tag saved".to_string()), None).await,
248        Err(e) => {
249            let message = match e {
250                CmdError::Args(msg) => msg,
251                _ => "Failed to set account tag".to_string(),
252            };
253            render_with(None, Some(message)).await
254        }
255    }
256}
257
258#[derive(Deserialize)]
259pub struct DeleteAccountTagForm {
260    account_id: Uuid,
261}
262
263pub async fn account_manage_delete_tag_submit(
264    Path(tag_id): Path<Uuid>,
265    Query(query): Query<AccountManageViewQuery>,
266    State(_data): State<Arc<AppState>>,
267    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
268    Form(form): Form<DeleteAccountTagForm>,
269) -> Result<impl IntoResponse, (StatusCode, String)> {
270    let selected_account_id = Some(query.account.unwrap_or(form.account_id));
271
272    let server_user = server::user::User {
273        id: jwt_auth.user.id,
274    };
275
276    let (success, error) = match server_user.delete_tag(tag_id).await {
277        Ok(()) => (Some("Tag deleted".to_string()), None),
278        Err(e) => {
279            log::error!("Failed to delete tag {tag_id}: {e:?}");
280            (None, Some("Failed to delete tag".to_string()))
281        }
282    };
283
284    let template = build_manage_content(jwt_auth.user.id, selected_account_id, success, error)
285        .await
286        .map_err(|e| {
287            log::error!("Failed to render account manage view after delete: {e:?}");
288            (
289                StatusCode::INTERNAL_SERVER_ERROR,
290                "Failed to render account management view".to_string(),
291            )
292        })?;
293
294    Ok(HtmlTemplate(template))
295}
296
297#[derive(Clone)]
298struct NormalizedTagInput {
299    tag_name: String,
300    tag_value: String,
301    description: Option<String>,
302}
303
304fn normalize_tag_input(
305    tag_name: String,
306    tag_value: String,
307    description: Option<String>,
308) -> Result<NormalizedTagInput, String> {
309    let tag_name = tag_name.trim().to_string();
310    let tag_value = tag_value.trim().to_string();
311
312    if tag_name.is_empty() || tag_value.is_empty() {
313        return Err("Tag name and value cannot be empty".to_string());
314    }
315
316    let description = description.and_then(|text| {
317        if text.trim().is_empty() {
318            None
319        } else {
320            Some(text)
321        }
322    });
323
324    Ok(NormalizedTagInput {
325        tag_name,
326        tag_value,
327        description,
328    })
329}
330
331async fn set_account_tag(
332    user_id: Uuid,
333    account_id: Uuid,
334    tag_name: &str,
335    tag_value: &str,
336    description: Option<String>,
337) -> Result<(), CmdError> {
338    let mut cmd = SetAccountTag::new()
339        .user_id(user_id)
340        .account_id(account_id)
341        .tag_name(tag_name.to_string())
342        .tag_value(tag_value.to_string());
343
344    if let Some(desc) = description {
345        cmd = cmd.description(desc);
346    }
347
348    cmd.run().await.map(|_| ())
349}
350
351async fn build_manage_content(
352    user_id: Uuid,
353    selected_account_id: Option<Uuid>,
354    flash_success: Option<String>,
355    flash_error: Option<String>,
356) -> Result<AccountManageContentTemplate, CmdError> {
357    let accounts = list_account_entries(user_id).await?;
358    if accounts.is_empty() {
359        return Ok(AccountManageContentTemplate {
360            tree_html: String::new(),
361            details: None,
362            flash_success,
363            flash_error,
364        });
365    }
366
367    let account_ids: std::collections::HashSet<Uuid> = accounts.iter().map(|a| a.id).collect();
368    let selected_account_id = selected_account_id
369        .filter(|id| account_ids.contains(id))
370        .unwrap_or(accounts[0].id);
371
372    let tree = build_tree(&accounts, selected_account_id);
373    let tree_html = render_tree_html(&tree);
374
375    let details = match get_account_details(user_id, selected_account_id).await {
376        Ok(details) => Some(details),
377        Err(e) => {
378            log::error!("Failed to load account details for {selected_account_id}: {e:?}");
379            None
380        }
381    };
382
383    Ok(AccountManageContentTemplate {
384        tree_html,
385        details,
386        flash_success,
387        flash_error,
388    })
389}
390
391async fn list_account_entries(user_id: Uuid) -> Result<Vec<AccountTreeEntry>, CmdError> {
392    let result = ListAccountsForManage::new().user_id(user_id).run().await?;
393
394    let Some(CmdResult::TaggedEntities { entities, .. }) = result else {
395        return Err(CmdError::Args("Unexpected command response".to_string()));
396    };
397
398    let mut accounts: Vec<AccountTreeEntry> = entities
399        .into_iter()
400        .filter_map(|(entity, tags)| match entity {
401            FinanceEntity::Account(account) => {
402                let name = extract_name(&tags).unwrap_or_else(|| account.id.to_string());
403                Some(AccountTreeEntry {
404                    id: account.id,
405                    parent_id: account.parent,
406                    name,
407                })
408            }
409            _ => None,
410        })
411        .collect();
412
413    accounts.sort_by(|a, b| {
414        a.name
415            .to_lowercase()
416            .cmp(&b.name.to_lowercase())
417            .then(a.id.cmp(&b.id))
418    });
419
420    Ok(accounts)
421}
422
423async fn get_account_details(
424    user_id: Uuid,
425    account_id: Uuid,
426) -> Result<AccountDetailsTemplateView, CmdError> {
427    let result = GetAccountForManage::new()
428        .user_id(user_id)
429        .account_id(account_id)
430        .run()
431        .await?;
432
433    let Some(CmdResult::TaggedEntities { entities, .. }) = result else {
434        return Err(CmdError::Args("Unexpected command response".to_string()));
435    };
436
437    let Some((FinanceEntity::Account(account), tags)) = entities.into_iter().next() else {
438        return Err(CmdError::Args("Account not found".to_string()));
439    };
440
441    let mut tag_views = extract_tags(tags);
442    tag_views.sort_by(|a, b| {
443        a.tag_name
444            .cmp(&b.tag_name)
445            .then(a.tag_value.cmp(&b.tag_value))
446            .then(a.id.cmp(&b.id))
447    });
448
449    let name = tag_views
450        .iter()
451        .find(|t| t.tag_name == "name")
452        .map_or_else(|| account.id.to_string(), |t| t.tag_value.clone());
453
454    Ok(AccountDetailsTemplateView {
455        id: account.id,
456        parent_id: account.parent,
457        name,
458        tags: tag_views,
459    })
460}
461
462fn build_tree(
463    accounts: &[AccountTreeEntry],
464    selected_account_id: Uuid,
465) -> Vec<AccountTreeNodeView> {
466    let mut by_parent: HashMap<Option<Uuid>, Vec<&AccountTreeEntry>> = HashMap::new();
467    let all_ids: std::collections::HashSet<Uuid> = accounts.iter().map(|a| a.id).collect();
468
469    for account in accounts {
470        by_parent
471            .entry(account.parent_id)
472            .or_default()
473            .push(account);
474    }
475
476    for children in by_parent.values_mut() {
477        children.sort_by(|a, b| {
478            a.name
479                .to_lowercase()
480                .cmp(&b.name.to_lowercase())
481                .then(a.id.cmp(&b.id))
482        });
483    }
484
485    let mut roots: Vec<&AccountTreeEntry> = by_parent.remove(&None).unwrap_or_default();
486
487    let mut orphan_parents: Vec<Uuid> = by_parent
488        .keys()
489        .filter_map(|id| *id)
490        .filter(|id| !all_ids.contains(id))
491        .collect();
492    orphan_parents.sort();
493
494    for orphan_parent in orphan_parents {
495        if let Some(children) = by_parent.remove(&Some(orphan_parent)) {
496            roots.extend(children);
497        }
498    }
499
500    roots
501        .into_iter()
502        .map(|account| build_tree_node(account, selected_account_id, &by_parent))
503        .collect()
504}
505
506fn render_tree_html(nodes: &[AccountTreeNodeView]) -> String {
507    let mut html = String::new();
508    if nodes.is_empty() {
509        return html;
510    }
511
512    html.push_str("<ul class=\"account-tree-root\">");
513    for node in nodes {
514        render_tree_node_html(node, &mut html);
515    }
516    html.push_str("</ul>");
517    html
518}
519
520fn render_tree_node_html(node: &AccountTreeNodeView, out: &mut String) {
521    use std::fmt::Write;
522
523    let selected_class = if node.selected { " selected" } else { "" };
524    let escaped_name = escape_html(&node.name);
525
526    let _ = write!(
527        out,
528        "<li class=\"account-tree-row\">\
529         <button type=\"button\" class=\"account-tree-btn{selected_class}\" \
530         hx-get=\"/api/account/manage/view?account={id}\" \
531         hx-target=\"#account-manage-content\" hx-swap=\"outerHTML\">\
532         {escaped_name}</button>",
533        id = node.id
534    );
535
536    if !node.children.is_empty() {
537        out.push_str("<ul class=\"account-tree-group\">");
538        for child in &node.children {
539            render_tree_node_html(child, out);
540        }
541        out.push_str("</ul>");
542    }
543
544    out.push_str("</li>");
545}
546
547fn escape_html(input: &str) -> String {
548    input
549        .replace('&', "&amp;")
550        .replace('<', "&lt;")
551        .replace('>', "&gt;")
552        .replace('\"', "&quot;")
553        .replace('\'', "&#39;")
554}
555
556fn build_tree_node(
557    account: &AccountTreeEntry,
558    selected_account_id: Uuid,
559    by_parent: &HashMap<Option<Uuid>, Vec<&AccountTreeEntry>>,
560) -> AccountTreeNodeView {
561    let children = by_parent
562        .get(&Some(account.id))
563        .map(|children| {
564            children
565                .iter()
566                .map(|child| build_tree_node(child, selected_account_id, by_parent))
567                .collect()
568        })
569        .unwrap_or_default();
570
571    AccountTreeNodeView {
572        id: account.id,
573        name: account.name.clone(),
574        selected: account.id == selected_account_id,
575        children,
576    }
577}
578
579fn extract_name(tags: &HashMap<String, FinanceEntity>) -> Option<String> {
580    tags.get("name").and_then(|entity| match entity {
581        FinanceEntity::Tag(tag) => Some(tag.tag_value.clone()),
582        _ => None,
583    })
584}
585
586fn extract_tags(tags: HashMap<String, FinanceEntity>) -> Vec<AccountTagView> {
587    tags.into_values()
588        .filter_map(|entity| match entity {
589            FinanceEntity::Tag(tag) => Some(AccountTagView {
590                id: tag.id,
591                tag_name: tag.tag_name,
592                tag_value: tag.tag_value,
593                description: tag.description,
594            }),
595            _ => None,
596        })
597        .collect()
598}
599
600fn json_error(status: StatusCode, message: String) -> (StatusCode, Json<serde_json::Value>) {
601    let error_response = serde_json::json!({
602        "status": "fail",
603        "message": message,
604    });
605    (status, Json(error_response))
606}
607
608fn cmd_error_to_json(
609    e: CmdError,
610    default_message: &str,
611    account_id: Uuid,
612) -> (StatusCode, Json<serde_json::Value>) {
613    let (status, message) = match &e {
614        CmdError::Args(msg) => (StatusCode::NOT_FOUND, msg.clone()),
615        _ => (
616            StatusCode::INTERNAL_SERVER_ERROR,
617            default_message.to_string(),
618        ),
619    };
620    log::error!("Account manage error for {account_id}: {e:?}");
621    json_error(status, message)
622}