1use std::collections::HashSet;
13use wasm_bindgen::JsCast;
14use web_sys::{Blob, BlobPropertyBag, HtmlAnchorElement, Url};
15use ferrox_front_core::dom::{div, span, button, input, select, option, DomBuilder};
16use crate::icons::Icon;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum SortDirection {
24 None,
25 Ascending,
26 Descending,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum PinnedPosition {
31 None,
32 Left,
33 Right,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum TextAlign {
38 Left,
39 Center,
40 Right,
41}
42
43pub struct ColumnDef<T> {
44 pub id: String,
45 pub header: String,
46 pub sortable: bool,
47 pub filterable: bool,
48 pub width: Option<String>,
49 pub pinned: PinnedPosition,
50 pub align: TextAlign,
51 pub accessor: Box<dyn Fn(&T) -> String + Send + Sync>,
52 pub custom_renderer: Option<Box<dyn Fn(&T) -> DomBuilder + Send + Sync>>,
53}
54
55impl<T> ColumnDef<T> {
56 pub fn new<F>(id: &str, header: &str, accessor: F) -> Self
57 where
58 F: Fn(&T) -> String + Send + Sync + 'static,
59 {
60 Self {
61 id: id.to_string(),
62 header: header.to_string(),
63 sortable: true,
64 filterable: true,
65 width: None,
66 pinned: PinnedPosition::None,
67 align: TextAlign::Left,
68 accessor: Box::new(accessor),
69 custom_renderer: None,
70 }
71 }
72
73 pub fn with_renderer<R>(mut self, renderer: R) -> Self
74 where
75 R: Fn(&T) -> DomBuilder + Send + Sync + 'static,
76 {
77 self.custom_renderer = Some(Box::new(renderer));
78 self
79 }
80
81 pub fn with_width(mut self, width: &str) -> Self {
82 self.width = Some(width.to_string());
83 self
84 }
85
86 pub fn with_align(mut self, align: TextAlign) -> Self {
87 self.align = align;
88 self
89 }
90
91 pub fn with_sortable(mut self, sortable: bool) -> Self {
92 self.sortable = sortable;
93 self
94 }
95
96 pub fn with_filterable(mut self, filterable: bool) -> Self {
97 self.filterable = filterable;
98 self
99 }
100
101 pub fn with_pinned(mut self, pinned: PinnedPosition) -> Self {
102 self.pinned = pinned;
103 self
104 }
105}
106
107#[derive(Debug, Clone)]
108pub struct EnterpriseDataGridState {
109 pub current_page: usize,
110 pub page_size: usize,
111 pub sort_column: Option<(String, SortDirection)>,
112 pub search_query: String,
113 pub selected_rows: HashSet<usize>,
114}
115
116impl Default for EnterpriseDataGridState {
117 fn default() -> Self {
118 Self {
119 current_page: 1,
120 page_size: 10,
121 sort_column: None,
122 search_query: String::new(),
123 selected_rows: HashSet::new(),
124 }
125 }
126}
127
128pub fn generate_rfc4180_csv(headers: &[&str], rows: &[Vec<String>]) -> String {
134 let mut out = String::new();
135
136 for (i, h) in headers.iter().enumerate() {
138 if i > 0 {
139 out.push(',');
140 }
141 escape_csv_field(&mut out, h);
142 }
143 out.push_str("\r\n");
144
145 for row in rows {
147 for (i, val) in row.iter().enumerate() {
148 if i > 0 {
149 out.push(',');
150 }
151 escape_csv_field(&mut out, val);
152 }
153 out.push_str("\r\n");
154 }
155
156 out
157}
158
159fn escape_csv_field(buf: &mut String, val: &str) {
160 if val.contains(',') || val.contains('"') || val.contains('\n') || val.contains('\r') {
161 buf.push('"');
162 for ch in val.chars() {
163 if ch == '"' {
164 buf.push('"');
165 buf.push('"');
166 } else {
167 buf.push(ch);
168 }
169 }
170 buf.push('"');
171 } else {
172 buf.push_str(val);
173 }
174}
175
176pub fn trigger_csv_download(filename: &str, csv_content: &str) -> Result<(), wasm_bindgen::JsValue> {
178 let parts = js_sys::Array::new();
179 parts.push(&wasm_bindgen::JsValue::from_str(csv_content));
180
181 let bag = BlobPropertyBag::new();
182 bag.set_type("text/csv;charset=utf-8;");
183 let blob = Blob::new_with_str_sequence_and_options(&parts, &bag)?;
184 let url = Url::create_object_url_with_blob(&blob)?;
185
186 if let Some(win) = web_sys::window() {
187 if let Some(doc) = win.document() {
188 let a: HtmlAnchorElement = doc.create_element("a")?.unchecked_into();
189 a.set_href(&url);
190 a.set_download(filename);
191 let _ = a.set_attribute("style", "display: none;");
192 if let Some(body) = doc.body() {
193 body.append_child(&a)?;
194 a.click();
195 a.remove();
196 let _ = Url::revoke_object_url(&url);
197 }
198 }
199 }
200 Ok(())
201}
202
203pub struct EnterpriseDataGrid<T> {
208 pub id: String,
209 pub columns: Vec<ColumnDef<T>>,
210 pub data: Vec<T>,
211 pub state: EnterpriseDataGridState,
212}
213
214impl<T: 'static> EnterpriseDataGrid<T> {
215 pub fn new(id: &str, columns: Vec<ColumnDef<T>>, data: Vec<T>) -> Self {
216 Self {
217 id: id.to_string(),
218 columns,
219 data,
220 state: EnterpriseDataGridState::default(),
221 }
222 }
223
224 pub fn with_state(mut self, state: EnterpriseDataGridState) -> Self {
225 self.state = state;
226 self
227 }
228
229 pub fn render(&self) -> DomBuilder {
230 let total_items = self.data.len();
231 let query = self.state.search_query.to_lowercase();
232
233 let mut indexed_rows: Vec<(usize, &T)> = self
235 .data
236 .iter()
237 .enumerate()
238 .filter(|(_, item)| {
239 if query.is_empty() {
240 return true;
241 }
242 self.columns.iter().any(|col| {
243 (col.accessor)(item).to_lowercase().contains(&query)
244 })
245 })
246 .collect();
247
248 if let Some((ref col_id, dir)) = self.state.sort_column {
250 if let Some(col) = self.columns.iter().find(|c| &c.id == col_id) {
251 indexed_rows.sort_by(|(_, a), (_, b)| {
252 let val_a = (col.accessor)(a);
253 let val_b = (col.accessor)(b);
254 match dir {
255 SortDirection::Ascending => val_a.cmp(&val_b),
256 SortDirection::Descending => val_b.cmp(&val_a),
257 SortDirection::None => std::cmp::Ordering::Equal,
258 }
259 });
260 }
261 }
262
263 let filtered_count = indexed_rows.len();
264
265 let page_size = if self.state.page_size == 0 { usize::MAX } else { self.state.page_size };
267 let total_pages = if page_size == usize::MAX {
268 1
269 } else {
270 ((filtered_count as f64) / (page_size as f64)).ceil().max(1.0) as usize
271 };
272 let current_page = self.state.current_page.clamp(1, total_pages);
273 let start_idx = (current_page - 1) * page_size;
274 let end_idx = (start_idx + page_size).min(filtered_count);
275
276 let paged_rows = if start_idx < filtered_count {
277 &indexed_rows[start_idx..end_idx]
278 } else {
279 &[]
280 };
281
282 let selected_count = self.state.selected_rows.len();
284 let mut batch_toolbar = div().attr("class", "ferrox-datagrid-batch-bar");
285 if selected_count > 0 {
286 batch_toolbar = batch_toolbar
287 .attr("style", "display: flex; justify-content: space-between; align-items: center; padding: 10px 16px; background: rgba(59, 130, 246, 0.15); border: 1px solid rgba(59, 130, 246, 0.3); border-radius: 8px; margin-bottom: 12px;")
288 .child(
289 div().attr("style", "display: flex; align-items: center; gap: 8px;")
290 .child(Icon::render("check_circle", 18, "#3b82f6"))
291 .child(span().attr("style", "font-weight: 600; font-size: 0.9rem; color: #3b82f6;").text(&format!("{} righe selezionate", selected_count)))
292 )
293 .child(
294 div().attr("style", "display: flex; gap: 8px;")
295 .child(
296 button()
297 .attr("class", "ferrox-btn")
298 .attr("style", "padding: 6px 12px; font-size: 0.8rem; background: #dc2626; color: white;")
299 .text("🗑️ Elimina selezionati")
300 )
301 .child(
302 button()
303 .attr("class", "ferrox-btn")
304 .attr("style", "padding: 6px 12px; font-size: 0.8rem; background: rgba(255,255,255,0.1); border: var(--border-glass);")
305 .text("📥 Esporta selezione CSV")
306 )
307 );
308 }
309
310 let datagrid_id_owned = self.id.clone();
312 let export_csv_btn = button()
313 .attr("class", "ferrox-btn")
314 .attr("style", "padding: 8px 16px; font-size: 0.85rem; display: inline-flex; align-items: center; gap: 6px; background: var(--bg-surface); border: var(--border-glass); cursor: pointer;")
315 .child(Icon::render("download", 16, "var(--accent-primary)"))
316 .child(span().text("Esporta Tabella CSV"));
317
318 let toolbar = div()
319 .attr("class", "ferrox-datagrid-toolbar")
320 .attr("style", "display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; flex-wrap: wrap; gap: 12px;")
321 .child(
322 div().attr("style", "display: flex; align-items: center; gap: 10px;")
323 .child(
324 div().attr("style", "position: relative; width: 280px;")
325 .child(span().attr("style", "position: absolute; left: 10px; top: 50%; transform: translateY(-50%); opacity: 0.5; pointer-events: none;").child(Icon::render("search", 16, "var(--text-secondary)")))
326 .child(
327 input()
328 .attr("type", "text")
329 .attr("placeholder", "Cerca tra tutti i campi...")
330 .attr("value", &self.state.search_query)
331 .attr("style", "width: 100%; padding: 8px 12px 8px 34px; border-radius: 6px; background: var(--bg-surface); border: var(--border-glass); color: var(--text-primary); font-size: 0.85rem; outline: none;")
332 )
333 )
334 .child(
335 div().attr("style", "display: flex; align-items: center; gap: 6px; font-size: 0.82rem; color: var(--text-secondary);")
336 .child(span().text("Mostra:"))
337 .child(
338 select()
339 .attr("style", "padding: 6px 10px; border-radius: 6px; background: var(--bg-surface); border: var(--border-glass); color: var(--text-primary); font-size: 0.82rem; outline: none;")
340 .child(option().attr("value", "10").text("10 righe"))
341 .child(option().attr("value", "25").text("25 righe"))
342 .child(option().attr("value", "50").text("50 righe"))
343 .child(option().attr("value", "100").text("100 righe"))
344 )
345 )
346 )
347 .child(export_csv_btn);
348
349 let all_selected = filtered_count > 0 && self.state.selected_rows.len() >= filtered_count;
351 let some_selected = !self.state.selected_rows.is_empty() && !all_selected;
352
353 let mut thead = div()
354 .attr("class", "ferrox-datagrid-header")
355 .attr("style", "display: flex; align-items: center; background: rgba(0,0,0,0.3); border-bottom: 2px solid rgba(255,255,255,0.1); padding: 10px 14px; font-weight: 700; font-size: 0.82rem; letter-spacing: 0.5px; color: var(--text-secondary);");
356
357 let header_checkbox = div()
359 .attr("style", "width: 36px; display: flex; align-items: center; justify-content: center; cursor: pointer;")
360 .child(
361 div().attr("style", &format!(
362 "width: 18px; height: 18px; border-radius: 4px; border: 1px solid rgba(255,255,255,0.3); background: {}; display: flex; align-items: center; justify-content: center;",
363 if all_selected || some_selected { "var(--accent-primary, #3b82f6)" } else { "transparent" }
364 )).child(
365 if all_selected {
366 Icon::render("check", 14, "#ffffff")
367 } else if some_selected {
368 Icon::render("minus", 14, "#ffffff")
369 } else {
370 span()
371 }
372 )
373 );
374 thead = thead.child(header_checkbox);
375
376 for col in &self.columns {
378 let width_style = match col.width {
379 Some(ref w) => format!("width: {}; flex-shrink: 0;", w),
380 None => "flex: 1;".to_string(),
381 };
382
383 let align_style = match col.align {
384 TextAlign::Left => "justify-content: flex-start; text-align: left;",
385 TextAlign::Center => "justify-content: center; text-align: center;",
386 TextAlign::Right => "justify-content: flex-end; text-align: right;",
387 };
388
389 let sort_icon = if let Some((ref c_id, dir)) = self.state.sort_column {
390 if c_id == &col.id {
391 match dir {
392 SortDirection::Ascending => Icon::render("chevron_up", 14, "var(--accent-primary)"),
393 SortDirection::Descending => Icon::render("chevron_down", 14, "var(--accent-primary)"),
394 SortDirection::None => Icon::render("filter", 12, "rgba(255,255,255,0.3)"),
395 }
396 } else {
397 Icon::render("filter", 12, "rgba(255,255,255,0.15)")
398 }
399 } else {
400 Icon::render("filter", 12, "rgba(255,255,255,0.15)")
401 };
402
403 let col_header = div()
404 .attr("class", "ferrox-datagrid-th")
405 .attr("style", &format!("display: flex; align-items: center; gap: 6px; padding: 0 10px; cursor: pointer; user-select: none; {} {}", width_style, align_style))
406 .child(span().text(&col.header))
407 .child(sort_icon);
408
409 thead = thead.child(col_header);
410 }
411
412 let mut tbody = div().attr("class", "ferrox-datagrid-body");
414
415 if paged_rows.is_empty() {
416 let empty_row = div()
417 .attr("style", "padding: 3rem; text-align: center; color: var(--text-secondary); font-size: 0.95rem;")
418 .child(div().attr("style", "margin-bottom: 8px;").child(Icon::render("search", 32, "rgba(255,255,255,0.2)")))
419 .child(span().text("Nessun record trovato corrispondente ai filtri impostati."));
420 tbody = tbody.child(empty_row);
421 } else {
422 for &(orig_idx, item) in paged_rows {
423 let is_selected = self.state.selected_rows.contains(&orig_idx);
424 let row_bg = if is_selected {
425 "background: rgba(59, 130, 246, 0.12); border-bottom: 1px solid rgba(59, 130, 246, 0.25);"
426 } else {
427 "border-bottom: 1px solid rgba(255,255,255,0.05);"
428 };
429
430 let mut tr = div()
431 .attr("class", "ferrox-datagrid-row")
432 .attr("style", &format!("display: flex; align-items: center; padding: 10px 14px; transition: background 0.15s; cursor: pointer; {}", row_bg));
433
434 let row_check = div()
436 .attr("style", "width: 36px; display: flex; align-items: center; justify-content: center;")
437 .child(
438 div().attr("style", &format!(
439 "width: 18px; height: 18px; border-radius: 4px; border: 1px solid rgba(255,255,255,0.3); background: {}; display: flex; align-items: center; justify-content: center;",
440 if is_selected { "var(--accent-primary, #3b82f6)" } else { "transparent" }
441 )).child(
442 if is_selected {
443 Icon::render("check", 14, "#ffffff")
444 } else {
445 span()
446 }
447 )
448 );
449 tr = tr.child(row_check);
450
451 for col in &self.columns {
453 let width_style = match col.width {
454 Some(ref w) => format!("width: {}; flex-shrink: 0;", w),
455 None => "flex: 1;".to_string(),
456 };
457 let align_style = match col.align {
458 TextAlign::Left => "text-align: left;",
459 TextAlign::Center => "text-align: center;",
460 TextAlign::Right => "text-align: right;",
461 };
462
463 let cell_content = match col.custom_renderer {
464 Some(ref renderer) => renderer(item),
465 None => span().text(&(col.accessor)(item)),
466 };
467
468 let td = div()
469 .attr("class", "ferrox-datagrid-cell")
470 .attr("style", &format!("padding: 0 10px; font-size: 0.88rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; {} {}", width_style, align_style))
471 .child(cell_content);
472
473 tr = tr.child(td);
474 }
475
476 tbody = tbody.child(tr);
477 }
478 }
479
480 let page_info = if filtered_count == 0 {
482 "Visualizzati 0 di 0 record".to_string()
483 } else {
484 format!(
485 "Visualizzati da {} a {} di {} record{}",
486 start_idx + 1,
487 end_idx,
488 filtered_count,
489 if filtered_count < total_items { format!(" (filtrati da {} totali)", total_items) } else { "".to_string() }
490 )
491 };
492
493 let footer = div()
494 .attr("class", "ferrox-datagrid-footer")
495 .attr("style", "display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; border-top: var(--border-glass); background: rgba(0,0,0,0.2); flex-wrap: wrap; gap: 12px;")
496 .child(
497 span().attr("style", "font-size: 0.82rem; color: var(--text-secondary);").text(&page_info)
498 )
499 .child(
500 div().attr("style", "display: flex; gap: 6px; align-items: center;")
501 .child(
502 button()
503 .attr("class", "ferrox-btn")
504 .attr("style", "padding: 6px 12px; font-size: 0.8rem; background: var(--bg-surface); border: var(--border-glass); opacity: 0.8;")
505 .text("« Prec")
506 )
507 .child(
508 span().attr("style", "padding: 6px 12px; font-size: 0.82rem; font-weight: 700; background: var(--accent-primary); border-radius: 6px; color: white;")
509 .text(&format!("Pagina {} di {}", current_page, total_pages))
510 )
511 .child(
512 button()
513 .attr("class", "ferrox-btn")
514 .attr("style", "padding: 6px 12px; font-size: 0.8rem; background: var(--bg-surface); border: var(--border-glass); opacity: 0.8;")
515 .text("Succ »")
516 )
517 );
518
519 let table_container = div()
521 .attr("id", &datagrid_id_owned)
522 .attr("class", "ferrox-datagrid-table-container")
523 .attr("style", "border: var(--border-glass); border-radius: 10px; overflow: hidden; background: var(--bg-surface); box-shadow: var(--shadow-glass);")
524 .child(thead)
525 .child(tbody)
526 .child(footer);
527
528 div()
529 .attr("class", "ferrox-datagrid-root")
530 .child(batch_toolbar)
531 .child(toolbar)
532 .child(table_container)
533 }
534}
535
536pub struct FerroxDataGridConfig {
538 pub schema_url: String,
539 pub entity_name: String,
540}
541
542pub fn ferrox_datagrid(config: &FerroxDataGridConfig) -> DomBuilder {
543 div()
544 .attr("class", "ferrox-datagrid")
545 .attr("style", "padding: 1rem; border: var(--border-glass); background: var(--bg-glass); border-radius: 8px;")
546 .child(
547 div().attr("style", "font-size: 1.2rem; font-weight: bold; margin-bottom: 1rem;")
548 .text(&format!("Entity DataGrid: {}", config.entity_name))
549 )
550 .child(
551 div().text(&format!("Schema and rows loaded from {}", config.schema_url))
552 )
553}