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 account prefill
242
    if container
243
        .query_selector(".split-entry")
244
        .ok()
245
        .flatten()
246
        .is_some()
247
    {
248
        if let Some(prefilled) = get_prefilled_account() {
249
            wasm_bindgen_futures::spawn_local(async move {
250
                prefill_from_account(&prefilled).await;
251
            });
252
        }
253
        return;
254
    }
255

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

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

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

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

            
273
        container.set_inner_html(&response);
274

            
275
        autocomplete::init_all();
276

            
277
        if let Some(prefilled) = get_prefilled_account() {
278
            prefill_from_account(&prefilled).await;
279
        }
280
    });
281
}
282

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

            
291
async fn fetch_json(url: &str) -> Result<String, JsValue> {
292
    let window = web_sys::window().ok_or("no window")?;
293
    let headers = web_sys::Headers::new()?;
294
    headers.set("Accept", "application/json")?;
295

            
296
    let opts = web_sys::RequestInit::new();
297
    opts.set_method("GET");
298
    opts.set_headers(&headers);
299

            
300
    let request = web_sys::Request::new_with_str_and_init(url, &opts)?;
301
    let response =
302
        wasm_bindgen_futures::JsFuture::from(window.fetch_with_request(&request)).await?;
303
    let response: web_sys::Response = response.dyn_into()?;
304
    let text = wasm_bindgen_futures::JsFuture::from(response.text()?).await?;
305
    text.as_string().ok_or_else(|| "not a string".into())
306
}
307

            
308
fn get_prefilled_account() -> Option<String> {
309
    let window = web_sys::window()?;
310
    js_sys::Reflect::get(&window, &"prefilledFromAccount".into())
311
        .ok()
312
        .and_then(|v| v.as_string())
313
}
314

            
315
async fn prefill_from_account(account_id: &str) {
316
    let Some(document) = web_sys::window().and_then(|w| w.document()) else {
317
        return;
318
    };
319

            
320
    let Some(first_split) = document.query_selector(".split-entry").ok().flatten() else {
321
        return;
322
    };
323

            
324
    let Some(hidden_input) = first_split
325
        .query_selector(r#".account-value[data-field="from-account"]"#)
326
        .ok()
327
        .flatten()
328
        .and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
329
    else {
330
        return;
331
    };
332

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

            
342
    hidden_input.set_value(account_id);
343

            
344
    let Ok(accounts_json) = fetch_json("/api/account/list").await else {
345
        return;
346
    };
347

            
348
    let Ok(accounts) = serde_json::from_str::<Vec<AccountInfo>>(&accounts_json) else {
349
        return;
350
    };
351

            
352
    if let Some(account) = accounts.iter().find(|a| a.id == account_id) {
353
        display_input.set_value(&account.name);
354
    }
355
}
356

            
357
#[derive(serde::Deserialize)]
358
struct AccountInfo {
359
    id: String,
360
    name: String,
361
}
362

            
363
#[cfg(test)]
364
mod tests {
365
    #[test]
366
1
    fn split_index_format() {
367
1
        let index = 2u32;
368
1
        let label = format!("Split {}", index + 1);
369
1
        assert_eq!(label, "Split 3");
370
1
    }
371
}