diff --git a/frontend/apps/ledger/assets/ledger-override.js b/frontend/apps/ledger/assets/ledger-override.js
index 853e51c..efa003b 100644
--- a/frontend/apps/ledger/assets/ledger-override.js
+++ b/frontend/apps/ledger/assets/ledger-override.js
@@ -10,6 +10,10 @@
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
}
+ function bgNormalizeText(value) {
+ return String(value || "").replace(/\s+/g, " ").trim();
+ }
+
function bgParseDate(value) {
var text = String(value || "").trim();
if (!text) return null;
@@ -36,6 +40,44 @@
return bgYearFromText(row && row.eDate);
}
+ function normalizedCategory(row) {
+ var category = bgNormalizeText(row && row.cat);
+ if (category.indexOf("가족사") >= 0) return "가족사";
+ var corp = bgNormalizeText(row && row.corp);
+ if (corp && corp !== "바론") return "가족사";
+ return "바론";
+ }
+
+ function isSupportServiceRow(row) {
+ return bgNormalizeText(row && row.name).indexOf("경영 및 기술지원 서비스") >= 0;
+ }
+
+ function projectTypeLabel(row) {
+ if (isSupportServiceRow(row)) return "기술지원서비스";
+ return normalizedCategory(row);
+ }
+
+ function projectTypeRank(row) {
+ var label = projectTypeLabel(row);
+ if (label === "바론") return 0;
+ if (label === "가족사") return 1;
+ return 2;
+ }
+
+ function normalizeStatusLabel(status) {
+ var value = bgNormalizeText(status);
+ if (!value) return "-";
+ if (value === "완료") return "준공";
+ if (value === "진행") return "과업진행중";
+ if (value === "대기") return "계약대기";
+ if (value === "중지") return "과업중지";
+ return value;
+ }
+
+ function rowStatusLabel(row) {
+ return normalizeStatusLabel(row && row.status);
+ }
+
function bgDisplayYear(row) {
var start = bgStartYear(row);
if (start) return start;
@@ -82,7 +124,7 @@
if (!(cutoff && yearStart && startDate)) return false;
if (startDate > cutoff) return false;
if (endDate && endDate < yearStart) return false;
- return !(endDate && endDate <= cutoff);
+ return rowStatusLabel(row) === "과업진행중";
}
function bgStartedInYear(row, year) {
@@ -96,7 +138,7 @@
var cutoff = bgYearCutoff(year);
var endDate = bgDateOrYearEnd(row);
if (!(cutoff && endDate)) return false;
- return endDate.getFullYear() === Number(year || 0) && endDate <= cutoff;
+ return rowStatusLabel(row) === "준공" && endDate.getFullYear() === Number(year || 0) && endDate <= cutoff;
}
function bgYearRange(row) {
@@ -140,16 +182,32 @@
}, { c: 0, col: 0, recv: 0 });
}
- function isSupportServiceRow(row) {
- var category = String((row && row.cat) || "").trim();
- return category.indexOf("경영지원") >= 0 || category.indexOf("서비스") >= 0;
+ function isBaronProjectRow(row) {
+ return projectTypeLabel(row) === "바론";
}
- function isBaronProjectRow(row) {
- var category = String((row && row.cat) || "").trim();
- if (category.indexOf("바론") < 0) return false;
- if (isSupportServiceRow(row)) return false;
- return true;
+ function isSoftwareProjectRow(row) {
+ var name = bgNormalizeText(row && row.name).toLowerCase();
+ if (!name) return false;
+ return [
+ "프로그램",
+ "소프트웨어",
+ "software",
+ " sw",
+ "sw ",
+ "erp",
+ "tova",
+ "ipipe",
+ "eg-bim",
+ "cad"
+ ].some(function (keyword) {
+ return name.indexOf(keyword) >= 0;
+ });
+ }
+
+ function shouldSinkProjectName(row) {
+ var name = bgNormalizeText(row && row.name);
+ return name.indexOf("프로그램") >= 0 || name.indexOf("사용") >= 0;
}
function bgSummarize(rows, selectedYear) {
@@ -158,14 +216,18 @@
var activeRows = items.filter(function (row) { return bgActiveInYear(row, targetYear); });
var newProjectRows = items.filter(function (row) { return bgStartedInYear(row, targetYear); });
var completedRows = items.filter(function (row) { return bgCompletedInYear(row, targetYear); });
- var managementRows = newProjectRows.filter(isSupportServiceRow);
+ var managementRows = activeRows.filter(isSupportServiceRow);
+ var baronActiveRows = activeRows.filter(isBaronProjectRow);
return {
targetYear: targetYear,
activeRows: activeRows,
newProjectRows: newProjectRows,
completedRows: completedRows,
managementRows: managementRows,
- managementTotals: bgTotals(managementRows)
+ managementTotals: bgTotals(managementRows),
+ baronActiveRows: baronActiveRows,
+ baronProjectTotals: bgTotals(baronActiveRows),
+ baronSoftwareCount: baronActiveRows.filter(isSoftwareProjectRow).length
};
}
@@ -177,13 +239,6 @@
return bgActiveInYear(row, selectedYear);
}
- function normalizeStatusLabel(status) {
- var value = String(status || "").trim();
- if (!value) return "-";
- if (value.indexOf("진행") >= 0) return "과업 진행중";
- return value;
- }
-
function formatSplitPercent(split) {
var numeric = parseFloat(String(split || "").replace(/[^0-9.\-]/g, ""));
if (!Number.isFinite(numeric) || numeric === 0) return "분담율 -%";
@@ -204,17 +259,57 @@
}
function groupSortRank(row) {
- var selectedYear = Number((S.dashboard && S.dashboard.year) || projectYear(row) || 0);
var startYear = Number(projectYear(row) || 0);
- if (typeof bgCompletedInYear === "function" && bgCompletedInYear(row, String(selectedYear))) return 9999;
if (!startYear) return 9998;
return startYear;
}
function tableGroupLabel(row) {
var startYear = projectYear(row);
- if (/^20\d{2}$/.test(startYear)) return startYear + "년";
- return "미지정";
+ if (/^20\d{2}$/.test(startYear)) return startYear + " " + projectTypeLabel(row);
+ return "미지정 " + projectTypeLabel(row);
+ }
+
+ function compareDashboardRows(a, b) {
+ var typeRankDiff = projectTypeRank(a) - projectTypeRank(b);
+ if (typeRankDiff !== 0) return typeRankDiff;
+ var groupDiff = groupSortRank(a) - groupSortRank(b);
+ if (groupDiff !== 0) return groupDiff;
+ var sinkDiff = Number(shouldSinkProjectName(a)) - Number(shouldSinkProjectName(b));
+ if (sinkDiff !== 0) return sinkDiff;
+ return bgNormalizeText(a && a.name).localeCompare(bgNormalizeText(b && b.name), "ko");
+ }
+
+ function filterCategoryLabel(row) {
+ return projectTypeLabel(row);
+ }
+
+ function filterClientLabel(row) {
+ if (typeof normalizeClientDisplay === "function") {
+ return normalizeClientDisplay(row && row.client);
+ }
+ return bgNormalizeText(row && row.client) || "-";
+ }
+
+ function filterOrderLabel(row) {
+ return bgNormalizeText(row && row.order) || "-";
+ }
+
+ function receivableFilterLabel(row) {
+ var amount = Number((row && row.recv) || 0);
+ if (amount <= 0) return "미수 없음";
+ if (amount < 10000000) return "1천만 미만";
+ if (amount < 100000000) return "1천만 이상";
+ return "1억 이상";
+ }
+
+ function refreshFilterDom() {
+ E.filterButtons = Object.fromEntries(Array.from(document.querySelectorAll(".th-trigger")).map(function (el) {
+ return [el.dataset.filter, el];
+ }));
+ E.filterMenus = Object.fromEntries(Array.from(document.querySelectorAll(".th-menu")).map(function (el) {
+ return [el.dataset.filter, el];
+ }));
}
function renderLedgerTable() {
@@ -236,12 +331,7 @@
+ '
'
+ '
";
var cards = [
- { label: summary.targetYear + "년 프로젝트", value: visibleBaronProjectRows.length.toLocaleString("ko-KR") + " 건", note: "" },
- { label: "계약금", value: won(totals.c), note: "" },
+ { label: summary.targetYear + "년 프로젝트", value: summary.baronActiveRows.length.toLocaleString("ko-KR") + "건 (" + summary.baronSoftwareCount.toLocaleString("ko-KR") + "건)", note: "바론 수행중 프로젝트 / SW" },
+ { label: "계약금 (VAT별도)", value: won(totals.c), note: "" },
{ label: "수금액", value: won(totals.col), note: "" },
{ label: "미수금", value: won(totals.recv), note: "" },
- { label: "수금률(%)", value: totalRate.toFixed(2) + "%", note: "" },
+ { label: "수금율", value: totalRate.toFixed(2) + "%", note: "계약금 대비 수금액" },
{ label: "경영지원서비스 금액", value: won(summary.managementTotals.c), note: "", className: "management" }
];
E.cards.innerHTML = toolbarHtml + cards.map(function (card) {
@@ -429,9 +519,62 @@
S.rows = searched.filter(function (r) {
return bgMatches(r) && matchesColumnFilters(r);
});
+ S.rows.sort(compareDashboardRows);
render();
};
+ filterDefinitions = function () {
+ return [
+ { key: "cat", map: filterCategoryLabel },
+ { key: "code", map: function (r) { return r.code || "-"; } },
+ { key: "name", map: function (r) { return r.name || "-"; } },
+ { key: "client", map: filterClientLabel },
+ { key: "order", map: filterOrderLabel },
+ { key: "status", map: rowStatusLabel },
+ { key: "amount", map: amountFilterLabel },
+ { key: "outsource", map: outsourceFilterLabel },
+ { key: "receivable", map: receivableFilterLabel },
+ { key: "collected", map: collectedFilterLabel },
+ { key: "rate", map: rateFilterLabel }
+ ];
+ };
+
+ updateFilterButtons = function () {
+ Object.keys(E.filterButtons || {}).forEach(function (key) {
+ var btn = E.filterButtons[key];
+ if (!btn) return;
+ var active = !!S.filters[key];
+ btn.classList.toggle("active", active);
+ btn.title = active ? ((btn.dataset.label || "") + ": " + S.filters[key]) : (btn.dataset.label || "");
+ var mark = btn.querySelector(".th-mark");
+ if (mark) mark.textContent = active ? "•" : "";
+ });
+ };
+
+ syncColumnFilters = function (rows) {
+ filterDefinitions().forEach(function (def) {
+ var values = uniqueFilterValues(rows, def.map);
+ if (S.filters[def.key] && !values.includes(S.filters[def.key])) delete S.filters[def.key];
+ renderFilterMenu(def.key, values);
+ });
+ updateFilterButtons();
+ };
+
+ matchesColumnFilters = function (r) {
+ if (S.filters.cat && filterCategoryLabel(r) !== S.filters.cat) return false;
+ if (S.filters.code && (r.code || "-") !== S.filters.code) return false;
+ if (S.filters.name && (r.name || "-") !== S.filters.name) return false;
+ if (S.filters.client && filterClientLabel(r) !== S.filters.client) return false;
+ if (S.filters.order && filterOrderLabel(r) !== S.filters.order) return false;
+ if (S.filters.status && rowStatusLabel(r) !== S.filters.status) return false;
+ if (S.filters.amount && amountFilterLabel(r) !== S.filters.amount) return false;
+ if (S.filters.outsource && outsourceFilterLabel(r) !== S.filters.outsource) return false;
+ if (S.filters.receivable && receivableFilterLabel(r) !== S.filters.receivable) return false;
+ if (S.filters.collected && collectedFilterLabel(r) !== S.filters.collected) return false;
+ if (S.filters.rate && rateFilterLabel(r) !== S.filters.rate) return false;
+ return true;
+ };
+
if (E.cards && !E.cards.dataset.dashboardBound) {
E.cards.dataset.dashboardBound = "true";
E.cards.addEventListener("click", function (event) {
@@ -472,6 +615,26 @@
});
}
+ var panel = document.querySelector(".panel");
+ if (panel && !panel.dataset.ledgerFilterBound) {
+ panel.dataset.ledgerFilterBound = "true";
+ panel.addEventListener("click", function (event) {
+ var trigger = event.target && event.target.closest ? event.target.closest(".th-trigger") : null;
+ if (trigger) {
+ refreshFilterDom();
+ event.stopPropagation();
+ toggleFilterMenu(trigger.dataset.filter);
+ return;
+ }
+ var option = event.target && event.target.closest ? event.target.closest("button[data-filter-value]") : null;
+ var menu = event.target && event.target.closest ? event.target.closest(".th-menu") : null;
+ if (option && menu) {
+ event.stopPropagation();
+ setFilterValue(menu.dataset.filter, option.getAttribute("data-filter-value") || "");
+ }
+ });
+ }
+
setTimeout(function () {
try {
filter();
diff --git a/incoming-files/served/ledger/ledger-override.js b/incoming-files/served/ledger/ledger-override.js
index 853e51c..efa003b 100644
--- a/incoming-files/served/ledger/ledger-override.js
+++ b/incoming-files/served/ledger/ledger-override.js
@@ -10,6 +10,10 @@
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
}
+ function bgNormalizeText(value) {
+ return String(value || "").replace(/\s+/g, " ").trim();
+ }
+
function bgParseDate(value) {
var text = String(value || "").trim();
if (!text) return null;
@@ -36,6 +40,44 @@
return bgYearFromText(row && row.eDate);
}
+ function normalizedCategory(row) {
+ var category = bgNormalizeText(row && row.cat);
+ if (category.indexOf("가족사") >= 0) return "가족사";
+ var corp = bgNormalizeText(row && row.corp);
+ if (corp && corp !== "바론") return "가족사";
+ return "바론";
+ }
+
+ function isSupportServiceRow(row) {
+ return bgNormalizeText(row && row.name).indexOf("경영 및 기술지원 서비스") >= 0;
+ }
+
+ function projectTypeLabel(row) {
+ if (isSupportServiceRow(row)) return "기술지원서비스";
+ return normalizedCategory(row);
+ }
+
+ function projectTypeRank(row) {
+ var label = projectTypeLabel(row);
+ if (label === "바론") return 0;
+ if (label === "가족사") return 1;
+ return 2;
+ }
+
+ function normalizeStatusLabel(status) {
+ var value = bgNormalizeText(status);
+ if (!value) return "-";
+ if (value === "완료") return "준공";
+ if (value === "진행") return "과업진행중";
+ if (value === "대기") return "계약대기";
+ if (value === "중지") return "과업중지";
+ return value;
+ }
+
+ function rowStatusLabel(row) {
+ return normalizeStatusLabel(row && row.status);
+ }
+
function bgDisplayYear(row) {
var start = bgStartYear(row);
if (start) return start;
@@ -82,7 +124,7 @@
if (!(cutoff && yearStart && startDate)) return false;
if (startDate > cutoff) return false;
if (endDate && endDate < yearStart) return false;
- return !(endDate && endDate <= cutoff);
+ return rowStatusLabel(row) === "과업진행중";
}
function bgStartedInYear(row, year) {
@@ -96,7 +138,7 @@
var cutoff = bgYearCutoff(year);
var endDate = bgDateOrYearEnd(row);
if (!(cutoff && endDate)) return false;
- return endDate.getFullYear() === Number(year || 0) && endDate <= cutoff;
+ return rowStatusLabel(row) === "준공" && endDate.getFullYear() === Number(year || 0) && endDate <= cutoff;
}
function bgYearRange(row) {
@@ -140,16 +182,32 @@
}, { c: 0, col: 0, recv: 0 });
}
- function isSupportServiceRow(row) {
- var category = String((row && row.cat) || "").trim();
- return category.indexOf("경영지원") >= 0 || category.indexOf("서비스") >= 0;
+ function isBaronProjectRow(row) {
+ return projectTypeLabel(row) === "바론";
}
- function isBaronProjectRow(row) {
- var category = String((row && row.cat) || "").trim();
- if (category.indexOf("바론") < 0) return false;
- if (isSupportServiceRow(row)) return false;
- return true;
+ function isSoftwareProjectRow(row) {
+ var name = bgNormalizeText(row && row.name).toLowerCase();
+ if (!name) return false;
+ return [
+ "프로그램",
+ "소프트웨어",
+ "software",
+ " sw",
+ "sw ",
+ "erp",
+ "tova",
+ "ipipe",
+ "eg-bim",
+ "cad"
+ ].some(function (keyword) {
+ return name.indexOf(keyword) >= 0;
+ });
+ }
+
+ function shouldSinkProjectName(row) {
+ var name = bgNormalizeText(row && row.name);
+ return name.indexOf("프로그램") >= 0 || name.indexOf("사용") >= 0;
}
function bgSummarize(rows, selectedYear) {
@@ -158,14 +216,18 @@
var activeRows = items.filter(function (row) { return bgActiveInYear(row, targetYear); });
var newProjectRows = items.filter(function (row) { return bgStartedInYear(row, targetYear); });
var completedRows = items.filter(function (row) { return bgCompletedInYear(row, targetYear); });
- var managementRows = newProjectRows.filter(isSupportServiceRow);
+ var managementRows = activeRows.filter(isSupportServiceRow);
+ var baronActiveRows = activeRows.filter(isBaronProjectRow);
return {
targetYear: targetYear,
activeRows: activeRows,
newProjectRows: newProjectRows,
completedRows: completedRows,
managementRows: managementRows,
- managementTotals: bgTotals(managementRows)
+ managementTotals: bgTotals(managementRows),
+ baronActiveRows: baronActiveRows,
+ baronProjectTotals: bgTotals(baronActiveRows),
+ baronSoftwareCount: baronActiveRows.filter(isSoftwareProjectRow).length
};
}
@@ -177,13 +239,6 @@
return bgActiveInYear(row, selectedYear);
}
- function normalizeStatusLabel(status) {
- var value = String(status || "").trim();
- if (!value) return "-";
- if (value.indexOf("진행") >= 0) return "과업 진행중";
- return value;
- }
-
function formatSplitPercent(split) {
var numeric = parseFloat(String(split || "").replace(/[^0-9.\-]/g, ""));
if (!Number.isFinite(numeric) || numeric === 0) return "분담율 -%";
@@ -204,17 +259,57 @@
}
function groupSortRank(row) {
- var selectedYear = Number((S.dashboard && S.dashboard.year) || projectYear(row) || 0);
var startYear = Number(projectYear(row) || 0);
- if (typeof bgCompletedInYear === "function" && bgCompletedInYear(row, String(selectedYear))) return 9999;
if (!startYear) return 9998;
return startYear;
}
function tableGroupLabel(row) {
var startYear = projectYear(row);
- if (/^20\d{2}$/.test(startYear)) return startYear + "년";
- return "미지정";
+ if (/^20\d{2}$/.test(startYear)) return startYear + " " + projectTypeLabel(row);
+ return "미지정 " + projectTypeLabel(row);
+ }
+
+ function compareDashboardRows(a, b) {
+ var typeRankDiff = projectTypeRank(a) - projectTypeRank(b);
+ if (typeRankDiff !== 0) return typeRankDiff;
+ var groupDiff = groupSortRank(a) - groupSortRank(b);
+ if (groupDiff !== 0) return groupDiff;
+ var sinkDiff = Number(shouldSinkProjectName(a)) - Number(shouldSinkProjectName(b));
+ if (sinkDiff !== 0) return sinkDiff;
+ return bgNormalizeText(a && a.name).localeCompare(bgNormalizeText(b && b.name), "ko");
+ }
+
+ function filterCategoryLabel(row) {
+ return projectTypeLabel(row);
+ }
+
+ function filterClientLabel(row) {
+ if (typeof normalizeClientDisplay === "function") {
+ return normalizeClientDisplay(row && row.client);
+ }
+ return bgNormalizeText(row && row.client) || "-";
+ }
+
+ function filterOrderLabel(row) {
+ return bgNormalizeText(row && row.order) || "-";
+ }
+
+ function receivableFilterLabel(row) {
+ var amount = Number((row && row.recv) || 0);
+ if (amount <= 0) return "미수 없음";
+ if (amount < 10000000) return "1천만 미만";
+ if (amount < 100000000) return "1천만 이상";
+ return "1억 이상";
+ }
+
+ function refreshFilterDom() {
+ E.filterButtons = Object.fromEntries(Array.from(document.querySelectorAll(".th-trigger")).map(function (el) {
+ return [el.dataset.filter, el];
+ }));
+ E.filterMenus = Object.fromEntries(Array.from(document.querySelectorAll(".th-menu")).map(function (el) {
+ return [el.dataset.filter, el];
+ }));
}
function renderLedgerTable() {
@@ -236,12 +331,7 @@
+ '
| '
+ "";
}
- var rows = (Array.isArray(S.viewRows) ? S.viewRows : []).slice().sort(function (a, b) {
- var ar = groupSortRank(a);
- var br = groupSortRank(b);
- if (ar !== br) return ar - br;
- return Number(b.recv || 0) - Number(a.recv || 0);
- });
+ var rows = (Array.isArray(S.viewRows) ? S.viewRows : []).slice().sort(compareDashboardRows);
S.viewRows = rows;
var lastGroupLabel = "";
E.tbody.innerHTML = rows.map(function (r) {
@@ -259,7 +349,7 @@
+ '
' + esc(r.periodText || "-") + ' | '
+ '
' + esc((r.client || "").trim() || "-") + ' ' + esc(formatSplitPercent(r.split)) + ' | '
+ '
' + esc(r.order || "-") + ' | '
- + '
= 0 ? 'ok' : '') + '">' + esc(normalizeStatusLabel(r.status)) + ' | '
+ + '
' + esc(rowStatusLabel(r)) + ' | '
+ '
' + esc(won(r.cSup || 0)) + ' | '
+ '
' + esc(r.outsourceCost ? won(r.outsourceCost) : "-") + ' | '
+ '
' + esc(won(r.recv || 0)) + ' | '
@@ -267,6 +357,8 @@
+ '
' + esc((Number(r.rate || 0)).toFixed(2) + "%") + ' | '
+ '';
}).join("");
+ refreshFilterDom();
+ if (typeof syncColumnFilters === "function") syncColumnFilters(S.all);
}
function renderCollectionBoard(r) {
@@ -379,10 +471,8 @@
}
var years = bgEnsureYear(S.all);
var summary = bgSummarize(S.all, S.dashboard.year);
- var rows = Array.isArray(S.rows) ? S.rows : [];
- var visibleBaronProjectRows = rows.filter(isBaronProjectRow);
- var totals = bgTotals(visibleBaronProjectRows);
- var totalRate = typeof rate === "function" ? rate("", totals.col, totals.col + totals.recv) : 0;
+ var totals = summary.baronProjectTotals;
+ var totalRate = totals.c > 0 ? (totals.col / totals.c) * 100 : 0;
var toolbarHtml = '
'
+ '
";
var cards = [
- { label: summary.targetYear + "년 프로젝트", value: visibleBaronProjectRows.length.toLocaleString("ko-KR") + " 건", note: "" },
- { label: "계약금", value: won(totals.c), note: "" },
+ { label: summary.targetYear + "년 프로젝트", value: summary.baronActiveRows.length.toLocaleString("ko-KR") + "건 (" + summary.baronSoftwareCount.toLocaleString("ko-KR") + "건)", note: "바론 수행중 프로젝트 / SW" },
+ { label: "계약금 (VAT별도)", value: won(totals.c), note: "" },
{ label: "수금액", value: won(totals.col), note: "" },
{ label: "미수금", value: won(totals.recv), note: "" },
- { label: "수금률(%)", value: totalRate.toFixed(2) + "%", note: "" },
+ { label: "수금율", value: totalRate.toFixed(2) + "%", note: "계약금 대비 수금액" },
{ label: "경영지원서비스 금액", value: won(summary.managementTotals.c), note: "", className: "management" }
];
E.cards.innerHTML = toolbarHtml + cards.map(function (card) {
@@ -429,9 +519,62 @@
S.rows = searched.filter(function (r) {
return bgMatches(r) && matchesColumnFilters(r);
});
+ S.rows.sort(compareDashboardRows);
render();
};
+ filterDefinitions = function () {
+ return [
+ { key: "cat", map: filterCategoryLabel },
+ { key: "code", map: function (r) { return r.code || "-"; } },
+ { key: "name", map: function (r) { return r.name || "-"; } },
+ { key: "client", map: filterClientLabel },
+ { key: "order", map: filterOrderLabel },
+ { key: "status", map: rowStatusLabel },
+ { key: "amount", map: amountFilterLabel },
+ { key: "outsource", map: outsourceFilterLabel },
+ { key: "receivable", map: receivableFilterLabel },
+ { key: "collected", map: collectedFilterLabel },
+ { key: "rate", map: rateFilterLabel }
+ ];
+ };
+
+ updateFilterButtons = function () {
+ Object.keys(E.filterButtons || {}).forEach(function (key) {
+ var btn = E.filterButtons[key];
+ if (!btn) return;
+ var active = !!S.filters[key];
+ btn.classList.toggle("active", active);
+ btn.title = active ? ((btn.dataset.label || "") + ": " + S.filters[key]) : (btn.dataset.label || "");
+ var mark = btn.querySelector(".th-mark");
+ if (mark) mark.textContent = active ? "•" : "";
+ });
+ };
+
+ syncColumnFilters = function (rows) {
+ filterDefinitions().forEach(function (def) {
+ var values = uniqueFilterValues(rows, def.map);
+ if (S.filters[def.key] && !values.includes(S.filters[def.key])) delete S.filters[def.key];
+ renderFilterMenu(def.key, values);
+ });
+ updateFilterButtons();
+ };
+
+ matchesColumnFilters = function (r) {
+ if (S.filters.cat && filterCategoryLabel(r) !== S.filters.cat) return false;
+ if (S.filters.code && (r.code || "-") !== S.filters.code) return false;
+ if (S.filters.name && (r.name || "-") !== S.filters.name) return false;
+ if (S.filters.client && filterClientLabel(r) !== S.filters.client) return false;
+ if (S.filters.order && filterOrderLabel(r) !== S.filters.order) return false;
+ if (S.filters.status && rowStatusLabel(r) !== S.filters.status) return false;
+ if (S.filters.amount && amountFilterLabel(r) !== S.filters.amount) return false;
+ if (S.filters.outsource && outsourceFilterLabel(r) !== S.filters.outsource) return false;
+ if (S.filters.receivable && receivableFilterLabel(r) !== S.filters.receivable) return false;
+ if (S.filters.collected && collectedFilterLabel(r) !== S.filters.collected) return false;
+ if (S.filters.rate && rateFilterLabel(r) !== S.filters.rate) return false;
+ return true;
+ };
+
if (E.cards && !E.cards.dataset.dashboardBound) {
E.cards.dataset.dashboardBound = "true";
E.cards.addEventListener("click", function (event) {
@@ -472,6 +615,26 @@
});
}
+ var panel = document.querySelector(".panel");
+ if (panel && !panel.dataset.ledgerFilterBound) {
+ panel.dataset.ledgerFilterBound = "true";
+ panel.addEventListener("click", function (event) {
+ var trigger = event.target && event.target.closest ? event.target.closest(".th-trigger") : null;
+ if (trigger) {
+ refreshFilterDom();
+ event.stopPropagation();
+ toggleFilterMenu(trigger.dataset.filter);
+ return;
+ }
+ var option = event.target && event.target.closest ? event.target.closest("button[data-filter-value]") : null;
+ var menu = event.target && event.target.closest ? event.target.closest(".th-menu") : null;
+ if (option && menu) {
+ event.stopPropagation();
+ setFilterValue(menu.dataset.filter, option.getAttribute("data-filter-value") || "");
+ }
+ });
+ }
+
setTimeout(function () {
try {
filter();
diff --git a/scripts/check_8081_smoke.sh b/scripts/check_8081_smoke.sh
index 56d8510..903245e 100644
--- a/scripts/check_8081_smoke.sh
+++ b/scripts/check_8081_smoke.sh
@@ -12,6 +12,7 @@ echo "[smoke] running 8081 endpoint checks"
docker exec mh-dashboard-organization-dev-backend-1 python - <<'PY'
import sys
import urllib.request
+import json
checks = [
@@ -43,6 +44,21 @@ for name, url, needle in checks:
except Exception as exc:
failed.append(f"{name}: {exc}")
+try:
+ with urllib.request.urlopen("http://127.0.0.1:8000/api/integration/summary", timeout=8) as response:
+ payload = json.loads(response.read().decode())
+ counts = payload.get("counts") or {}
+ work_logs = int(counts.get("work_logs") or 0)
+ vouchers = int(counts.get("vouchers") or 0)
+ if work_logs <= 0:
+ failed.append(f"analysis-summary: work_logs is {work_logs}")
+ if vouchers <= 0:
+ failed.append(f"analysis-summary: vouchers is {vouchers}")
+ if work_logs > 0 and vouchers > 0:
+ print(f"[ok] analysis-summary -> work_logs={work_logs}, vouchers={vouchers}")
+except Exception as exc:
+ failed.append(f"analysis-summary: {exc}")
+
if failed:
print("[smoke] failures detected:")
for item in failed:
diff --git a/scripts/sync_prod_db_to_dev.sh b/scripts/sync_prod_db_to_dev.sh
index 8618d20..daaa8cc 100755
--- a/scripts/sync_prod_db_to_dev.sh
+++ b/scripts/sync_prod_db_to_dev.sh
@@ -9,6 +9,28 @@ DEV_PROJECT_NAME="${DEV_PROJECT_NAME:-mh-dashboard-organization-dev}"
DEV_COMPOSE_FILE="${DEV_COMPOSE_FILE:-${DEV_DIR}/docker-compose.8081.yml}"
SCOPE="${1:-minimal}"
+ANALYSIS_TABLES=(
+ integration_import_batches
+ integration_raw_organization_rows
+ integration_raw_mh_rows
+ integration_raw_mh_pm_rows
+ integration_raw_payment_rows
+ integration_project_aliases
+ integration_project_category_mappings
+ integration_project_pm_assignments
+ integration_projects
+ integration_work_logs
+ integration_work_log_segments
+ integration_vouchers
+)
+
+MINIMAL_PRESERVE_TABLES=(
+ integration_project_pm_assignments
+ integration_work_logs
+ integration_work_log_segments
+ integration_vouchers
+)
+
if [[ ! -f "${PROD_DIR}/docker-compose.yml" ]]; then
echo "Production workspace not found: ${PROD_DIR}" >&2
exit 1
@@ -38,35 +60,11 @@ case "${SCOPE}" in
)
;;
analysis)
- TABLES=(
- integration_import_batches
- integration_raw_organization_rows
- integration_raw_mh_rows
- integration_raw_mh_pm_rows
- integration_raw_payment_rows
- integration_project_aliases
- integration_project_category_mappings
- integration_project_pm_assignments
- integration_projects
- integration_work_logs
- integration_work_log_segments
- integration_vouchers
- )
+ TABLES=("${ANALYSIS_TABLES[@]}")
;;
full)
TABLES=(
- integration_import_batches
- integration_raw_organization_rows
- integration_raw_mh_rows
- integration_raw_mh_pm_rows
- integration_raw_payment_rows
- integration_project_aliases
- integration_project_category_mappings
- integration_project_pm_assignments
- integration_projects
- integration_work_logs
- integration_work_log_segments
- integration_vouchers
+ "${ANALYSIS_TABLES[@]}"
member_aliases
member_overrides
member_retirements
@@ -81,6 +79,16 @@ case "${SCOPE}" in
;;
esac
+PRESERVE_TABLES=()
+if [[ "${SCOPE}" == "minimal" ]]; then
+ PRESERVE_TABLES=("${MINIMAL_PRESERVE_TABLES[@]}")
+fi
+
+DUMP_TABLES=("${TABLES[@]}")
+if [[ ${#PRESERVE_TABLES[@]} -gt 0 ]]; then
+ DUMP_TABLES+=("${PRESERVE_TABLES[@]}")
+fi
+
PROD_COMPOSE=(docker compose --project-directory "${PROD_DIR}")
DEV_COMPOSE=(docker compose -p "${DEV_PROJECT_NAME}" --env-file "${DEV_DIR}/.env" -f "${DEV_COMPOSE_FILE}")
@@ -129,7 +137,7 @@ echo "[4/8] Building truncate script for ${SCOPE} scope"
echo "[5/8] Dumping ${SCOPE} data from 8080 source DB"
TABLE_ARGS=()
-for table in "${TABLES[@]}"; do
+for table in "${DUMP_TABLES[@]}"; do
TABLE_ARGS+=(-t "public.${table}")
done
run_compose "${PROD_DIR}" "${PROD_COMPOSE[@]}" exec -T db \
@@ -193,7 +201,7 @@ echo "[7.8/8] Resetting serial sequences"
echo "SELECT setval(pg_get_serial_sequence('public.member_retirements', 'id'), COALESCE((SELECT MAX(id) FROM public.member_retirements), 1), true);"
echo "SELECT setval(pg_get_serial_sequence('public.seat_maps', 'id'), COALESCE((SELECT MAX(id) FROM public.seat_maps), 1), true);"
echo "SELECT setval(pg_get_serial_sequence('public.seat_slots', 'id'), COALESCE((SELECT MAX(id) FROM public.seat_slots), 1), true);"
- if [[ "${SCOPE}" == "analysis" || "${SCOPE}" == "full" ]]; then
+ if [[ "${SCOPE}" == "analysis" || "${SCOPE}" == "full" || "${#PRESERVE_TABLES[@]}" -gt 0 ]]; then
echo "SELECT setval(pg_get_serial_sequence('public.integration_import_batches', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_import_batches), 1), true);"
echo "SELECT setval(pg_get_serial_sequence('public.integration_raw_organization_rows', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_raw_organization_rows), 1), true);"
echo "SELECT setval(pg_get_serial_sequence('public.integration_raw_mh_rows', 'id'), COALESCE((SELECT MAX(id) FROM public.integration_raw_mh_rows), 1), true);"
@@ -236,7 +244,7 @@ UNION ALL
SELECT 'auth_users', COUNT(*)::text FROM auth.users
ORDER BY table_name;
SQL
- if [[ "${SCOPE}" == "analysis" || "${SCOPE}" == "full" ]]; then
+ if [[ "${SCOPE}" == "analysis" || "${SCOPE}" == "full" || "${#PRESERVE_TABLES[@]}" -gt 0 ]]; then
cat <<'SQL'
SELECT 'integration_work_logs', COUNT(*)::text FROM public.integration_work_logs
UNION ALL