mantenimento-app 2.4.7 → 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
 
@@ -4918,8 +5033,10 @@ const defaultExpenseItems = [
4918
5033
  [tr("pdfAmountPerChild"), eur((Math.max(m.assegnoDa1a2, m.assegnoDa2a1)) / m.figli), "warn"]
4919
5034
  ];
4920
5035
 
4921
- if (benefitsInline) {
4922
- items.push([tr("calcCompBenefitsLabel"), benefitsInline, "warn"]);
5036
+ const kpiBenefitLines = getCompensativeBenefitRows(m, c1n(), c2n())
5037
+ .map((row) => `${escapeHtml(row.label)}: ${eur(row.amount)}`);
5038
+ if (kpiBenefitLines.length) {
5039
+ items.push([tr("calcCompBenefitsLabel"), kpiBenefitLines.join("<br />"), "warn", true]);
4923
5040
  }
4924
5041
 
4925
5042
  if (m.incomeMode === "cu") {
@@ -4932,13 +5049,20 @@ const defaultExpenseItems = [
4932
5049
  );
4933
5050
  }
4934
5051
 
4935
- items.forEach(([label, value, cls]) => {
5052
+ items.forEach(([label, value, cls, isHtml = false]) => {
4936
5053
  const el = document.createElement("div");
4937
5054
  el.className = "kpi-item";
4938
- if (label === tr("calcCompBenefitsLabel")) {
5055
+ const isBenefitsRow = label === tr("calcCompBenefitsLabel");
5056
+ if (isBenefitsRow) {
4939
5057
  el.classList.add("kpi-item--longtext");
4940
5058
  }
4941
- el.innerHTML = `<span>${label}</span><strong class="${cls}">${value}</strong>`;
5059
+ const safeLabel = escapeHtml(String(label || ""));
5060
+ const safeValue = isHtml ? String(value || "") : escapeHtml(String(value || ""));
5061
+ if (isBenefitsRow) {
5062
+ el.innerHTML = `<span>${safeLabel}</span><p class="kpi-longtext-value ${cls}">${safeValue}</p>`;
5063
+ } else {
5064
+ el.innerHTML = `<span>${safeLabel}</span><strong class="${cls}">${safeValue}</strong>`;
5065
+ }
4942
5066
  kpi.appendChild(el);
4943
5067
  });
4944
5068
  }
@@ -6308,12 +6432,49 @@ ${scenarioLab.length ? `
6308
6432
  detailBtn.classList.toggle("is-open", willOpen);
6309
6433
  if (target) {
6310
6434
  updateExpenseDetailTextareaUi(target);
6311
- if (willOpen) target.focus();
6435
+ if (willOpen) {
6436
+ const firstField = document.querySelector(`#${targetId}TableHost .spese-detail-cell-input`);
6437
+ if (firstField) firstField.focus();
6438
+ }
6312
6439
  }
6313
6440
  }
6314
6441
  return;
6315
6442
  }
6316
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
+
6317
6478
  const btn = e.target && e.target.closest("button[data-remove-expense-idx]");
6318
6479
  if (!btn) return;
6319
6480
  const idx = Number(btn.getAttribute("data-remove-expense-idx"));
