From c0564ee326dfa33737393aab909d95ad0dd7b026 Mon Sep 17 00:00:00 2001 From: hyunho Date: Thu, 2 Apr 2026 11:17:01 +0900 Subject: [PATCH] Improve ledger filters and dev sync checks --- .../apps/ledger/assets/ledger-override.js | 239 +++++++++++++++--- .../served/ledger/ledger-override.js | 239 +++++++++++++++--- scripts/check_8081_smoke.sh | 16 ++ scripts/sync_prod_db_to_dev.sh | 66 ++--- 4 files changed, 455 insertions(+), 105 deletions(-) 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 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 = '
' + '
' + years.map(function (year) { @@ -393,14 +483,14 @@ + '
' + '' + '' - + '' + + '' + "
"; 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 = '
' + '
' + years.map(function (year) { @@ -393,14 +483,14 @@ + '
' + '' + '' - + '' + + '' + "
"; 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