mantenimento-app 2.4.8 → 2.4.10

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,8 @@ 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;
34
+ const EXPENSE_DETAIL_NOTE_SEPARATOR = "\n---\n";
33
35
 
34
36
  const QUOTA_MANTENIMENTO_PERC = 35;
35
37
 
@@ -250,6 +252,12 @@ const defaultExpenseItems = [
250
252
  expenseDetailBtn: "Dettaglio",
251
253
  expenseDetailTitle: "Apri dettaglio voce spesa",
252
254
  expenseDetailPlaceholder: "Scrivi qui il dettaglio di questa cifra (es. mesi, quota, riferimento).",
255
+ expenseDetailColThing: "Cosa",
256
+ expenseDetailColAmount: "Quanto",
257
+ expenseDetailColDue: "Scadenza",
258
+ expenseDetailAddRow: "Aggiungi riga",
259
+ expenseDetailRemoveRow: "Rimuovi riga",
260
+ expenseDetailFreeTextPlaceholder: "Note libere aggiuntive...",
253
261
  expenseDetailCharsRemaining: "Caratteri rimanenti: {count}",
254
262
  expenseRemoveTitle: "Rimuovi voce spesa",
255
263
  expenseRemoveBtn: "Rimuovi",
@@ -612,6 +620,12 @@ const defaultExpenseItems = [
612
620
  expenseDetailBtn: "Detail",
613
621
  expenseDetailTitle: "Open expense detail",
614
622
  expenseDetailPlaceholder: "Write details for this amount (e.g. months, share, reference).",
623
+ expenseDetailColThing: "What",
624
+ expenseDetailColAmount: "How much",
625
+ expenseDetailColDue: "Due date",
626
+ expenseDetailAddRow: "Add row",
627
+ expenseDetailRemoveRow: "Remove row",
628
+ expenseDetailFreeTextPlaceholder: "Additional free notes...",
615
629
  expenseDetailCharsRemaining: "Remaining characters: {count}",
616
630
  expenseRemoveTitle: "Remove expense item",
617
631
  expenseRemoveBtn: "Remove",
@@ -1457,6 +1471,140 @@ const defaultExpenseItems = [
1457
1471
  if (!textarea) return;
1458
1472
  autoResizeExpenseDetailTextarea(textarea, preferredHeight);
1459
1473
  updateExpenseDetailCounter(textarea);
1474
+ syncExpenseDetailTableFromStore(textarea);
1475
+ }
1476
+
1477
+ function parseExpenseDetailPayload(raw) {
1478
+ const source = String(raw || "").trim();
1479
+ let tablePart = source;
1480
+ let notePart = "";
1481
+ const sepIdx = source.indexOf(EXPENSE_DETAIL_NOTE_SEPARATOR);
1482
+ if (sepIdx >= 0) {
1483
+ tablePart = source.slice(0, sepIdx).trim();
1484
+ notePart = source.slice(sepIdx + EXPENSE_DETAIL_NOTE_SEPARATOR.length).trim();
1485
+ }
1486
+
1487
+ const tableLines = tablePart
1488
+ .split(/\r?\n/)
1489
+ .map((line) => line.trim())
1490
+ .filter(Boolean);
1491
+ const rows = [];
1492
+ const looseNoteLines = [];
1493
+ tableLines.forEach((line) => {
1494
+ const cols = line.split("|").map((part) => part.trim());
1495
+ if (cols.length >= 2) {
1496
+ rows.push({
1497
+ what: cols[0] || "",
1498
+ amount: cols[1] || "",
1499
+ due: cols.slice(2).join(" | ") || ""
1500
+ });
1501
+ } else {
1502
+ looseNoteLines.push(line);
1503
+ }
1504
+ });
1505
+
1506
+ const resolvedNote = [notePart, looseNoteLines.join("\n")]
1507
+ .filter(Boolean)
1508
+ .join(notePart && looseNoteLines.length ? "\n" : "")
1509
+ .trim();
1510
+
1511
+ return {
1512
+ rows: (rows.length ? rows : [{ what: "", amount: "", due: "" }]).slice(0, EXPENSE_DETAIL_MAX_ROWS),
1513
+ note: resolvedNote
1514
+ };
1515
+ }
1516
+
1517
+ function parseExpenseDetailRows(raw) {
1518
+ return parseExpenseDetailPayload(raw).rows;
1519
+ }
1520
+
1521
+ function sanitizeExpenseDetailCell(value) {
1522
+ return String(value || "")
1523
+ .replace(/[\r\n]+/g, " ")
1524
+ .replace(/\|/g, "/")
1525
+ .trim();
1526
+ }
1527
+
1528
+ function serializeExpenseDetailRows(rows) {
1529
+ const compact = (Array.isArray(rows) ? rows : [])
1530
+ .map((row) => ({
1531
+ what: sanitizeExpenseDetailCell(row && row.what),
1532
+ amount: sanitizeExpenseDetailCell(row && row.amount),
1533
+ due: sanitizeExpenseDetailCell(row && row.due)
1534
+ }))
1535
+ .filter((row) => row.what || row.amount || row.due);
1536
+ return compact.map((row) => `${row.what} | ${row.amount} | ${row.due}`.trim()).join("\n");
1537
+ }
1538
+
1539
+ function serializeExpenseDetailPayload(rows, note) {
1540
+ const tablePart = serializeExpenseDetailRows(rows);
1541
+ const safeNote = String(note || "").trim();
1542
+ if (!safeNote) return tablePart;
1543
+ return `${tablePart}${tablePart ? EXPENSE_DETAIL_NOTE_SEPARATOR : ""}${safeNote}`;
1544
+ }
1545
+
1546
+ function buildExpenseDetailTableHtml(textareaId, rows, note = "") {
1547
+ const safeRows = (Array.isArray(rows) && rows.length ? rows : [{ what: "", amount: "", due: "" }])
1548
+ .slice(0, EXPENSE_DETAIL_MAX_ROWS);
1549
+ const rowsHtml = safeRows.map((row) => {
1550
+ const canRemove = safeRows.length > 1;
1551
+ return `<tr>
1552
+ <td><input class="spese-detail-cell-input" data-col="what" type="text" maxlength="120" value="${escapeHtml(row.what || "")}" /></td>
1553
+ <td><input class="spese-detail-cell-input" data-col="amount" type="text" maxlength="80" value="${escapeHtml(row.amount || "")}" /></td>
1554
+ <td><input class="spese-detail-cell-input" data-col="due" type="text" maxlength="80" value="${escapeHtml(row.due || "")}" /></td>
1555
+ <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>
1556
+ </tr>`;
1557
+ }).join("");
1558
+ return `<div class="spese-detail-grid-wrap" data-detail-table="${textareaId}">
1559
+ <table class="spese-detail-grid" aria-label="${escapeHtml(tr("expenseDetailTitle"))}">
1560
+ <thead>
1561
+ <tr>
1562
+ <th>${escapeHtml(tr("expenseDetailColThing"))}</th>
1563
+ <th>${escapeHtml(tr("expenseDetailColAmount"))}</th>
1564
+ <th>${escapeHtml(tr("expenseDetailColDue"))}</th>
1565
+ <th></th>
1566
+ </tr>
1567
+ </thead>
1568
+ <tbody>${rowsHtml}</tbody>
1569
+ </table>
1570
+ <button type="button" class="btn-secondary spese-detail-add-row" data-row-add="${textareaId}">${escapeHtml(tr("expenseDetailAddRow"))}</button>
1571
+ <textarea class="spese-detail-note" data-detail-note-for="${textareaId}" rows="2" maxlength="360" placeholder="${escapeHtml(tr("expenseDetailFreeTextPlaceholder"))}">${escapeHtml(note || "")}</textarea>
1572
+ </div>`;
1573
+ }
1574
+
1575
+ function readExpenseDetailRowsFromTable(tableWrap) {
1576
+ if (!tableWrap) return [];
1577
+ return Array.from(tableWrap.querySelectorAll("tbody tr")).map((trEl) => ({
1578
+ what: String(trEl.querySelector('input[data-col="what"]')?.value || "").trim(),
1579
+ amount: String(trEl.querySelector('input[data-col="amount"]')?.value || "").trim(),
1580
+ due: String(trEl.querySelector('input[data-col="due"]')?.value || "").trim()
1581
+ }));
1582
+ }
1583
+
1584
+ function syncExpenseDetailStoreFromTable(tableWrap) {
1585
+ if (!tableWrap) return;
1586
+ const textareaId = String(tableWrap.getAttribute("data-detail-table") || "");
1587
+ const textarea = textareaId ? document.getElementById(textareaId) : null;
1588
+ if (!textarea) return;
1589
+ const noteEl = tableWrap.querySelector(`textarea[data-detail-note-for='${textareaId}']`);
1590
+ const note = String(noteEl && noteEl.value ? noteEl.value : "");
1591
+ const serialized = serializeExpenseDetailPayload(readExpenseDetailRowsFromTable(tableWrap), note);
1592
+ textarea.value = serialized.slice(0, EXPENSE_DETAIL_MAX_CHARS);
1593
+ updateExpenseDetailCounter(textarea);
1594
+ }
1595
+
1596
+ function syncExpenseDetailTableFromStore(textarea) {
1597
+ if (!textarea || !textarea.id) return;
1598
+ const host = document.getElementById(`${textarea.id}TableHost`);
1599
+ if (!host) return;
1600
+ const existing = host.querySelector(`[data-detail-table='${textarea.id}']`);
1601
+ const existingNote = existing ? String(existing.querySelector(`textarea[data-detail-note-for='${textarea.id}']`)?.value || "") : "";
1602
+ const existingSerialized = serializeExpenseDetailPayload(existing ? readExpenseDetailRowsFromTable(existing) : [], existingNote);
1603
+ const storeSerialized = String(textarea.value || "").trim();
1604
+ if (existing && existingSerialized === storeSerialized) return;
1605
+ const payload = parseExpenseDetailPayload(storeSerialized);
1606
+ host.innerHTML = buildExpenseDetailTableHtml(textarea.id, payload.rows, payload.note);
1607
+ syncExpenseDetailStoreFromTable(host.querySelector(`[data-detail-table='${textarea.id}']`));
1460
1608
  }
1461
1609
 
1462
1610
  function collectExpenseDetailUiMeta(spouseKey, idx) {
@@ -2938,7 +3086,8 @@ const defaultExpenseItems = [
2938
3086
  <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
3087
  </div>
2940
3088
  <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>
3089
+ <div id="c1d_${idx}TableHost"></div>
3090
+ <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
3091
  <div class="spese-detail-counter" id="c1d_${idx}Counter" aria-live="polite"></div>
2943
3092
  </div>
2944
3093
  <span class="spese-partial" id="p1_${idx}" title="${tr("expensePartialTitle")}">${tr("expensePartialLabel")}: ${eurTiny(0)}</span>
@@ -2951,7 +3100,8 @@ const defaultExpenseItems = [
2951
3100
  <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
3101
  </div>
2953
3102
  <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>
3103
+ <div id="c2d_${idx}TableHost"></div>
3104
+ <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
3105
  <div class="spese-detail-counter" id="c2d_${idx}Counter" aria-live="polite"></div>
2956
3106
  </div>
2957
3107
  <span class="spese-partial" id="p2_${idx}" title="${tr("expensePartialTitle")}">${tr("expensePartialLabel")}: ${eurTiny(0)}</span>
@@ -2966,6 +3116,9 @@ const defaultExpenseItems = [
2966
3116
  rowsSpese.querySelectorAll("textarea.spese-detail-text").forEach((el) => {
2967
3117
  updateExpenseDetailTextareaUi(el);
2968
3118
  });
3119
+ rowsSpese.querySelectorAll("textarea.spese-detail-store").forEach((el) => {
3120
+ syncExpenseDetailTableFromStore(el);
3121
+ });
2969
3122
  refreshExpenseDetailButtonState();
2970
3123
  }
2971
3124
 
@@ -6317,12 +6470,50 @@ ${scenarioLab.length ? `
6317
6470
  detailBtn.classList.toggle("is-open", willOpen);
6318
6471
  if (target) {
6319
6472
  updateExpenseDetailTextareaUi(target);
6320
- if (willOpen) target.focus();
6473
+ if (willOpen) {
6474
+ const firstField = document.querySelector(`#${targetId}TableHost .spese-detail-cell-input`);
6475
+ if (firstField) firstField.focus();
6476
+ }
6321
6477
  }
6322
6478
  }
6323
6479
  return;
6324
6480
  }
6325
6481
 
6482
+ const addRowBtn = e.target && e.target.closest("button[data-row-add]");
6483
+ if (addRowBtn) {
6484
+ const textareaId = String(addRowBtn.getAttribute("data-row-add") || "");
6485
+ const textarea = textareaId ? document.getElementById(textareaId) : null;
6486
+ if (!textarea) return;
6487
+ const payload = parseExpenseDetailPayload(textarea.value);
6488
+ const rows = payload.rows;
6489
+ if (rows.length < EXPENSE_DETAIL_MAX_ROWS) {
6490
+ rows.push({ what: "", amount: "", due: "" });
6491
+ textarea.value = serializeExpenseDetailPayload(rows, payload.note);
6492
+ syncExpenseDetailTableFromStore(textarea);
6493
+ refreshExpenseDetailButtonState();
6494
+ }
6495
+ return;
6496
+ }
6497
+
6498
+ const removeRowBtn = e.target && e.target.closest("button[data-row-remove]");
6499
+ if (removeRowBtn) {
6500
+ const tableWrap = removeRowBtn.closest("[data-detail-table]");
6501
+ const rowEl = removeRowBtn.closest("tr");
6502
+ if (!tableWrap || !rowEl) return;
6503
+ const rows = readExpenseDetailRowsFromTable(tableWrap);
6504
+ const rowEls = Array.from(tableWrap.querySelectorAll("tbody tr"));
6505
+ const removeIdx = rowEls.indexOf(rowEl);
6506
+ if (removeIdx >= 0) rows.splice(removeIdx, 1);
6507
+ const nextRows = rows.length ? rows : [{ what: "", amount: "", due: "" }];
6508
+ const textareaId = String(tableWrap.getAttribute("data-detail-table") || "");
6509
+ const textarea = textareaId ? document.getElementById(textareaId) : null;
6510
+ if (!textarea) return;
6511
+ textarea.value = serializeExpenseDetailRows(nextRows);
6512
+ syncExpenseDetailTableFromStore(textarea);
6513
+ refreshExpenseDetailButtonState();
6514
+ return;
6515
+ }
6516
+
6326
6517
  const btn = e.target && e.target.closest("button[data-remove-expense-idx]");
6327
6518
  if (!btn) return;
6328
6519
  const idx = Number(btn.getAttribute("data-remove-expense-idx"));
@@ -6333,6 +6524,18 @@ ${scenarioLab.length ? `
6333
6524
  if (e.target && e.target.matches("textarea.spese-detail-text")) {
6334
6525
  updateExpenseDetailTextareaUi(e.target);
6335
6526
  refreshExpenseDetailButtonState();
6527
+ return;
6528
+ }
6529
+ if (e.target && e.target.matches(".spese-detail-cell-input")) {
6530
+ const tableWrap = e.target.closest("[data-detail-table]");
6531
+ syncExpenseDetailStoreFromTable(tableWrap);
6532
+ refreshExpenseDetailButtonState();
6533
+ return;
6534
+ }
6535
+ if (e.target && e.target.matches(".spese-detail-note")) {
6536
+ const tableWrap = e.target.closest("[data-detail-table]");
6537
+ syncExpenseDetailStoreFromTable(tableWrap);
6538
+ refreshExpenseDetailButtonState();
6336
6539
  }
6337
6540
  });
6338
6541
 
@@ -30,6 +30,8 @@ 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;
34
+ const EXPENSE_DETAIL_NOTE_SEPARATOR = "\n---\n";
33
35
 
34
36
  const QUOTA_MANTENIMENTO_PERC = 35;
35
37
 
@@ -250,6 +252,12 @@ const defaultExpenseItems = [
250
252
  expenseDetailBtn: "Dettaglio",
251
253
  expenseDetailTitle: "Apri dettaglio voce spesa",
252
254
  expenseDetailPlaceholder: "Scrivi qui il dettaglio di questa cifra (es. mesi, quota, riferimento).",
255
+ expenseDetailColThing: "Cosa",
256
+ expenseDetailColAmount: "Quanto",
257
+ expenseDetailColDue: "Scadenza",
258
+ expenseDetailAddRow: "Aggiungi riga",
259
+ expenseDetailRemoveRow: "Rimuovi riga",
260
+ expenseDetailFreeTextPlaceholder: "Note libere aggiuntive...",
253
261
  expenseDetailCharsRemaining: "Caratteri rimanenti: {count}",
254
262
  expenseRemoveTitle: "Rimuovi voce spesa",
255
263
  expenseRemoveBtn: "Rimuovi",
@@ -612,6 +620,12 @@ const defaultExpenseItems = [
612
620
  expenseDetailBtn: "Detail",
613
621
  expenseDetailTitle: "Open expense detail",
614
622
  expenseDetailPlaceholder: "Write details for this amount (e.g. months, share, reference).",
623
+ expenseDetailColThing: "What",
624
+ expenseDetailColAmount: "How much",
625
+ expenseDetailColDue: "Due date",
626
+ expenseDetailAddRow: "Add row",
627
+ expenseDetailRemoveRow: "Remove row",
628
+ expenseDetailFreeTextPlaceholder: "Additional free notes...",
615
629
  expenseDetailCharsRemaining: "Remaining characters: {count}",
616
630
  expenseRemoveTitle: "Remove expense item",
617
631
  expenseRemoveBtn: "Remove",
@@ -1457,6 +1471,140 @@ const defaultExpenseItems = [
1457
1471
  if (!textarea) return;
1458
1472
  autoResizeExpenseDetailTextarea(textarea, preferredHeight);
1459
1473
  updateExpenseDetailCounter(textarea);
1474
+ syncExpenseDetailTableFromStore(textarea);
1475
+ }
1476
+
1477
+ function parseExpenseDetailPayload(raw) {
1478
+ const source = String(raw || "").trim();
1479
+ let tablePart = source;
1480
+ let notePart = "";
1481
+ const sepIdx = source.indexOf(EXPENSE_DETAIL_NOTE_SEPARATOR);
1482
+ if (sepIdx >= 0) {
1483
+ tablePart = source.slice(0, sepIdx).trim();
1484
+ notePart = source.slice(sepIdx + EXPENSE_DETAIL_NOTE_SEPARATOR.length).trim();
1485
+ }
1486
+
1487
+ const tableLines = tablePart
1488
+ .split(/\r?\n/)
1489
+ .map((line) => line.trim())
1490
+ .filter(Boolean);
1491
+ const rows = [];
1492
+ const looseNoteLines = [];
1493
+ tableLines.forEach((line) => {
1494
+ const cols = line.split("|").map((part) => part.trim());
1495
+ if (cols.length >= 2) {
1496
+ rows.push({
1497
+ what: cols[0] || "",
1498
+ amount: cols[1] || "",
1499
+ due: cols.slice(2).join(" | ") || ""
1500
+ });
1501
+ } else {
1502
+ looseNoteLines.push(line);
1503
+ }
1504
+ });
1505
+
1506
+ const resolvedNote = [notePart, looseNoteLines.join("\n")]
1507
+ .filter(Boolean)
1508
+ .join(notePart && looseNoteLines.length ? "\n" : "")
1509
+ .trim();
1510
+
1511
+ return {
1512
+ rows: (rows.length ? rows : [{ what: "", amount: "", due: "" }]).slice(0, EXPENSE_DETAIL_MAX_ROWS),
1513
+ note: resolvedNote
1514
+ };
1515
+ }
1516
+
1517
+ function parseExpenseDetailRows(raw) {
1518
+ return parseExpenseDetailPayload(raw).rows;
1519
+ }
1520
+
1521
+ function sanitizeExpenseDetailCell(value) {
1522
+ return String(value || "")
1523
+ .replace(/[\r\n]+/g, " ")
1524
+ .replace(/\|/g, "/")
1525
+ .trim();
1526
+ }
1527
+
1528
+ function serializeExpenseDetailRows(rows) {
1529
+ const compact = (Array.isArray(rows) ? rows : [])
1530
+ .map((row) => ({
1531
+ what: sanitizeExpenseDetailCell(row && row.what),
1532
+ amount: sanitizeExpenseDetailCell(row && row.amount),
1533
+ due: sanitizeExpenseDetailCell(row && row.due)
1534
+ }))
1535
+ .filter((row) => row.what || row.amount || row.due);
1536
+ return compact.map((row) => `${row.what} | ${row.amount} | ${row.due}`.trim()).join("\n");
1537
+ }
1538
+
1539
+ function serializeExpenseDetailPayload(rows, note) {
1540
+ const tablePart = serializeExpenseDetailRows(rows);
1541
+ const safeNote = String(note || "").trim();
1542
+ if (!safeNote) return tablePart;
1543
+ return `${tablePart}${tablePart ? EXPENSE_DETAIL_NOTE_SEPARATOR : ""}${safeNote}`;
1544
+ }
1545
+
1546
+ function buildExpenseDetailTableHtml(textareaId, rows, note = "") {
1547
+ const safeRows = (Array.isArray(rows) && rows.length ? rows : [{ what: "", amount: "", due: "" }])
1548
+ .slice(0, EXPENSE_DETAIL_MAX_ROWS);
1549
+ const rowsHtml = safeRows.map((row) => {
1550
+ const canRemove = safeRows.length > 1;
1551
+ return `<tr>
1552
+ <td><input class="spese-detail-cell-input" data-col="what" type="text" maxlength="120" value="${escapeHtml(row.what || "")}" /></td>
1553
+ <td><input class="spese-detail-cell-input" data-col="amount" type="text" maxlength="80" value="${escapeHtml(row.amount || "")}" /></td>
1554
+ <td><input class="spese-detail-cell-input" data-col="due" type="text" maxlength="80" value="${escapeHtml(row.due || "")}" /></td>
1555
+ <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>
1556
+ </tr>`;
1557
+ }).join("");
1558
+ return `<div class="spese-detail-grid-wrap" data-detail-table="${textareaId}">
1559
+ <table class="spese-detail-grid" aria-label="${escapeHtml(tr("expenseDetailTitle"))}">
1560
+ <thead>
1561
+ <tr>
1562
+ <th>${escapeHtml(tr("expenseDetailColThing"))}</th>
1563
+ <th>${escapeHtml(tr("expenseDetailColAmount"))}</th>
1564
+ <th>${escapeHtml(tr("expenseDetailColDue"))}</th>
1565
+ <th></th>
1566
+ </tr>
1567
+ </thead>
1568
+ <tbody>${rowsHtml}</tbody>
1569
+ </table>
1570
+ <button type="button" class="btn-secondary spese-detail-add-row" data-row-add="${textareaId}">${escapeHtml(tr("expenseDetailAddRow"))}</button>
1571
+ <textarea class="spese-detail-note" data-detail-note-for="${textareaId}" rows="2" maxlength="360" placeholder="${escapeHtml(tr("expenseDetailFreeTextPlaceholder"))}">${escapeHtml(note || "")}</textarea>
1572
+ </div>`;
1573
+ }
1574
+
1575
+ function readExpenseDetailRowsFromTable(tableWrap) {
1576
+ if (!tableWrap) return [];
1577
+ return Array.from(tableWrap.querySelectorAll("tbody tr")).map((trEl) => ({
1578
+ what: String(trEl.querySelector('input[data-col="what"]')?.value || "").trim(),
1579
+ amount: String(trEl.querySelector('input[data-col="amount"]')?.value || "").trim(),
1580
+ due: String(trEl.querySelector('input[data-col="due"]')?.value || "").trim()
1581
+ }));
1582
+ }
1583
+
1584
+ function syncExpenseDetailStoreFromTable(tableWrap) {
1585
+ if (!tableWrap) return;
1586
+ const textareaId = String(tableWrap.getAttribute("data-detail-table") || "");
1587
+ const textarea = textareaId ? document.getElementById(textareaId) : null;
1588
+ if (!textarea) return;
1589
+ const noteEl = tableWrap.querySelector(`textarea[data-detail-note-for='${textareaId}']`);
1590
+ const note = String(noteEl && noteEl.value ? noteEl.value : "");
1591
+ const serialized = serializeExpenseDetailPayload(readExpenseDetailRowsFromTable(tableWrap), note);
1592
+ textarea.value = serialized.slice(0, EXPENSE_DETAIL_MAX_CHARS);
1593
+ updateExpenseDetailCounter(textarea);
1594
+ }
1595
+
1596
+ function syncExpenseDetailTableFromStore(textarea) {
1597
+ if (!textarea || !textarea.id) return;
1598
+ const host = document.getElementById(`${textarea.id}TableHost`);
1599
+ if (!host) return;
1600
+ const existing = host.querySelector(`[data-detail-table='${textarea.id}']`);
1601
+ const existingNote = existing ? String(existing.querySelector(`textarea[data-detail-note-for='${textarea.id}']`)?.value || "") : "";
1602
+ const existingSerialized = serializeExpenseDetailPayload(existing ? readExpenseDetailRowsFromTable(existing) : [], existingNote);
1603
+ const storeSerialized = String(textarea.value || "").trim();
1604
+ if (existing && existingSerialized === storeSerialized) return;
1605
+ const payload = parseExpenseDetailPayload(storeSerialized);
1606
+ host.innerHTML = buildExpenseDetailTableHtml(textarea.id, payload.rows, payload.note);
1607
+ syncExpenseDetailStoreFromTable(host.querySelector(`[data-detail-table='${textarea.id}']`));
1460
1608
  }
1461
1609
 
1462
1610
  function collectExpenseDetailUiMeta(spouseKey, idx) {
@@ -2938,7 +3086,8 @@ const defaultExpenseItems = [
2938
3086
  <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
3087
  </div>
2940
3088
  <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>
3089
+ <div id="c1d_${idx}TableHost"></div>
3090
+ <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
3091
  <div class="spese-detail-counter" id="c1d_${idx}Counter" aria-live="polite"></div>
2943
3092
  </div>
2944
3093
  <span class="spese-partial" id="p1_${idx}" title="${tr("expensePartialTitle")}">${tr("expensePartialLabel")}: ${eurTiny(0)}</span>
@@ -2951,7 +3100,8 @@ const defaultExpenseItems = [
2951
3100
  <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
3101
  </div>
2953
3102
  <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>
3103
+ <div id="c2d_${idx}TableHost"></div>
3104
+ <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
3105
  <div class="spese-detail-counter" id="c2d_${idx}Counter" aria-live="polite"></div>
2956
3106
  </div>
2957
3107
  <span class="spese-partial" id="p2_${idx}" title="${tr("expensePartialTitle")}">${tr("expensePartialLabel")}: ${eurTiny(0)}</span>
@@ -2966,6 +3116,9 @@ const defaultExpenseItems = [
2966
3116
  rowsSpese.querySelectorAll("textarea.spese-detail-text").forEach((el) => {
2967
3117
  updateExpenseDetailTextareaUi(el);
2968
3118
  });
3119
+ rowsSpese.querySelectorAll("textarea.spese-detail-store").forEach((el) => {
3120
+ syncExpenseDetailTableFromStore(el);
3121
+ });
2969
3122
  refreshExpenseDetailButtonState();
2970
3123
  }
2971
3124
 
@@ -6317,12 +6470,50 @@ ${scenarioLab.length ? `
6317
6470
  detailBtn.classList.toggle("is-open", willOpen);
6318
6471
  if (target) {
6319
6472
  updateExpenseDetailTextareaUi(target);
6320
- if (willOpen) target.focus();
6473
+ if (willOpen) {
6474
+ const firstField = document.querySelector(`#${targetId}TableHost .spese-detail-cell-input`);
6475
+ if (firstField) firstField.focus();
6476
+ }
6321
6477
  }
6322
6478
  }
6323
6479
  return;
6324
6480
  }
6325
6481
 
6482
+ const addRowBtn = e.target && e.target.closest("button[data-row-add]");
6483
+ if (addRowBtn) {
6484
+ const textareaId = String(addRowBtn.getAttribute("data-row-add") || "");
6485
+ const textarea = textareaId ? document.getElementById(textareaId) : null;
6486
+ if (!textarea) return;
6487
+ const payload = parseExpenseDetailPayload(textarea.value);
6488
+ const rows = payload.rows;
6489
+ if (rows.length < EXPENSE_DETAIL_MAX_ROWS) {
6490
+ rows.push({ what: "", amount: "", due: "" });
6491
+ textarea.value = serializeExpenseDetailPayload(rows, payload.note);
6492
+ syncExpenseDetailTableFromStore(textarea);
6493
+ refreshExpenseDetailButtonState();
6494
+ }
6495
+ return;
6496
+ }
6497
+
6498
+ const removeRowBtn = e.target && e.target.closest("button[data-row-remove]");
6499
+ if (removeRowBtn) {
6500
+ const tableWrap = removeRowBtn.closest("[data-detail-table]");
6501
+ const rowEl = removeRowBtn.closest("tr");
6502
+ if (!tableWrap || !rowEl) return;
6503
+ const rows = readExpenseDetailRowsFromTable(tableWrap);
6504
+ const rowEls = Array.from(tableWrap.querySelectorAll("tbody tr"));
6505
+ const removeIdx = rowEls.indexOf(rowEl);
6506
+ if (removeIdx >= 0) rows.splice(removeIdx, 1);
6507
+ const nextRows = rows.length ? rows : [{ what: "", amount: "", due: "" }];
6508
+ const textareaId = String(tableWrap.getAttribute("data-detail-table") || "");
6509
+ const textarea = textareaId ? document.getElementById(textareaId) : null;
6510
+ if (!textarea) return;
6511
+ textarea.value = serializeExpenseDetailRows(nextRows);
6512
+ syncExpenseDetailTableFromStore(textarea);
6513
+ refreshExpenseDetailButtonState();
6514
+ return;
6515
+ }
6516
+
6326
6517
  const btn = e.target && e.target.closest("button[data-remove-expense-idx]");
6327
6518
  if (!btn) return;
6328
6519
  const idx = Number(btn.getAttribute("data-remove-expense-idx"));
@@ -6333,6 +6524,18 @@ ${scenarioLab.length ? `
6333
6524
  if (e.target && e.target.matches("textarea.spese-detail-text")) {
6334
6525
  updateExpenseDetailTextareaUi(e.target);
6335
6526
  refreshExpenseDetailButtonState();
6527
+ return;
6528
+ }
6529
+ if (e.target && e.target.matches(".spese-detail-cell-input")) {
6530
+ const tableWrap = e.target.closest("[data-detail-table]");
6531
+ syncExpenseDetailStoreFromTable(tableWrap);
6532
+ refreshExpenseDetailButtonState();
6533
+ return;
6534
+ }
6535
+ if (e.target && e.target.matches(".spese-detail-note")) {
6536
+ const tableWrap = e.target.closest("[data-detail-table]");
6537
+ syncExpenseDetailStoreFromTable(tableWrap);
6538
+ refreshExpenseDetailButtonState();
6336
6539
  }
6337
6540
  });
6338
6541
 
@@ -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.10"></script>
634
634
  </body>
635
635
  </html>
636
636
 
@@ -1523,6 +1523,89 @@
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
+
1595
+ .spese-detail-note {
1596
+ width: 100%;
1597
+ margin-top: 6px;
1598
+ border: 1px solid #bfd5d0;
1599
+ border-radius: 6px;
1600
+ background: #ffffff;
1601
+ color: #214b47;
1602
+ font-size: 0.72rem;
1603
+ line-height: 1.3;
1604
+ padding: 6px 7px;
1605
+ resize: vertical;
1606
+ min-height: 46px;
1607
+ }
1608
+
1526
1609
  .spese-detail-counter {
1527
1610
  margin-top: 3px;
1528
1611
  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.10",
4
4
  "description": "Frontend + backend architecture for the mantenimento calculator",
5
5
  "type": "commonjs",
6
6
  "main": "backend/calculate-model.js",