@@ -6324,6 +6485,12 @@ ${scenarioLab.length ? `
6324
6485
  if (e.target && e.target.matches("textarea.spese-detail-text")) {
6325
6486
  updateExpenseDetailTextareaUi(e.target);
6326
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();
6327
6494
  }
6328
6495
  });
6329
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
 
@@ -4918,8 +5033,10 @@ const defaultExpenseItems = [
4918
5033
  [tr("pdfAmountPerChild"), eur((Math.max(m.assegnoDa1a2, m.assegnoDa2a1)) / m.figli), "warn"]
4919
5034
  ];
4920
5035
 
4921
- if (benefitsInline) {
4922
- items.push([tr("calcCompBenefitsLabel"), benefitsInline, "warn"]);
5036
+ const kpiBenefitLines = getCompensativeBenefitRows(m, c1n(), c2n())
5037
+ .map((row) => `${escapeHtml(row.label)}: ${eur(row.amount)}`);
5038
+ if (kpiBenefitLines.length) {
5039
+ items.push([tr("calcCompBenefitsLabel"), kpiBenefitLines.join("<br />"), "warn", true]);
4923
5040
  }
4924
5041
 
4925
5042
  if (m.incomeMode === "cu") {
@@ -4932,13 +5049,20 @@ const defaultExpenseItems = [
4932
5049
  );
4933
5050
  }
4934
5051
 
4935
- items.forEach(([label, value, cls]) => {
5052
+ items.forEach(([label, value, cls, isHtml = false]) => {
4936
5053
  const el = document.createElement("div");
4937
5054
  el.className = "kpi-item";
4938
- if (label === tr("calcCompBenefitsLabel")) {
5055
+ const isBenefitsRow = label === tr("calcCompBenefitsLabel");
5056
+ if (isBenefitsRow) {
4939
5057
  el.classList.add("kpi-item--longtext");
4940
5058
  }
4941
- el.innerHTML = `<span>${label}</span><strong class="${cls}">${value}</strong>`;
5059
+ const safeLabel = escapeHtml(String(label || ""));
5060
+ const safeValue = isHtml ? String(value || "") : escapeHtml(String(value || ""));
5061
+ if (isBenefitsRow) {
5062
+ el.innerHTML = `<span>${safeLabel}</span><p class="kpi-longtext-value ${cls}">${safeValue}</p>`;
5063
+ } else {
5064
+ el.innerHTML = `<span>${safeLabel}</span><strong class="${cls}">${safeValue}</strong>`;
5065
+ }
4942
5066
  kpi.appendChild(el);
4943
5067
  });
4944
5068
  }
@@ -6308,12 +6432,49 @@ ${scenarioLab.length ? `
6308
6432
  detailBtn.classList.toggle("is-open", willOpen);
6309
6433
  if (target) {
6310
6434
  updateExpenseDetailTextareaUi(target);
6311
- if (willOpen) target.focus();
6435
+ if (willOpen) {
6436
+ const firstField = document.querySelector(`#${targetId}TableHost .spese-detail-cell-input`);
6437
+ if (firstField) firstField.focus();
6438
+ }
6312
6439
  }
6313
6440
  }
6314
6441
  return;
6315
6442
  }
6316
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
+
6317
6478
  const btn = e.target && e.target.closest("button[data-remove-expense-idx]");
6318
6479
  if (!btn) return;
6319
6480
  const idx = Number(btn.getAttribute("data-remove-expense-idx"));
