mantenimento-app 2.4.8 → 2.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/app.js CHANGED
@@ -30,6 +30,7 @@ const defaultExpenseItems = [
30
30
  const SCENARIO_LABELS = ["A", "B", "C", "D", "E", "F"];
31
31
  const EXPENSE_DETAIL_MAX_CHARS = 560;
32
32
  const EXPENSE_DETAIL_MAX_LINES = 10;
33
+ const EXPENSE_DETAIL_MAX_ROWS = 8;
33
34
 
34
35
  const QUOTA_MANTENIMENTO_PERC = 35;
35
36
 
@@ -250,6 +251,11 @@ const defaultExpenseItems = [
250
251
  expenseDetailBtn: "Dettaglio",
251
252
  expenseDetailTitle: "Apri dettaglio voce spesa",
252
253
  expenseDetailPlaceholder: "Scrivi qui il dettaglio di questa cifra (es. mesi, quota, riferimento).",
254
+ expenseDetailColThing: "Cosa",
255
+ expenseDetailColAmount: "Quanto",
256
+ expenseDetailColDue: "Scadenza",
257
+ expenseDetailAddRow: "Aggiungi riga",
258
+ expenseDetailRemoveRow: "Rimuovi riga",
253
259
  expenseDetailCharsRemaining: "Caratteri rimanenti: {count}",
254
260
  expenseRemoveTitle: "Rimuovi voce spesa",
255
261
  expenseRemoveBtn: "Rimuovi",
@@ -612,6 +618,11 @@ const defaultExpenseItems = [
612
618
  expenseDetailBtn: "Detail",
613
619
  expenseDetailTitle: "Open expense detail",
614
620
  expenseDetailPlaceholder: "Write details for this amount (e.g. months, share, reference).",
621
+ expenseDetailColThing: "What",
622
+ expenseDetailColAmount: "How much",
623
+ expenseDetailColDue: "Due date",
624
+ expenseDetailAddRow: "Add row",
625
+ expenseDetailRemoveRow: "Remove row",
615
626
  expenseDetailCharsRemaining: "Remaining characters: {count}",
616
627
  expenseRemoveTitle: "Remove expense item",
617
628
  expenseRemoveBtn: "Remove",
@@ -1457,6 +1468,105 @@ const defaultExpenseItems = [
1457
1468
  if (!textarea) return;
1458
1469
  autoResizeExpenseDetailTextarea(textarea, preferredHeight);
1459
1470
  updateExpenseDetailCounter(textarea);
1471
+ syncExpenseDetailTableFromStore(textarea);
1472
+ }
1473
+
1474
+ function parseExpenseDetailRows(raw) {
1475
+ const lines = String(raw || "")
1476
+ .split(/\r?\n/)
1477
+ .map((line) => line.trim())
1478
+ .filter(Boolean);
1479
+ if (!lines.length) return [{ what: "", amount: "", due: "" }];
1480
+ return lines
1481
+ .map((line) => {
1482
+ const cols = line.split("|").map((part) => part.trim());
1483
+ if (cols.length >= 2) {
1484
+ return {
1485
+ what: cols[0] || "",
1486
+ amount: cols[1] || "",
1487
+ due: cols.slice(2).join(" | ") || ""
1488
+ };
1489
+ }
1490
+ return { what: line, amount: "", due: "" };
1491
+ })
1492
+ .slice(0, EXPENSE_DETAIL_MAX_ROWS);
1493
+ }
1494
+
1495
+ function sanitizeExpenseDetailCell(value) {
1496
+ return String(value || "")
1497
+ .replace(/[\r\n]+/g, " ")
1498
+ .replace(/\|/g, "/")
1499
+ .trim();
1500
+ }
1501
+
1502
+ function serializeExpenseDetailRows(rows) {
1503
+ const compact = (Array.isArray(rows) ? rows : [])
1504
+ .map((row) => ({
1505
+ what: sanitizeExpenseDetailCell(row && row.what),
1506
+ amount: sanitizeExpenseDetailCell(row && row.amount),
1507
+ due: sanitizeExpenseDetailCell(row && row.due)
1508
+ }))
1509
+ .filter((row) => row.what || row.amount || row.due);
1510
+ return compact.map((row) => `${row.what} | ${row.amount} | ${row.due}`.trim()).join("\n");
1511
+ }
1512
+
1513
+ function buildExpenseDetailTableHtml(textareaId, rows) {
1514
+ const safeRows = (Array.isArray(rows) && rows.length ? rows : [{ what: "", amount: "", due: "" }])
1515
+ .slice(0, EXPENSE_DETAIL_MAX_ROWS);
1516
+ const rowsHtml = safeRows.map((row) => {
1517
+ const canRemove = safeRows.length > 1;
1518
+ return `<tr>
1519
+ <td><input class="spese-detail-cell-input" data-col="what" type="text" maxlength="120" value="${escapeHtml(row.what || "")}" /></td>
1520
+ <td><input class="spese-detail-cell-input" data-col="amount" type="text" maxlength="80" value="${escapeHtml(row.amount || "")}" /></td>
1521
+ <td><input class="spese-detail-cell-input" data-col="due" type="text" maxlength="80" value="${escapeHtml(row.due || "")}" /></td>
1522
+ <td class="spese-detail-actions-cell"><button type="button" class="spese-detail-row-remove" data-row-remove="1" ${canRemove ? "" : "disabled"} title="${escapeHtml(tr("expenseDetailRemoveRow"))}">-</button></td>
1523
+ </tr>`;
1524
+ }).join("");
1525
+ return `<div class="spese-detail-grid-wrap" data-detail-table="${textareaId}">
1526
+ <table class="spese-detail-grid" aria-label="${escapeHtml(tr("expenseDetailTitle"))}">
1527
+ <thead>
1528
+ <tr>
1529
+ <th>${escapeHtml(tr("expenseDetailColThing"))}</th>
1530
+ <th>${escapeHtml(tr("expenseDetailColAmount"))}</th>
1531
+ <th>${escapeHtml(tr("expenseDetailColDue"))}</th>
1532
+ <th></th>
1533
+ </tr>
1534
+ </thead>
1535
+ <tbody>${rowsHtml}</tbody>
1536
+ </table>
1537
+ <button type="button" class="btn-secondary spese-detail-add-row" data-row-add="${textareaId}">${escapeHtml(tr("expenseDetailAddRow"))}</button>
1538
+ </div>`;
1539
+ }
1540
+
1541
+ function readExpenseDetailRowsFromTable(tableWrap) {
1542
+ if (!tableWrap) return [];
1543
+ return Array.from(tableWrap.querySelectorAll("tbody tr")).map((trEl) => ({
1544
+ what: String(trEl.querySelector('input[data-col="what"]')?.value || "").trim(),
1545
+ amount: String(trEl.querySelector('input[data-col="amount"]')?.value || "").trim(),
1546
+ due: String(trEl.querySelector('input[data-col="due"]')?.value || "").trim()
1547
+ }));
1548
+ }
1549
+
1550
+ function syncExpenseDetailStoreFromTable(tableWrap) {
1551
+ if (!tableWrap) return;
1552
+ const textareaId = String(tableWrap.getAttribute("data-detail-table") || "");
1553
+ const textarea = textareaId ? document.getElementById(textareaId) : null;
1554
+ if (!textarea) return;
1555
+ const serialized = serializeExpenseDetailRows(readExpenseDetailRowsFromTable(tableWrap));
1556
+ textarea.value = serialized.slice(0, EXPENSE_DETAIL_MAX_CHARS);
1557
+ updateExpenseDetailCounter(textarea);
1558
+ }
1559
+
1560
+ function syncExpenseDetailTableFromStore(textarea) {
1561
+ if (!textarea || !textarea.id) return;
1562
+ const host = document.getElementById(`${textarea.id}TableHost`);
1563
+ if (!host) return;
1564
+ const existing = host.querySelector(`[data-detail-table='${textarea.id}']`);
1565
+ const existingSerialized = serializeExpenseDetailRows(existing ? readExpenseDetailRowsFromTable(existing) : []);
1566
+ const storeSerialized = String(textarea.value || "").trim();
1567
+ if (existing && existingSerialized === storeSerialized) return;
1568
+ host.innerHTML = buildExpenseDetailTableHtml(textarea.id, parseExpenseDetailRows(storeSerialized));
1569
+ syncExpenseDetailStoreFromTable(host.querySelector(`[data-detail-table='${textarea.id}']`));
1460
1570
  }
1461
1571
 
1462
1572
  function collectExpenseDetailUiMeta(spouseKey, idx) {
@@ -2938,7 +3048,8 @@ const defaultExpenseItems = [
2938
3048
  <button class="btn-secondary spese-detail-btn" type="button" data-detail-target="c1d_${idx}" data-detail-wrap="c1dw_${idx}" title="${tr("expenseDetailTitle")}"><span class="spese-detail-label">${tr("expenseDetailBtn")}</span></button>
2939
3049
  </div>
2940
3050
  <div class="spese-detail-wrap is-hidden" id="c1dw_${idx}">
2941
- <textarea id="c1d_${idx}" class="spese-detail-text" rows="2" maxlength="${EXPENSE_DETAIL_MAX_CHARS}" placeholder="${escapeHtml(tr("expenseDetailPlaceholder"))}" aria-describedby="c1d_${idx}Counter"></textarea>
3051
+ <div id="c1d_${idx}TableHost"></div>
3052
+ <textarea id="c1d_${idx}" class="spese-detail-text spese-detail-store" rows="2" maxlength="${EXPENSE_DETAIL_MAX_CHARS}" placeholder="${escapeHtml(tr("expenseDetailPlaceholder"))}" aria-describedby="c1d_${idx}Counter"></textarea>
2942
3053
  <div class="spese-detail-counter" id="c1d_${idx}Counter" aria-live="polite"></div>
2943
3054
  </div>
2944
3055
  <span class="spese-partial" id="p1_${idx}" title="${tr("expensePartialTitle")}">${tr("expensePartialLabel")}: ${eurTiny(0)}</span>
@@ -2951,7 +3062,8 @@ const defaultExpenseItems = [
2951
3062
  <button class="btn-secondary spese-detail-btn" type="button" data-detail-target="c2d_${idx}" data-detail-wrap="c2dw_${idx}" title="${tr("expenseDetailTitle")}"><span class="spese-detail-label">${tr("expenseDetailBtn")}</span></button>
2952
3063
  </div>
2953
3064
  <div class="spese-detail-wrap is-hidden" id="c2dw_${idx}">
2954
- <textarea id="c2d_${idx}" class="spese-detail-text" rows="2" maxlength="${EXPENSE_DETAIL_MAX_CHARS}" placeholder="${escapeHtml(tr("expenseDetailPlaceholder"))}" aria-describedby="c2d_${idx}Counter"></textarea>
3065
+ <div id="c2d_${idx}TableHost"></div>
3066
+ <textarea id="c2d_${idx}" class="spese-detail-text spese-detail-store" rows="2" maxlength="${EXPENSE_DETAIL_MAX_CHARS}" placeholder="${escapeHtml(tr("expenseDetailPlaceholder"))}" aria-describedby="c2d_${idx}Counter"></textarea>
2955
3067
  <div class="spese-detail-counter" id="c2d_${idx}Counter" aria-live="polite"></div>
2956
3068
  </div>
2957
3069
  <span class="spese-partial" id="p2_${idx}" title="${tr("expensePartialTitle")}">${tr("expensePartialLabel")}: ${eurTiny(0)}</span>
@@ -2966,6 +3078,9 @@ const defaultExpenseItems = [
2966
3078
  rowsSpese.querySelectorAll("textarea.spese-detail-text").forEach((el) => {
2967
3079
  updateExpenseDetailTextareaUi(el);
2968
3080
  });
3081
+ rowsSpese.querySelectorAll("textarea.spese-detail-store").forEach((el) => {
3082
+ syncExpenseDetailTableFromStore(el);
3083
+ });
2969
3084
  refreshExpenseDetailButtonState();
2970
3085
  }
2971
3086
 
@@ -6317,12 +6432,49 @@ ${scenarioLab.length ? `
6317
6432
  detailBtn.classList.toggle("is-open", willOpen);
6318
6433
  if (target) {
6319
6434
  updateExpenseDetailTextareaUi(target);
6320
- if (willOpen) target.focus();
6435
+ if (willOpen) {
6436
+ const firstField = document.querySelector(`#${targetId}TableHost .spese-detail-cell-input`);
6437
+ if (firstField) firstField.focus();
6438
+ }
6321
6439
  }
6322
6440
  }
6323
6441
  return;
6324
6442
  }
6325
6443
 
6444
+ const addRowBtn = e.target && e.target.closest("button[data-row-add]");
6445
+ if (addRowBtn) {
6446
+ const textareaId = String(addRowBtn.getAttribute("data-row-add") || "");
6447
+ const textarea = textareaId ? document.getElementById(textareaId) : null;
6448
+ if (!textarea) return;
6449
+ const rows = parseExpenseDetailRows(textarea.value);
6450
+ if (rows.length < EXPENSE_DETAIL_MAX_ROWS) {
6451
+ rows.push({ what: "", amount: "", due: "" });
6452
+ textarea.value = serializeExpenseDetailRows(rows);
6453
+ syncExpenseDetailTableFromStore(textarea);
6454
+ refreshExpenseDetailButtonState();
6455
+ }
6456
+ return;
6457
+ }
6458
+
6459
+ const removeRowBtn = e.target && e.target.closest("button[data-row-remove]");
6460
+ if (removeRowBtn) {
6461
+ const tableWrap = removeRowBtn.closest("[data-detail-table]");
6462
+ const rowEl = removeRowBtn.closest("tr");
6463
+ if (!tableWrap || !rowEl) return;
6464
+ const rows = readExpenseDetailRowsFromTable(tableWrap);
6465
+ const rowEls = Array.from(tableWrap.querySelectorAll("tbody tr"));
6466
+ const removeIdx = rowEls.indexOf(rowEl);
6467
+ if (removeIdx >= 0) rows.splice(removeIdx, 1);
6468
+ const nextRows = rows.length ? rows : [{ what: "", amount: "", due: "" }];
6469
+ const textareaId = String(tableWrap.getAttribute("data-detail-table") || "");
6470
+ const textarea = textareaId ? document.getElementById(textareaId) : null;
6471
+ if (!textarea) return;
6472
+ textarea.value = serializeExpenseDetailRows(nextRows);
6473
+ syncExpenseDetailTableFromStore(textarea);
6474
+ refreshExpenseDetailButtonState();
6475
+ return;
6476
+ }
6477
+
6326
6478
  const btn = e.target && e.target.closest("button[data-remove-expense-idx]");
6327
6479
  if (!btn) return;
6328
6480
  const idx = Number(btn.getAttribute("data-remove-expense-idx"));
@@ -6333,6 +6485,12 @@ ${scenarioLab.length ? `
6333
6485
  if (e.target && e.target.matches("textarea.spese-detail-text")) {
6334
6486
  updateExpenseDetailTextareaUi(e.target);
6335
6487
  refreshExpenseDetailButtonState();
6488
+ return;
6489
+ }
6490
+ if (e.target && e.target.matches(".spese-detail-cell-input")) {
6491
+ const tableWrap = e.target.closest("[data-detail-table]");
6492
+ syncExpenseDetailStoreFromTable(tableWrap);
6493
+ refreshExpenseDetailButtonState();
6336
6494
  }
6337
6495
  });
6338
6496
 
@@ -30,6 +30,7 @@ const defaultExpenseItems = [
30
30
  const SCENARIO_LABELS = ["A", "B", "C", "D", "E", "F"];
31
31
  const EXPENSE_DETAIL_MAX_CHARS = 560;
32
32
  const EXPENSE_DETAIL_MAX_LINES = 10;
33
+ const EXPENSE_DETAIL_MAX_ROWS = 8;
33
34
 
34
35
  const QUOTA_MANTENIMENTO_PERC = 35;
35
36
 
@@ -250,6 +251,11 @@ const defaultExpenseItems = [
250
251
  expenseDetailBtn: "Dettaglio",
251
252
  expenseDetailTitle: "Apri dettaglio voce spesa",
252
253
  expenseDetailPlaceholder: "Scrivi qui il dettaglio di questa cifra (es. mesi, quota, riferimento).",
254
+ expenseDetailColThing: "Cosa",
255
+ expenseDetailColAmount: "Quanto",
256
+ expenseDetailColDue: "Scadenza",
257
+ expenseDetailAddRow: "Aggiungi riga",
258
+ expenseDetailRemoveRow: "Rimuovi riga",
253
259
  expenseDetailCharsRemaining: "Caratteri rimanenti: {count}",
254
260
  expenseRemoveTitle: "Rimuovi voce spesa",
255
261
  expenseRemoveBtn: "Rimuovi",
@@ -612,6 +618,11 @@ const defaultExpenseItems = [
612
618
  expenseDetailBtn: "Detail",
613
619
  expenseDetailTitle: "Open expense detail",
614
620
  expenseDetailPlaceholder: "Write details for this amount (e.g. months, share, reference).",
621
+ expenseDetailColThing: "What",
622
+ expenseDetailColAmount: "How much",
623
+ expenseDetailColDue: "Due date",
624
+ expenseDetailAddRow: "Add row",
625
+ expenseDetailRemoveRow: "Remove row",
615
626
  expenseDetailCharsRemaining: "Remaining characters: {count}",
616
627
  expenseRemoveTitle: "Remove expense item",
617
628
  expenseRemoveBtn: "Remove",
@@ -1457,6 +1468,105 @@ const defaultExpenseItems = [
1457
1468
  if (!textarea) return;
1458
1469
  autoResizeExpenseDetailTextarea(textarea, preferredHeight);
1459
1470
  updateExpenseDetailCounter(textarea);
1471
+ syncExpenseDetailTableFromStore(textarea);
1472
+ }
1473
+
1474
+ function parseExpenseDetailRows(raw) {
1475
+ const lines = String(raw || "")
1476
+ .split(/\r?\n/)
1477
+ .map((line) => line.trim())
1478
+ .filter(Boolean);
1479
+ if (!lines.length) return [{ what: "", amount: "", due: "" }];
1480
+ return lines
1481
+ .map((line) => {
1482
+ const cols = line.split("|").map((part) => part.trim());
1483
+ if (cols.length >= 2) {
1484
+ return {
1485
+ what: cols[0] || "",
1486
+ amount: cols[1] || "",
1487
+ due: cols.slice(2).join(" | ") || ""
1488
+ };
1489
+ }
1490
+ return { what: line, amount: "", due: "" };
1491
+ })
1492
+ .slice(0, EXPENSE_DETAIL_MAX_ROWS);
1493
+ }
1494
+
1495
+ function sanitizeExpenseDetailCell(value) {
1496
+ return String(value || "")
1497
+ .replace(/[\r\n]+/g, " ")
1498
+ .replace(/\|/g, "/")
1499
+ .trim();
1500
+ }
1501
+
1502
+ function serializeExpenseDetailRows(rows) {
1503
+ const compact = (Array.isArray(rows) ? rows : [])
1504
+ .map((row) => ({
1505
+ what: sanitizeExpenseDetailCell(row && row.what),
1506
+ amount: sanitizeExpenseDetailCell(row && row.amount),
1507
+ due: sanitizeExpenseDetailCell(row && row.due)
1508
+ }))
1509
+ .filter((row) => row.what || row.amount || row.due);
1510
+ return compact.map((row) => `${row.what} | ${row.amount} | ${row.due}`.trim()).join("\n");
1511
+ }
1512
+
1513
+ function buildExpenseDetailTableHtml(textareaId, rows) {
1514
+ const safeRows = (Array.isArray(rows) && rows.length ? rows : [{ what: "", amount: "", due: "" }])
1515
+ .slice(0, EXPENSE_DETAIL_MAX_ROWS);
1516
+ const rowsHtml = safeRows.map((row) => {
1517
+ const canRemove = safeRows.length > 1;
1518
+ return `<tr>
1519
+ <td><input class="spese-detail-cell-input" data-col="what" type="text" maxlength="120" value="${escapeHtml(row.what || "")}" /></td>
1520
+ <td><input class="spese-detail-cell-input" data-col="amount" type="text" maxlength="80" value="${escapeHtml(row.amount || "")}" /></td>
1521
+ <td><input class="spese-detail-cell-input" data-col="due" type="text" maxlength="80" value="${escapeHtml(row.due || "")}" /></td>
1522
+ <td class="spese-detail-actions-cell"><button type="button" class="spese-detail-row-remove" data-row-remove="1" ${canRemove ? "" : "disabled"} title="${escapeHtml(tr("expenseDetailRemoveRow"))}">-</button></td>
1523
+ </tr>`;
1524
+ }).join("");
1525
+ return `<div class="spese-detail-grid-wrap" data-detail-table="${textareaId}">
1526
+ <table class="spese-detail-grid" aria-label="${escapeHtml(tr("expenseDetailTitle"))}">
1527
+ <thead>
1528
+ <tr>
1529
+ <th>${escapeHtml(tr("expenseDetailColThing"))}</th>
1530
+ <th>${escapeHtml(tr("expenseDetailColAmount"))}</th>
1531
+ <th>${escapeHtml(tr("expenseDetailColDue"))}</th>
1532
+ <th></th>
1533
+ </tr>
1534
+ </thead>
1535
+ <tbody>${rowsHtml}</tbody>
1536
+ </table>
1537
+ <button type="button" class="btn-secondary spese-detail-add-row" data-row-add="${textareaId}">${escapeHtml(tr("expenseDetailAddRow"))}</button>
1538
+ </div>`;
1539
+ }
1540
+
1541
+ function readExpenseDetailRowsFromTable(tableWrap) {
1542
+ if (!tableWrap) return [];
1543
+ return Array.from(tableWrap.querySelectorAll("tbody tr")).map((trEl) => ({
1544
+ what: String(trEl.querySelector('input[data-col="what"]')?.value || "").trim(),
1545
+ amount: String(trEl.querySelector('input[data-col="amount"]')?.value || "").trim(),
1546
+ due: String(trEl.querySelector('input[data-col="due"]')?.value || "").trim()
1547
+ }));
1548
+ }
1549
+
1550
+ function syncExpenseDetailStoreFromTable(tableWrap) {
1551
+ if (!tableWrap) return;
1552
+ const textareaId = String(tableWrap.getAttribute("data-detail-table") || "");
1553
+ const textarea = textareaId ? document.getElementById(textareaId) : null;
1554
+ if (!textarea) return;
1555
+ const serialized = serializeExpenseDetailRows(readExpenseDetailRowsFromTable(tableWrap));
1556
+ textarea.value = serialized.slice(0, EXPENSE_DETAIL_MAX_CHARS);
1557
+ updateExpenseDetailCounter(textarea);
1558
+ }
1559
+
1560
+ function syncExpenseDetailTableFromStore(textarea) {
1561
+ if (!textarea || !textarea.id) return;
1562
+ const host = document.getElementById(`${textarea.id}TableHost`);
1563
+ if (!host) return;
1564
+ const existing = host.querySelector(`[data-detail-table='${textarea.id}']`);
1565
+ const existingSerialized = serializeExpenseDetailRows(existing ? readExpenseDetailRowsFromTable(existing) : []);
1566
+ const storeSerialized = String(textarea.value || "").trim();
1567
+ if (existing && existingSerialized === storeSerialized) return;
1568
+ host.innerHTML = buildExpenseDetailTableHtml(textarea.id, parseExpenseDetailRows(storeSerialized));
1569
+ syncExpenseDetailStoreFromTable(host.querySelector(`[data-detail-table='${textarea.id}']`));
1460
1570
  }
1461
1571
 
1462
1572
  function collectExpenseDetailUiMeta(spouseKey, idx) {
@@ -2938,7 +3048,8 @@ const defaultExpenseItems = [
2938
3048
  <button class="btn-secondary spese-detail-btn" type="button" data-detail-target="c1d_${idx}" data-detail-wrap="c1dw_${idx}" title="${tr("expenseDetailTitle")}"><span class="spese-detail-label">${tr("expenseDetailBtn")}</span></button>
2939
3049
  </div>
2940
3050
  <div class="spese-detail-wrap is-hidden" id="c1dw_${idx}">
2941
- <textarea id="c1d_${idx}" class="spese-detail-text" rows="2" maxlength="${EXPENSE_DETAIL_MAX_CHARS}" placeholder="${escapeHtml(tr("expenseDetailPlaceholder"))}" aria-describedby="c1d_${idx}Counter"></textarea>
3051
+ <div id="c1d_${idx}TableHost"></div>
3052
+ <textarea id="c1d_${idx}" class="spese-detail-text spese-detail-store" rows="2" maxlength="${EXPENSE_DETAIL_MAX_CHARS}" placeholder="${escapeHtml(tr("expenseDetailPlaceholder"))}" aria-describedby="c1d_${idx}Counter"></textarea>
2942
3053
  <div class="spese-detail-counter" id="c1d_${idx}Counter" aria-live="polite"></div>
2943
3054
  </div>
2944
3055
  <span class="spese-partial" id="p1_${idx}" title="${tr("expensePartialTitle")}">${tr("expensePartialLabel")}: ${eurTiny(0)}</span>
@@ -2951,7 +3062,8 @@ const defaultExpenseItems = [
2951
3062
  <button class="btn-secondary spese-detail-btn" type="button" data-detail-target="c2d_${idx}" data-detail-wrap="c2dw_${idx}" title="${tr("expenseDetailTitle")}"><span class="spese-detail-label">${tr("expenseDetailBtn")}</span></button>
2952
3063
  </div>
2953
3064
  <div class="spese-detail-wrap is-hidden" id="c2dw_${idx}">
2954
- <textarea id="c2d_${idx}" class="spese-detail-text" rows="2" maxlength="${EXPENSE_DETAIL_MAX_CHARS}" placeholder="${escapeHtml(tr("expenseDetailPlaceholder"))}" aria-describedby="c2d_${idx}Counter"></textarea>
3065
+ <div id="c2d_${idx}TableHost"></div>
3066
+ <textarea id="c2d_${idx}" class="spese-detail-text spese-detail-store" rows="2" maxlength="${EXPENSE_DETAIL_MAX_CHARS}" placeholder="${escapeHtml(tr("expenseDetailPlaceholder"))}" aria-describedby="c2d_${idx}Counter"></textarea>
2955
3067
  <div class="spese-detail-counter" id="c2d_${idx}Counter" aria-live="polite"></div>
2956
3068
  </div>
2957
3069
  <span class="spese-partial" id="p2_${idx}" title="${tr("expensePartialTitle")}">${tr("expensePartialLabel")}: ${eurTiny(0)}</span>
@@ -2966,6 +3078,9 @@ const defaultExpenseItems = [
2966
3078
  rowsSpese.querySelectorAll("textarea.spese-detail-text").forEach((el) => {
2967
3079
  updateExpenseDetailTextareaUi(el);
2968
3080
  });
3081
+ rowsSpese.querySelectorAll("textarea.spese-detail-store").forEach((el) => {
3082
+ syncExpenseDetailTableFromStore(el);
3083
+ });
2969
3084
  refreshExpenseDetailButtonState();
2970
3085
  }
2971
3086
 
@@ -6317,12 +6432,49 @@ ${scenarioLab.length ? `
6317
6432
  detailBtn.classList.toggle("is-open", willOpen);
6318
6433
  if (target) {
6319
6434
  updateExpenseDetailTextareaUi(target);
6320
- if (willOpen) target.focus();
6435
+ if (willOpen) {
6436
+ const firstField = document.querySelector(`#${targetId}TableHost .spese-detail-cell-input`);
6437
+ if (firstField) firstField.focus();
6438
+ }
6321
6439
  }
6322
6440
  }
6323
6441
  return;
6324
6442
  }
6325
6443
 
6444
+ const addRowBtn = e.target && e.target.closest("button[data-row-add]");
6445
+ if (addRowBtn) {
6446
+ const textareaId = String(addRowBtn.getAttribute("data-row-add") || "");
6447
+ const textarea = textareaId ? document.getElementById(textareaId) : null;
6448
+ if (!textarea) return;
6449
+ const rows = parseExpenseDetailRows(textarea.value);
6450
+ if (rows.length < EXPENSE_DETAIL_MAX_ROWS) {
6451
+ rows.push({ what: "", amount: "", due: "" });
6452
+ textarea.value = serializeExpenseDetailRows(rows);
6453
+ syncExpenseDetailTableFromStore(textarea);
6454
+ refreshExpenseDetailButtonState();
6455
+ }
6456
+ return;
6457
+ }
6458
+
6459
+ const removeRowBtn = e.target && e.target.closest("button[data-row-remove]");
6460
+ if (removeRowBtn) {
6461
+ const tableWrap = removeRowBtn.closest("[data-detail-table]");
6462
+ const rowEl = removeRowBtn.closest("tr");
6463
+ if (!tableWrap || !rowEl) return;
6464
+ const rows = readExpenseDetailRowsFromTable(tableWrap);
6465
+ const rowEls = Array.from(tableWrap.querySelectorAll("tbody tr"));
6466
+ const removeIdx = rowEls.indexOf(rowEl);
6467
+ if (removeIdx >= 0) rows.splice(removeIdx, 1);
6468
+ const nextRows = rows.length ? rows : [{ what: "", amount: "", due: "" }];
6469
+ const textareaId = String(tableWrap.getAttribute("data-detail-table") || "");
6470
+ const textarea = textareaId ? document.getElementById(textareaId) : null;
6471
+ if (!textarea) return;
6472
+ textarea.value = serializeExpenseDetailRows(nextRows);
6473
+ syncExpenseDetailTableFromStore(textarea);
6474
+ refreshExpenseDetailButtonState();
6475
+ return;
6476
+ }
6477
+
6326
6478
  const btn = e.target && e.target.closest("button[data-remove-expense-idx]");
6327
6479
  if (!btn) return;
6328
6480
  const idx = Number(btn.getAttribute("data-remove-expense-idx"));
@@ -6333,6 +6485,12 @@ ${scenarioLab.length ? `
6333
6485
  if (e.target && e.target.matches("textarea.spese-detail-text")) {
6334
6486
  updateExpenseDetailTextareaUi(e.target);
6335
6487
  refreshExpenseDetailButtonState();
6488
+ return;
6489
+ }
6490
+ if (e.target && e.target.matches(".spese-detail-cell-input")) {
6491
+ const tableWrap = e.target.closest("[data-detail-table]");
6492
+ syncExpenseDetailStoreFromTable(tableWrap);
6493
+ refreshExpenseDetailButtonState();
6336
6494
  }
6337
6495
  });
6338
6496
 
@@ -630,7 +630,7 @@
630
630
  <script src="supabase.min.js"></script>
631
631
  <script src="fabric.min.js"></script>
632
632
  <script src="html2pdf.bundle.min.js"></script>
633
- <script src="app.js?v=2.4.8"></script>
633
+ <script src="app.js?v=2.4.9"></script>
634
634
  </body>
635
635
  </html>
636
636
 
@@ -1523,6 +1523,75 @@
1523
1523
  color: #204644;
1524
1524
  }
1525
1525
 
1526
+ .spese-detail-store {
1527
+ display: none;
1528
+ }
1529
+
1530
+ .spese-detail-grid-wrap {
1531
+ border: 1px solid #c8dad4;
1532
+ border-radius: 8px;
1533
+ background: #f9fcfb;
1534
+ padding: 6px;
1535
+ }
1536
+
1537
+ .spese-detail-grid {
1538
+ width: 100%;
1539
+ border-collapse: collapse;
1540
+ table-layout: fixed;
1541
+ font-size: 0.72rem;
1542
+ color: #204644;
1543
+ }
1544
+
1545
+ .spese-detail-grid th {
1546
+ text-align: left;
1547
+ font-size: 0.68rem;
1548
+ font-weight: 800;
1549
+ color: #3b6964;
1550
+ padding: 2px 4px 5px;
1551
+ }
1552
+
1553
+ .spese-detail-grid td {
1554
+ padding: 3px;
1555
+ vertical-align: middle;
1556
+ }
1557
+
1558
+ .spese-detail-cell-input {
1559
+ width: 100%;
1560
+ border: 1px solid #bfd5d0;
1561
+ border-radius: 6px;
1562
+ background: #ffffff;
1563
+ color: #214b47;
1564
+ font-size: 0.72rem;
1565
+ padding: 4px 6px;
1566
+ }
1567
+
1568
+ .spese-detail-actions-cell {
1569
+ width: 30px;
1570
+ text-align: center;
1571
+ }
1572
+
1573
+ .spese-detail-row-remove {
1574
+ width: 24px;
1575
+ height: 24px;
1576
+ border: 1px solid #c4d7d2;
1577
+ border-radius: 6px;
1578
+ background: #ffffff;
1579
+ color: #355b57;
1580
+ font-weight: 800;
1581
+ cursor: pointer;
1582
+ }
1583
+
1584
+ .spese-detail-row-remove:disabled {
1585
+ opacity: 0.45;
1586
+ cursor: not-allowed;
1587
+ }
1588
+
1589
+ .spese-detail-add-row {
1590
+ margin-top: 6px;
1591
+ padding: 3px 8px;
1592
+ font-size: 0.67rem;
1593
+ }
1594
+
1526
1595
  .spese-detail-counter {
1527
1596
  margin-top: 3px;
1528
1597
  font-size: 0.66rem;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mantenimento-app",
3
- "version": "2.4.8",
3
+ "version": "2.4.9",
4
4
  "description": "Frontend + backend architecture for the mantenimento calculator",
5
5
  "type": "commonjs",
6
6
  "main": "backend/calculate-model.js",