Refactor data loading and query projections
This commit is contained in:
@@ -250,9 +250,9 @@
|
||||
|
||||
{% block script %}
|
||||
<script>
|
||||
const yearlySeries = {{ yearly_financial_series | tojson }};
|
||||
const monthlySeries = {{ monthly_financial_series | tojson }};
|
||||
const availableYears = [...new Set(yearlySeries.map((item) => item.year).filter((year) => year !== null && year !== undefined))];
|
||||
let yearlySeries = [];
|
||||
let monthlySeries = [];
|
||||
let availableYears = [];
|
||||
const annualMetricCards = {{ annual_metric_cards | tojson }};
|
||||
const annualExpenseChartMetrics = {{ annual_expense_chart_metrics | tojson }};
|
||||
const annualBalanceChartMetrics = {{ annual_balance_chart_metrics | tojson }};
|
||||
@@ -356,6 +356,23 @@
|
||||
return String(availableYears[availableYears.length - 1] || "recent10");
|
||||
}
|
||||
|
||||
async function loadAnnualSummaryBootstrapData() {
|
||||
const response = await fetch("/annual-summary/bootstrap-data", {
|
||||
method: "GET",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
},
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok || payload?.error) {
|
||||
throw new Error(payload?.error || `HTTP ${response.status}`);
|
||||
}
|
||||
yearlySeries = Array.isArray(payload?.yearly_financial_series) ? payload.yearly_financial_series : [];
|
||||
monthlySeries = Array.isArray(payload?.monthly_financial_series) ? payload.monthly_financial_series : [];
|
||||
availableYears = [...new Set(yearlySeries.map((item) => item.year).filter((year) => year !== null && year !== undefined))];
|
||||
}
|
||||
|
||||
function syncYearFilter() {
|
||||
const granularity = document.getElementById("granularity").value;
|
||||
const yearFilterEl = document.getElementById("yearFilter");
|
||||
@@ -581,6 +598,13 @@
|
||||
if (yearFilter) {
|
||||
yearFilter.value = "recent10";
|
||||
}
|
||||
renderAll();
|
||||
(async () => {
|
||||
try {
|
||||
await loadAnnualSummaryBootstrapData();
|
||||
} catch (error) {
|
||||
console.error("연도별 수익/비용 부트스트랩 데이터 조회 에러", error);
|
||||
}
|
||||
renderAll();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -377,11 +377,11 @@
|
||||
{% block script %}
|
||||
<script>
|
||||
const availableYears = {{ available_years | tojson }};
|
||||
const yearlySummary = {{ yearly_summary | tojson }};
|
||||
const monthlySummary = {{ monthly_summary | tojson }};
|
||||
const revenueYearly = {{ project_revenue_mix_yearly | tojson }};
|
||||
const revenueMonthly = {{ project_revenue_mix_monthly | tojson }};
|
||||
const pageSelectedYear = {{ overview_selected_year | tojson }};
|
||||
let yearlySummary = [];
|
||||
let monthlySummary = [];
|
||||
let revenueYearly = [];
|
||||
let revenueMonthly = [];
|
||||
const dashboardRevenueMetricOptions = {{ dashboard_revenue_metric_options | tojson }};
|
||||
const dashboardExpenseMetricOptions = {{ dashboard_expense_metric_options | tojson }};
|
||||
|
||||
@@ -595,6 +595,29 @@
|
||||
.slice(granularity === "yearly" ? -10 : 0);
|
||||
}
|
||||
|
||||
async function loadDashboardBootstrapData() {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (pageSelectedYear) {
|
||||
queryParams.set("overview_year", String(pageSelectedYear));
|
||||
}
|
||||
const query = queryParams.toString() ? `?${queryParams.toString()}` : "";
|
||||
const response = await fetch(`/bootstrap-data${query}`, {
|
||||
method: "GET",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
},
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok || payload?.error) {
|
||||
throw new Error(payload?.error || `HTTP ${response.status}`);
|
||||
}
|
||||
yearlySummary = Array.isArray(payload?.yearly_summary) ? payload.yearly_summary : [];
|
||||
monthlySummary = Array.isArray(payload?.monthly_summary) ? payload.monthly_summary : [];
|
||||
revenueYearly = Array.isArray(payload?.project_revenue_mix_yearly) ? payload.project_revenue_mix_yearly : [];
|
||||
revenueMonthly = Array.isArray(payload?.project_revenue_mix_monthly) ? payload.project_revenue_mix_monthly : [];
|
||||
}
|
||||
|
||||
function updateRevenueChart() {
|
||||
const granularity = document.getElementById("revenueGranularity").value;
|
||||
syncYearSelection("revenueYear", granularity);
|
||||
@@ -636,9 +659,16 @@
|
||||
}
|
||||
});
|
||||
|
||||
syncYearSelection("revenueYear", document.getElementById("revenueGranularity")?.value || "yearly");
|
||||
syncYearSelection("expenseYear", document.getElementById("expenseGranularity")?.value || "yearly");
|
||||
updateRevenueChart();
|
||||
updateExpenseChart();
|
||||
(async () => {
|
||||
try {
|
||||
await loadDashboardBootstrapData();
|
||||
} catch (error) {
|
||||
console.error("대시보드 부트스트랩 데이터 조회 에러", error);
|
||||
}
|
||||
syncYearSelection("revenueYear", document.getElementById("revenueGranularity")?.value || "yearly");
|
||||
syncYearSelection("expenseYear", document.getElementById("expenseGranularity")?.value || "yearly");
|
||||
updateRevenueChart();
|
||||
updateExpenseChart();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
+617
-57
@@ -367,12 +367,12 @@
|
||||
|
||||
.hanmac-aggregate-table th,
|
||||
.hanmac-aggregate-table td {
|
||||
padding: 10px 10px;
|
||||
padding: 7px 10px;
|
||||
border-bottom: 1px solid #eceff3;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
vertical-align: middle;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
line-height: 1.35;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -383,6 +383,69 @@
|
||||
background: #f7f9fc;
|
||||
color: var(--ink);
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.hanmac-aggregate-sort-button {
|
||||
all: unset;
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-height: 20px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.hanmac-aggregate-sort-button:hover,
|
||||
.hanmac-aggregate-sort-button:focus {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.hanmac-aggregate-sort-button::after {
|
||||
content: "↕";
|
||||
color: #8a96a8;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.hanmac-aggregate-sort-button.is-sorted-asc::after {
|
||||
content: "↑";
|
||||
color: #111111;
|
||||
}
|
||||
|
||||
.hanmac-aggregate-sort-button.is-sorted-desc::after {
|
||||
content: "↓";
|
||||
color: #111111;
|
||||
}
|
||||
|
||||
.hanmac-aggregate-table td.is-numeric {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.hanmac-aggregate-table th.is-numeric {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hanmac-aggregate-table th.is-numeric .hanmac-aggregate-sort-button {
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hanmac-aggregate-table tbody tr:nth-child(even) td {
|
||||
@@ -395,23 +458,107 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.hanmac-aggregate-metric-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-height: 18px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.hanmac-aggregate-metric-value {
|
||||
display: inline-flex;
|
||||
justify-content: flex-end;
|
||||
min-width: 64px;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
line-height: 18px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.hanmac-aggregate-metric-button {
|
||||
all: unset;
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
justify-content: flex-end;
|
||||
min-width: 64px;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
line-height: 18px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
border: 0;
|
||||
border-bottom: 1px solid transparent;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
box-shadow: none;
|
||||
min-height: 0;
|
||||
transform: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.hanmac-aggregate-metric-button:hover {
|
||||
background: transparent;
|
||||
border-bottom-color: #1f4d8f;
|
||||
color: #1f4d8f;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.hanmac-aggregate-day-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
width: 42px;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: #4a5565;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
line-height: 18px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.hanmac-aggregate-detail-link {
|
||||
all: unset;
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 24px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid #d8dfeb;
|
||||
border-radius: 999px;
|
||||
background: #f5f8fc;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-bottom: 1px solid transparent;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: #1f4d8f;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 18px;
|
||||
cursor: pointer;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.hanmac-aggregate-detail-link:hover {
|
||||
background: #ebf2fb;
|
||||
background: transparent;
|
||||
border-bottom-color: #1f4d8f;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.hanmac-modal-copy {
|
||||
@@ -481,6 +628,12 @@
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.hanmac-detail-table th.is-numeric,
|
||||
.hanmac-detail-table td.is-numeric {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.hanmac-preview-hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -909,6 +1062,7 @@
|
||||
<option value="retired">퇴사자</option>
|
||||
</select>
|
||||
<button type="button" class="hanmac-button secondary" id="hanmacAggregateLoadButton">집계 조회</button>
|
||||
<button type="button" class="hanmac-button secondary hanmac-emoji-button" id="hanmacExportAggregateButton" title="집계 엑셀 다운로드" aria-label="집계 엑셀 다운로드">📥</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hanmac-aggregate-summary">
|
||||
@@ -985,6 +1139,8 @@
|
||||
<span class="hanmac-panel-meta" id="hanmacPreviewMeta"></span>
|
||||
</div>
|
||||
<div class="hanmac-preview-toolbar-right">
|
||||
<button type="button" class="hanmac-button secondary" id="hanmacPreviewPrevButton">이전</button>
|
||||
<button type="button" class="hanmac-button secondary" id="hanmacPreviewNextButton">다음</button>
|
||||
<input id="hanmacPreviewLimit" type="number" min="1" max="300" value="100">
|
||||
<button type="button" class="hanmac-button secondary" id="hanmacRefreshPreviewButton">다시 조회</button>
|
||||
<button type="button" class="hanmac-button secondary hanmac-emoji-button" id="hanmacExportPreviewButton" title="엑셀 다운로드" aria-label="엑셀 다운로드">📥</button>
|
||||
@@ -1098,6 +1254,7 @@
|
||||
const aggregateValueSearch = document.getElementById("hanmacAggregateValueSearch");
|
||||
const aggregateValueOptions = document.getElementById("hanmacAggregateValueOptions");
|
||||
const aggregateValueReset = document.getElementById("hanmacAggregateValueReset");
|
||||
const exportAggregateButton = document.getElementById("hanmacExportAggregateButton");
|
||||
const multiEntryModal = document.getElementById("hanmacMultiEntryModal");
|
||||
const closeMultiEntryButton = document.getElementById("hanmacCloseMultiEntryButton");
|
||||
const multiEntryTitle = document.getElementById("hanmacMultiEntryTitle");
|
||||
@@ -1109,6 +1266,8 @@
|
||||
const previewValueReset = document.getElementById("hanmacPreviewValueReset");
|
||||
const previewLimit = document.getElementById("hanmacPreviewLimit");
|
||||
const refreshPreviewButton = document.getElementById("hanmacRefreshPreviewButton");
|
||||
const previewPrevButton = document.getElementById("hanmacPreviewPrevButton");
|
||||
const previewNextButton = document.getElementById("hanmacPreviewNextButton");
|
||||
const exportPreviewButton = document.getElementById("hanmacExportPreviewButton");
|
||||
const previewMeta = document.getElementById("hanmacPreviewMeta");
|
||||
const previewEmpty = document.getElementById("hanmacPreviewEmpty");
|
||||
@@ -1124,7 +1283,7 @@
|
||||
const summaryKey = document.getElementById("hanmacSummaryKey");
|
||||
const schemaTabs = Array.from(document.querySelectorAll("[data-schema-filter]"));
|
||||
const focusButtons = Array.from(document.querySelectorAll("[data-focus]"));
|
||||
if (!form || !modal || !openConfigButton || !closeConfigButton || !statusBox || !testButton || !loadTablesButton || !tableListMeta || !tableList || !tableSearch || !aggregateStartDate || !aggregateEndDate || !aggregateEmployment || !aggregateLoadButton || !aggregateMeta || !aggregateMemberCount || !aggregateRegularHours || !aggregateOvertimeHours || !aggregateTotalHours || !aggregateLeaveDays || !aggregateProjectCount || !aggregateEmpty || !aggregateTable || !aggregateHead || !aggregateBody || !aggregateValueColumn || !aggregateValueSearch || !aggregateValueOptions || !aggregateValueReset || !multiEntryModal || !closeMultiEntryButton || !multiEntryTitle || !multiEntryMeta || !multiEntryBody || !previewValueColumn || !previewValueSearch || !previewValueOptions || !previewValueReset || !previewLimit || !refreshPreviewButton || !exportPreviewButton || !previewMeta || !previewEmpty || !previewControls || !previewTable || !previewHead || !previewBody || !previewToggleButton || !selectedTitle || !summarySchema || !summaryRows || !summaryColumns || !summaryKey) return;
|
||||
if (!form || !modal || !openConfigButton || !closeConfigButton || !statusBox || !testButton || !loadTablesButton || !tableListMeta || !tableList || !tableSearch || !aggregateStartDate || !aggregateEndDate || !aggregateEmployment || !aggregateLoadButton || !aggregateMeta || !aggregateMemberCount || !aggregateRegularHours || !aggregateOvertimeHours || !aggregateTotalHours || !aggregateLeaveDays || !aggregateProjectCount || !aggregateEmpty || !aggregateTable || !aggregateHead || !aggregateBody || !aggregateValueColumn || !aggregateValueSearch || !aggregateValueOptions || !aggregateValueReset || !exportAggregateButton || !multiEntryModal || !closeMultiEntryButton || !multiEntryTitle || !multiEntryMeta || !multiEntryBody || !previewValueColumn || !previewValueSearch || !previewValueOptions || !previewValueReset || !previewLimit || !refreshPreviewButton || !previewPrevButton || !previewNextButton || !exportPreviewButton || !previewMeta || !previewEmpty || !previewControls || !previewTable || !previewHead || !previewBody || !previewToggleButton || !selectedTitle || !summarySchema || !summaryRows || !summaryColumns || !summaryKey) return;
|
||||
|
||||
let allTables = [];
|
||||
let visibleTables = [];
|
||||
@@ -1135,10 +1294,16 @@
|
||||
let currentPreviewColumns = [];
|
||||
let currentPreviewRows = [];
|
||||
let currentPreviewAllRows = [];
|
||||
let currentPreviewCursor = "";
|
||||
let currentPreviewNextCursor = "";
|
||||
let currentPreviewCursorHistory = [];
|
||||
let currentPreviewExportJobKey = "";
|
||||
let currentAggregateExportJobKey = "";
|
||||
let currentAggregateRows = [];
|
||||
let currentAggregateAllRows = [];
|
||||
let currentAggregateColumns = [];
|
||||
let currentAggregateView = "member";
|
||||
let currentAggregateSort = { key: "", direction: "desc" };
|
||||
const credentialStorageKey = "hanmac-db-external-credentials-v1";
|
||||
|
||||
const priorityTableNames = ["dallyproject_tbl", "dallyproject_addwork_tbl", "member_tbl", "project_tbl", "worker_tardy_tbl"];
|
||||
@@ -1262,28 +1427,137 @@
|
||||
return columnLabelMap[String(column)] || String(column);
|
||||
};
|
||||
|
||||
const csvEscape = (value) => {
|
||||
const textValue = value == null ? "" : String(value);
|
||||
return `"${textValue.replace(/"/g, '""')}"`;
|
||||
const setPreviewExportBusy = (busy) => {
|
||||
exportPreviewButton.disabled = busy;
|
||||
exportPreviewButton.dataset.loading = busy ? "true" : "false";
|
||||
exportPreviewButton.title = busy ? "파일 준비 중입니다..." : "엑셀 다운로드";
|
||||
exportPreviewButton.setAttribute("aria-label", busy ? "엑셀 준비 중" : "엑셀 다운로드");
|
||||
};
|
||||
|
||||
const exportPreviewToExcel = () => {
|
||||
if (!currentPreviewColumns.length || !currentPreviewRows.length) return;
|
||||
const header = currentPreviewColumns.map((column) => csvEscape(getColumnLabel(column))).join(",");
|
||||
const body = currentPreviewRows.map((row) => (
|
||||
currentPreviewColumns.map((column) => csvEscape(row[column])).join(",")
|
||||
));
|
||||
const csvContent = ["\uFEFF" + header, ...body].join("\r\n");
|
||||
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
anchor.href = url;
|
||||
anchor.download = `${selectedSchema || "hanmac"}_${selectedTable || "preview"}_${today}.csv`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
const setAggregateExportBusy = (busy) => {
|
||||
exportAggregateButton.disabled = busy;
|
||||
exportAggregateButton.dataset.loading = busy ? "true" : "false";
|
||||
exportAggregateButton.title = busy ? "집계 파일 준비 중입니다..." : "집계 엑셀 다운로드";
|
||||
exportAggregateButton.setAttribute("aria-label", busy ? "집계 엑셀 준비 중" : "집계 엑셀 다운로드");
|
||||
};
|
||||
|
||||
const pollPreviewExportJob = async (jobKey) => {
|
||||
currentPreviewExportJobKey = jobKey;
|
||||
for (let attempt = 0; attempt < 240; attempt += 1) {
|
||||
const response = await fetch(`/hanmac-browser/api/preview-export-jobs/${encodeURIComponent(jobKey)}`);
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || "파일 준비 상태를 확인하지 못했습니다.");
|
||||
}
|
||||
if (payload.state === "ready" && payload.download_url) {
|
||||
setPreviewExportBusy(false);
|
||||
currentPreviewExportJobKey = "";
|
||||
window.location.assign(payload.download_url);
|
||||
return;
|
||||
}
|
||||
if (payload.state === "failed") {
|
||||
setPreviewExportBusy(false);
|
||||
currentPreviewExportJobKey = "";
|
||||
throw new Error(payload.error_message || "파일 준비 중 오류가 발생했습니다.");
|
||||
}
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 1500));
|
||||
}
|
||||
setPreviewExportBusy(false);
|
||||
currentPreviewExportJobKey = "";
|
||||
throw new Error("파일 준비 시간이 길어지고 있습니다. 잠시 후 다시 시도해주세요.");
|
||||
};
|
||||
|
||||
const exportPreviewToExcel = async () => {
|
||||
if (!selectedTable || !selectedSchema || !currentPreviewColumns.length) return;
|
||||
setPreviewExportBusy(true);
|
||||
try {
|
||||
const response = await fetch("/hanmac-browser/api/preview-export-jobs", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
...getPayload(),
|
||||
schema: selectedSchema,
|
||||
table: selectedTable,
|
||||
limit: previewLimit.value,
|
||||
cursor: currentPreviewCursor,
|
||||
value_column: previewValueColumn.value,
|
||||
value_search: previewValueSearch.value,
|
||||
}),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || "파일 준비 요청에 실패했습니다.");
|
||||
}
|
||||
if (payload.state === "ready" && payload.download_url) {
|
||||
setPreviewExportBusy(false);
|
||||
window.location.assign(payload.download_url);
|
||||
return;
|
||||
}
|
||||
await pollPreviewExportJob(payload.job_key || "");
|
||||
} catch (error) {
|
||||
setPreviewExportBusy(false);
|
||||
alert(error.message || "엑셀 다운로드 준비 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const pollAggregateExportJob = async (jobKey) => {
|
||||
currentAggregateExportJobKey = jobKey;
|
||||
for (let attempt = 0; attempt < 240; attempt += 1) {
|
||||
const response = await fetch(`/hanmac-browser/api/preview-export-jobs/${encodeURIComponent(jobKey)}`);
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || "집계 파일 준비 상태를 확인하지 못했습니다.");
|
||||
}
|
||||
if (payload.state === "ready" && payload.download_url) {
|
||||
setAggregateExportBusy(false);
|
||||
currentAggregateExportJobKey = "";
|
||||
window.location.assign(payload.download_url);
|
||||
return;
|
||||
}
|
||||
if (payload.state === "failed") {
|
||||
setAggregateExportBusy(false);
|
||||
currentAggregateExportJobKey = "";
|
||||
throw new Error(payload.error_message || "집계 파일 준비 중 오류가 발생했습니다.");
|
||||
}
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 1500));
|
||||
}
|
||||
setAggregateExportBusy(false);
|
||||
currentAggregateExportJobKey = "";
|
||||
throw new Error("집계 파일 준비 시간이 길어지고 있습니다. 잠시 후 다시 시도해주세요.");
|
||||
};
|
||||
|
||||
const exportAggregateToExcel = async () => {
|
||||
setAggregateExportBusy(true);
|
||||
try {
|
||||
const response = await fetch("/hanmac-browser/api/aggregate-export-jobs", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
...getPayload(),
|
||||
start_date: aggregateStartDate.value,
|
||||
end_date: aggregateEndDate.value,
|
||||
employment: aggregateEmployment.value,
|
||||
view: currentAggregateView,
|
||||
value_column: aggregateValueColumn.value,
|
||||
value_search: aggregateValueSearch.value,
|
||||
sort_key: currentAggregateSort.key,
|
||||
sort_direction: currentAggregateSort.direction,
|
||||
}),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || "집계 파일 준비 요청에 실패했습니다.");
|
||||
}
|
||||
if (payload.state === "ready" && payload.download_url) {
|
||||
setAggregateExportBusy(false);
|
||||
window.location.assign(payload.download_url);
|
||||
return;
|
||||
}
|
||||
await pollAggregateExportJob(payload.job_key || "");
|
||||
} catch (error) {
|
||||
setAggregateExportBusy(false);
|
||||
alert(error.message || "집계 엑셀 다운로드 준비 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const getFocusTitle = () => {
|
||||
@@ -1368,11 +1642,21 @@
|
||||
currentPreviewColumns = [];
|
||||
currentPreviewRows = [];
|
||||
currentPreviewAllRows = [];
|
||||
currentPreviewCursor = "";
|
||||
currentPreviewNextCursor = "";
|
||||
currentPreviewCursorHistory = [];
|
||||
previewValueColumn.innerHTML = `<option value="">전체 컬럼</option>`;
|
||||
previewValueSearch.value = "";
|
||||
previewValueOptions.innerHTML = "";
|
||||
summaryColumns.textContent = "0";
|
||||
summaryKey.textContent = "-";
|
||||
previewPrevButton.disabled = true;
|
||||
previewNextButton.disabled = true;
|
||||
};
|
||||
|
||||
const updatePreviewPager = () => {
|
||||
previewPrevButton.disabled = !currentPreviewCursorHistory.length;
|
||||
previewNextButton.disabled = !currentPreviewNextCursor;
|
||||
};
|
||||
|
||||
const setAggregateEmpty = (message) => {
|
||||
@@ -1403,6 +1687,37 @@
|
||||
return number.toLocaleString("ko-KR", { maximumFractionDigits: 2 });
|
||||
};
|
||||
|
||||
const aggregateDayColumnMap = {
|
||||
regular_hours: "regular_work_days",
|
||||
overtime_hours: "overtime_work_days",
|
||||
legal_leave_days: "legal_leave_days",
|
||||
};
|
||||
const aggregateValueColumnMap = {
|
||||
legal_leave_days: "legal_leave_hours",
|
||||
};
|
||||
const aggregateNumericColumns = new Set([
|
||||
"member_count",
|
||||
"regular_hours",
|
||||
"overtime_hours",
|
||||
"total_hours",
|
||||
"legal_leave_days",
|
||||
"project_count",
|
||||
]);
|
||||
const aggregateDetailColumns = new Set([
|
||||
"regular_hours",
|
||||
"overtime_hours",
|
||||
"total_hours",
|
||||
"legal_leave_days",
|
||||
"project_count",
|
||||
]);
|
||||
const aggregateDetailLabels = {
|
||||
regular_hours: "정규근로",
|
||||
overtime_hours: "연장근로",
|
||||
total_hours: "총근로",
|
||||
legal_leave_days: "법정휴가",
|
||||
project_count: "프로젝트수",
|
||||
};
|
||||
|
||||
const escapeHtml = (value) => String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
@@ -1458,9 +1773,13 @@
|
||||
return columnsToRead.some((column) => getRowValueText(row, column).toLowerCase().includes(searchValue));
|
||||
});
|
||||
renderPreviewTableRows(currentPreviewColumns, filteredRows);
|
||||
previewMeta.textContent = searchValue
|
||||
? `최근 ${Number(filteredRows.length).toLocaleString("ko-KR")}건 표시`
|
||||
: `최근 ${Number(currentPreviewAllRows.length).toLocaleString("ko-KR")}건 표시`;
|
||||
const countText = searchValue
|
||||
? `현재 ${Number(filteredRows.length).toLocaleString("ko-KR")}건 표시`
|
||||
: `현재 ${Number(currentPreviewAllRows.length).toLocaleString("ko-KR")}건 표시`;
|
||||
const pageStart = Number(currentPreviewCursor || 0) + 1;
|
||||
const pageEnd = Number(currentPreviewCursor || 0) + Number(currentPreviewAllRows.length || 0);
|
||||
const pageText = currentPreviewAllRows.length ? `${pageStart.toLocaleString("ko-KR")}~${pageEnd.toLocaleString("ko-KR")}행` : "";
|
||||
previewMeta.textContent = [countText, pageText].filter(Boolean).join(" / ");
|
||||
};
|
||||
|
||||
const renderAggregateValueOptions = () => {
|
||||
@@ -1481,17 +1800,86 @@
|
||||
)).join("");
|
||||
};
|
||||
|
||||
const getAggregateSortValue = (row, key) => {
|
||||
const valueKey = aggregateValueColumnMap[key] || key;
|
||||
const value = row?.[valueKey];
|
||||
const numeric = Number(value);
|
||||
if (value !== null && value !== "" && Number.isFinite(numeric)) return numeric;
|
||||
return String(value ?? "").toLowerCase();
|
||||
};
|
||||
|
||||
const getSortedAggregateRows = (rows) => {
|
||||
if (!currentAggregateSort.key) return rows;
|
||||
const direction = currentAggregateSort.direction === "asc" ? 1 : -1;
|
||||
return [...rows].sort((left, right) => {
|
||||
const leftValue = getAggregateSortValue(left, currentAggregateSort.key);
|
||||
const rightValue = getAggregateSortValue(right, currentAggregateSort.key);
|
||||
if (typeof leftValue === "number" && typeof rightValue === "number") {
|
||||
return (leftValue - rightValue) * direction;
|
||||
}
|
||||
return String(leftValue).localeCompare(String(rightValue), "ko", { numeric: true }) * direction;
|
||||
});
|
||||
};
|
||||
|
||||
const getEquivalentProjectTitle = (row) => {
|
||||
const equivalentCodes = Array.isArray(row?.equivalent_project_codes) ? row.equivalent_project_codes.filter(Boolean) : [];
|
||||
return equivalentCodes.length ? equivalentCodes.join(", ") : "";
|
||||
};
|
||||
|
||||
const renderProjectCodeText = (row, fallbackKey = "project_code") => {
|
||||
const text = row?.[fallbackKey] || "(미지정)";
|
||||
const title = getEquivalentProjectTitle(row);
|
||||
return `<span${title ? ` title="${escapeHtml(title)}"` : ""}>${escapeHtml(text)}</span>`;
|
||||
};
|
||||
|
||||
const canOpenAggregateDetail = (row, columnKey) => {
|
||||
if (!aggregateDetailColumns.has(columnKey)) return false;
|
||||
const details = row?.aggregate_details?.[columnKey];
|
||||
return Array.isArray(details) && details.length > 0;
|
||||
};
|
||||
|
||||
const renderAggregateMetricCell = (row, column, rowIndex, view) => {
|
||||
const isNumeric = aggregateNumericColumns.has(column.key);
|
||||
const valueKey = aggregateValueColumnMap[column.key] || column.key;
|
||||
const valueText = formatNumberText(row[valueKey]);
|
||||
const valueHtml = canOpenAggregateDetail(row, column.key)
|
||||
? `<button type="button" class="hanmac-aggregate-metric-value hanmac-aggregate-metric-button" data-aggregate-detail-index="${rowIndex}" data-aggregate-detail-key="${escapeHtml(column.key)}">${valueText}</button>`
|
||||
: `<span class="hanmac-aggregate-metric-value">${valueText}</span>`;
|
||||
const dayKey = aggregateDayColumnMap[column.key];
|
||||
const dayValue = Number(row[dayKey] || 0);
|
||||
const dayBadge = dayKey ? `<span class="hanmac-aggregate-day-badge">${formatNumberText(dayValue)}일</span>` : "";
|
||||
if (view === "member" && column.key === "regular_hours") {
|
||||
const duplicateDays = Number(row.multi_entry_days || 0);
|
||||
const detailButton = duplicateDays > 0
|
||||
? `<button type="button" class="hanmac-aggregate-detail-link" data-aggregate-duplicate-index="${rowIndex}">${duplicateDays}일</button>`
|
||||
: "";
|
||||
return `<td class="is-numeric"><div class="hanmac-aggregate-metric-cell">${valueHtml}${dayBadge}${detailButton}</div></td>`;
|
||||
}
|
||||
if (dayKey) {
|
||||
return `<td class="is-numeric"><div class="hanmac-aggregate-metric-cell">${valueHtml}${dayBadge}</div></td>`;
|
||||
}
|
||||
if (isNumeric) {
|
||||
return `<td class="is-numeric"><div class="hanmac-aggregate-metric-cell">${valueHtml}</div></td>`;
|
||||
}
|
||||
if (column.key === "project_code") {
|
||||
return `<td>${renderProjectCodeText(row)}</td>`;
|
||||
}
|
||||
return `<td>${row[column.key] == null ? "" : escapeHtml(String(row[column.key]))}</td>`;
|
||||
};
|
||||
|
||||
const renderAggregateTableRows = (columns, rows, view) => {
|
||||
currentAggregateColumns = columns;
|
||||
currentAggregateRows = rows;
|
||||
aggregateHead.innerHTML = `<tr>${columns.map((column) => `<th>${column.label}</th>`).join("")}</tr>`;
|
||||
aggregateHead.innerHTML = `<tr>${columns.map((column) => {
|
||||
const sortedClass = currentAggregateSort.key === column.key
|
||||
? ` is-sorted-${currentAggregateSort.direction}`
|
||||
: "";
|
||||
const numericClass = aggregateNumericColumns.has(column.key) ? " class=\"is-numeric\"" : "";
|
||||
return `<th${numericClass}><button type="button" class="hanmac-aggregate-sort-button${sortedClass}" data-aggregate-sort="${escapeHtml(column.key)}">${escapeHtml(column.label)}</button></th>`;
|
||||
}).join("")}</tr>`;
|
||||
aggregateBody.innerHTML = rows.map((row, rowIndex) => (
|
||||
`<tr>${columns.map((column) => {
|
||||
if (view === "member" && column.key === "regular_hours") {
|
||||
const duplicateDays = Number(row.multi_entry_days || 0);
|
||||
return `<td><div class="hanmac-aggregate-regular-cell"><span>${formatNumberText(row[column.key])}</span>${duplicateDays > 0 ? `<button type="button" class="hanmac-aggregate-detail-link" data-aggregate-duplicate-index="${rowIndex}">${duplicateDays}일</button>` : ""}</div></td>`;
|
||||
}
|
||||
return `<td>${row[column.key] == null ? "" : escapeHtml(String(row[column.key]))}</td>`;
|
||||
return renderAggregateMetricCell(row, column, rowIndex, view);
|
||||
}).join("")}</tr>`
|
||||
)).join("");
|
||||
aggregateEmpty.hidden = true;
|
||||
@@ -1509,7 +1897,7 @@
|
||||
const columnsToRead = selectedColumn ? [selectedColumn] : currentAggregateColumns.map((column) => column.key);
|
||||
return columnsToRead.some((columnKey) => getRowValueText(row, columnKey).toLowerCase().includes(searchValue));
|
||||
});
|
||||
renderAggregateTableRows(currentAggregateColumns, filteredRows, currentAggregateView);
|
||||
renderAggregateTableRows(currentAggregateColumns, getSortedAggregateRows(filteredRows), currentAggregateView);
|
||||
};
|
||||
|
||||
const openMultiEntryModal = (row) => {
|
||||
@@ -1538,9 +1926,9 @@
|
||||
<tbody>
|
||||
${(Array.isArray(detail.entries) ? detail.entries : []).map((entry) => `
|
||||
<tr>
|
||||
<td>${escapeHtml(entry.project_code || "(미지정)")}</td>
|
||||
<td>${escapeHtml(entry.project_name || entry.project_code || "(미지정)")}</td>
|
||||
<td>${formatNumberText(entry.regular_hours)}</td>
|
||||
<td>${renderProjectCodeText(entry)}</td>
|
||||
<td>${escapeHtml(entry.project_name || entry.project_code || "(미지정)")}</td>
|
||||
<td>${formatNumberText(entry.regular_hours)}</td>
|
||||
</tr>
|
||||
`).join("")}
|
||||
</tbody>
|
||||
@@ -1551,6 +1939,130 @@
|
||||
multiEntryModal.hidden = false;
|
||||
};
|
||||
|
||||
const renderDetailProjectList = (projects) => {
|
||||
const list = Array.isArray(projects) ? projects : [];
|
||||
if (!list.length) return "";
|
||||
return `
|
||||
<table class="hanmac-detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>프로젝트코드</th>
|
||||
<th>프로젝트명</th>
|
||||
<th class="is-numeric">시간</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${list.map((project) => `
|
||||
<tr>
|
||||
<td>${renderProjectCodeText(project)}</td>
|
||||
<td>${escapeHtml(project.project_name || project.project_code || "(미지정)")}</td>
|
||||
<td class="is-numeric">${formatNumberText(project.hours)}</td>
|
||||
</tr>
|
||||
`).join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
};
|
||||
|
||||
const renderAggregateDetailRows = (detailKey, details) => {
|
||||
if (detailKey === "project_count") {
|
||||
return `
|
||||
<table class="hanmac-detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>프로젝트코드</th>
|
||||
<th>프로젝트명</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${details.map((detail) => `
|
||||
<tr>
|
||||
<td>${renderProjectCodeText(detail)}</td>
|
||||
<td>${escapeHtml(detail.project_name || detail.project_code || "(미지정)")}</td>
|
||||
</tr>
|
||||
`).join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
if (detailKey === "legal_leave_days") {
|
||||
return `
|
||||
<table class="hanmac-detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>일자</th>
|
||||
<th>구분</th>
|
||||
<th class="is-numeric">일수</th>
|
||||
<th class="is-numeric">시간</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${details.map((detail) => `
|
||||
<tr>
|
||||
<td>${escapeHtml(detail.work_date || "-")}</td>
|
||||
<td>${escapeHtml(detail.leave_type || "법정휴가")}</td>
|
||||
<td class="is-numeric">${formatNumberText(detail.leave_days)}</td>
|
||||
<td class="is-numeric">${formatNumberText(detail.leave_hours)}</td>
|
||||
</tr>
|
||||
`).join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
if (detailKey === "overtime_hours") {
|
||||
return `
|
||||
<table class="hanmac-detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>일자</th>
|
||||
<th>${currentAggregateView === "project" ? "사원" : "프로젝트"}</th>
|
||||
<th class="is-numeric">시간</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${details.map((detail) => `
|
||||
<tr>
|
||||
<td>${escapeHtml(detail.work_date || "-")}</td>
|
||||
<td>${escapeHtml(currentAggregateView === "project" ? `${detail.member_name || detail.member_no || ""} ${detail.member_no ? `(${detail.member_no})` : ""}`.trim() : `${detail.project_name || detail.project_code || "(미지정)"} ${detail.project_code ? `(${detail.project_code})` : ""}`.trim())}</td>
|
||||
<td class="is-numeric">${formatNumberText(detail.overtime_hours)}</td>
|
||||
</tr>
|
||||
`).join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
return details.map((detail) => `
|
||||
<section class="hanmac-detail-day">
|
||||
<div class="hanmac-detail-day-head">
|
||||
<strong class="hanmac-detail-day-title">${escapeHtml(detail.work_date || "-")}</strong>
|
||||
${detail.detail_type ? `<span class="hanmac-detail-badge">${escapeHtml(detail.detail_type)}</span>` : ""}
|
||||
${detail.member_name || detail.member_no ? `<span class="hanmac-detail-badge">${escapeHtml(`${detail.member_name || detail.member_no || ""} ${detail.member_no ? `(${detail.member_no})` : ""}`.trim())}</span>` : ""}
|
||||
${detail.regular_hours != null ? `<span class="hanmac-detail-badge">정규 ${formatNumberText(detail.regular_hours)}시간</span>` : ""}
|
||||
${detail.holiday_hours != null ? `<span class="hanmac-detail-badge">휴일 ${formatNumberText(detail.holiday_hours)}시간</span>` : ""}
|
||||
${detail.overtime_hours != null ? `<span class="hanmac-detail-badge">연장 ${formatNumberText(detail.overtime_hours)}시간</span>` : ""}
|
||||
${detail.raw_total_hours != null ? `<span class="hanmac-detail-badge">원본 ${formatNumberText(detail.raw_total_hours)}시간</span>` : ""}
|
||||
${detail.leave_days ? `<span class="hanmac-detail-badge">휴가 ${formatNumberText(detail.leave_days)}일</span>` : ""}
|
||||
${detail.leave_hours ? `<span class="hanmac-detail-badge">휴가 ${formatNumberText(detail.leave_hours)}시간</span>` : ""}
|
||||
</div>
|
||||
${renderDetailProjectList(detail.projects)}
|
||||
</section>
|
||||
`).join("");
|
||||
};
|
||||
|
||||
const openAggregateDetailModal = (row, detailKey) => {
|
||||
const details = Array.isArray(row?.aggregate_details?.[detailKey]) ? row.aggregate_details[detailKey] : [];
|
||||
const label = aggregateDetailLabels[detailKey] || detailKey;
|
||||
const owner = currentAggregateView === "project"
|
||||
? `${row?.project_name || row?.project_code || "프로젝트"}`
|
||||
: `${row?.member_name || row?.member_no || "사원"}`;
|
||||
multiEntryTitle.textContent = `${owner} ${label} 상세`;
|
||||
multiEntryMeta.textContent = `${Number(details.length).toLocaleString("ko-KR")}건 · ${label} ${formatNumberText(row?.[detailKey])}`;
|
||||
multiEntryBody.innerHTML = details.length
|
||||
? renderAggregateDetailRows(detailKey, details)
|
||||
: `<div class="hanmac-preview-empty">상세 내역이 없습니다.</div>`;
|
||||
multiEntryModal.hidden = false;
|
||||
};
|
||||
|
||||
const updateSelectedSummary = (tableItem, columns = []) => {
|
||||
if (!tableItem) {
|
||||
selectedTitle.textContent = `${getFocusTitle()} · 선택 대기`;
|
||||
@@ -1590,6 +2102,9 @@
|
||||
button.addEventListener("click", () => {
|
||||
selectedSchema = item.schema;
|
||||
selectedTable = item.name;
|
||||
currentPreviewCursor = "";
|
||||
currentPreviewNextCursor = "";
|
||||
currentPreviewCursorHistory = [];
|
||||
renderTableList();
|
||||
loadPreview();
|
||||
});
|
||||
@@ -1609,12 +2124,20 @@
|
||||
return;
|
||||
}
|
||||
currentPreviewAllRows = rows;
|
||||
currentPreviewCursor = String(payload.cursor || "");
|
||||
currentPreviewNextCursor = String(payload.next_cursor || "");
|
||||
previewValueColumn.innerHTML = `<option value="">전체 컬럼</option>${columns.map((column) => `<option value="${escapeHtml(column)}">${getColumnLabel(column)}</option>`).join("")}`;
|
||||
previewValueSearch.value = "";
|
||||
renderPreviewValueOptions();
|
||||
renderPreviewTableRows(columns, rows);
|
||||
previewMeta.textContent = `최근 ${Number(payload.shown_count || 0).toLocaleString("ko-KR")}건 표시`;
|
||||
const shownText = `현재 ${Number(payload.shown_count || 0).toLocaleString("ko-KR")}건 표시`;
|
||||
const pageStart = Number(payload.cursor || 0) + 1;
|
||||
const pageEnd = Number(payload.cursor || 0) + Number(payload.shown_count || 0);
|
||||
const pageText = Number(payload.shown_count || 0) > 0 ? `${pageStart.toLocaleString("ko-KR")}~${pageEnd.toLocaleString("ko-KR")}행` : "";
|
||||
const pendingText = payload.cache_meta?.pending_refresh ? " · 캐시 갱신 중" : "";
|
||||
previewMeta.textContent = [shownText, pageText].filter(Boolean).join(" / ") + pendingText;
|
||||
updateSelectedSummary(activeItem, columns);
|
||||
updatePreviewPager();
|
||||
};
|
||||
|
||||
const renderAggregate = (payload) => {
|
||||
@@ -1627,26 +2150,35 @@
|
||||
aggregateRegularHours.textContent = Number(summary.regular_hours || 0).toLocaleString("ko-KR");
|
||||
aggregateOvertimeHours.textContent = Number(summary.overtime_hours || 0).toLocaleString("ko-KR");
|
||||
aggregateTotalHours.textContent = Number(summary.total_hours || 0).toLocaleString("ko-KR");
|
||||
aggregateLeaveDays.textContent = Number(summary.legal_leave_days || 0).toLocaleString("ko-KR");
|
||||
aggregateLeaveDays.textContent = Number(summary.legal_leave_hours || 0).toLocaleString("ko-KR");
|
||||
aggregateProjectCount.textContent = Number(summary.project_count || 0).toLocaleString("ko-KR");
|
||||
aggregateMeta.textContent = `${payload.start_date || ""} ~ ${payload.end_date || ""}`;
|
||||
const diagnostics = payload.source_diagnostics || {};
|
||||
const diagnosticText = diagnostics
|
||||
? ` · HolidayTime ${Number(diagnostics.holiday_time_rows || 0).toLocaleString("ko-KR")}행 · 휴가 ${Number(diagnostics.leave_matched_rows || 0).toLocaleString("ko-KR")}/${Number(diagnostics.tardy_candidate_rows || 0).toLocaleString("ko-KR")}행`
|
||||
: "";
|
||||
aggregateMeta.textContent = `${payload.start_date || ""} ~ ${payload.end_date || ""}${diagnosticText}`;
|
||||
if (!columns.length || !rows.length) {
|
||||
setAggregateEmpty("");
|
||||
return;
|
||||
}
|
||||
if (currentAggregateSort.key && !columns.some((column) => column.key === currentAggregateSort.key)) {
|
||||
currentAggregateSort = { key: "", direction: "desc" };
|
||||
}
|
||||
aggregateValueColumn.innerHTML = `<option value="">전체 컬럼</option>${columns.map((column) => `<option value="${escapeHtml(column.key)}">${escapeHtml(column.label)}</option>`).join("")}`;
|
||||
aggregateValueSearch.value = "";
|
||||
renderAggregateValueOptions();
|
||||
renderAggregateTableRows(columns, rows, payload.view || "member");
|
||||
renderAggregateTableRows(columns, getSortedAggregateRows(rows), payload.view || "member");
|
||||
};
|
||||
|
||||
const loadPreview = async () => {
|
||||
const loadPreview = async (cursor = "", preserveHistory = false) => {
|
||||
if (!selectedTable || !selectedSchema) {
|
||||
setPreviewEmpty("");
|
||||
previewMeta.textContent = "";
|
||||
return;
|
||||
}
|
||||
refreshPreviewButton.disabled = true;
|
||||
previewPrevButton.disabled = true;
|
||||
previewNextButton.disabled = true;
|
||||
previewMeta.textContent = "조회 중...";
|
||||
try {
|
||||
const response = await fetch("/hanmac-browser/api/preview", {
|
||||
@@ -1657,18 +2189,23 @@
|
||||
schema: selectedSchema,
|
||||
table: selectedTable,
|
||||
limit: previewLimit.value,
|
||||
cursor,
|
||||
}),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok || payload.status !== "ok") {
|
||||
throw new Error(payload.message || "테이블 미리보기에 실패했습니다.");
|
||||
}
|
||||
if (!preserveHistory) {
|
||||
currentPreviewCursorHistory = cursor ? [...currentPreviewCursorHistory, currentPreviewCursor] : [];
|
||||
}
|
||||
renderPreview(payload);
|
||||
} catch (error) {
|
||||
setPreviewEmpty(error.message || "테이블 미리보기 중 오류가 발생했습니다.");
|
||||
previewMeta.textContent = "조회 실패";
|
||||
} finally {
|
||||
refreshPreviewButton.disabled = false;
|
||||
updatePreviewPager();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1749,12 +2286,31 @@
|
||||
|
||||
tableSearch.addEventListener("input", renderTableList);
|
||||
aggregateBody.addEventListener("click", (event) => {
|
||||
const detailButton = event.target.closest("[data-aggregate-detail-index]");
|
||||
if (detailButton) {
|
||||
const rowIndex = Number(detailButton.dataset.aggregateDetailIndex || -1);
|
||||
const detailKey = detailButton.dataset.aggregateDetailKey || "";
|
||||
if (rowIndex >= 0 && rowIndex < currentAggregateRows.length && detailKey) {
|
||||
openAggregateDetailModal(currentAggregateRows[rowIndex], detailKey);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const button = event.target.closest("[data-aggregate-duplicate-index]");
|
||||
if (!button || currentAggregateView !== "member") return;
|
||||
const rowIndex = Number(button.dataset.aggregateDuplicateIndex || -1);
|
||||
if (rowIndex < 0 || rowIndex >= currentAggregateRows.length) return;
|
||||
openMultiEntryModal(currentAggregateRows[rowIndex]);
|
||||
});
|
||||
aggregateHead.addEventListener("click", (event) => {
|
||||
const button = event.target.closest("[data-aggregate-sort]");
|
||||
if (!button) return;
|
||||
const key = button.dataset.aggregateSort || "";
|
||||
if (!key) return;
|
||||
currentAggregateSort = currentAggregateSort.key === key
|
||||
? { key, direction: currentAggregateSort.direction === "asc" ? "desc" : "asc" }
|
||||
: { key, direction: "desc" };
|
||||
applyAggregateValueFilter();
|
||||
});
|
||||
|
||||
schemaTabs.forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
@@ -1769,9 +2325,6 @@
|
||||
focusFilter = button.dataset.focus || "all";
|
||||
focusButtons.forEach((item) => item.classList.toggle("is-active", item === button));
|
||||
renderTableList();
|
||||
if (getPayload().password) {
|
||||
loadAggregate().catch(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1799,8 +2352,19 @@
|
||||
|
||||
loadTablesButton.addEventListener("click", loadTables);
|
||||
aggregateLoadButton.addEventListener("click", loadAggregate);
|
||||
refreshPreviewButton.addEventListener("click", loadPreview);
|
||||
refreshPreviewButton.addEventListener("click", () => loadPreview(currentPreviewCursor, true));
|
||||
previewPrevButton.addEventListener("click", async () => {
|
||||
if (!currentPreviewCursorHistory.length) return;
|
||||
const previousCursor = currentPreviewCursorHistory[currentPreviewCursorHistory.length - 1] || "";
|
||||
currentPreviewCursorHistory = currentPreviewCursorHistory.slice(0, -1);
|
||||
await loadPreview(previousCursor, true);
|
||||
});
|
||||
previewNextButton.addEventListener("click", async () => {
|
||||
if (!currentPreviewNextCursor) return;
|
||||
await loadPreview(currentPreviewNextCursor);
|
||||
});
|
||||
exportPreviewButton.addEventListener("click", exportPreviewToExcel);
|
||||
exportAggregateButton.addEventListener("click", exportAggregateToExcel);
|
||||
aggregateValueColumn.addEventListener("change", () => {
|
||||
renderAggregateValueOptions();
|
||||
applyAggregateValueFilter();
|
||||
@@ -1849,11 +2413,7 @@
|
||||
aggregateStartDate.value = startOfYear.toISOString().slice(0, 10);
|
||||
aggregateEndDate.value = today.toISOString().slice(0, 10);
|
||||
|
||||
const hasSavedPassword = restoreCredentials();
|
||||
if (hasSavedPassword) {
|
||||
loadTables().catch(() => {});
|
||||
loadAggregate().catch(() => {});
|
||||
}
|
||||
restoreCredentials();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
+97
-73
@@ -1048,47 +1048,27 @@
|
||||
<div class="pc-note" id="processFlowModalNote"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script id="processCostPageState" type="application/json">
|
||||
{{ {
|
||||
"projects": process_cost_projects or [],
|
||||
"selectedCode": process_cost_selected_code,
|
||||
"selectedProject": process_cost_selected_project or {},
|
||||
"relatedCodes": process_cost_related_codes or [],
|
||||
"activeRelatedCodes": process_cost_active_related_codes or [],
|
||||
"quickLinkCodes": process_cost_quick_link_codes or [],
|
||||
"source": process_cost_source,
|
||||
"selectedStartYear": process_cost_selected_start_year,
|
||||
"selectedEndYear": process_cost_selected_end_year,
|
||||
"includeRelatedEnabled": process_cost_include_related,
|
||||
"monthlyRows": (process_cost_detail.monthly_rows or []),
|
||||
} | tojson }}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script>
|
||||
(() => {
|
||||
const stateNode = document.getElementById("processCostPageState");
|
||||
const pageState = stateNode ? JSON.parse(stateNode.textContent || "{}") : {};
|
||||
const processCostProjects = Array.isArray(pageState.projects) ? pageState.projects : [];
|
||||
const bootstrapQuery = new URLSearchParams(window.location.search);
|
||||
const bootstrapDataUrl = `/process-cost/bootstrap-data?${bootstrapQuery.toString()}`;
|
||||
let pageState = {};
|
||||
let processCostProjects = [];
|
||||
const escapeHtml = (value) => String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
const processCostProjectMap = new Map(
|
||||
processCostProjects
|
||||
.filter((item) => item && item.support_dept_code)
|
||||
.map((item) => [item.support_dept_code, item])
|
||||
);
|
||||
const selectedCode = typeof pageState.selectedCode === "string" ? pageState.selectedCode : "";
|
||||
const selectedProject = pageState.selectedProject && typeof pageState.selectedProject === "object"
|
||||
? pageState.selectedProject
|
||||
: {};
|
||||
const initialRelatedCodes = Array.isArray(pageState.relatedCodes) ? pageState.relatedCodes : [];
|
||||
const initialActiveRelatedCodes = Array.isArray(pageState.activeRelatedCodes) ? pageState.activeRelatedCodes : [];
|
||||
const initialQuickLinkCodes = Array.isArray(pageState.quickLinkCodes) ? pageState.quickLinkCodes : [];
|
||||
let processCostProjectMap = new Map();
|
||||
let selectedCode = "";
|
||||
let selectedProject = {};
|
||||
let initialRelatedCodes = [];
|
||||
let initialActiveRelatedCodes = [];
|
||||
let initialQuickLinkCodes = [];
|
||||
const searchInput = document.getElementById("projectSearchInput");
|
||||
const list = document.getElementById("projectList");
|
||||
let projectListEmpty = document.getElementById("projectListEmpty");
|
||||
@@ -1101,12 +1081,48 @@
|
||||
const processModalNote = document.getElementById("processFlowModalNote");
|
||||
const processModalClose = document.getElementById("processFlowModalClose");
|
||||
const processButtons = document.querySelectorAll("[data-process-modal]");
|
||||
const selectedStartYear = pageState.selectedStartYear;
|
||||
const selectedEndYear = pageState.selectedEndYear;
|
||||
const selectedSource = typeof pageState.source === "string" ? pageState.source : "hanmac";
|
||||
const includeRelatedEnabled = Boolean(pageState.includeRelatedEnabled);
|
||||
let selectedStartYear = pageState.selectedStartYear;
|
||||
let selectedEndYear = pageState.selectedEndYear;
|
||||
let selectedSource = typeof pageState.source === "string" ? pageState.source : "hanmac";
|
||||
let includeRelatedEnabled = Boolean(pageState.includeRelatedEnabled);
|
||||
let bookmarkedCodes = Array.isArray(initialQuickLinkCodes) ? [...initialQuickLinkCodes] : [];
|
||||
let activeRelatedCodes = Array.isArray(initialActiveRelatedCodes) ? [...initialActiveRelatedCodes] : [];
|
||||
let monthlyRows = [];
|
||||
|
||||
async function loadProcessCostBootstrapData() {
|
||||
const response = await fetch(bootstrapDataUrl, {
|
||||
method: "GET",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
},
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok || payload?.error) {
|
||||
throw new Error(payload?.error || `HTTP ${response.status}`);
|
||||
}
|
||||
pageState = payload || {};
|
||||
processCostProjects = Array.isArray(pageState.projects) ? pageState.projects : [];
|
||||
processCostProjectMap = new Map(
|
||||
processCostProjects
|
||||
.filter((item) => item && item.support_dept_code)
|
||||
.map((item) => [item.support_dept_code, item])
|
||||
);
|
||||
selectedCode = typeof pageState.selectedCode === "string" ? pageState.selectedCode : "";
|
||||
selectedProject = pageState.selectedProject && typeof pageState.selectedProject === "object"
|
||||
? pageState.selectedProject
|
||||
: {};
|
||||
initialRelatedCodes = Array.isArray(pageState.relatedCodes) ? pageState.relatedCodes : [];
|
||||
initialActiveRelatedCodes = Array.isArray(pageState.activeRelatedCodes) ? pageState.activeRelatedCodes : [];
|
||||
initialQuickLinkCodes = Array.isArray(pageState.quickLinkCodes) ? pageState.quickLinkCodes : [];
|
||||
selectedStartYear = pageState.selectedStartYear;
|
||||
selectedEndYear = pageState.selectedEndYear;
|
||||
selectedSource = typeof pageState.source === "string" ? pageState.source : "hanmac";
|
||||
includeRelatedEnabled = Boolean(pageState.includeRelatedEnabled);
|
||||
bookmarkedCodes = [...initialQuickLinkCodes];
|
||||
activeRelatedCodes = [...initialActiveRelatedCodes];
|
||||
monthlyRows = Array.isArray(pageState.monthlyRows) ? pageState.monthlyRows : [];
|
||||
}
|
||||
|
||||
function buildProcessCostUrl(code, options = {}) {
|
||||
const params = new URLSearchParams();
|
||||
@@ -1342,11 +1358,6 @@
|
||||
});
|
||||
}
|
||||
|
||||
renderQuickLinks();
|
||||
syncBookmarkButton();
|
||||
syncSelectedProjectCard();
|
||||
filterProjectList();
|
||||
|
||||
if (selectedProjectBookmark) {
|
||||
selectedProjectBookmark.addEventListener("click", async () => {
|
||||
const code = selectedProjectBookmark.dataset.bookmarkCode || "";
|
||||
@@ -1491,8 +1502,6 @@
|
||||
});
|
||||
}
|
||||
|
||||
renderRelatedChips();
|
||||
|
||||
const startYearSelect = document.getElementById("processCostStartYear");
|
||||
const endYearSelect = document.getElementById("processCostEndYear");
|
||||
const syncYearRangeOptions = () => {
|
||||
@@ -1530,43 +1539,58 @@
|
||||
syncYearRangeOptions();
|
||||
}
|
||||
|
||||
const monthlyRows = Array.isArray(pageState.monthlyRows) ? pageState.monthlyRows : [];
|
||||
const svg = document.getElementById("processMonthlyChart");
|
||||
if (!svg || !Array.isArray(monthlyRows) || !monthlyRows.length) {
|
||||
return;
|
||||
}
|
||||
const renderMonthlyChart = () => {
|
||||
if (!svg || !Array.isArray(monthlyRows) || !monthlyRows.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const width = 540;
|
||||
const height = 220;
|
||||
const pad = { top: 16, right: 14, bottom: 36, left: 42 };
|
||||
const plotW = width - pad.left - pad.right;
|
||||
const plotH = height - pad.top - pad.bottom;
|
||||
const maxVal = Math.max(
|
||||
...monthlyRows.map((row) => Number(row.revenue_amount || 0)),
|
||||
...monthlyRows.map((row) => Number(row.expense_amount || 0)),
|
||||
1
|
||||
);
|
||||
const stepX = monthlyRows.length > 1 ? plotW / (monthlyRows.length - 1) : 0;
|
||||
const y = (v) => pad.top + (1 - Math.min(v / maxVal, 1)) * plotH;
|
||||
const x = (i) => pad.left + stepX * i;
|
||||
const width = 540;
|
||||
const height = 220;
|
||||
const pad = { top: 16, right: 14, bottom: 36, left: 42 };
|
||||
const plotW = width - pad.left - pad.right;
|
||||
const plotH = height - pad.top - pad.bottom;
|
||||
const maxVal = Math.max(
|
||||
...monthlyRows.map((row) => Number(row.revenue_amount || 0)),
|
||||
...monthlyRows.map((row) => Number(row.expense_amount || 0)),
|
||||
1
|
||||
);
|
||||
const stepX = monthlyRows.length > 1 ? plotW / (monthlyRows.length - 1) : 0;
|
||||
const y = (v) => pad.top + (1 - Math.min(v / maxVal, 1)) * plotH;
|
||||
const x = (i) => pad.left + stepX * i;
|
||||
|
||||
const revenuePoints = monthlyRows.map((row, i) => `${x(i)},${y(Number(row.revenue_amount || 0))}`).join(" ");
|
||||
const expensePoints = monthlyRows.map((row, i) => `${x(i)},${y(Number(row.expense_amount || 0))}`).join(" ");
|
||||
const revenuePoints = monthlyRows.map((row, i) => `${x(i)},${y(Number(row.revenue_amount || 0))}`).join(" ");
|
||||
const expensePoints = monthlyRows.map((row, i) => `${x(i)},${y(Number(row.expense_amount || 0))}`).join(" ");
|
||||
|
||||
let labels = "";
|
||||
monthlyRows.forEach((row, i) => {
|
||||
labels += `<text x="${x(i)}" y="${height - 12}" text-anchor="middle" fill="#677182" font-size="10">${escapeHtml((row.month_label || "").slice(2))}</text>`;
|
||||
});
|
||||
let labels = "";
|
||||
monthlyRows.forEach((row, i) => {
|
||||
labels += `<text x="${x(i)}" y="${height - 12}" text-anchor="middle" fill="#677182" font-size="10">${escapeHtml((row.month_label || "").slice(2))}</text>`;
|
||||
});
|
||||
|
||||
svg.innerHTML = `
|
||||
<line x1="${pad.left}" y1="${pad.top}" x2="${pad.left}" y2="${height - pad.bottom}" stroke="#d9dde3" />
|
||||
<line x1="${pad.left}" y1="${height - pad.bottom}" x2="${width - pad.right}" y2="${height - pad.bottom}" stroke="#d9dde3" />
|
||||
<polyline points="${revenuePoints}" fill="none" stroke="#2c5f96" stroke-width="2.3" />
|
||||
<polyline points="${expensePoints}" fill="none" stroke="#d9822b" stroke-width="2.3" />
|
||||
${labels}
|
||||
<text x="${pad.left}" y="${pad.top - 3}" fill="#2c5f96" font-size="11">수익</text>
|
||||
<text x="${pad.left + 36}" y="${pad.top - 3}" fill="#d9822b" font-size="11">비용</text>
|
||||
`;
|
||||
svg.innerHTML = `
|
||||
<line x1="${pad.left}" y1="${pad.top}" x2="${pad.left}" y2="${height - pad.bottom}" stroke="#d9dde3" />
|
||||
<line x1="${pad.left}" y1="${height - pad.bottom}" x2="${width - pad.right}" y2="${height - pad.bottom}" stroke="#d9dde3" />
|
||||
<polyline points="${revenuePoints}" fill="none" stroke="#2c5f96" stroke-width="2.3" />
|
||||
<polyline points="${expensePoints}" fill="none" stroke="#d9822b" stroke-width="2.3" />
|
||||
${labels}
|
||||
<text x="${pad.left}" y="${pad.top - 3}" fill="#2c5f96" font-size="11">수익</text>
|
||||
<text x="${pad.left + 36}" y="${pad.top - 3}" fill="#d9822b" font-size="11">비용</text>
|
||||
`;
|
||||
};
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
await loadProcessCostBootstrapData();
|
||||
} catch (error) {
|
||||
console.error("프로세스 원가 부트스트랩 데이터 조회 에러", error);
|
||||
}
|
||||
renderQuickLinks();
|
||||
syncBookmarkButton();
|
||||
syncSelectedProjectCard();
|
||||
filterProjectList();
|
||||
renderRelatedChips();
|
||||
renderMonthlyChart();
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
+177
-10
@@ -3666,20 +3666,23 @@
|
||||
const serverFocusCode = {{ project_focus_code | tojson }};
|
||||
const projectEditCache = new Map();
|
||||
let modalOriginalEdit = null;
|
||||
const revenueMix = {{ project_revenue_mix | tojson }};
|
||||
let revenueMix = [];
|
||||
const revenueMixMode = {{ ("monthly" if selected_year else "yearly") | tojson }};
|
||||
const projectCostRows = {{ project_cost_by_year | tojson }};
|
||||
let projectCostRows = [];
|
||||
const projectMonthlyCostRows = {{ project_monthly_cost_rows | tojson }};
|
||||
const projectStatusRows = {{ project_status_rows | tojson }};
|
||||
let projectStatusRows = [];
|
||||
const projectComparisonNotes = {{ project_comparison_notes | tojson }};
|
||||
const projectAnalysisSettings = {{ project_analysis_settings | tojson }};
|
||||
const persistedProjectPageState = {{ project_page_state | tojson }};
|
||||
const persistedProjectRelatedLinks = {{ project_related_links | tojson }};
|
||||
const persistedUncontractedCategoryOverrides = {{ project_uncontracted_classifications | tojson }};
|
||||
const projectPageSessionId = window.clientSessionId || "";
|
||||
const projectStatusMap = Object.fromEntries(projectStatusRows.map((item) => [item.support_dept_code, item]));
|
||||
const projectAccountBreakdowns = {{ project_account_breakdowns | tojson }};
|
||||
const projectCostMap = Object.fromEntries(projectCostRows.map((item) => [item.support_dept_code, item]));
|
||||
let projectStatusMap = {};
|
||||
let projectAccountBreakdowns = {};
|
||||
let projectAccountBreakdownsLoaded = false;
|
||||
let projectAccountBreakdownsLoadedCodes = new Set();
|
||||
let projectAccountBreakdownsPromise = null;
|
||||
let projectCostMap = {};
|
||||
const currencyFormatter = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 0 });
|
||||
window.__projectRelatedSelections = new Map();
|
||||
const persistedUncontractedFilterState = {
|
||||
@@ -4586,6 +4589,7 @@
|
||||
} else {
|
||||
projectStatusRows.push(projectRow);
|
||||
}
|
||||
refreshProjectDerivedCaches();
|
||||
}
|
||||
|
||||
function createCollectionRow(row = {}) {
|
||||
@@ -6126,6 +6130,126 @@
|
||||
return "";
|
||||
}
|
||||
|
||||
function buildProjectBreakdownRequestCodes(baseCode = "") {
|
||||
const normalizedBaseCode = String(baseCode || selectedCode || "").trim();
|
||||
if (!normalizedBaseCode) {
|
||||
return [];
|
||||
}
|
||||
const codeSet = new Set([normalizedBaseCode]);
|
||||
getRelatedCodes(normalizedBaseCode).forEach((code) => {
|
||||
const normalizedCode = String(code || "").trim();
|
||||
if (normalizedCode) {
|
||||
codeSet.add(normalizedCode);
|
||||
}
|
||||
});
|
||||
return [...codeSet];
|
||||
}
|
||||
|
||||
function hasProjectBreakdownDataForCodes(codes = []) {
|
||||
const requestedCodes = Array.isArray(codes)
|
||||
? codes.map((code) => String(code || "").trim()).filter(Boolean)
|
||||
: [];
|
||||
if (!requestedCodes.length) {
|
||||
return projectAccountBreakdownsLoaded;
|
||||
}
|
||||
return requestedCodes.every((code) => projectAccountBreakdownsLoadedCodes.has(code));
|
||||
}
|
||||
|
||||
async function loadProjectAccountBreakdowns(force = false, codes = []) {
|
||||
const requestedCodes = Array.isArray(codes)
|
||||
? codes.map((code) => String(code || "").trim()).filter(Boolean)
|
||||
: [];
|
||||
const needsFullLoad = !requestedCodes.length;
|
||||
if (!force) {
|
||||
if (needsFullLoad && projectAccountBreakdownsLoaded) {
|
||||
return projectAccountBreakdowns;
|
||||
}
|
||||
if (!needsFullLoad && requestedCodes.every((code) => projectAccountBreakdownsLoadedCodes.has(code))) {
|
||||
return projectAccountBreakdowns;
|
||||
}
|
||||
if (projectAccountBreakdownsPromise) {
|
||||
await projectAccountBreakdownsPromise;
|
||||
if (needsFullLoad && projectAccountBreakdownsLoaded) {
|
||||
return projectAccountBreakdowns;
|
||||
}
|
||||
if (!needsFullLoad && requestedCodes.every((code) => projectAccountBreakdownsLoadedCodes.has(code))) {
|
||||
return projectAccountBreakdowns;
|
||||
}
|
||||
}
|
||||
}
|
||||
const codesToLoad = force || needsFullLoad
|
||||
? requestedCodes
|
||||
: requestedCodes.filter((code) => !projectAccountBreakdownsLoadedCodes.has(code));
|
||||
const queryParams = new URLSearchParams();
|
||||
if (selectedYear) {
|
||||
queryParams.set("year", String(selectedYear));
|
||||
}
|
||||
if (codesToLoad.length) {
|
||||
queryParams.set("codes", codesToLoad.join(","));
|
||||
}
|
||||
const query = queryParams.toString() ? `?${queryParams.toString()}` : "";
|
||||
projectAccountBreakdownsPromise = fetch(`/projects/account-breakdowns${query}`, {
|
||||
method: "GET",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
},
|
||||
}).then(async (response) => {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok || payload?.error) {
|
||||
throw new Error(payload?.error || `HTTP ${response.status}`);
|
||||
}
|
||||
const nextPayload = payload || {};
|
||||
if (needsFullLoad) {
|
||||
projectAccountBreakdowns = nextPayload;
|
||||
projectAccountBreakdownsLoaded = true;
|
||||
projectAccountBreakdownsLoadedCodes = new Set(Object.keys(nextPayload || {}));
|
||||
} else {
|
||||
projectAccountBreakdowns = {
|
||||
...projectAccountBreakdowns,
|
||||
...nextPayload,
|
||||
};
|
||||
codesToLoad.forEach((code) => {
|
||||
projectAccountBreakdownsLoadedCodes.add(code);
|
||||
});
|
||||
}
|
||||
return projectAccountBreakdowns;
|
||||
}).catch((error) => {
|
||||
console.error("프로젝트 계정 분해 조회 에러", error);
|
||||
if (needsFullLoad) {
|
||||
projectAccountBreakdowns = {};
|
||||
projectAccountBreakdownsLoaded = false;
|
||||
projectAccountBreakdownsLoadedCodes = new Set();
|
||||
}
|
||||
throw error;
|
||||
}).finally(() => {
|
||||
projectAccountBreakdownsPromise = null;
|
||||
});
|
||||
return projectAccountBreakdownsPromise;
|
||||
}
|
||||
|
||||
async function loadProjectBootstrapData(force = false) {
|
||||
if (!force && projectStatusRows.length && projectCostRows.length) {
|
||||
return;
|
||||
}
|
||||
const query = selectedYear ? `?year=${encodeURIComponent(String(selectedYear))}` : "";
|
||||
const response = await fetch(`/projects/bootstrap-data${query}`, {
|
||||
method: "GET",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
},
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok || payload?.error) {
|
||||
throw new Error(payload?.error || `HTTP ${response.status}`);
|
||||
}
|
||||
revenueMix = Array.isArray(payload?.revenue_mix) ? payload.revenue_mix : [];
|
||||
projectCostRows = Array.isArray(payload?.project_cost_by_year) ? payload.project_cost_by_year : [];
|
||||
projectStatusRows = Array.isArray(payload?.project_status_rows) ? payload.project_status_rows : [];
|
||||
refreshProjectDerivedCaches();
|
||||
}
|
||||
|
||||
function getBreakdownList(code, kind) {
|
||||
const item = projectAccountBreakdowns?.[code] || {};
|
||||
return Array.isArray(item?.[kind]) ? item[kind] : [];
|
||||
@@ -7934,10 +8058,18 @@
|
||||
}
|
||||
|
||||
const aggregatedProjectCostDatasetCache = new Map();
|
||||
const aggregatedAllProjectCostRows = mergeProjectExplorerRows(projectCostRows, projectStatusRows);
|
||||
const aggregatedAllProjectCostMap = new Map(
|
||||
aggregatedAllProjectCostRows.map((item) => [item.support_dept_code, item]),
|
||||
);
|
||||
let aggregatedAllProjectCostRows = [];
|
||||
let aggregatedAllProjectCostMap = new Map();
|
||||
|
||||
function refreshProjectDerivedCaches() {
|
||||
projectStatusMap = Object.fromEntries(projectStatusRows.map((item) => [item.support_dept_code, item]));
|
||||
projectCostMap = Object.fromEntries(projectCostRows.map((item) => [item.support_dept_code, item]));
|
||||
aggregatedProjectCostDatasetCache.clear();
|
||||
aggregatedAllProjectCostRows = mergeProjectExplorerRows(projectCostRows, projectStatusRows);
|
||||
aggregatedAllProjectCostMap = new Map(
|
||||
aggregatedAllProjectCostRows.map((item) => [item.support_dept_code, item]),
|
||||
);
|
||||
}
|
||||
|
||||
function getAggregatedProjectCostDataset(yearFilterValue) {
|
||||
const cacheKey = String(yearFilterValue || "");
|
||||
@@ -8950,6 +9082,36 @@
|
||||
scheduleStandaloneUncontractedDashboardRender();
|
||||
return;
|
||||
}
|
||||
const requestedBreakdownCodes = buildProjectBreakdownRequestCodes(item.support_dept_code);
|
||||
if (!hasProjectBreakdownDataForCodes(requestedBreakdownCodes)) {
|
||||
relatedBarBox.innerHTML = "";
|
||||
heroBox.innerHTML = renderAnalysisHero(item);
|
||||
metricsBox.innerHTML = renderAnalysisMetrics(item);
|
||||
comparisonBox.innerHTML = `
|
||||
<div class="detail-block">
|
||||
<p class="stacked-note">프로젝트 세부 집계 데이터를 불러오는 중입니다...</p>
|
||||
</div>
|
||||
`;
|
||||
notesBox.innerHTML = `
|
||||
<div class="detail-block">
|
||||
<p class="stacked-note">프로젝트 세부 집계 데이터를 준비한 뒤 표시합니다.</p>
|
||||
</div>
|
||||
`;
|
||||
loadProjectAccountBreakdowns(false, requestedBreakdownCodes).then(() => {
|
||||
const latestItem = getAnalysisItem(item.support_dept_code);
|
||||
if (latestItem && String(currentSelectedProjectCode || "") === String(item.support_dept_code || "")) {
|
||||
renderAnalysis(latestItem);
|
||||
}
|
||||
}).catch(() => {
|
||||
comparisonBox.innerHTML = `
|
||||
<div class="detail-block">
|
||||
<p class="stacked-note">프로젝트 세부 집계 데이터를 불러오지 못했습니다. 잠시 후 다시 시도해 주세요.</p>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
scheduleStandaloneUncontractedDashboardRender();
|
||||
return;
|
||||
}
|
||||
const baseCode = String(item.support_dept_code || "").trim();
|
||||
const allRelatedItems = getRelatedItems(item.support_dept_code, { includeInactive: true });
|
||||
const relatedItems = getRelatedItems(item.support_dept_code);
|
||||
@@ -9502,6 +9664,11 @@
|
||||
(async () => {
|
||||
await loadProjectPageStateFromServer();
|
||||
await loadProjectQuickLinksFromServer();
|
||||
try {
|
||||
await loadProjectBootstrapData();
|
||||
} catch (error) {
|
||||
console.error("프로젝트 부트스트랩 데이터 조회 에러", error);
|
||||
}
|
||||
if (serverEditCode) {
|
||||
selectedCode = serverEditCode;
|
||||
currentSelectedProjectCode = serverEditCode;
|
||||
|
||||
+189
-18
@@ -16,7 +16,7 @@
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.voucher-title-wrap h2 {
|
||||
@@ -947,6 +947,38 @@
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.voucher-detail-wrap {
|
||||
max-height: 65vh;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.voucher-detail-wrap table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.voucher-detail-wrap th,
|
||||
.voucher-detail-wrap td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.voucher-detail-wrap td.cell-desc,
|
||||
.voucher-detail-wrap th.cell-desc {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.voucher-summary-action {
|
||||
min-width: 64px;
|
||||
}
|
||||
|
||||
.bridge-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
@@ -1439,10 +1471,7 @@
|
||||
|
||||
<section class="panel-shell">
|
||||
<div class="panel-header">
|
||||
<div><h2>현황</h2></div>
|
||||
<div class="panel-meta">
|
||||
기간 <strong>{{ wehago_compare.selected_start_year or '-' }} ~ {{ wehago_compare.selected_end_year or '-' }}</strong>
|
||||
</div>
|
||||
<div><h2>기간 {{ wehago_compare.selected_start_year or '-' }} ~ {{ wehago_compare.selected_end_year or '-' }}</h2></div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="status-grid">
|
||||
@@ -1613,6 +1642,21 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="bridge-modal" id="voucherDetailModal" aria-hidden="true">
|
||||
<div class="bridge-modal-dialog" role="dialog" aria-modal="true" aria-labelledby="voucherDetailModalTitle">
|
||||
<div class="bridge-modal-head">
|
||||
<div class="bridge-modal-title">
|
||||
<strong id="voucherDetailModalTitle">전표 상세</strong>
|
||||
<p id="voucherDetailModalMeta">선택한 전표의 세부 행입니다.</p>
|
||||
</div>
|
||||
<button type="button" class="button-secondary" id="voucherDetailCloseBtn">닫기</button>
|
||||
</div>
|
||||
<div class="voucher-detail-wrap" id="voucherDetailTableWrap">
|
||||
<div class="table-placeholder">상세 데이터를 불러오지 않았습니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bridge-modal" id="bridgeReviewModal" aria-hidden="true">
|
||||
<div class="bridge-modal-dialog" role="dialog" aria-modal="true" aria-labelledby="bridgeReviewModalTitle">
|
||||
<div class="bridge-modal-head">
|
||||
@@ -1718,6 +1762,14 @@
|
||||
const recheckRowStore = new Map();
|
||||
const voucherRecheckSelections = new Map();
|
||||
const voucherRecheckRowStore = new Map();
|
||||
const voucherGroupDetailStore = new Map();
|
||||
const summaryRefreshState = { timerId: null, pending: false };
|
||||
const detailRetryTimers = new Map();
|
||||
const voucherDetailModal = document.getElementById('voucherDetailModal');
|
||||
const voucherDetailCloseBtn = document.getElementById('voucherDetailCloseBtn');
|
||||
const voucherDetailTableWrap = document.getElementById('voucherDetailTableWrap');
|
||||
const voucherDetailModalTitle = document.getElementById('voucherDetailModalTitle');
|
||||
const voucherDetailModalMeta = document.getElementById('voucherDetailModalMeta');
|
||||
const pairSelections = new Map();
|
||||
const pairLedgerStore = new Map();
|
||||
const pairVoucherStore = new Map();
|
||||
@@ -1754,10 +1806,83 @@
|
||||
|
||||
const getColumnClass = (field) => `col-${String(field || '').replace(/[^a-zA-Z0-9_]/g, '_')}`;
|
||||
|
||||
const renderVoucherDetailTable = (group) => {
|
||||
if (!voucherDetailTableWrap) return;
|
||||
const rows = Array.isArray(group?.rows) ? group.rows : [];
|
||||
const lineColumns = [
|
||||
['fiscal_year', '연도'],
|
||||
['status_label', '구분'],
|
||||
['ledger_date', 'WEHAGO 일자'],
|
||||
['voucher_no', '전표번호'],
|
||||
['draft_no', '가전표번호'],
|
||||
['ledger_account_name', 'WEHAGO 계정'],
|
||||
['voucher_account_name', 'ERP 계정'],
|
||||
['ledger_vendor', 'WEHAGO 거래처'],
|
||||
['voucher_vendor', 'ERP 거래처'],
|
||||
['ledger_debit', 'WEHAGO 차변'],
|
||||
['ledger_credit', 'WEHAGO 대변'],
|
||||
['voucher_debit', 'ERP 차변'],
|
||||
['voucher_credit', 'ERP 대변'],
|
||||
['ledger_desc', 'WEHAGO 적요'],
|
||||
['voucher_desc', 'ERP 적요'],
|
||||
];
|
||||
if (!rows.length) {
|
||||
voucherDetailTableWrap.innerHTML = '<div class="table-placeholder">표시할 상세 행이 없습니다.</div>';
|
||||
return;
|
||||
}
|
||||
const head = lineColumns.map(([field, label]) => `<th class="${getColumnClass(field)} ${getCellClass(field, ['voucher_no'])}">${escapeHtml(label)}</th>`).join('');
|
||||
const body = rows.map((row) => {
|
||||
const cells = lineColumns.map(([field]) => {
|
||||
const raw = row[field];
|
||||
const display = typeof raw === 'number'
|
||||
? (Number.isInteger(raw) ? raw.toLocaleString() : raw.toLocaleString(undefined, { maximumFractionDigits: 2 }))
|
||||
: String(raw ?? '');
|
||||
return `<td class="${getColumnClass(field)} ${getCellClass(field, ['voucher_no'])}" title="${escapeHtml(display)}">${escapeHtml(display)}</td>`;
|
||||
}).join('');
|
||||
return `<tr>${cells}</tr>`;
|
||||
}).join('');
|
||||
voucherDetailTableWrap.innerHTML = `<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
|
||||
};
|
||||
|
||||
const openVoucherDetailModal = (groupKey) => {
|
||||
const group = voucherGroupDetailStore.get(groupKey);
|
||||
if (!group || !voucherDetailModal) return;
|
||||
const summary = group.summary || {};
|
||||
if (voucherDetailModalTitle) {
|
||||
voucherDetailModalTitle.textContent = `${summary.voucher_no || '전표'} 상세`;
|
||||
}
|
||||
if (voucherDetailModalMeta) {
|
||||
const bits = [
|
||||
summary.ledger_date || '',
|
||||
summary.draft_no || '',
|
||||
`WEHAGO ${Number(summary.ledger_row_count || 0).toLocaleString()}행`,
|
||||
`ERP ${Number(summary.voucher_row_count || 0).toLocaleString()}행`,
|
||||
].filter(Boolean);
|
||||
voucherDetailModalMeta.textContent = bits.join(' / ');
|
||||
}
|
||||
renderVoucherDetailTable(group);
|
||||
voucherDetailModal.classList.add('active');
|
||||
voucherDetailModal.setAttribute('aria-hidden', 'false');
|
||||
};
|
||||
|
||||
const closeVoucherDetailModal = () => {
|
||||
if (!voucherDetailModal) return;
|
||||
voucherDetailModal.classList.remove('active');
|
||||
voucherDetailModal.setAttribute('aria-hidden', 'true');
|
||||
};
|
||||
|
||||
voucherDetailCloseBtn?.addEventListener('click', closeVoucherDetailModal);
|
||||
voucherDetailModal?.addEventListener('click', (event) => {
|
||||
if (event.target === voucherDetailModal) {
|
||||
closeVoucherDetailModal();
|
||||
}
|
||||
});
|
||||
|
||||
const renderVoucherGroups = (container, payload, append = false, statusKey = '') => {
|
||||
if (!container) return;
|
||||
const groups = payload.groups || [];
|
||||
const includeReviewCheckbox = statusKey === 'voucher_recheck';
|
||||
const pendingPlaceholder = String(payload?.notice || '').trim() || '조건에 맞는 항목이 없습니다.';
|
||||
if (includeReviewCheckbox && !append) {
|
||||
voucherRecheckSelections.clear();
|
||||
voucherRecheckRowStore.clear();
|
||||
@@ -1815,8 +1940,18 @@
|
||||
['ledger_desc', 'WEHAGO 적요'],
|
||||
['voucher_desc', 'ERP 적요'],
|
||||
];
|
||||
const stripAccountCodePrefix = (value) => {
|
||||
const text = String(value ?? '');
|
||||
return text
|
||||
.split(',')
|
||||
.map((part) => part.trim().replace(/^\d+\s+/, ''))
|
||||
.join(', ');
|
||||
};
|
||||
const formatValue = (field, raw) => {
|
||||
if (field === 'fiscal_year') return String(raw ?? '');
|
||||
if (['ledger_account_name', 'voucher_account_name', 'ledger_accounts', 'voucher_accounts'].includes(field)) {
|
||||
return stripAccountCodePrefix(raw);
|
||||
}
|
||||
if (typeof raw === 'number') {
|
||||
return Number.isInteger(raw)
|
||||
? raw.toLocaleString()
|
||||
@@ -1825,16 +1960,16 @@
|
||||
return String(raw ?? '');
|
||||
};
|
||||
if (!groups.length) {
|
||||
const summaryRows = Array.isArray(payload.rows) ? payload.rows : [];
|
||||
if (!summaryRows.length && !append) {
|
||||
container.innerHTML = '<div class="table-placeholder">조건에 맞는 항목이 없습니다.</div>';
|
||||
const fallbackRows = Array.isArray(payload.rows) ? payload.rows : [];
|
||||
if (!fallbackRows.length && !append) {
|
||||
container.innerHTML = `<div class="table-placeholder">${escapeHtml(pendingPlaceholder)}</div>`;
|
||||
return;
|
||||
}
|
||||
const lineHead = [
|
||||
includeReviewCheckbox ? '<th class="selection-col">선택</th>' : '',
|
||||
...lineColumns.map(([field, label]) => `<th class="${getColumnClass(field)}">${escapeHtml(label)}</th>`),
|
||||
].join('');
|
||||
const summaryBody = summaryRows.map((row, summaryIndex) => {
|
||||
const fallbackBody = fallbackRows.map((row, summaryIndex) => {
|
||||
const groupKey = `${row.fiscal_year || ''}|${row.voucher_no || ''}|${row.draft_no || ''}|${row.ledger_date || ''}|${row.proof_date || ''}|${summaryIndex}`;
|
||||
const cells = lineColumns.map(([field]) => {
|
||||
const display = formatValue(field, row[field]);
|
||||
@@ -1848,20 +1983,20 @@
|
||||
}
|
||||
return `<tr>${checkboxCell}${cells}</tr>`;
|
||||
}).join('');
|
||||
const summaryTable = `
|
||||
const fallbackTable = `
|
||||
<table>
|
||||
<thead><tr>${lineHead}</tr></thead>
|
||||
<tbody>${summaryBody}</tbody>
|
||||
<tbody>${fallbackBody}</tbody>
|
||||
</table>
|
||||
`;
|
||||
if (!append || !container.querySelector('.voucher-group-lines')) {
|
||||
container.innerHTML = `<div class="voucher-group-lines">${summaryTable}</div>`;
|
||||
container.innerHTML = `<div class="voucher-group-lines">${fallbackTable}</div>`;
|
||||
} else {
|
||||
const tbody = container.querySelector('.voucher-group-lines tbody');
|
||||
if (tbody) {
|
||||
tbody.insertAdjacentHTML('beforeend', summaryBody);
|
||||
tbody.insertAdjacentHTML('beforeend', fallbackBody);
|
||||
} else {
|
||||
container.innerHTML = `<div class="voucher-group-lines">${summaryTable}</div>`;
|
||||
container.innerHTML = `<div class="voucher-group-lines">${fallbackTable}</div>`;
|
||||
}
|
||||
}
|
||||
bindVoucherRecheckCheckboxes();
|
||||
@@ -1930,7 +2065,7 @@
|
||||
const columns = payload.columns || [];
|
||||
const rows = payload.rows || [];
|
||||
if (!rows.length && !append) {
|
||||
container.innerHTML = '<div class="table-placeholder">조건에 맞는 항목이 없습니다.</div>';
|
||||
container.innerHTML = `<div class="table-placeholder">${escapeHtml(String(payload?.notice || '').trim() || '조건에 맞는 항목이 없습니다.')}</div>`;
|
||||
return;
|
||||
}
|
||||
const includeReviewCheckbox = statusKey === 'amount_mismatch';
|
||||
@@ -2021,6 +2156,14 @@
|
||||
const shownText = shownCount
|
||||
? ` / 현재 ${Number(shownCount || 0).toLocaleString()}건`
|
||||
: '';
|
||||
const readyYears = Array.isArray(stats.ready_years) ? stats.ready_years : [];
|
||||
const pendingYears = Array.isArray(stats.pending_years) ? stats.pending_years : [];
|
||||
const readyText = readyYears.length
|
||||
? ` / 준비 연도 ${readyYears.join(', ')}`
|
||||
: '';
|
||||
const pendingText = pendingYears.length
|
||||
? ` / 갱신 중 ${pendingYears.join(', ')}`
|
||||
: '';
|
||||
const noticeText = notice ? ` / ${notice}` : '';
|
||||
const bankPayableCount = Number(stats.bank_payable_case_count || 0);
|
||||
const bankPayableText = key === 'matched' && bankPayableCount
|
||||
@@ -2030,7 +2173,7 @@
|
||||
const boundaryText = key === 'ledger_only' && boundaryCount
|
||||
? ` <button type="button" class="case-stat" data-case-filter="boundary_excluded">연초/연말 대체·이월 : ${boundaryCount.toLocaleString()}</button>`
|
||||
: '';
|
||||
target.innerHTML = `${escapeHtml(`${countText}${shownText}${noticeText}`)}${bankPayableText}${boundaryText}`;
|
||||
target.innerHTML = `${escapeHtml(`${countText}${shownText}${readyText}${pendingText}${noticeText}`)}${bankPayableText}${boundaryText}`;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2193,6 +2336,17 @@
|
||||
if (undoLastActionBtn) {
|
||||
undoLastActionBtn.disabled = !(lastAction && lastAction.id);
|
||||
}
|
||||
const pending = Boolean(payload?.pending);
|
||||
summaryRefreshState.pending = pending;
|
||||
if (summaryRefreshState.timerId) {
|
||||
clearTimeout(summaryRefreshState.timerId);
|
||||
summaryRefreshState.timerId = null;
|
||||
}
|
||||
if (pending) {
|
||||
summaryRefreshState.timerId = window.setTimeout(() => {
|
||||
loadDashboardSummary();
|
||||
}, 4000);
|
||||
}
|
||||
};
|
||||
|
||||
const renderSnapshotStatus = (payload) => {
|
||||
@@ -3140,6 +3294,10 @@
|
||||
const loadStatusRows = async (statusKey, form = null, append = false) => {
|
||||
const wrap = document.querySelector(`[data-table-wrap="${statusKey}"]`);
|
||||
if (!wrap) return;
|
||||
if (detailRetryTimers.has(statusKey)) {
|
||||
clearTimeout(detailRetryTimers.get(statusKey));
|
||||
detailRetryTimers.delete(statusKey);
|
||||
}
|
||||
const current = detailState.get(statusKey) || { offset: 0, totalCount: 0, loading: false };
|
||||
if (current.loading) return;
|
||||
if (!append && form) {
|
||||
@@ -3147,20 +3305,26 @@
|
||||
cumulativeFiltersByStatus.set(statusKey, selected);
|
||||
renderActiveFilterCards(statusKey);
|
||||
}
|
||||
const usesCursorPaging = ['voucher_matched', 'erp_voucher_matched', 'voucher_unmatched', 'erp_voucher_unmatched', 'voucher_recheck'].includes(statusKey);
|
||||
const nextOffset = append ? (current.nextOffset || 0) : 0;
|
||||
const nextCursor = append ? String(current.nextCursor || '') : '';
|
||||
detailState.set(statusKey, { ...current, loading: true, form });
|
||||
setLoadMoreState(statusKey, true, true);
|
||||
if (!append) {
|
||||
wrap.innerHTML = '<div class="table-placeholder">조회 중입니다...</div>';
|
||||
}
|
||||
try {
|
||||
const pageLimit = ['voucher_matched', 'erp_voucher_matched', 'voucher_unmatched', 'erp_voucher_unmatched', 'voucher_recheck'].includes(statusKey) ? 24 : 60;
|
||||
const payload = await fetchJson(`/wehago-compare/api/status-rows?${buildStatusQuery(form, statusKey, { offset: nextOffset, limit: pageLimit })}`);
|
||||
const pageLimit = usesCursorPaging ? 24 : 60;
|
||||
const extra = usesCursorPaging
|
||||
? { offset: append ? nextOffset : 0, cursor: nextCursor, limit: pageLimit }
|
||||
: { offset: nextOffset, limit: pageLimit };
|
||||
const payload = await fetchJson(`/wehago-compare/api/status-rows?${buildStatusQuery(form, statusKey, extra)}`);
|
||||
renderTable(wrap, payload, ['voucher_no'], append, statusKey);
|
||||
const shownCount = append ? nextOffset + payload.shown_count : payload.shown_count;
|
||||
detailState.set(statusKey, {
|
||||
offset: payload.offset,
|
||||
nextOffset: payload.next_offset,
|
||||
nextCursor: payload.next_cursor || '',
|
||||
totalCount: payload.total_count,
|
||||
hasMore: payload.has_more,
|
||||
loading: false,
|
||||
@@ -3169,6 +3333,13 @@
|
||||
});
|
||||
setMeta(statusKey, payload.total_count, shownCount, payload.notice, payload);
|
||||
setLoadMoreState(statusKey, payload.has_more, false);
|
||||
const rebuildingNotice = Boolean(payload?.pending) || String(payload.notice || '').includes('최신 전표 스냅샷을 갱신 중');
|
||||
if (!append && rebuildingNotice) {
|
||||
detailRetryTimers.set(statusKey, window.setTimeout(() => {
|
||||
detailRetryTimers.delete(statusKey);
|
||||
loadStatusRows(statusKey, form, false);
|
||||
}, 4000));
|
||||
}
|
||||
} catch (error) {
|
||||
if (!append) {
|
||||
wrap.innerHTML = `<div class="table-placeholder">${escapeHtml(error.message)}</div>`;
|
||||
|
||||
Reference in New Issue
Block a user