1
//! Split management for transaction forms.
2

            
3
use wasm_bindgen::JsCast;
4
use wasm_bindgen::prelude::*;
5
use web_sys::{Element, HtmlElement, HtmlInputElement};
6

            
7
use crate::autocomplete;
8

            
9
/// Returns the current number of split entries in the DOM.
10
/// Exported to window for htmx hx-vals usage.
11
#[wasm_bindgen(js_name = getSplitCount)]
12
#[must_use]
13
pub fn get_split_count() -> u32 {
14
    web_sys::window()
15
        .and_then(|w| w.document())
16
        .and_then(|d| d.query_selector_all(".split-entry").ok())
17
        .map_or(0, |list| list.length())
18
}
19

            
20
/// Sets up event handlers for split management.
21
pub fn setup_split_handlers() {
22
    setup_split_removal_handler();
23
    setup_currency_change_handler();
24
}
25

            
26
fn setup_split_removal_handler() {
27
    let Some(document) = web_sys::window().and_then(|w| w.document()) else {
28
        return;
29
    };
30

            
31
    let callback = Closure::wrap(Box::new(move |event: web_sys::MouseEvent| {
32
        let Some(target) = event.target() else {
33
            return;
34
        };
35
        let Ok(el) = target.dyn_into::<HtmlElement>() else {
36
            return;
37
        };
38

            
39
        if !el.class_list().contains("remove-split-btn") {
40
            return;
41
        }
42

            
43
        handle_split_removal(&el);
44
    }) as Box<dyn FnMut(_)>);
45

            
46
    let _ = document.add_event_listener_with_callback("click", callback.as_ref().unchecked_ref());
47
    callback.forget();
48
}
49

            
50
fn handle_split_removal(button: &HtmlElement) {
51
    let Some(document) = web_sys::window().and_then(|w| w.document()) else {
52
        return;
53
    };
54

            
55
    let split_count = document
56
        .query_selector_all(".split-entry")
57
        .map_or(0, |list| list.length());
58

            
59
    if split_count <= 1 {
60
        if let Some(window) = web_sys::window() {
61
            let _ = window.alert_with_message(
62
                "Cannot remove the last split. At least one split is required.",
63
            );
64
        }
65
        return;
66
    }
67

            
68
    let Some(split_entry) = button.closest(".split-entry").ok().flatten() else {
69
        return;
70
    };
71

            
72
    split_entry.remove();
73
    update_split_labels();
74
}
75

            
76
fn update_split_labels() {
77
    let Some(document) = web_sys::window().and_then(|w| w.document()) else {
78
        return;
79
    };
80

            
81
    let Ok(splits) = document.query_selector_all(".split-entry") else {
82
        return;
83
    };
84

            
85
    for i in 0..splits.length() {
86
        let Some(node) = splits.get(i) else {
87
            continue;
88
        };
89
        let Ok(split) = node.dyn_into::<Element>() else {
90
            continue;
91
        };
92

            
93
        if let Some(label) = split.query_selector(".split-label").ok().flatten() {
94
            label.set_text_content(Some(&format!("Split {}", i + 1)));
95
        }
96

            
97
        let _ = split.set_attribute("data-split-index", &i.to_string());
98
    }
99
}
100

            
101
fn setup_currency_change_handler() {
102
    let Some(document) = web_sys::window().and_then(|w| w.document()) else {
103
        return;
104
    };
105

            
106
    let callback = Closure::wrap(Box::new(move |event: web_sys::Event| {
107
        let Some(target) = event.target() else {
108
            return;
109
        };
110
        let Ok(input) = target.dyn_into::<HtmlInputElement>() else {
111
            return;
112
        };
113

            
114
        let class_list = input.class_name();
115
        if !class_list.contains("commodity-value") {
116
            return;
117
        }
118

            
119
        let Some(split_entry) = input.closest(".split-entry").ok().flatten() else {
120
            return;
121
        };
122

            
123
        check_currency_mismatch(&split_entry);
124
    }) as Box<dyn FnMut(_)>);
125

            
126
    let _ = document.add_event_listener_with_callback("change", callback.as_ref().unchecked_ref());
127
    callback.forget();
128
}
129

            
130
fn check_currency_mismatch(split_entry: &Element) {
131
    let from_commodity = split_entry
132
        .query_selector(r#".commodity-value[data-field="from-commodity"]"#)
133
        .ok()
134
        .flatten()
135
        .and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
136
        .map(|input| input.value());
137

            
138
    let to_commodity = split_entry
139
        .query_selector(r#".commodity-value[data-field="to-commodity"]"#)
140
        .ok()
141
        .flatten()
142
        .and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
143
        .map(|input| input.value());
144

            
145
    let Some(amount_converted_group) = split_entry
146
        .query_selector(".amount-converted-group")
147
        .ok()
148
        .flatten()
149
    else {
150
        return;
151
    };
152

            
153
    let show_converted = match (from_commodity, to_commodity) {
154
        (Some(from), Some(to)) if !from.is_empty() && !to.is_empty() => from != to,
155
        _ => false,
156
    };
157

            
158
    if show_converted {
159
        let _ = amount_converted_group.class_list().remove_1("hidden-field");
160
    } else {
161
        let _ = amount_converted_group.class_list().add_1("hidden-field");
162
    }
163
}
164

            
165
/// Initializes the transaction form on page load.
166
pub fn initialize_transaction_form() {
167
    let Some(window) = web_sys::window() else {
168
        return;
169
    };
170
    let Some(document) = window.document() else {
171
        return;
172
    };
173

            
174
    set_default_datetime(&document);
175
    fetch_initial_split();
176
}
177

            
178
fn set_default_datetime(document: &web_sys::Document) {
179
    let Some(date_input) = document
180
        .get_element_by_id("date")
181
        .and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
182
    else {
183
        return;
184
    };
185

            
186
    let current_value = date_input.value();
187
    if current_value.is_empty() {
188
        set_current_local_time(&date_input);
189
    } else {
190
        convert_utc_to_local(&date_input, &current_value);
191
    }
192
}
193

            
194
fn set_current_local_time(date_input: &HtmlInputElement) {
195
    let now = js_sys::Date::new_0();
196
    let offset_ms = now.get_timezone_offset() * 60.0 * 1000.0;
197
    let local_time = now.get_time() - offset_ms;
198
    let local_date = js_sys::Date::new(&JsValue::from_f64(local_time));
199

            
200
    let iso_string = local_date.to_iso_string();
201
    let datetime_local = iso_string
202
        .as_string()
203
        .map(|s| s.chars().take(16).collect::<String>())
204
        .unwrap_or_default();
205

            
206
    date_input.set_value(&datetime_local);
207
}
208

            
209
fn convert_utc_to_local(date_input: &HtmlInputElement, utc_value: &str) {
210
    let utc_string = format!("{utc_value}Z");
211
    let utc_date = js_sys::Date::new(&JsValue::from_str(&utc_string));
212

            
213
    if utc_date.get_time().is_nan() {
214
        return;
215
    }
216

            
217
    let offset_ms = utc_date.get_timezone_offset() * 60.0 * 1000.0;
218
    let local_time = utc_date.get_time() - offset_ms;
219
    let local_date = js_sys::Date::new(&JsValue::from_f64(local_time));
220

            
221
    let iso_string = local_date.to_iso_string();
222
    let datetime_local = iso_string
223
        .as_string()
224
        .map(|s| s.chars().take(16).collect::<String>())
225
        .unwrap_or_default();
226

            
227
    date_input.set_value(&datetime_local);
228
}
229

            
230
fn fetch_initial_split() {
231
    let Some(document) = web_sys::window().and_then(|w| w.document()) else {
232
        return;
233
    };
234

            
235
    let Some(container) = document.get_element_by_id("splits-container") else {
236
        return;
237
    };
238

            
239
    autocomplete::init_all();
240

            
241
    // If there are already splits (pre-rendered), handle prefill.
242
    if container
243
        .query_selector(".split-entry")
244
        .ok()
245
        .flatten()
246
        .is_some()
247
    {
248
        wasm_bindgen_futures::spawn_local(async move {
249
            apply_prefill().await;
250
        });
251
        return;
252
    }
253

            
254
    // Fallback: fetch the initial split if none exists
255
    wasm_bindgen_futures::spawn_local(async move {
256
        let url = "/api/transaction/split/create?display_index=0";
257

            
258
        let Ok(response) = fetch_text(url).await else {
259
            web_sys::console::error_1(&"Error loading initial split".into());
260
            return;
261
        };
262

            
263
        let Some(document) = web_sys::window().and_then(|w| w.document()) else {
264
            return;
265
        };
266

            
267
        let Some(container) = document.get_element_by_id("splits-container") else {
268
            return;
269
        };
270

            
271
        container.set_inner_html(&response);
272

            
273
        autocomplete::init_all();
274

            
275
        apply_prefill().await;
276
    });
277
}
278

            
279
/// Applies whichever prefill the page requested: a single from-account
280
/// (`window.prefilledFromAccount`) or a full template draft
281
/// (`window.prefilledDraft`). The draft path wins when present.
282
async fn apply_prefill() {
283
    if get_prefilled_draft().is_some() {
284
        prefill_from_draft().await;
285
    } else if let Some(account) = get_prefilled_account() {
286
        prefill_from_account(&account).await;
287
    }
288
}
289

            
290
async fn fetch_text(url: &str) -> Result<String, JsValue> {
291
    let window = web_sys::window().ok_or("no window")?;
292
    let response = wasm_bindgen_futures::JsFuture::from(window.fetch_with_str(url)).await?;
293
    let response: web_sys::Response = response.dyn_into()?;
294
    let text = wasm_bindgen_futures::JsFuture::from(response.text()?).await?;
295
    text.as_string().ok_or_else(|| "not a string".into())
296
}
297

            
298
async fn fetch_json(url: &str) -> Result<String, JsValue> {
299
    let window = web_sys::window().ok_or("no window")?;
300
    let headers = web_sys::Headers::new()?;
301
    headers.set("Accept", "application/json")?;
302

            
303
    let opts = web_sys::RequestInit::new();
304
    opts.set_method("GET");
305
    opts.set_headers(&headers);
306

            
307
    let request = web_sys::Request::new_with_str_and_init(url, &opts)?;
308
    let response =
309
        wasm_bindgen_futures::JsFuture::from(window.fetch_with_request(&request)).await?;
310
    let response: web_sys::Response = response.dyn_into()?;
311
    let text = wasm_bindgen_futures::JsFuture::from(response.text()?).await?;
312
    text.as_string().ok_or_else(|| "not a string".into())
313
}
314

            
315
fn get_prefilled_account() -> Option<String> {
316
    let window = web_sys::window()?;
317
    js_sys::Reflect::get(&window, &"prefilledFromAccount".into())
318
        .ok()
319
        .and_then(|v| v.as_string())
320
}
321

            
322
async fn prefill_from_account(account_id: &str) {
323
    let Some(document) = web_sys::window().and_then(|w| w.document()) else {
324
        return;
325
    };
326

            
327
    let Some(first_split) = document.query_selector(".split-entry").ok().flatten() else {
328
        return;
329
    };
330

            
331
    let Some(hidden_input) = first_split
332
        .query_selector(r#".account-value[data-field="from-account"]"#)
333
        .ok()
334
        .flatten()
335
        .and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
336
    else {
337
        return;
338
    };
339

            
340
    let Some(display_input) = first_split
341
        .query_selector(r#".account-display[data-field="from-account"]"#)
342
        .ok()
343
        .flatten()
344
        .and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
345
    else {
346
        return;
347
    };
348

            
349
    hidden_input.set_value(account_id);
350

            
351
    let Ok(accounts_json) = fetch_json("/api/account/list").await else {
352
        return;
353
    };
354

            
355
    let Ok(accounts) = serde_json::from_str::<Vec<AccountInfo>>(&accounts_json) else {
356
        return;
357
    };
358

            
359
    if let Some(account) = accounts.iter().find(|a| a.id == account_id) {
360
        display_input.set_value(&account.name);
361
    }
362
}
363

            
364
#[derive(serde::Deserialize)]
365
struct AccountInfo {
366
    id: String,
367
    name: String,
368
}
369

            
370
#[derive(serde::Deserialize)]
371
struct CommodityInfo {
372
    id: String,
373
    symbol: String,
374
}
375

            
376
/// A template-rendered transfer row (matches the server's `PrefilledRow`).
377
#[derive(serde::Deserialize, Default)]
378
struct PrefilledRow {
379
    amount: String,
380
    from_account: String,
381
    from_commodity: String,
382
    to_account: String,
383
    to_commodity: String,
384
    amount_converted: Option<String>,
385
    #[serde(default)]
386
    from_tags: Vec<PrefilledTag>,
387
    #[serde(default)]
388
    to_tags: Vec<PrefilledTag>,
389
}
390

            
391
#[derive(serde::Deserialize, Default)]
392
struct PrefilledTag {
393
    name: String,
394
    value: String,
395
}
396

            
397
#[derive(serde::Deserialize, Default)]
398
struct PrefilledDraft {
399
    note: Option<String>,
400
    date: Option<String>,
401
    #[serde(default)]
402
    rows: Vec<PrefilledRow>,
403
    #[serde(default)]
404
    tags: Vec<PrefilledTag>,
405
}
406

            
407
fn get_prefilled_draft() -> Option<PrefilledDraft> {
408
    let window = web_sys::window()?;
409
    let value = js_sys::Reflect::get(&window, &"prefilledDraft".into()).ok()?;
410
    if value.is_undefined() || value.is_null() {
411
        return None;
412
    }
413
    serde_wasm_bindgen::from_value(value).ok()
414
}
415

            
416
/// Fills the form from a rendered template draft: note, date, every transfer
417
/// row (adding rows as needed), and transaction-level tags. Display names are
418
/// resolved from the account/commodity list endpoints; the submitted hidden
419
/// inputs always carry the canonical uuids.
420
async fn prefill_from_draft() {
421
    let Some(draft) = get_prefilled_draft() else {
422
        return;
423
    };
424
    let Some(document) = web_sys::window().and_then(|w| w.document()) else {
425
        return;
426
    };
427

            
428
    if let Some(note) = draft.note.as_deref() {
429
        set_input_value_by_id(&document, "note", note);
430
    }
431
    if let Some(date) = draft.date.as_deref() {
432
        set_input_value_by_id(&document, "date", date);
433
    }
434

            
435
    // Resolve display-name lookups once, shared across rows.
436
    let accounts = fetch_json("/api/account/list")
437
        .await
438
        .ok()
439
        .and_then(|j| serde_json::from_str::<Vec<AccountInfo>>(&j).ok())
440
        .unwrap_or_default();
441
    let commodities = fetch_json("/api/commodity/list")
442
        .await
443
        .ok()
444
        .and_then(|j| serde_json::from_str::<Vec<CommodityInfo>>(&j).ok())
445
        .unwrap_or_default();
446

            
447
    for (index, row) in draft.rows.iter().enumerate() {
448
        ensure_split_row(&document, index).await;
449
        let Some(split) = nth_split_entry(&document, index) else {
450
            continue;
451
        };
452
        fill_split_row(&split, row, &accounts, &commodities);
453
        prefill_split_tags(&document, &split, index, row);
454
    }
455

            
456
    prefill_tags(&document, &draft.tags);
457
}
458

            
459
/// Appends each rendered transaction-level tag as a committed tag row so the
460
/// form submits them — the rendered draft's tags are preserved, not dropped.
461
fn prefill_tags(document: &web_sys::Document, tags: &[PrefilledTag]) {
462
    if tags.is_empty() {
463
        return;
464
    }
465
    let Some(container) = document
466
        .query_selector(".entity-tags-container")
467
        .ok()
468
        .flatten()
469
    else {
470
        return;
471
    };
472
    for tag in tags {
473
        crate::entity_form_tag::append_entity_form_tag(
474
            document, &container, &tag.name, &tag.value, "",
475
        );
476
    }
477
    crate::autocomplete::init_all();
478
}
479

            
480
/// Appends the draft's per-split tags onto a split row. The create form's
481
/// split-tag rows apply to BOTH legs of the transfer (a row mirrors from→to at
482
/// submit), so the split's debit and credit draft tags are merged and
483
/// de-duplicated into one set of rows rather than added per leg. (`prefill.rs`
484
/// still tracks them per leg for fidelity; the form can't express a single-leg
485
/// split tag.)
486
fn prefill_split_tags(
487
    document: &web_sys::Document,
488
    split: &Element,
489
    index: usize,
490
    row: &PrefilledRow,
491
) {
492
    if row.from_tags.is_empty() && row.to_tags.is_empty() {
493
        return;
494
    }
495
    let Some(container) = split.query_selector(".split-tags-container").ok().flatten() else {
496
        return;
497
    };
498
    let idx = index.to_string();
499
    let mut seen: Vec<(&str, &str)> = Vec::new();
500
    for tag in row.from_tags.iter().chain(row.to_tags.iter()) {
501
        let key = (tag.name.as_str(), tag.value.as_str());
502
        if seen.contains(&key) {
503
            continue;
504
        }
505
        seen.push(key);
506
        super::split_tag::append_split_tag(document, &container, &idx, &tag.name, &tag.value);
507
    }
508
    crate::autocomplete::init_all();
509
}
510

            
511
fn account_name<'a>(accounts: &'a [AccountInfo], id: &str) -> Option<&'a str> {
512
    accounts
513
        .iter()
514
        .find(|a| a.id == id)
515
        .map(|a| a.name.as_str())
516
}
517

            
518
fn commodity_symbol<'a>(commodities: &'a [CommodityInfo], id: &str) -> Option<&'a str> {
519
    commodities
520
        .iter()
521
        .find(|c| c.id == id)
522
        .map(|c| c.symbol.as_str())
523
}
524

            
525
fn fill_split_row(
526
    split: &Element,
527
    row: &PrefilledRow,
528
    accounts: &[AccountInfo],
529
    commodities: &[CommodityInfo],
530
) {
531
    set_split_input(split, "input[data-field=\"amount\"]", &row.amount);
532
    if let Some(converted) = row.amount_converted.as_deref() {
533
        set_split_input(split, "input[data-field=\"amount-converted\"]", converted);
534
    }
535

            
536
    fill_entity_field(
537
        split,
538
        ".account-value[data-field=\"from-account\"]",
539
        ".account-display[data-field=\"from-account\"]",
540
        &row.from_account,
541
        account_name(accounts, &row.from_account),
542
    );
543
    fill_entity_field(
544
        split,
545
        ".account-value[data-field=\"to-account\"]",
546
        ".account-display[data-field=\"to-account\"]",
547
        &row.to_account,
548
        account_name(accounts, &row.to_account),
549
    );
550
    fill_entity_field(
551
        split,
552
        ".commodity-value[data-field=\"from-commodity\"]",
553
        ".commodity-display[data-field=\"from-commodity\"]",
554
        &row.from_commodity,
555
        commodity_symbol(commodities, &row.from_commodity),
556
    );
557
    fill_entity_field(
558
        split,
559
        ".commodity-value[data-field=\"to-commodity\"]",
560
        ".commodity-display[data-field=\"to-commodity\"]",
561
        &row.to_commodity,
562
        commodity_symbol(commodities, &row.to_commodity),
563
    );
564
}
565

            
566
/// Sets a row's hidden uuid input and its display label. The hidden input
567
/// carries the canonical uuid (always submitted); the display shows the
568
/// resolved name, falling back to the uuid if the lookup missed.
569
fn fill_entity_field(
570
    split: &Element,
571
    hidden_selector: &str,
572
    display_selector: &str,
573
    id: &str,
574
    display: Option<&str>,
575
) {
576
    if let Some(hidden) = query_input(split, hidden_selector) {
577
        hidden.set_value(id);
578
    }
579
    if let Some(input) = query_input(split, display_selector) {
580
        input.set_value(display.unwrap_or(id));
581
    }
582
}
583

            
584
fn query_input(root: &Element, selector: &str) -> Option<HtmlInputElement> {
585
    root.query_selector(selector)
586
        .ok()
587
        .flatten()
588
        .and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
589
}
590

            
591
fn set_split_input(split: &Element, selector: &str, value: &str) {
592
    if let Some(input) = query_input(split, selector) {
593
        input.set_value(value);
594
    }
595
}
596

            
597
fn set_input_value_by_id(document: &web_sys::Document, id: &str, value: &str) {
598
    if let Some(input) = document
599
        .get_element_by_id(id)
600
        .and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
601
    {
602
        input.set_value(value);
603
    }
604
}
605

            
606
fn nth_split_entry(document: &web_sys::Document, index: usize) -> Option<Element> {
607
    document
608
        .query_selector_all(".split-entry")
609
        .ok()
610
        .and_then(|list| list.item(index as u32))
611
        .and_then(|node| node.dyn_into::<Element>().ok())
612
}
613

            
614
/// Ensures a split row at `index` exists, fetching and appending one (with the
615
/// correct `display_index`) when the draft has more rows than the form. Row 0
616
/// is always pre-rendered.
617
async fn ensure_split_row(document: &web_sys::Document, index: usize) {
618
    if nth_split_entry(document, index).is_some() {
619
        return;
620
    }
621
    let Some(container) = document.get_element_by_id("splits-container") else {
622
        return;
623
    };
624
    let url = format!("/api/transaction/split/create?display_index={index}");
625
    let Ok(html) = fetch_text(&url).await else {
626
        return;
627
    };
628
    let _ = container.insert_adjacent_html("beforeend", &html);
629
    autocomplete::init_all();
630
}
631

            
632
#[cfg(test)]
633
mod tests {
634
    #[test]
635
1
    fn split_index_format() {
636
1
        let index = 2u32;
637
1
        let label = format!("Split {}", index + 1);
638
1
        assert_eq!(label, "Split 3");
639
1
    }
640
}