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 +206 -3
- package/frontend/public/app.js +206 -3
- package/frontend/public/index.html +1 -1
- package/frontend/public/styles.css +83 -0
- package/package.json +1 -1
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
|
-
<
|
|
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
|
-
<
|
|
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)
|
|
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
|
|
package/frontend/public/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
|
-
<
|
|
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
|
-
<
|
|
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)
|
|
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
|
|
|
@@ -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;
|