Improve ledger filters and dev sync checks
This commit is contained in:
@@ -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 @@
|
||||
+ '<th class="num"><div class="th-head end"><button type="button" class="th-trigger" data-filter="rate" data-label="수금률"><span class="th-title">수금률</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterRateMenu" class="th-menu" data-filter="rate"></div></div></th>'
|
||||
+ "</tr>";
|
||||
}
|
||||
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 @@
|
||||
+ '<td><button type="button" class="project-link" data-project-key="' + escAttr(String(r.code || "") + "|" + String(r.name || "")) + '">' + esc(r.name || "-") + '</button><div class="subline">' + esc(r.periodText || "-") + '</div></td>'
|
||||
+ '<td><div class="client-main">' + esc((r.client || "").trim() || "-") + '</div><div class="subline">' + esc(formatSplitPercent(r.split)) + '</div></td>'
|
||||
+ '<td><div>' + esc(r.order || "-") + '</div></td>'
|
||||
+ '<td><div class="badge ' + (String(r.status || "").indexOf("완료") >= 0 ? 'ok' : '') + '">' + esc(normalizeStatusLabel(r.status)) + '</div></td>'
|
||||
+ '<td><div class="badge ' + (rowStatusLabel(r) === "준공" ? 'ok' : '') + '">' + esc(rowStatusLabel(r)) + '</div></td>'
|
||||
+ '<td class="num"><strong>' + esc(won(r.cSup || 0)) + '</strong></td>'
|
||||
+ '<td class="num"><strong>' + esc(r.outsourceCost ? won(r.outsourceCost) : "-") + '</strong></td>'
|
||||
+ '<td class="num"><strong>' + esc(won(r.recv || 0)) + '</strong></td>'
|
||||
@@ -267,6 +357,8 @@
|
||||
+ '<td class="num"><strong style="color:' + (isSettledRow(r) ? '#b7aa93' : '#1a5645') + '">' + esc((Number(r.rate || 0)).toFixed(2) + "%") + '</strong></td>'
|
||||
+ '</tr>';
|
||||
}).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 = '<div class="cards-toolbar">'
|
||||
+ '<div class="cards-toolbar-row">'
|
||||
+ years.map(function (year) {
|
||||
@@ -393,14 +483,14 @@
|
||||
+ '<div class="cards-toolbar-metrics">'
|
||||
+ '<button type="button" class="summary-filter-chip ' + (S.dashboard.section === "active" ? "active" : "") + '" data-dashboard-section="active"><span class="label">' + esc(summary.targetYear) + '년 진행과업</span><span class="count">' + summary.activeRows.length.toLocaleString("ko-KR") + '건</span><span class="meta">전년도 이월 사업 포함</span></button>'
|
||||
+ '<button type="button" class="summary-filter-chip ' + (S.dashboard.section === "new" ? "active" : "") + '" data-dashboard-section="new"><span class="label">' + esc(summary.targetYear) + '년 신규프로젝트</span><span class="count">' + summary.newProjectRows.length.toLocaleString("ko-KR") + '건</span><span class="meta">계약기간 시작년도 기준</span></button>'
|
||||
+ '<button type="button" class="summary-filter-chip ' + (S.dashboard.section === "completed" ? "active" : "") + '" data-dashboard-section="completed"><span class="label">' + esc(summary.targetYear) + '년 완료과업</span><span class="count">' + summary.completedRows.length.toLocaleString("ko-KR") + '건</span><span class="meta">해당년도 종료 사업 기준</span></button>'
|
||||
+ '<button type="button" class="summary-filter-chip ' + (S.dashboard.section === "completed" ? "active" : "") + '" data-dashboard-section="completed"><span class="label">' + esc(summary.targetYear) + '년 완료과업</span><span class="count">' + summary.completedRows.length.toLocaleString("ko-KR") + '건</span><span class="meta">진행상태 준공 기준</span></button>'
|
||||
+ "</div></div>";
|
||||
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();
|
||||
|
||||
@@ -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 @@
|
||||
+ '<th class="num"><div class="th-head end"><button type="button" class="th-trigger" data-filter="rate" data-label="수금률"><span class="th-title">수금률</span><span class="th-mark"></span><span class="th-caret">▼</span></button><div id="filterRateMenu" class="th-menu" data-filter="rate"></div></div></th>'
|
||||
+ "</tr>";
|
||||
}
|
||||
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 @@
|
||||
+ '<td><button type="button" class="project-link" data-project-key="' + escAttr(String(r.code || "") + "|" + String(r.name || "")) + '">' + esc(r.name || "-") + '</button><div class="subline">' + esc(r.periodText || "-") + '</div></td>'
|
||||
+ '<td><div class="client-main">' + esc((r.client || "").trim() || "-") + '</div><div class="subline">' + esc(formatSplitPercent(r.split)) + '</div></td>'
|
||||
+ '<td><div>' + esc(r.order || "-") + '</div></td>'
|
||||
+ '<td><div class="badge ' + (String(r.status || "").indexOf("완료") >= 0 ? 'ok' : '') + '">' + esc(normalizeStatusLabel(r.status)) + '</div></td>'
|
||||
+ '<td><div class="badge ' + (rowStatusLabel(r) === "준공" ? 'ok' : '') + '">' + esc(rowStatusLabel(r)) + '</div></td>'
|
||||
+ '<td class="num"><strong>' + esc(won(r.cSup || 0)) + '</strong></td>'
|
||||
+ '<td class="num"><strong>' + esc(r.outsourceCost ? won(r.outsourceCost) : "-") + '</strong></td>'
|
||||
+ '<td class="num"><strong>' + esc(won(r.recv || 0)) + '</strong></td>'
|
||||
@@ -267,6 +357,8 @@
|
||||
+ '<td class="num"><strong style="color:' + (isSettledRow(r) ? '#b7aa93' : '#1a5645') + '">' + esc((Number(r.rate || 0)).toFixed(2) + "%") + '</strong></td>'
|
||||
+ '</tr>';
|
||||
}).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 = '<div class="cards-toolbar">'
|
||||
+ '<div class="cards-toolbar-row">'
|
||||
+ years.map(function (year) {
|
||||
@@ -393,14 +483,14 @@
|
||||
+ '<div class="cards-toolbar-metrics">'
|
||||
+ '<button type="button" class="summary-filter-chip ' + (S.dashboard.section === "active" ? "active" : "") + '" data-dashboard-section="active"><span class="label">' + esc(summary.targetYear) + '년 진행과업</span><span class="count">' + summary.activeRows.length.toLocaleString("ko-KR") + '건</span><span class="meta">전년도 이월 사업 포함</span></button>'
|
||||
+ '<button type="button" class="summary-filter-chip ' + (S.dashboard.section === "new" ? "active" : "") + '" data-dashboard-section="new"><span class="label">' + esc(summary.targetYear) + '년 신규프로젝트</span><span class="count">' + summary.newProjectRows.length.toLocaleString("ko-KR") + '건</span><span class="meta">계약기간 시작년도 기준</span></button>'
|
||||
+ '<button type="button" class="summary-filter-chip ' + (S.dashboard.section === "completed" ? "active" : "") + '" data-dashboard-section="completed"><span class="label">' + esc(summary.targetYear) + '년 완료과업</span><span class="count">' + summary.completedRows.length.toLocaleString("ko-KR") + '건</span><span class="meta">해당년도 종료 사업 기준</span></button>'
|
||||
+ '<button type="button" class="summary-filter-chip ' + (S.dashboard.section === "completed" ? "active" : "") + '" data-dashboard-section="completed"><span class="label">' + esc(summary.targetYear) + '년 완료과업</span><span class="count">' + summary.completedRows.length.toLocaleString("ko-KR") + '건</span><span class="meta">진행상태 준공 기준</span></button>'
|
||||
+ "</div></div>";
|
||||
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();
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user