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(|list| list.length())
58
        .unwrap_or(0);
59

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
240
    // If the container already has splits (edit page), just init autocomplete
241
    if container
242
        .query_selector(".split-entry")
243
        .ok()
244
        .flatten()
245
        .is_some()
246
    {
247
        autocomplete::init_all();
248
        return;
249
    }
250

            
251
    // Otherwise fetch the initial empty split (create page)
252
    wasm_bindgen_futures::spawn_local(async move {
253
        let url = "/api/transaction/split/create?display_index=0";
254

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

            
260
        let Some(document) = web_sys::window().and_then(|w| w.document()) else {
261
            return;
262
        };
263

            
264
        let Some(container) = document.get_element_by_id("splits-container") else {
265
            return;
266
        };
267

            
268
        container.set_inner_html(&response);
269

            
270
        autocomplete::init_all();
271

            
272
        if let Some(prefilled) = get_prefilled_account() {
273
            prefill_from_account(&prefilled).await;
274
        }
275
    });
276
}
277

            
278
async fn fetch_text(url: &str) -> Result<String, JsValue> {
279
    let window = web_sys::window().ok_or("no window")?;
280
    let response = wasm_bindgen_futures::JsFuture::from(window.fetch_with_str(url)).await?;
281
    let response: web_sys::Response = response.dyn_into()?;
282
    let text = wasm_bindgen_futures::JsFuture::from(response.text()?).await?;
283
    text.as_string().ok_or_else(|| "not a string".into())
284
}
285

            
286
async fn fetch_json(url: &str) -> Result<String, JsValue> {
287
    let window = web_sys::window().ok_or("no window")?;
288
    let headers = web_sys::Headers::new()?;
289
    headers.set("Accept", "application/json")?;
290

            
291
    let opts = web_sys::RequestInit::new();
292
    opts.set_method("GET");
293
    opts.set_headers(&headers);
294

            
295
    let request = web_sys::Request::new_with_str_and_init(url, &opts)?;
296
    let response =
297
        wasm_bindgen_futures::JsFuture::from(window.fetch_with_request(&request)).await?;
298
    let response: web_sys::Response = response.dyn_into()?;
299
    let text = wasm_bindgen_futures::JsFuture::from(response.text()?).await?;
300
    text.as_string().ok_or_else(|| "not a string".into())
301
}
302

            
303
fn get_prefilled_account() -> Option<String> {
304
    let window = web_sys::window()?;
305
    js_sys::Reflect::get(&window, &"prefilledFromAccount".into())
306
        .ok()
307
        .and_then(|v| v.as_string())
308
}
309

            
310
async fn prefill_from_account(account_id: &str) {
311
    let Some(document) = web_sys::window().and_then(|w| w.document()) else {
312
        return;
313
    };
314

            
315
    let Some(first_split) = document.query_selector(".split-entry").ok().flatten() else {
316
        return;
317
    };
318

            
319
    let Some(hidden_input) = first_split
320
        .query_selector(r#".account-value[data-field="from-account"]"#)
321
        .ok()
322
        .flatten()
323
        .and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
324
    else {
325
        return;
326
    };
327

            
328
    let Some(display_input) = first_split
329
        .query_selector(r#".account-display[data-field="from-account"]"#)
330
        .ok()
331
        .flatten()
332
        .and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
333
    else {
334
        return;
335
    };
336

            
337
    hidden_input.set_value(account_id);
338

            
339
    let Ok(accounts_json) = fetch_json("/api/account/list").await else {
340
        return;
341
    };
342

            
343
    let Ok(accounts) = serde_json::from_str::<Vec<AccountInfo>>(&accounts_json) else {
344
        return;
345
    };
346

            
347
    if let Some(account) = accounts.iter().find(|a| a.id == account_id) {
348
        display_input.set_value(&account.name);
349
    }
350
}
351

            
352
#[derive(serde::Deserialize)]
353
struct AccountInfo {
354
    id: String,
355
    name: String,
356
}
357

            
358
#[cfg(test)]
359
mod tests {
360
    #[test]
361
1
    fn split_index_format() {
362
1
        let index = 2u32;
363
1
        let label = format!("Split {}", index + 1);
364
1
        assert_eq!(label, "Split 3");
365
1
    }
366
}