@@ -6324,6 +6485,12 @@ ${scenarioLab.length ? `
6324
6485
  if (e.target && e.target.matches("textarea.spese-detail-text")) {
6325
6486
  updateExpenseDetailTextareaUi(e.target);
6326
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();
6327
6494
  }
6328
6495
  });
6329
6496
 
@@ -88,7 +88,7 @@
88
88
  ]
89
89
  }
90
90
  </script>
91
- <link rel="stylesheet" href="styles.css?v=2.4.7" />
91
+ <link rel="stylesheet" href="styles.css?v=2.4.8" />
92
92
  </head>
93
93
  <body>
94
94
  <div class="wrap">
@@ -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.7"></script>
633
+ <script src="app.js?v=2.4.9"></script>
634
634
  </body>
635
635
  </html>
636
636
 
@@ -1000,7 +1000,7 @@
1000
1000
  linear-gradient(180deg, #fafffe, #f2f8f7);
1001
1001
  padding: 14px;
1002
1002
  display: grid;
1003
- grid-template-columns: minmax(0, 1fr) minmax(180px, 2fr) minmax(0, 1fr);
1003
+ grid-template-columns: minmax(170px, 0.9fr) minmax(380px, 2.9fr) minmax(170px, 0.9fr);
1004
1004
  gap: 10px;
1005
1005
  align-items: center;
1006
1006
  box-shadow: 0 10px 18px rgba(27, 141, 127, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.65);
@@ -1039,7 +1039,9 @@
1039
1039
  min-height: 74px;
1040
1040
  display: grid;
1041
1041
  align-content: center;
1042
+ justify-items: center;
1042
1043
  gap: 4px;
1044
+ text-align: center;
1043
1045
  box-shadow: 0 2px 6px rgba(27, 141, 127, 0.08), inset 0 1px 2px rgba(255, 255, 255, 0.7);
1044
1046
  transition: all 0.28s cubic-bezier(0.34, 1.56, 0.64, 1);
1045
1047
  position: relative;
@@ -1081,7 +1083,7 @@
1081
1083
 
1082
1084
  .mortgage-split-side-right {
1083
1085
  box-shadow: 0 2px 6px rgba(27, 141, 127, 0.08), inset 0 1px 2px rgba(255, 255, 255, 0.7), inset -3px 0 0 #d89a35;
1084
- text-align: right;
1086
+ text-align: center;
1085
1087
  background: linear-gradient(135deg, #fef9f1 0%, #fdf5e8 100%);
1086
1088
  }
1087
1089
 
@@ -1097,6 +1099,7 @@
1097
1099
  overflow-wrap: anywhere;
1098
1100
  letter-spacing: 0.2px;
1099
1101
  text-shadow: 0 1px 2px rgba(255, 255, 255, 0.5);
1102
+ text-align: center;
1100
1103
  }
1101
1104
 
1102
1105
  .mortgage-split-amount {
@@ -1109,6 +1112,7 @@
1109
1112
  -webkit-text-fill-color: transparent;
1110
1113
  background-clip: text;
1111
1114
  filter: drop-shadow(0 1px 2px rgba(27, 141, 127, 0.2));
1115
+ text-align: center;
1112
1116
  }
1113
1117
 
1114
1118
  .mortgage-split-side-right .mortgage-split-amount {
@@ -1120,7 +1124,7 @@
1120
1124
 
1121
1125
  .mortgage-split-range-wrap {
1122
1126
  position: relative;
1123
- padding: 20px 0 8px;
1127
+ padding: 30px 0 8px;
1124
1128
  --split-left: 50%;
1125
1129
  }
1126
1130
 
@@ -1231,7 +1235,7 @@
1231
1235
 
1232
1236
  .mortgage-split-center {
1233
1237
  position: absolute;
1234
- top: 2px;
1238
+ top: -14px;
1235
1239
  left: var(--split-left);
1236
1240
  transform: translateX(-50%);
1237
1241
  font-size: 0.92rem;
@@ -1246,6 +1250,8 @@
1246
1250
  transition: all 0.2s ease;
1247
1251
  letter-spacing: 0.2px;
1248
1252
  animation: split-center-breathe 2.6s ease-in-out infinite;
1253
+ z-index: 3;
1254
+ pointer-events: none;
1249
1255
  }
1250
1256
 
1251
1257
  @keyframes first-home-aurora {
@@ -1517,6 +1523,75 @@
1517
1523
  color: #204644;
1518
1524
  }
1519
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
+
1520
1595
  .spese-detail-counter {
1521
1596
  margin-top: 3px;
1522
1597
  font-size: 0.66rem;
@@ -2552,19 +2627,33 @@
2552
2627
  }
2553
2628
 
2554
2629
  .kpi-item--longtext {
2630
+ flex-direction: column;
2555
2631
  align-items: flex-start;
2632
+ gap: 5px;
2633
+ }
2634
+
2635
+ .kpi-item--longtext span {
2636
+ width: 100%;
2637
+ flex: none;
2556
2638
  }
2557
2639
 
2558
- .kpi-item--longtext strong {
2640
+ .kpi-longtext-value {
2641
+ margin: 0;
2642
+ width: 100%;
2559
2643
  white-space: normal;
2560
2644
  text-align: left;
2561
- font-size: 0.88rem;
2562
- line-height: 1.32;
2645
+ font-size: 0.95rem;
2646
+ font-weight: 700;
2647
+ line-height: 1.42;
2563
2648
  overflow-wrap: anywhere;
2564
2649
  word-break: break-word;
2565
- flex: 1;
2650
+ color: #9a5a00;
2566
2651
  }
2567
2652
 
2653
+ .kpi-longtext-value.ok { color: #0c6c52; }
2654
+ .kpi-longtext-value.warn { color: #9a5a00; }
2655
+ .kpi-longtext-value.bad { color: #b53c2f; }
2656
+
2568
2657
  .spieg-details {
2569
2658
  border: 1px solid #bed9d4;
2570
2659
  border-radius: 12px;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mantenimento-app",
3
- "version": "2.4.7",
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",