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
    if !date_input.value().is_empty() {
188
        return;
189
    }
190

            
191
    let now = js_sys::Date::new_0();
192
    let offset_ms = now.get_timezone_offset() * 60.0 * 1000.0;
193
    let local_time = now.get_time() - offset_ms;
194
    let local_date = js_sys::Date::new(&JsValue::from_f64(local_time));
195

            
196
    let iso_string = local_date.to_iso_string();
197
    let datetime_local = iso_string
198
        .as_string()
199
        .map(|s| s.chars().take(16).collect::<String>())
200
        .unwrap_or_default();
201

            
202
    date_input.set_value(&datetime_local);
203
}
204

            
205
fn fetch_initial_split() {
206
    let Some(document) = web_sys::window().and_then(|w| w.document()) else {
207
        return;
208
    };
209

            
210
    let Some(container) = document.get_element_by_id("splits-container") else {
211
        return;
212
    };
213

            
214
    // If the container already has splits (edit page), just init autocomplete
215
    if container
216
        .query_selector(".split-entry")
217
        .ok()
218
        .flatten()
219
        .is_some()
220
    {
221
        autocomplete::init_all();
222
        return;
223
    }
224

            
225
    // Otherwise fetch the initial empty split (create page)
226
    wasm_bindgen_futures::spawn_local(async move {
227
        let url = "/api/transaction/split/create?display_index=0";
228

            
229
        let Ok(response) = fetch_text(url).await else {
230
            web_sys::console::error_1(&"Error loading initial split".into());
231
            return;
232
        };
233

            
234
        let Some(document) = web_sys::window().and_then(|w| w.document()) else {
235
            return;
236
        };
237

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

            
242
        container.set_inner_html(&response);
243

            
244
        autocomplete::init_all();
245

            
246
        if let Some(prefilled) = get_prefilled_account() {
247
            prefill_from_account(&prefilled).await;
248
        }
249
    });
250
}
251

            
252
async fn fetch_text(url: &str) -> Result<String, JsValue> {
253
    let window = web_sys::window().ok_or("no window")?;
254
    let response = wasm_bindgen_futures::JsFuture::from(window.fetch_with_str(url)).await?;
255
    let response: web_sys::Response = response.dyn_into()?;
256
    let text = wasm_bindgen_futures::JsFuture::from(response.text()?).await?;
257
    text.as_string().ok_or_else(|| "not a string".into())
258
}
259

            
260
async fn fetch_json(url: &str) -> Result<String, JsValue> {
261
    let window = web_sys::window().ok_or("no window")?;
262
    let headers = web_sys::Headers::new()?;
263
    headers.set("Accept", "application/json")?;
264

            
265
    let opts = web_sys::RequestInit::new();
266
    opts.set_method("GET");
267
    opts.set_headers(&headers);
268

            
269
    let request = web_sys::Request::new_with_str_and_init(url, &opts)?;
270
    let response =
271
        wasm_bindgen_futures::JsFuture::from(window.fetch_with_request(&request)).await?;
272
    let response: web_sys::Response = response.dyn_into()?;
273
    let text = wasm_bindgen_futures::JsFuture::from(response.text()?).await?;
274
    text.as_string().ok_or_else(|| "not a string".into())
275
}
276

            
277
fn get_prefilled_account() -> Option<String> {
278
    let window = web_sys::window()?;
279
    js_sys::Reflect::get(&window, &"prefilledFromAccount".into())
280
        .ok()
281
        .and_then(|v| v.as_string())
282
}
283

            
284
async fn prefill_from_account(account_id: &str) {
285
    let Some(document) = web_sys::window().and_then(|w| w.document()) else {
286
        return;
287
    };
288

            
289
    let Some(first_split) = document.query_selector(".split-entry").ok().flatten() else {
290
        return;
291
    };
292

            
293
    let Some(hidden_input) = first_split
294
        .query_selector(r#".account-value[data-field="from-account"]"#)
295
        .ok()
296
        .flatten()
297
        .and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
298
    else {
299
        return;
300
    };
301

            
302
    let Some(display_input) = first_split
303
        .query_selector(r#".account-display[data-field="from-account"]"#)
304
        .ok()
305
        .flatten()
306
        .and_then(|el| el.dyn_into::<HtmlInputElement>().ok())
307
    else {
308
        return;
309
    };
310

            
311
    hidden_input.set_value(account_id);
312

            
313
    let Ok(accounts_json) = fetch_json("/api/account/list").await else {
314
        return;
315
    };
316

            
317
    let Ok(accounts) = serde_json::from_str::<Vec<AccountInfo>>(&accounts_json) else {
318
        return;
319
    };
320

            
321
    if let Some(account) = accounts.iter().find(|a| a.id == account_id) {
322
        display_input.set_value(&account.name);
323
    }
324
}
325

            
326
#[derive(serde::Deserialize)]
327
struct AccountInfo {
328
    id: String,
329
    name: String,
330
}
331

            
332
#[cfg(test)]
333
mod tests {
334
    #[test]
335
1
    fn split_index_format() {
336
1
        let index = 2u32;
337
1
        let label = format!("Split {}", index + 1);
338
1
        assert_eq!(label, "Split 3");
339
1
    }
340
}