1
use std::{collections::HashMap, sync::Arc};
2

            
3
use askama::Template;
4
use axum::{
5
    Extension, Form, Json,
6
    extract::{Path, Query, State},
7
    http::StatusCode,
8
    response::IntoResponse,
9
};
10
use serde::{Deserialize, Serialize};
11
use server::command::{
12
    CmdError, CmdResult, FinanceEntity,
13
    account::{GetAccountForManage, ListAccountsForManage, SetAccountTag},
14
};
15
use sqlx::types::Uuid;
16

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

            
19
#[derive(Template)]
20
#[template(path = "pages/account/manage.html")]
21
struct AccountManagePage;
22

            
23
#[derive(Clone)]
24
struct AccountTreeEntry {
25
    id: Uuid,
26
    parent_id: Option<Uuid>,
27
    name: String,
28
}
29

            
30
#[derive(Clone)]
31
struct AccountTreeNodeView {
32
    id: Uuid,
33
    name: String,
34
    selected: bool,
35
    children: Vec<AccountTreeNodeView>,
36
}
37

            
38
#[derive(Clone)]
39
struct AccountTagView {
40
    id: Uuid,
41
    tag_name: String,
42
    tag_value: String,
43
    description: Option<String>,
44
}
45

            
46
#[derive(Clone)]
47
struct 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")]
56
struct AccountManageContentTemplate {
57
    tree_html: String,
58
    details: Option<AccountDetailsTemplateView>,
59
    flash_success: Option<String>,
60
    flash_error: Option<String>,
61
}
62

            
63
pub async fn account_manage_page() -> impl IntoResponse {
64
    HtmlTemplate(AccountManagePage {})
65
}
66

            
67
#[derive(Serialize)]
68
pub struct AccountTreeNode {
69
    id: Uuid,
70
    parent_id: Option<Uuid>,
71
    name: String,
72
}
73

            
74
1
pub async fn account_tree(
75
1
    State(_data): State<Arc<AppState>>,
76
1
    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
77
1
) -> Result<Json<Vec<AccountTreeNode>>, (StatusCode, Json<serde_json::Value>)> {
78
1
    let entries = list_account_entries(jwt_auth.user.id).await.map_err(|e| {
79
1
        let error_response = serde_json::json!({
80
1
            "status": "fail",
81
1
            "message": "Failed to fetch account tree",
82
        });
83
1
        log::error!("Failed to fetch account tree: {e:?}");
84
1
        (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
85
1
    })?;
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
1
}
98

            
99
#[derive(Deserialize)]
100
pub struct AccountDetailsQuery {
101
    account: Uuid,
102
}
103

            
104
#[derive(Serialize)]
105
pub struct AccountDetailsTagView {
106
    id: Uuid,
107
    name: String,
108
    value: String,
109
    description: Option<String>,
110
}
111

            
112
#[derive(Serialize)]
113
pub struct AccountDetailsView {
114
    id: Uuid,
115
    parent_id: Option<Uuid>,
116
    name: String,
117
    tags: Vec<AccountDetailsTagView>,
118
}
119

            
120
pub 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)]
148
pub struct AccountManageViewQuery {
149
    account: Option<Uuid>,
150
}
151

            
152
pub 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)]
171
pub struct SetAccountTagForm {
172
    account_id: Uuid,
173
    tag_name: String,
174
    tag_value: String,
175
    description: Option<String>,
176
}
177

            
178
#[derive(Serialize)]
179
pub struct SetAccountTagResponse {
180
    status: &'static str,
181
    account_id: Uuid,
182
    tag_name: String,
183
    tag_value: String,
184
}
185

            
186
2
pub async fn account_manage_set_tag(
187
2
    State(_data): State<Arc<AppState>>,
188
2
    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
189
2
    Json(form): Json<SetAccountTagForm>,
190
2
) -> Result<Json<SetAccountTagResponse>, (StatusCode, Json<serde_json::Value>)> {
191
2
    let normalized = normalize_tag_input(form.tag_name, form.tag_value, form.description)
192
2
        .map_err(|message| json_error(StatusCode::BAD_REQUEST, message))?;
193

            
194
1
    set_account_tag(
195
1
        jwt_auth.user.id,
196
1
        form.account_id,
197
1
        &normalized.tag_name,
198
1
        &normalized.tag_value,
199
1
        normalized.description,
200
1
    )
201
1
    .await
202
1
    .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
2
}
211

            
212
pub 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)]
259
pub struct DeleteAccountTagForm {
260
    account_id: Uuid,
261
}
262

            
263
pub 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)]
298
struct NormalizedTagInput {
299
    tag_name: String,
300
    tag_value: String,
301
    description: Option<String>,
302
}
303

            
304
2
fn normalize_tag_input(
305
2
    tag_name: String,
306
2
    tag_value: String,
307
2
    description: Option<String>,
308
2
) -> Result<NormalizedTagInput, String> {
309
2
    let tag_name = tag_name.trim().to_string();
310
2
    let tag_value = tag_value.trim().to_string();
311

            
312
2
    if tag_name.is_empty() || tag_value.is_empty() {
313
1
        return Err("Tag name and value cannot be empty".to_string());
314
1
    }
315

            
316
1
    let description = description.and_then(|text| {
317
        if text.trim().is_empty() {
318
            None
319
        } else {
320
            Some(text)
321
        }
322
    });
323

            
324
1
    Ok(NormalizedTagInput {
325
1
        tag_name,
326
1
        tag_value,
327
1
        description,
328
1
    })
329
2
}
330

            
331
1
async fn set_account_tag(
332
1
    user_id: Uuid,
333
1
    account_id: Uuid,
334
1
    tag_name: &str,
335
1
    tag_value: &str,
336
1
    description: Option<String>,
337
1
) -> Result<(), CmdError> {
338
1
    let mut cmd = SetAccountTag::new()
339
1
        .user_id(user_id)
340
1
        .account_id(account_id)
341
1
        .tag_name(tag_name.to_string())
342
1
        .tag_value(tag_value.to_string());
343

            
344
1
    if let Some(desc) = description {
345
        cmd = cmd.description(desc);
346
1
    }
347

            
348
1
    cmd.run().await.map(|_| ())
349
1
}
350

            
351
async 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

            
391
1
async fn list_account_entries(user_id: Uuid) -> Result<Vec<AccountTreeEntry>, CmdError> {
392
1
    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
1
}
422

            
423
async 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(|t| t.tag_value.clone())
453
        .unwrap_or_else(|| account.id.to_string());
