Refactor data loading and query projections

This commit is contained in:
b17301
2026-05-18 21:01:46 +09:00
parent 49a7a45070
commit 2ab74bac88
11 changed files with 6218 additions and 968 deletions
+617 -57
View File
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
@@ -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 %}