mantenimento-app 2.4.8 → 2.4.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app.js +161 -3
- package/frontend/public/app.js +161 -3
- package/frontend/public/index.html +1 -1
- package/frontend/public/styles.css +69 -0
- package/package.json +1 -1
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
|
-
<
|
|
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
|
-
<
|
|
3065
|
+
<div id="c2d_${idx}TableHost"></div>
|
|
3066
|
+
<textarea id="c2d_${idx}" class="spese-detail-text spese-detail-store" rows="2" maxlength="${EXPENSE_DETAIL_MAX_CHARS}" placeholder="${escapeHtml(tr("expenseDetailPlaceholder"))}" aria-describedby="c2d_${idx}Counter"></textarea>
|
|
2955
3067
|
<div class="spese-detail-counter" id="c2d_${idx}Counter" aria-live="polite"></div>
|
|
2956
3068
|
</div>
|
|
2957
3069
|
<span class="spese-partial" id="p2_${idx}" title="${tr("expensePartialTitle")}">${tr("expensePartialLabel")}: ${eurTiny(0)}</span>
|
|
@@ -2966,6 +3078,9 @@ const defaultExpenseItems = [
|
|
|
2966
3078
|
rowsSpese.querySelectorAll("textarea.spese-detail-text").forEach((el) => {
|
|
2967
3079
|
updateExpenseDetailTextareaUi(el);
|
|
2968
3080
|
});
|
|
3081
|
+
rowsSpese.querySelectorAll("textarea.spese-detail-store").forEach((el) => {
|
|
3082
|
+
syncExpenseDetailTableFromStore(el);
|
|
3083
|
+
});
|
|
2969
3084
|
refreshExpenseDetailButtonState();
|
|
2970
3085
|
}
|
|
2971
3086
|
|
|
@@ -6317,12 +6432,49 @@ ${scenarioLab.length ? `
|
|
|
6317
6432
|
detailBtn.classList.toggle("is-open", willOpen);
|
|
6318
6433
|
if (target) {
|
|
6319
6434
|
updateExpenseDetailTextareaUi(target);
|
|
6320
|
-
if (willOpen)
|
|
6435
|
+
if (willOpen) {
|
|
6436
|
+
const firstField = document.querySelector(`#${targetId}TableHost .spese-detail-cell-input`);
|
|
6437
|
+
if (firstField) firstField.focus();
|
|
6438
|
+
}
|
|
6321
6439
|
}
|
|
6322
6440
|
}
|
|
6323
6441
|
return;
|
|
6324
6442
|
}
|
|
6325
6443
|
|
|
6444
|
+
const addRowBtn = e.target && e.target.closest("button[data-row-add]");
|
|
6445
|
+
if (addRowBtn) {
|
|
6446
|
+
const textareaId = String(addRowBtn.getAttribute("data-row-add") || "");
|
|
6447
|
+
const textarea = textareaId ? document.getElementById(textareaId) : null;
|
|
6448
|
+
if (!textarea) return;
|
|
6449
|
+
const rows = parseExpenseDetailRows(textarea.value);
|
|
6450
|
+
if (rows.length < EXPENSE_DETAIL_MAX_ROWS) {
|
|
6451
|
+
rows.push({ what: "", amount: "", due: "" });
|
|
6452
|
+
textarea.value = serializeExpenseDetailRows(rows);
|
|
6453
|
+
syncExpenseDetailTableFromStore(textarea);
|
|
6454
|
+
refreshExpenseDetailButtonState();
|
|
6455
|
+
}
|
|
6456
|
+
return;
|
|
6457
|
+
}
|
|
6458
|
+
|
|
6459
|
+
const removeRowBtn = e.target && e.target.closest("button[data-row-remove]");
|
|
6460
|
+
if (removeRowBtn) {
|
|
6461
|
+
const tableWrap = removeRowBtn.closest("[data-detail-table]");
|
|
6462
|
+
const rowEl = removeRowBtn.closest("tr");
|
|
6463
|
+
if (!tableWrap || !rowEl) return;
|
|
6464
|
+
const rows = readExpenseDetailRowsFromTable(tableWrap);
|
|
6465
|
+
const rowEls = Array.from(tableWrap.querySelectorAll("tbody tr"));
|
|
6466
|
+
const removeIdx = rowEls.indexOf(rowEl);
|
|
6467
|
+
if (removeIdx >= 0) rows.splice(removeIdx, 1);
|
|
6468
|
+
const nextRows = rows.length ? rows : [{ what: "", amount: "", due: "" }];
|
|
6469
|
+
const textareaId = String(tableWrap.getAttribute("data-detail-table") || "");
|
|
6470
|
+
const textarea = textareaId ? document.getElementById(textareaId) : null;
|
|
6471
|
+
if (!textarea) return;
|
|
6472
|
+
textarea.value = serializeExpenseDetailRows(nextRows);
|
|
6473
|
+
syncExpenseDetailTableFromStore(textarea);
|
|
6474
|
+
refreshExpenseDetailButtonState();
|
|
6475
|
+
return;
|
|
6476
|
+
}
|
|
6477
|
+
|
|
6326
6478
|
const btn = e.target && e.target.closest("button[data-remove-expense-idx]");
|
|
6327
6479
|
if (!btn) return;
|
|
6328
6480
|
const idx = Number(btn.getAttribute("data-remove-expense-idx"));
|
|
@@ -6333,6 +6485,12 @@ ${scenarioLab.length ? `
|
|
|
6333
6485
|
if (e.target && e.target.matches("textarea.spese-detail-text")) {
|
|
6334
6486
|
updateExpenseDetailTextareaUi(e.target);
|
|
6335
6487
|
refreshExpenseDetailButtonState();
|
|
6488
|
+
return;
|
|
6489
|
+
}
|
|
6490
|
+
if (e.target && e.target.matches(".spese-detail-cell-input")) {
|
|
6491
|
+
const tableWrap = e.target.closest("[data-detail-table]");
|
|
6492
|
+
syncExpenseDetailStoreFromTable(tableWrap);
|
|
6493
|
+
refreshExpenseDetailButtonState();
|
|
6336
6494
|
}
|
|
6337
6495
|
});
|
|
6338
6496
|
|
package/frontend/public/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
|
-
<
|
|
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
|
-
<
|
|
3065
|
+
<div id="c2d_${idx}TableHost"></div>
|
|
3066
|
+
<textarea id="c2d_${idx}" class="spese-detail-text spese-detail-store" rows="2" maxlength="${EXPENSE_DETAIL_MAX_CHARS}" placeholder="${escapeHtml(tr("expenseDetailPlaceholder"))}" aria-describedby="c2d_${idx}Counter"></textarea>
|
|
2955
3067
|
<div class="spese-detail-counter" id="c2d_${idx}Counter" aria-live="polite"></div>
|
|
2956
3068
|
</div>
|
|
2957
3069
|
<span class="spese-partial" id="p2_${idx}" title="${tr("expensePartialTitle")}">${tr("expensePartialLabel")}: ${eurTiny(0)}</span>
|
|
@@ -2966,6 +3078,9 @@ const defaultExpenseItems = [
|
|
|
2966
3078
|
rowsSpese.querySelectorAll("textarea.spese-detail-text").forEach((el) => {
|
|
2967
3079
|
updateExpenseDetailTextareaUi(el);
|
|
2968
3080
|
});
|
|
3081
|
+
rowsSpese.querySelectorAll("textarea.spese-detail-store").forEach((el) => {
|
|
3082
|
+
syncExpenseDetailTableFromStore(el);
|
|
3083
|
+
});
|
|
2969
3084
|
refreshExpenseDetailButtonState();
|
|
2970
3085
|
}
|
|
2971
3086
|
|
|
@@ -6317,12 +6432,49 @@ ${scenarioLab.length ? `
|
|
|
6317
6432
|
detailBtn.classList.toggle("is-open", willOpen);
|
|
6318
6433
|
if (target) {
|
|
6319
6434
|
updateExpenseDetailTextareaUi(target);
|
|
6320
|
-
if (willOpen)
|
|
6435
|
+
if (willOpen) {
|
|
6436
|
+
const firstField = document.querySelector(`#${targetId}TableHost .spese-detail-cell-input`);
|
|
6437
|
+
if (firstField) firstField.focus();
|
|
6438
|
+
}
|
|
6321
6439
|
}
|
|
6322
6440
|
}
|
|
6323
6441
|
return;
|
|
6324
6442
|
}
|
|
6325
6443
|
|
|
6444
|
+
const addRowBtn = e.target && e.target.closest("button[data-row-add]");
|
|
6445
|
+
if (addRowBtn) {
|
|
6446
|
+
const textareaId = String(addRowBtn.getAttribute("data-row-add") || "");
|
|
6447
|
+
const textarea = textareaId ? document.getElementById(textareaId) : null;
|
|
6448
|
+
if (!textarea) return;
|
|
6449
|
+
const rows = parseExpenseDetailRows(textarea.value);
|
|
6450
|
+
if (rows.length < EXPENSE_DETAIL_MAX_ROWS) {
|
|
6451
|
+
rows.push({ what: "", amount: "", due: "" });
|
|
6452
|
+
textarea.value = serializeExpenseDetailRows(rows);
|
|
6453
|
+
syncExpenseDetailTableFromStore(textarea);
|
|
6454
|
+
refreshExpenseDetailButtonState();
|
|
6455
|
+
}
|
|
6456
|
+
return;
|
|
6457
|
+
}
|
|
6458
|
+
|
|
6459
|
+
const removeRowBtn = e.target && e.target.closest("button[data-row-remove]");
|
|
6460
|
+
if (removeRowBtn) {
|
|
6461
|
+
const tableWrap = removeRowBtn.closest("[data-detail-table]");
|
|
6462
|
+
const rowEl = removeRowBtn.closest("tr");
|
|
6463
|
+
if (!tableWrap || !rowEl) return;
|
|
6464
|
+
const rows = readExpenseDetailRowsFromTable(tableWrap);
|
|
6465
|
+
const rowEls = Array.from(tableWrap.querySelectorAll("tbody tr"));
|
|
6466
|
+
const removeIdx = rowEls.indexOf(rowEl);
|
|
6467
|
+
if (removeIdx >= 0) rows.splice(removeIdx, 1);
|
|
6468
|
+
const nextRows = rows.length ? rows : [{ what: "", amount: "", due: "" }];
|
|
6469
|
+
const textareaId = String(tableWrap.getAttribute("data-detail-table") || "");
|
|
6470
|
+
const textarea = textareaId ? document.getElementById(textareaId) : null;
|
|
6471
|
+
if (!textarea) return;
|
|
6472
|
+
textarea.value = serializeExpenseDetailRows(nextRows);
|
|
6473
|
+
syncExpenseDetailTableFromStore(textarea);
|
|
6474
|
+
refreshExpenseDetailButtonState();
|
|
6475
|
+
return;
|
|
6476
|
+
}
|
|
6477
|
+
|
|
6326
6478
|
const btn = e.target && e.target.closest("button[data-remove-expense-idx]");
|
|
6327
6479
|
if (!btn) return;
|
|
6328
6480
|
const idx = Number(btn.getAttribute("data-remove-expense-idx"));
|
|
@@ -6333,6 +6485,12 @@ ${scenarioLab.length ? `
|
|
|
6333
6485
|
if (e.target && e.target.matches("textarea.spese-detail-text")) {
|
|
6334
6486
|
updateExpenseDetailTextareaUi(e.target);
|
|
6335
6487
|
refreshExpenseDetailButtonState();
|
|
6488
|
+
return;
|
|
6489
|
+
}
|
|
6490
|
+
if (e.target && e.target.matches(".spese-detail-cell-input")) {
|
|
6491
|
+
const tableWrap = e.target.closest("[data-detail-table]");
|
|
6492
|
+
syncExpenseDetailStoreFromTable(tableWrap);
|
|
6493
|
+
refreshExpenseDetailButtonState();
|
|
6336
6494
|
}
|
|
6337
6495
|
});
|
|
6338
6496
|
|
|
@@ -1523,6 +1523,75 @@
|
|
|
1523
1523
|
color: #204644;
|
|
1524
1524
|
}
|
|
1525
1525
|
|
|
1526
|
+
.spese-detail-store {
|
|
1527
|
+
display: none;
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
.spese-detail-grid-wrap {
|
|
1531
|
+
border: 1px solid #c8dad4;
|
|
1532
|
+
border-radius: 8px;
|
|
1533
|
+
background: #f9fcfb;
|
|
1534
|
+
padding: 6px;
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
.spese-detail-grid {
|
|
1538
|
+
width: 100%;
|
|
1539
|
+
border-collapse: collapse;
|
|
1540
|
+
table-layout: fixed;
|
|
1541
|
+
font-size: 0.72rem;
|
|
1542
|
+
color: #204644;
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
.spese-detail-grid th {
|
|
1546
|
+
text-align: left;
|
|
1547
|
+
font-size: 0.68rem;
|
|
1548
|
+
font-weight: 800;
|
|
1549
|
+
color: #3b6964;
|
|
1550
|
+
padding: 2px 4px 5px;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
.spese-detail-grid td {
|
|
1554
|
+
padding: 3px;
|
|
1555
|
+
vertical-align: middle;
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
.spese-detail-cell-input {
|
|
1559
|
+
width: 100%;
|
|
1560
|
+
border: 1px solid #bfd5d0;
|
|
1561
|
+
border-radius: 6px;
|
|
1562
|
+
background: #ffffff;
|
|
1563
|
+
color: #214b47;
|
|
1564
|
+
font-size: 0.72rem;
|
|
1565
|
+
padding: 4px 6px;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
.spese-detail-actions-cell {
|
|
1569
|
+
width: 30px;
|
|
1570
|
+
text-align: center;
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
.spese-detail-row-remove {
|
|
1574
|
+
width: 24px;
|
|
1575
|
+
height: 24px;
|
|
1576
|
+
border: 1px solid #c4d7d2;
|
|
1577
|
+
border-radius: 6px;
|
|
1578
|
+
background: #ffffff;
|
|
1579
|
+
color: #355b57;
|
|
1580
|
+
font-weight: 800;
|
|
1581
|
+
cursor: pointer;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
.spese-detail-row-remove:disabled {
|
|
1585
|
+
opacity: 0.45;
|
|
1586
|
+
cursor: not-allowed;
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
.spese-detail-add-row {
|
|
1590
|
+
margin-top: 6px;
|
|
1591
|
+
padding: 3px 8px;
|
|
1592
|
+
font-size: 0.67rem;
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1526
1595
|
.spese-detail-counter {
|
|
1527
1596
|
margin-top: 3px;
|
|
1528
1597
|
font-size: 0.66rem;
|