454

            
455
    Ok(AccountDetailsTemplateView {
456
        id: account.id,
457
        parent_id: account.parent,
458
        name,
459
        tags: tag_views,
460
    })
461
}
462

            
463
fn build_tree(
464
    accounts: &[AccountTreeEntry],
465
    selected_account_id: Uuid,
466
) -> Vec<AccountTreeNodeView> {
467
    let mut by_parent: HashMap<Option<Uuid>, Vec<&AccountTreeEntry>> = HashMap::new();
468
    let all_ids: std::collections::HashSet<Uuid> = accounts.iter().map(|a| a.id).collect();
469

            
470
    for account in accounts {
471
        by_parent
472
            .entry(account.parent_id)
473
            .or_default()
474
            .push(account);
475
    }
476

            
477
    for children in by_parent.values_mut() {
478
        children.sort_by(|a, b| {
479
            a.name
480
                .to_lowercase()
481
                .cmp(&b.name.to_lowercase())
482
                .then(a.id.cmp(&b.id))
483
        });
484
    }
485

            
486
    let mut roots: Vec<&AccountTreeEntry> = by_parent.remove(&None).unwrap_or_default();
487

            
488
    let mut orphan_parents: Vec<Uuid> = by_parent
489
        .keys()
490
        .filter_map(|id| *id)
491
        .filter(|id| !all_ids.contains(id))
492
        .collect();
493
    orphan_parents.sort();
494

            
495
    for orphan_parent in orphan_parents {
496
        if let Some(children) = by_parent.remove(&Some(orphan_parent)) {
497
            roots.extend(children);
498
        }
499
    }
500

            
501
    roots
502
        .into_iter()
503
        .map(|account| build_tree_node(account, selected_account_id, &by_parent))
504
        .collect()
505
}
506

            
507
fn render_tree_html(nodes: &[AccountTreeNodeView]) -> String {
508
    let mut html = String::new();
509
    if nodes.is_empty() {
510
        return html;
511
    }
512

            
513
    html.push_str("<ul class=\"account-tree-root\">");
514
    for node in nodes {
515
        render_tree_node_html(node, &mut html);
516
    }
517
    html.push_str("</ul>");
518
    html
519
}
520

            
521
fn render_tree_node_html(node: &AccountTreeNodeView, out: &mut String) {
522
    use std::fmt::Write;
523

            
524
    let selected_class = if node.selected { " selected" } else { "" };
525
    let escaped_name = escape_html(&node.name);
526

            
527
    let _ = write!(
528
        out,
529
        "<li class=\"account-tree-row\">\
530
         <button type=\"button\" class=\"account-tree-btn{selected_class}\" \
531
         hx-get=\"/api/account/manage/view?account={id}\" \
532
         hx-target=\"#account-manage-content\" hx-swap=\"outerHTML\">\
533
         {escaped_name}</button>",
534
        id = node.id
535
    );
536

            
537
    if !node.children.is_empty() {
538
        out.push_str("<ul class=\"account-tree-group\">");
539
        for child in &node.children {
540
            render_tree_node_html(child, out);
541
        }
542
        out.push_str("</ul>");
543
    }
544

            
545
    out.push_str("</li>");
546
}
547

            
548
fn escape_html(input: &str) -> String {
549
    input
550
        .replace('&', "&amp;")
551
        .replace('<', "&lt;")
552
        .replace('>', "&gt;")
553
        .replace('\"', "&quot;")
554
        .replace('\'', "&#39;")
555
}
556

            
557
fn build_tree_node(
558
    account: &AccountTreeEntry,
559
    selected_account_id: Uuid,
560
    by_parent: &HashMap<Option<Uuid>, Vec<&AccountTreeEntry>>,
561
) -> AccountTreeNodeView {
562
    let children = by_parent
563
        .get(&Some(account.id))
564
        .map(|children| {
565
            children
566
                .iter()
567
                .map(|child| build_tree_node(child, selected_account_id, by_parent))
568
                .collect()
569
        })
570
        .unwrap_or_default();
571

            
572
    AccountTreeNodeView {
573
        id: account.id,
574
        name: account.name.clone(),
575
        selected: account.id == selected_account_id,
576
        children,
577
    }
578
}
579

            
580
fn extract_name(tags: &HashMap<String, FinanceEntity>) -> Option<String> {
581
    tags.get("name").and_then(|entity| match entity {
582
        FinanceEntity::Tag(tag) => Some(tag.tag_value.clone()),
583
        _ => None,
584
    })
585
}
586

            
587
fn extract_tags(tags: HashMap<String, FinanceEntity>) -> Vec<AccountTagView> {
588
    tags.into_values()
589
        .filter_map(|entity| match entity {
590
            FinanceEntity::Tag(tag) => Some(AccountTagView {
591
                id: tag.id,
592
                tag_name: tag.tag_name,
593
                tag_value: tag.tag_value,
594
                description: tag.description,
595
            }),
596
            _ => None,
597
        })
598
        .collect()
599
}
600

            
601
2
fn json_error(status: StatusCode, message: String) -> (StatusCode, Json<serde_json::Value>) {
602
2
    let error_response = serde_json::json!({
603
2
        "status": "fail",
604
2
        "message": message,
605
    });
606
2
    (status, Json(error_response))
607
2
}
608

            
609
1
fn cmd_error_to_json(
610
1
    e: CmdError,
611
1
    default_message: &str,
612
1
    account_id: Uuid,
613
1
) -> (StatusCode, Json<serde_json::Value>) {
614
1
    let (status, message) = match &e {
615
        CmdError::Args(msg) => (StatusCode::NOT_FOUND, msg.clone()),
616
1
        _ => (
617
1
            StatusCode::INTERNAL_SERVER_ERROR,
618
1
            default_message.to_string(),
619
1
        ),
620
    };
621
1
    log::error!("Account manage error for {account_id}: {e:?}");
622
1
    json_error(status, message)
623
1
}