Preserve latest comparison functionality

This commit is contained in:
b17301
2026-06-08 14:25:47 +09:00
parent b93289bfa8
commit 886192ae58
12 changed files with 7966 additions and 485 deletions
+212 -1
View File
@@ -121,6 +121,104 @@
color: var(--ink);
}
.last-login-button {
border: 0;
background: transparent;
box-shadow: none;
padding: 0;
min-height: 0;
color: var(--ink);
font: inherit;
font-weight: 700;
line-height: 1.25;
text-align: left;
text-decoration: none;
cursor: pointer;
}
.last-login-button:hover,
.last-login-button:focus {
background: transparent;
box-shadow: none;
text-decoration: none;
}
.login-history-modal {
position: fixed;
inset: 0;
z-index: 80;
display: none;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(17, 24, 39, 0.28);
}
.login-history-modal.open {
display: flex;
}
.login-history-dialog {
width: min(1080px, calc(100vw - 48px));
max-height: min(760px, calc(100vh - 48px));
overflow: auto;
border: 1px solid var(--line);
border-radius: 0;
background: #ffffff;
box-shadow: none;
padding: 18px;
}
.login-history-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.login-history-head strong {
font-size: 18px;
line-height: 1.3;
}
.login-history-close {
border: 0;
background: transparent;
box-shadow: none;
color: var(--ink);
padding: 0;
min-height: 0;
font-weight: 800;
cursor: pointer;
}
.login-history-close:hover,
.login-history-close:focus {
background: transparent;
box-shadow: none;
}
.login-history-table-wrap {
overflow: auto;
}
.login-history-table {
width: 100%;
table-layout: fixed;
}
.login-history-table th,
.login-history-table td {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.login-history-table .ua-cell {
max-width: 360px;
}
@media (max-width: 1100px) {
.admin-user-main-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -201,12 +299,14 @@
<th>페이지 권한</th>
<th>상태</th>
<th>관리자</th>
<th>최종접속기록(KST)</th>
<th>수정일</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr
data-user-id="{{ user.id }}"
data-username="{{ user.username }}"
data-display-name="{{ user.display_name }}"
data-role="{{ 'admin' if user.is_admin else 'viewer' }}"
@@ -232,14 +332,35 @@
</td>
<td>{{ '활성' if user.is_active else '비활성' }}</td>
<td>{{ '예' if user.is_admin else '-' }}</td>
<td>
{% if user.last_login_at %}
<button type="button" class="last-login-button" data-login-history-user="{{ user.id }}">
{{ user.last_login_at_kst }}
</button>
{% else %}
<span class="muted">-</span>
{% endif %}
</td>
<td>{{ user.updated_at }}</td>
</tr>
{% else %}
<tr><td colspan="7" class="empty">사용자가 없습니다.</td></tr>
<tr><td colspan="8" class="empty">사용자가 없습니다.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
<div class="login-history-modal" id="loginHistoryModal" aria-hidden="true">
<div class="login-history-dialog" role="dialog" aria-modal="true" aria-labelledby="loginHistoryTitle">
<div class="login-history-head">
<strong id="loginHistoryTitle">접속이력</strong>
<button type="button" class="login-history-close" id="loginHistoryClose">닫기</button>
</div>
<div id="loginHistoryBody" class="login-history-table-wrap">
<div class="empty">접속이력을 불러오지 않았습니다.</div>
</div>
</div>
</div>
{% endblock %}
{% block script %}
@@ -253,6 +374,79 @@
const role = form.querySelector("[name='role']");
const active = form.querySelector("[name='is_active']");
const permissionInputs = [...form.querySelectorAll("[name='permissions']")];
const loginHistoryModal = document.getElementById("loginHistoryModal");
const loginHistoryTitle = document.getElementById("loginHistoryTitle");
const loginHistoryBody = document.getElementById("loginHistoryBody");
const loginHistoryClose = document.getElementById("loginHistoryClose");
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function openLoginHistoryModal() {
loginHistoryModal?.classList.add("open");
loginHistoryModal?.setAttribute("aria-hidden", "false");
}
function closeLoginHistoryModal() {
loginHistoryModal?.classList.remove("open");
loginHistoryModal?.setAttribute("aria-hidden", "true");
}
function renderLoginHistory(payload) {
const events = Array.isArray(payload?.events) ? payload.events : [];
if (!events.length) {
loginHistoryBody.innerHTML = '<div class="empty">접속이력이 없습니다.</div>';
return;
}
loginHistoryBody.innerHTML = `
<table class="login-history-table">
<thead>
<tr>
<th>일시(KST)</th>
<th>결과</th>
<th>아이디</th>
<th>IP</th>
<th>실패사유</th>
<th>사용환경</th>
</tr>
</thead>
<tbody>
${events.map((event) => `
<tr>
<td title="${escapeHtml(event.created_at_kst || event.created_at)}">${escapeHtml(event.created_at_kst || event.created_at)}</td>
<td>${event.success ? "성공" : "실패"}</td>
<td title="${escapeHtml(event.username)}">${escapeHtml(event.username || "-")}</td>
<td title="${escapeHtml(event.ip_address)}">${escapeHtml(event.ip_address || "-")}</td>
<td title="${escapeHtml(event.failure_reason)}">${escapeHtml(event.failure_reason || "-")}</td>
<td class="ua-cell" title="${escapeHtml(event.user_agent)}">${escapeHtml(event.user_agent || "-")}</td>
</tr>
`).join("")}
</tbody>
</table>
`;
}
async function loadLoginHistory(userId, label) {
loginHistoryTitle.textContent = `${label || "사용자"} 접속이력`;
loginHistoryBody.innerHTML = '<div class="empty">접속이력을 불러오는 중입니다.</div>';
openLoginHistoryModal();
try {
const response = await fetch(`/admin/users/${encodeURIComponent(userId)}/login-events?limit=100`, {
headers: { Accept: "application/json" },
});
const payload = await response.json();
if (!response.ok) throw new Error(payload?.error || "접속이력을 불러오지 못했습니다.");
renderLoginHistory(payload);
} catch (error) {
loginHistoryBody.innerHTML = `<div class="empty">${escapeHtml(error.message || "접속이력을 불러오지 못했습니다.")}</div>`;
}
}
document.querySelectorAll(".user-edit-button").forEach((button) => {
button.addEventListener("click", () => {
@@ -270,6 +464,23 @@
});
});
document.querySelectorAll(".last-login-button").forEach((button) => {
button.addEventListener("click", () => {
const row = button.closest("tr");
loadLoginHistory(button.dataset.loginHistoryUser, row?.dataset.username || button.textContent.trim());
});
});
loginHistoryClose?.addEventListener("click", closeLoginHistoryModal);
loginHistoryModal?.addEventListener("click", (event) => {
if (event.target === loginHistoryModal) closeLoginHistoryModal();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && loginHistoryModal?.classList.contains("open")) {
closeLoginHistoryModal();
}
});
role.addEventListener("change", () => {
const isAdmin = role.value === "admin";
permissionInputs.forEach((input) => {
+18 -10
View File
@@ -82,28 +82,34 @@
.nav {
display: flex;
gap: 10px;
flex-wrap: wrap;
gap: 7px;
flex-wrap: nowrap;
align-items: center;
background: rgba(255, 255, 255, 0.9);
border: 1px solid var(--line);
border-radius: 18px;
padding: 8px;
padding: 7px 8px;
box-shadow: 0 10px 24px rgba(21, 24, 29, 0.06);
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
}
.nav-spacer {
flex: 1 1 auto;
min-width: 12px;
min-width: 6px;
}
.nav a {
flex: 0 0 auto;
text-decoration: none;
color: var(--ink);
padding: 9px 13px;
padding: 8px 10px;
border-radius: 10px;
font-weight: 700;
font-size: 14px;
font-size: 13px;
line-height: 1.15;
white-space: nowrap;
transition: background 0.18s ease, color 0.18s ease, border-color 0.18s ease;
}
@@ -519,8 +525,9 @@
}
.sync-status {
flex: 0 0 auto;
margin-left: auto;
max-width: 320px;
max-width: 230px;
border: 1px solid var(--line);
border-radius: 999px;
background: rgba(248, 250, 252, 0.92);
@@ -535,10 +542,11 @@
.view-mode-switch {
position: relative;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 80px;
width: 72px;
height: var(--status-widget-height);
min-height: var(--status-widget-height);
border-radius: 999px;
@@ -589,11 +597,11 @@
}
.view-mode-switch[data-mode="dual"]::before {
transform: translateX(38px);
transform: translateX(30px);
}
.view-mode-switch[data-mode="dual"] .label {
left: 41px;
left: 33px;
}
.view-mode-switch[data-mode="single"]::before {
+86 -39
View File
@@ -53,8 +53,8 @@
.cost-kpis {
display: grid;
grid-template-columns: repeat(10, minmax(0, 1fr));
gap: 8px;
grid-template-columns: repeat(9, minmax(0, 1fr));
gap: 6px;
}
.cost-kpi {
@@ -69,6 +69,9 @@
font-size: 11px;
font-weight: 800;
margin-bottom: 5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cost-kpi strong {
@@ -77,6 +80,8 @@
line-height: 1.15;
white-space: nowrap;
word-break: keep-all;
overflow: hidden;
text-overflow: ellipsis;
}
.summary-accordion {
@@ -174,7 +179,8 @@
}
.cost-table {
min-width: 2820px;
width: 100%;
min-width: 0;
table-layout: fixed;
border-collapse: separate;
border-spacing: 0;
@@ -182,8 +188,8 @@
.cost-table th,
.cost-table td {
padding: 8px 9px;
font-size: 12px;
padding: 7px 5px;
font-size: 11px;
vertical-align: middle;
border-right: 1px solid #e6e8ec;
background: #ffffff;
@@ -238,29 +244,50 @@
position: sticky;
left: 0;
z-index: 6;
background: #ffffff;
background-clip: padding-box;
box-shadow: 1px 0 0 #d9dde3;
}
.sticky-col-2 {
position: sticky;
left: 130px;
left: 94px;
z-index: 6;
background: #ffffff;
background-clip: padding-box;
box-shadow: 1px 0 0 #d9dde3;
}
.sticky-col-3 {
position: sticky;
left: 236px;
left: 172px;
z-index: 6;
background: #ffffff;
background-clip: padding-box;
box-shadow: 1px 0 0 #d9dde3;
}
thead .sticky-col,
thead .sticky-col-2,
thead .sticky-col-3 {
background: #edf4f4;
z-index: 70 !important;
}
.cost-table tbody td.sticky-col,
.cost-table tbody td.sticky-col-2,
.cost-table tbody td.sticky-col-3 {
background: #ffffff;
background-clip: padding-box;
z-index: 16;
}
.cost-table tbody tr:hover td.sticky-col,
.cost-table tbody tr:hover td.sticky-col-2,
.cost-table tbody tr:hover td.sticky-col-3 {
background: #f8fbfb;
}
.summary-table-body td {
background: #fbfcfd;
font-weight: 900;
@@ -308,12 +335,15 @@
.summary-table-body .sticky-col-3 {
z-index: 8;
background: #fbfcfd;
background-clip: padding-box;
}
.summary-table-body.open .summary-data-row .sticky-col,
.summary-table-body.open .summary-data-row .sticky-col-2,
.summary-table-body.open .summary-data-row .sticky-col-3 {
z-index: 58;
background: #fbfcfd;
background-clip: padding-box;
}
.summary-row-toggle {
@@ -450,13 +480,13 @@
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.12);
}
.col-pm { width: 130px; }
.col-code { width: 106px; }
.col-name { width: 240px; }
.col-small { width: 82px; }
.col-date { width: 104px; }
.col-money { width: 118px; text-align: right; }
.col-rate { width: 86px; text-align: right; }
.col-pm { width: 94px; }
.col-code { width: 78px; }
.col-name { width: 148px; }
.col-small { width: 54px; }
.col-date { width: 72px; }
.col-money { width: 72px; text-align: right; }
.col-rate { width: 62px; text-align: right; }
.group-pre { background: #f3f8ee !important; }
.group-during { background: #eef5fb !important; }
@@ -475,7 +505,7 @@
background: transparent;
color: #111827;
box-shadow: none;
font-size: 12px;
font-size: 11px;
font-weight: 800;
text-align: right;
}
@@ -520,7 +550,7 @@
background: transparent;
color: #0f766e;
box-shadow: none;
font-size: 12px;
font-size: 11px;
font-weight: 900;
text-align: left;
}
@@ -588,7 +618,7 @@
@media (max-width: 720px) {
.cost-kpis {
overflow-x: auto;
grid-template-columns: repeat(10, minmax(96px, 1fr));
grid-template-columns: repeat(9, minmax(96px, 1fr));
}
.summary-grid {
@@ -600,12 +630,6 @@
{% block content %}
<section class="panel cost-analysis-shell">
<div class="section-title">
<div>
<h2>프로젝트 손익분석</h2>
</div>
</div>
<form class="cost-toolbar" id="costAnalysisForm">
<div class="field">
<label>조회방식</label>
@@ -681,7 +705,7 @@
<th colspan="4" class="group-pre">사업전</th>
<th colspan="5" class="group-during">사업중</th>
<th colspan="5" class="group-post">사업후</th>
<th colspan="8" class="group-total">총계</th>
<th colspan="9" class="group-total">총계</th>
</tr>
<tr>
<th colspan="2" class="group-pre sub-cost">원가</th>
@@ -698,6 +722,7 @@
<th rowspan="2" class="group-total col-money"><button type="button" class="sort-button" data-sort-key="profit_amount" data-sort-type="number">이윤</button></th>
<th rowspan="2" class="group-total col-rate"><button type="button" class="sort-button" data-sort-key="revenue_profit_rate" data-sort-type="number">청구수익률</button></th>
<th rowspan="2" class="group-total col-rate"><button type="button" class="sort-button" data-sort-key="collection_profit_rate" data-sort-type="number">수금수익률</button></th>
<th rowspan="2" class="group-total col-rate"><button type="button" class="sort-button" data-sort-key="cumulative_profit_rate" data-sort-type="number">누적수익률</button></th>
</tr>
<tr>
<th class="group-pre col-money"><button type="button" class="sort-button" data-sort-key="phases.pre.labor" data-sort-type="number">인건비</button></th>
@@ -718,7 +743,7 @@
</thead>
<tbody class="summary-table-body" id="summaryTableBody">
<tr class="summary-control-row">
<td colspan="35">
<td colspan="36">
<button type="button" class="summary-row-toggle" id="summaryToggle" aria-expanded="false">
<span class="summary-row-arrow"></span>
<span>합계</span>
@@ -728,7 +753,7 @@
<tr class="summary-data-row" id="summaryTableRow"></tr>
</tbody>
<tbody id="costAnalysisBody">
<tr><td colspan="35" class="empty">조회 중입니다.</td></tr>
<tr><td colspan="36" class="empty">조회 중입니다.</td></tr>
</tbody>
</table>
</div>
@@ -897,14 +922,13 @@
const items = [
["프로젝트", `${formatNumber(summary.project_count)}`],
["도급금액", formatNumber(summary.contract_amount)],
["청구금액", formatNumber(summary.billing_amount)],
["청구수익", formatNumber(summary.revenue_amount)],
["수금금액", formatNumber(summary.collection_amount)],
["기간 청구금액", formatNumber(summary.billing_amount)],
["기간 수금금액", formatNumber(summary.collection_amount)],
["잔여도급금액", formatBalance(summary.contract_balance_amount)],
["총비용", formatNumber(summary.total_cost)],
["이윤", formatNumber(summary.profit_amount)],
["청구수익률", formatRate(summary.revenue_profit_rate)],
["수금수익률", formatRate(summary.collection_profit_rate)],
["누적수익률", formatRate(summary.cumulative_profit_rate)],
];
kpis.innerHTML = items.map(([label, value]) => `<div class="cost-kpi"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`).join("");
}
@@ -963,6 +987,7 @@
${moneyCell(summary.profit_amount)}
${rateCell(summary.revenue_profit_rate)}
${rateCell(summary.collection_profit_rate)}
${rateCell(summary.cumulative_profit_rate)}
`;
}
@@ -1024,7 +1049,7 @@
function renderTable() {
const rows = latestDisplayRows.length ? latestDisplayRows : getFilteredRows();
if (!rows.length) {
body.innerHTML = `<tr><td colspan="35" class="empty">조건에 맞는 데이터가 없습니다.</td></tr>`;
body.innerHTML = `<tr><td colspan="36" class="empty">조건에 맞는 데이터가 없습니다.</td></tr>`;
return;
}
const viewportHeight = tableWrap?.clientHeight || 600;
@@ -1035,8 +1060,8 @@
const topHeight = startIndex * virtualRowHeight;
const bottomHeight = Math.max(0, (rows.length - endIndex) * virtualRowHeight);
const visibleRows = rows.slice(startIndex, endIndex);
const topSpacer = topHeight ? `<tr class="virtual-spacer"><td colspan="35" style="height:${topHeight}px;padding:0;border:0;background:#fff;"></td></tr>` : "";
const bottomSpacer = bottomHeight ? `<tr class="virtual-spacer"><td colspan="35" style="height:${bottomHeight}px;padding:0;border:0;background:#fff;"></td></tr>` : "";
const topSpacer = topHeight ? `<tr class="virtual-spacer"><td colspan="36" style="height:${topHeight}px;padding:0;border:0;background:#fff;"></td></tr>` : "";
const bottomSpacer = bottomHeight ? `<tr class="virtual-spacer"><td colspan="36" style="height:${bottomHeight}px;padding:0;border:0;background:#fff;"></td></tr>` : "";
body.innerHTML = topSpacer + visibleRows.map((row, visibleOffset) => {
const rowIndex = startIndex + visibleOffset;
const pre = row.phases?.pre || {};
@@ -1083,6 +1108,7 @@
<td class="col-money">${formatNumber(row.profit_amount)}</td>
<td class="ratio-cell">${formatRate(row.revenue_profit_rate)}</td>
<td class="ratio-cell">${formatRate(row.collection_profit_rate)}</td>
<td class="ratio-cell">${formatRate(row.cumulative_profit_rate)}</td>
</tr>
`;
}).join("") + bottomSpacer;
@@ -1091,10 +1117,20 @@
function recalcVisibleSummary() {
const rows = getFilteredRows();
latestDisplayRows = rows;
const summary = rows.reduce((acc, row) => {
const buildSummary = (usePeriodValues = false) => rows.reduce((acc, row) => {
for (const key of ["contract_amount", "billing_amount", "collection_amount", "contract_balance_amount", "revenue_amount", "cost_total", "sga_total", "sales_total", "total_cost", "profit_amount"]) {
acc[key] += Number(row[key] || 0);
}
if (usePeriodValues) {
acc.billing_amount += Number(row.period_billing_amount || 0) - Number(row.billing_amount || 0);
acc.collection_amount += Number(row.period_collection_amount || 0) - Number(row.collection_amount || 0);
acc.revenue_amount += Number(row.period_revenue_amount || 0) - Number(row.revenue_amount || 0);
acc.cost_total += Number(row.period_cost_total || 0) - Number(row.cost_total || 0);
acc.sga_total += Number(row.period_sga_total || 0) - Number(row.sga_total || 0);
acc.sales_total += Number(row.period_sales_total || 0) - Number(row.sales_total || 0);
acc.total_cost += Number(row.period_total_cost || 0) - Number(row.total_cost || 0);
acc.profit_amount += Number(row.period_profit_amount || 0) - Number(row.profit_amount || 0);
}
for (const phase of ["pre", "during", "post"]) {
const bucket = row.phases?.[phase] || {};
for (const item of ["labor", "outsource", "overhead", "sga", "sales"]) {
@@ -1115,17 +1151,26 @@
sales_total: 0,
total_cost: 0,
profit_amount: 0,
cumulative_profit_amount: 0,
phases: {
pre: { labor: 0, outsource: 0, overhead: 0, sga: 0, sales: 0 },
during: { labor: 0, outsource: 0, overhead: 0, sga: 0, sales: 0 },
post: { labor: 0, outsource: 0, overhead: 0, sga: 0, sales: 0 },
},
});
const summary = buildSummary(false);
summary.collection_rate = summary.contract_amount ? summary.collection_amount / summary.contract_amount * 100 : 0;
summary.contract_profit_rate = summary.contract_amount ? summary.profit_amount / summary.contract_amount * 100 : 0;
summary.revenue_profit_rate = summary.revenue_amount ? summary.profit_amount / summary.revenue_amount * 100 : 0;
summary.collection_profit_rate = summary.collection_amount ? summary.profit_amount / summary.collection_amount * 100 : 0;
renderKpis(summary);
summary.cumulative_profit_rate = summary.collection_amount ? (summary.collection_amount - summary.total_cost) / summary.collection_amount * 100 : 0;
const kpiSummary = buildSummary(true);
kpiSummary.collection_rate = kpiSummary.contract_amount ? kpiSummary.collection_amount / kpiSummary.contract_amount * 100 : 0;
kpiSummary.contract_profit_rate = kpiSummary.contract_amount ? kpiSummary.profit_amount / kpiSummary.contract_amount * 100 : 0;
kpiSummary.revenue_profit_rate = kpiSummary.revenue_amount ? kpiSummary.profit_amount / kpiSummary.revenue_amount * 100 : 0;
kpiSummary.collection_profit_rate = kpiSummary.collection_amount ? kpiSummary.profit_amount / kpiSummary.collection_amount * 100 : 0;
kpiSummary.cumulative_profit_rate = summary.cumulative_profit_rate;
renderKpis(kpiSummary);
renderSummaryTable(summary);
}
@@ -1136,7 +1181,9 @@
const controller = activeLoadController;
searchButton.disabled = true;
searchButton.textContent = "조회 중";
body.innerHTML = `<tr><td colspan="35" class="empty">조회 중입니다.</td></tr>`;
kpis.innerHTML = `<div class="cost-kpi"><span>조회</span><strong>조회 중</strong></div>`;
summaryTableRow.innerHTML = "";
body.innerHTML = `<tr><td colspan="36" class="empty">조회 중입니다.</td></tr>`;
try {
const params = new URLSearchParams({ start_date: lockedStartDate, end_date: lockedEndDate, mode: currentMode });
const response = await fetch(`/cost-analysis/data?${params.toString()}`, {
@@ -1365,7 +1412,7 @@
event.preventDefault();
rememberDateInputs();
loadData().catch((error) => {
body.innerHTML = `<tr><td colspan="35" class="empty">${escapeHtml(error.message)}</td></tr>`;
body.innerHTML = `<tr><td colspan="36" class="empty">${escapeHtml(error.message)}</td></tr>`;
});
});
@@ -1376,7 +1423,7 @@
currentMode = button.dataset.mode || "individual";
modeToggle.querySelectorAll("[data-mode]").forEach((item) => item.classList.toggle("active", item === button));
loadData().catch((error) => {
body.innerHTML = `<tr><td colspan="35" class="empty">${escapeHtml(error.message)}</td></tr>`;
body.innerHTML = `<tr><td colspan="36" class="empty">${escapeHtml(error.message)}</td></tr>`;
});
});
@@ -1464,7 +1511,7 @@
});
loadData().catch((error) => {
body.innerHTML = `<tr><td colspan="35" class="empty">${escapeHtml(error.message)}</td></tr>`;
body.innerHTML = `<tr><td colspan="36" class="empty">${escapeHtml(error.message)}</td></tr>`;
kpis.innerHTML = "";
summaryTableRow.innerHTML = "";
});
+1 -1
View File
@@ -81,7 +81,7 @@
</head>
<body>
<form class="login-card" method="post" action="/login">
<h1>한맥 인트라넷 로그인</h1>
<h1>한맥기술 ERP 점검</h1>
<input type="hidden" name="next" value="{{ next_url }}">
<label for="username">아이디</label>
<input id="username" name="username" autocomplete="username" required autofocus>
+921
View File
@@ -0,0 +1,921 @@
{% extends "base.html" %}
{% block title %}복리/접대비 분류{% endblock %}
{% block content %}
{% set filters = benefit_report.filters %}
<style>
.report-head {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 18px;
padding-bottom: 14px;
border-bottom: 1px solid var(--line);
}
.report-head h1 {
font-size: 26px;
line-height: 1.2;
}
.report-head p {
margin-top: 6px;
color: var(--muted);
font-size: 13px;
}
.report-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.report-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 38px;
padding: 0 14px;
border: 1px solid #111;
background: #111;
color: #fff;
border-radius: 8px;
text-decoration: none;
font-size: 13px;
font-weight: 800;
white-space: nowrap;
}
.report-filter {
display: grid;
grid-template-columns: 82px 82px 136px 164px 150px 180px 62px;
gap: 10px;
align-items: end;
padding: 14px 0;
border-bottom: 1px solid var(--line);
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
}
.report-filter .field {
min-width: 0;
}
.report-filter input,
.report-filter select {
width: 100%;
min-height: 38px;
border-radius: 8px;
border: 1px solid var(--line);
background: #fff;
padding: 0 10px;
}
.report-filter button {
min-height: 38px;
border-radius: 8px;
border: 1px solid #111;
background: #111;
color: #fff;
font-weight: 800;
cursor: pointer;
}
.report-kpis {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 14px;
padding: 16px 0 4px;
}
.report-kpi {
border-bottom: 1px solid var(--line);
padding-bottom: 12px;
}
.report-kpi span {
display: block;
color: var(--muted);
font-size: 12px;
font-weight: 800;
}
.report-kpi strong {
display: block;
margin-top: 6px;
font-size: 24px;
line-height: 1.15;
}
.report-toggle-kpi {
display: flex;
align-items: flex-end;
justify-content: flex-end;
border-bottom: 1px solid var(--line);
padding-bottom: 12px;
}
.report-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 30px;
padding: 0 10px;
border: 1px solid var(--line);
background: #fff;
color: #111;
border-radius: 0;
text-decoration: none;
font-size: 12px;
font-weight: 800;
white-space: nowrap;
}
.report-toggle.is-on {
border-color: #111;
background: #111;
color: #fff;
}
.report-grid {
display: grid;
grid-template-columns: minmax(320px, 0.88fr) minmax(520px, 1.12fr);
gap: 18px;
align-items: start;
margin-top: 18px;
}
.report-summary-band {
margin-top: 16px;
}
.report-section h2 {
font-size: 17px;
margin-bottom: 10px;
}
.report-table-wrap {
overflow: auto;
border-top: 1px solid var(--line);
}
.report-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
line-height: 1.15;
min-width: 860px;
}
.report-table th {
position: sticky;
top: 0;
z-index: 1;
background: #f5f6f8;
color: #4b5563;
text-align: left;
font-size: 11px;
padding: 4px 5px;
border-bottom: 1px solid var(--line);
white-space: nowrap;
}
.report-table td {
padding: 2px 5px;
border-bottom: 1px solid #eceff3;
vertical-align: middle;
}
.report-table .number {
text-align: right;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.report-table .desc {
min-width: 260px;
line-height: 1.25;
white-space: nowrap;
}
.report-table .counterpart {
min-width: 300px;
max-width: 420px;
line-height: 1.25;
white-space: nowrap;
position: relative;
}
.counterpart-text {
display: block;
max-width: 420px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.counterpart-hover-tooltip {
position: fixed;
display: none;
z-index: 1400;
min-width: 520px;
max-width: min(820px, 72vw);
padding: 8px 10px;
border: 1px solid var(--line);
background: #fff;
color: #111;
white-space: pre-line;
line-height: 1.45;
box-shadow: none;
pointer-events: none;
}
.counterpart-hover-tooltip.is-open {
display: block;
}
#voucherDetailBody td {
white-space: nowrap;
}
.report-table button.link-cell {
all: unset;
display: inline;
border: 0 !important;
background: transparent !important;
box-shadow: none !important;
color: #111;
font: inherit;
font-weight: inherit;
cursor: pointer;
text-decoration: none;
padding: 0 !important;
margin: 0;
min-height: 0;
line-height: inherit;
border-radius: 0 !important;
}
.report-table button.link-cell:hover,
.report-table button.link-cell:focus,
.report-table button.link-cell:active {
background: transparent !important;
box-shadow: none !important;
text-decoration: none !important;
outline: none;
}
.summary-account-group {
display: -webkit-box;
max-width: 58px;
overflow: hidden;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
line-height: 1.15;
word-break: keep-all;
}
.summary-person-cell,
.summary-person-cell .link-cell {
display: -webkit-box !important;
max-width: 128px;
overflow: hidden;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
line-height: 1.15;
word-break: keep-all;
}
.category-save-cell {
display: block;
min-width: 138px;
}
.category-save-cell select {
min-height: 20px;
border: 1px solid var(--line);
border-radius: 0;
background: #fff;
font-size: 12px;
padding: 0 3px;
width: 100%;
}
.report-pill {
display: inline-flex;
align-items: center;
min-height: 0;
padding: 0;
border-radius: 0;
background: transparent;
font-weight: 800;
white-space: nowrap;
}
.report-muted {
color: var(--muted);
font-size: 12px;
margin-top: 8px;
}
.detail-more-row {
display: flex;
justify-content: center;
padding-top: 10px;
}
.detail-more-row button {
min-height: 30px;
border: 1px solid var(--line);
background: #fff;
border-radius: 0;
padding: 0 12px;
cursor: pointer;
font-weight: 800;
}
.modal-detail-toggle {
margin-top: 12px;
min-height: 30px;
border: 1px solid var(--line);
background: #fff;
border-radius: 0;
padding: 0 12px;
cursor: pointer;
font-weight: 800;
}
.modal-detail-panel {
display: none;
margin-top: 10px;
}
.modal-detail-panel.is-open {
display: block;
}
.report-modal-backdrop {
position: fixed;
inset: 0;
display: none;
align-items: center;
justify-content: center;
background: rgba(17, 24, 39, 0.42);
z-index: 1000;
padding: 20px;
}
.report-modal-backdrop.is-open {
display: flex;
}
.report-modal {
width: min(1680px, calc(100vw - 24px));
max-height: min(860px, calc(100vh - 24px));
background: #fff;
border-radius: 8px;
box-shadow: none;
display: grid;
grid-template-rows: auto 1fr;
overflow: hidden;
}
.report-modal-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--line);
}
.report-modal-head strong {
font-size: 16px;
}
.report-modal-head button {
border: 1px solid var(--line);
background: #fff;
border-radius: 7px;
min-height: 32px;
padding: 0 10px;
cursor: pointer;
font-weight: 800;
}
.report-modal-body {
overflow: auto;
padding: 12px 16px 16px;
}
.report-modal .report-table {
min-width: 1680px;
}
.report-modal .report-table th,
.report-modal .report-table td {
white-space: nowrap;
}
.report-modal .report-table .desc {
min-width: 460px;
white-space: nowrap;
}
.report-modal .report-table .counterpart {
min-width: 360px;
white-space: nowrap;
}
.report-modal .counterpart-text {
max-width: 560px;
}
@media (max-width: 980px) {
.report-head,
.report-grid {
display: grid;
}
.report-kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>
<section class="panel">
<div class="report-head">
<div>
<h1>복리후생비/접대비 분류</h1>
<p>{{ filters.start_year }}년부터 {{ filters.end_year }}년까지 WEHAGO 전표 기준</p>
</div>
<div class="report-actions">
<a class="report-button" href="/wehago-benefit-entertainment/export?start_year={{ filters.start_year }}&end_year={{ filters.end_year }}&account_group={{ filters.account_group|urlencode }}&category={{ filters.category|urlencode }}&person_keyword={{ filters.person_keyword|urlencode }}&desc_keyword={{ filters.desc_keyword|urlencode }}&include_adjustments={{ '1' if filters.include_adjustments else '0' }}">엑셀 다운로드</a>
</div>
</div>
<form class="report-filter" method="get" action="/wehago-benefit-entertainment">
<div class="field">
<label for="start_year">시작연도</label>
<input id="start_year" name="start_year" value="{{ filters.start_year }}" inputmode="numeric">
</div>
<div class="field">
<label for="end_year">종료연도</label>
<input id="end_year" name="end_year" value="{{ filters.end_year }}" inputmode="numeric">
</div>
<div class="field">
<label for="account_group">구분</label>
<select id="account_group" name="account_group">
<option value="all" {% if filters.account_group in ['all', ''] %}selected{% endif %}>전체</option>
{% for option in benefit_report.account_group_options %}
<option value="{{ option }}" {% if filters.account_group == option %}selected{% endif %}>{{ option }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="category">분류</label>
<select id="category" name="category">
<option value="all" {% if filters.category in ['all', ''] %}selected{% endif %}>전체</option>
{% for option in benefit_report.category_options %}
<option value="{{ option }}" {% if filters.category == option %}selected{% endif %}>{{ option }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label for="person_keyword">개인/거래처</label>
<input id="person_keyword" name="person_keyword" value="{{ filters.person_keyword }}" autocomplete="off">
</div>
<div class="field">
<label for="desc_keyword">적요</label>
<input id="desc_keyword" name="desc_keyword" value="{{ filters.desc_keyword }}" autocomplete="off">
</div>
<input type="hidden" name="include_adjustments" value="{{ '1' if filters.include_adjustments else '0' }}">
<button type="submit">검색</button>
</form>
<div class="report-kpis">
<div class="report-kpi"><span>총 금액</span><strong>{{ "{:,.0f}".format(benefit_report.total_amount) }}</strong></div>
<div class="report-kpi"><span>전표 행</span><strong>{{ "{:,}".format(benefit_report.row_count) }}</strong></div>
<div class="report-kpi"><span>거래처 요약</span><strong>{{ "{:,}".format(benefit_report.vendor_summary_rows|length) }}</strong></div>
<div class="report-kpi"><span>직급 확인</span><strong>{{ "{:,}".format(benefit_report.hanmac_grade_match_count) }}</strong></div>
<div class="report-kpi"><span>미리보기</span><strong>{{ "{:,}".format(benefit_report.shown_count) }}</strong></div>
<div class="report-toggle-kpi">
<a class="report-toggle {% if filters.include_adjustments %}is-on{% endif %}" href="/wehago-benefit-entertainment?start_year={{ filters.start_year }}&end_year={{ filters.end_year }}&account_group={{ filters.account_group|urlencode }}&category={{ filters.category|urlencode }}&person_keyword={{ filters.person_keyword|urlencode }}&desc_keyword={{ filters.desc_keyword|urlencode }}&include_adjustments={{ '0' if filters.include_adjustments else '1' }}">
대체/원가/손익 {{ '포함' if filters.include_adjustments else '제외' }}
</a>
</div>
</div>
<section class="report-section report-summary-band">
<h2>분류/연도별 합계</h2>
<div class="report-table-wrap">
<table class="report-table">
<thead>
<tr>
<th>구분</th>
<th>분류</th>
{% for year in benefit_report.summary_years %}
<th class="number">{{ year }}</th>
{% endfor %}
<th class="number">합계</th>
<th class="number">건수</th>
</tr>
</thead>
<tbody>
{% for row in benefit_report.category_year_summary_rows %}
{% set is_drill_category = (row.account_group == '복리후생비' and row.category in ['임원 개인성 비용', '기타', '경조사']) or (row.account_group == '접대비' and row.category in ['기타', '경조사', '운동비']) %}
<tr>
<td>{{ row.account_group }}</td>
<td><span class="report-pill">{{ row.category }}</span></td>
{% for year in benefit_report.summary_years %}
<td class="number">
{% if is_drill_category and row.year_amounts.get(year, 0) %}
<button type="button" class="link-cell" data-category-year-detail data-account-group="{{ row.account_group }}" data-category="{{ row.category }}" data-year="{{ year }}">{{ "{:,.0f}".format(row.year_amounts.get(year, 0)) }}</button>
{% else %}
{{ "{:,.0f}".format(row.year_amounts.get(year, 0)) }}
{% endif %}
</td>
{% endfor %}
<td class="number">
{% if is_drill_category and row.total_amount %}
<button type="button" class="link-cell" data-category-year-detail data-account-group="{{ row.account_group }}" data-category="{{ row.category }}" data-year="all">{{ "{:,.0f}".format(row.total_amount) }}</button>
{% else %}
{{ "{:,.0f}".format(row.total_amount) }}
{% endif %}
</td>
<td class="number">
{% if is_drill_category and row.row_count %}
<button type="button" class="link-cell" data-category-year-detail data-account-group="{{ row.account_group }}" data-category="{{ row.category }}" data-year="all">{{ "{:,}".format(row.row_count) }}</button>
{% else %}
{{ "{:,}".format(row.row_count) }}
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="{{ benefit_report.summary_years|length + 4 }}">조회된 자료가 없습니다.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
<div class="report-grid">
<section class="report-section">
<h2>거래처별 요약</h2>
<div class="report-table-wrap">
<table class="report-table">
<thead>
<tr>
<th>구분</th>
<th>분류</th>
<th>거래처</th>
<th>개인/귀속</th>
<th>직급</th>
<th class="number">금액</th>
<th class="number">건수</th>
</tr>
</thead>
<tbody>
{% for row in benefit_report.vendor_summary_rows %}
<tr>
<td><span class="summary-account-group">{{ row.account_group }}</span></td>
<td><span class="report-pill">{{ row.category }}</span></td>
<td>{{ row.vendor_name }}</td>
<td class="summary-person-cell"><button type="button" class="link-cell" data-summary-detail data-field="person" data-vendor="{{ row.vendor_name }}" data-category="{{ row.category }}" data-account-group="{{ row.account_group }}">{{ row.person_name or "-" }}</button></td>
<td>{{ row.hanmac_member_grade or "" }}</td>
<td class="number"><button type="button" class="link-cell" data-summary-detail data-field="amount" data-vendor="{{ row.vendor_name }}" data-category="{{ row.category }}" data-account-group="{{ row.account_group }}">{{ "{:,.0f}".format(row.amount) }}</button></td>
<td class="number"><button type="button" class="link-cell" data-summary-detail data-field="count" data-vendor="{{ row.vendor_name }}" data-category="{{ row.category }}" data-account-group="{{ row.account_group }}">{{ "{:,}".format(row.row_count) }}</button></td>
</tr>
{% else %}
<tr><td colspan="7">조회된 자료가 없습니다.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
<section class="report-section">
<h2>전표 상세</h2>
<div class="report-table-wrap">
<table class="report-table">
<thead>
<tr>
<th>연도</th>
<th>일자</th>
<th>전표번호</th>
<th>계정</th>
<th>거래처</th>
<th>직급</th>
<th>분류</th>
<th class="number">차변금액</th>
<th class="number">대변금액</th>
<th>적요</th>
<th>상대계정</th>
</tr>
</thead>
<tbody id="voucherDetailBody">
{% for row in benefit_report.detail_rows %}
<tr data-detail-row>
<td>{{ row.year }}</td>
<td>{{ row.ledger_date }}</td>
<td>{{ row.voucher_no }}</td>
<td>{{ row.account_name }}</td>
<td>{{ row.vendor_name }}</td>
<td>{{ row.hanmac_member_grade or "" }}</td>
<td>
<div class="category-save-cell">
<select data-category-select data-row-id="{{ row.ledger_row_id }}">
{% for option in benefit_report.category_options %}
<option value="{{ option }}" {% if row.category == option %}selected{% endif %}>{{ option }}</option>
{% endfor %}
</select>
</div>
</td>
<td class="number">{{ "{:,.0f}".format(row.debit) }}</td>
<td class="number">{{ "{:,.0f}".format(row.credit) }}</td>
<td class="desc">{{ row.description }}</td>
<td class="counterpart" data-tooltip="{{ row.counterpart_tooltip }}"><span class="counterpart-text">{{ row.counterpart_details }}</span></td>
</tr>
{% else %}
<tr><td colspan="11">조회된 자료가 없습니다.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="detail-more-row" {% if benefit_report.row_count <= benefit_report.shown_count %}style="display:none"{% endif %}>
<button type="button" id="voucherDetailMore">더 보기</button>
</div>
<p class="report-muted" id="voucherDetailMeta">화면에는 {{ "{:,}".format(benefit_report.shown_count) }}행이 표시됩니다. 엑셀에는 현재 필터의 전체 {{ "{:,}".format(benefit_report.row_count) }}행이 포함됩니다.</p>
</section>
</div>
</section>
<div class="report-modal-backdrop" id="summaryDetailModal" aria-hidden="true">
<div class="report-modal" role="dialog" aria-modal="true" aria-labelledby="summaryDetailTitle">
<div class="report-modal-head">
<strong id="summaryDetailTitle">상세</strong>
<button type="button" id="summaryDetailClose">닫기</button>
</div>
<div class="report-modal-body" id="summaryDetailBody"></div>
</div>
</div>
<div class="counterpart-hover-tooltip" id="counterpartHoverTooltip" aria-hidden="true"></div>
{% endblock %}
{% block script %}
<script>
(() => {
const reportData = {{ benefit_report_json|tojson }};
const modal = document.getElementById("summaryDetailModal");
const modalTitle = document.getElementById("summaryDetailTitle");
const modalBody = document.getElementById("summaryDetailBody");
const modalClose = document.getElementById("summaryDetailClose");
const detailBody = document.getElementById("voucherDetailBody");
const detailMore = document.getElementById("voucherDetailMore");
const detailMeta = document.getElementById("voucherDetailMeta");
const counterpartTooltip = document.getElementById("counterpartHoverTooltip");
const detailPageSize = 300;
const detailRows = reportData.detail_rows || [];
const categoryOptions = reportData.category_options || Array.from(document.querySelectorAll("[data-category-select] option")).map((option) => option.value);
let detailVisibleCount = document.querySelectorAll("[data-detail-row]").length;
function escapeHtml(value) {
return String(value ?? "").replace(/[&<>"']/g, (char) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"\"": "&quot;",
"'": "&#39;",
}[char]));
}
function formatNumber(value) {
return Number(value || 0).toLocaleString("ko-KR", { maximumFractionDigits: 0 });
}
function displayVendor(value) {
const text = String(value || "").trim();
return text || "미지정";
}
function renderCategoryOptions(selectedCategory) {
return categoryOptions.map((option) => `
<option value="${escapeHtml(option)}" ${String(option) === String(selectedCategory || "") ? "selected" : ""}>${escapeHtml(option)}</option>
`).join("");
}
function renderDetailRow(row) {
return `
<tr data-detail-row>
<td>${escapeHtml(row.year)}</td>
<td>${escapeHtml(row.ledger_date)}</td>
<td>${escapeHtml(row.voucher_no)}</td>
<td>${escapeHtml(row.account_name)}</td>
<td>${escapeHtml(displayVendor(row.vendor_name))}</td>
<td>${escapeHtml(row.hanmac_member_grade || "")}</td>
<td>
<div class="category-save-cell">
<select data-category-select data-row-id="${escapeHtml(row.ledger_row_id)}">
${renderCategoryOptions(row.category)}
</select>
</div>
</td>
<td class="number">${formatNumber(row.debit)}</td>
<td class="number">${formatNumber(row.credit)}</td>
<td class="desc">${escapeHtml(row.description)}</td>
<td class="counterpart" data-tooltip="${escapeHtml(row.counterpart_tooltip || row.counterpart_details)}"><span class="counterpart-text">${escapeHtml(row.counterpart_details)}</span></td>
</tr>
`;
}
function updateDetailControls() {
const totalRows = Number(reportData.row_count || detailRows.length || 0);
if (detailMeta) {
detailMeta.textContent = `화면에는 ${detailVisibleCount.toLocaleString("ko-KR")}행이 표시됩니다. 엑셀에는 현재 필터의 전체 ${totalRows.toLocaleString("ko-KR")}행이 포함됩니다.`;
}
if (detailMore?.parentElement) {
detailMore.parentElement.style.display = detailVisibleCount < detailRows.length ? "flex" : "none";
}
}
function appendDetailRows() {
if (!detailBody) return;
const nextRows = detailRows.slice(detailVisibleCount, detailVisibleCount + detailPageSize);
if (!nextRows.length) {
updateDetailControls();
return;
}
detailBody.insertAdjacentHTML("beforeend", nextRows.map(renderDetailRow).join(""));
detailVisibleCount += nextRows.length;
updateDetailControls();
}
function buildVendorUsageRows(rows) {
const map = new Map();
rows.forEach((row) => {
const vendor = displayVendor(row.vendor_name);
const item = map.get(vendor) || { vendor, amount: 0, count: 0, people: new Set() };
item.amount += Number(row.amount || 0);
item.count += 1;
const person = String(row.person_name || "").trim();
if (person) item.people.add(person);
map.set(vendor, item);
});
return Array.from(map.values()).sort((a, b) => b.amount - a.amount || a.vendor.localeCompare(b.vendor, "ko"));
}
function renderDetailTable(rows, options = {}) {
const showCategory = options.showCategory !== false;
return `
<div class="report-table-wrap">
<table class="report-table">
<thead>
<tr>
<th>일자</th>
<th>전표번호</th>
<th>계정</th>
<th>거래처</th>
<th>개인/귀속</th>
${showCategory ? "<th>분류</th>" : ""}
<th class="number">금액</th>
<th>적요</th>
<th>상대계정</th>
</tr>
</thead>
<tbody>
${rows.map((row) => `
<tr>
<td>${escapeHtml(row.ledger_date)}</td>
<td>${escapeHtml(row.voucher_no)}</td>
<td>${escapeHtml(row.account_name)}</td>
<td>${escapeHtml(displayVendor(row.vendor_name))}</td>
<td>${escapeHtml(row.person_name)}</td>
${showCategory ? `<td>${escapeHtml(row.category)}</td>` : ""}
<td class="number">${formatNumber(row.amount)}</td>
<td class="desc">${escapeHtml(row.description)}</td>
<td class="counterpart" data-tooltip="${escapeHtml(row.counterpart_tooltip || row.counterpart_details)}"><span class="counterpart-text">${escapeHtml(row.counterpart_details)}</span></td>
</tr>
`).join("")}
</tbody>
</table>
</div>
`;
}
function openModal(rows, meta) {
modalTitle.textContent = meta.title || `${meta.vendor || "-"} / ${meta.category || "-"} 상세`;
if (!rows.length) {
modalBody.innerHTML = '<div class="report-muted">상세 값이 없습니다.</div>';
} else {
const total = rows.reduce((sum, row) => sum + Number(row.amount || 0), 0);
if (meta.mode === "categoryYear") {
const vendorRows = buildVendorUsageRows(rows);
modalBody.innerHTML = `
<p class="report-muted">총 ${rows.length.toLocaleString("ko-KR")}건 / ${formatNumber(total)}원</p>
<div class="report-table-wrap">
<table class="report-table">
<thead>
<tr>
<th>거래처</th>
<th>개인/귀속</th>
<th class="number">금액</th>
<th class="number">건수</th>
</tr>
</thead>
<tbody>
${vendorRows.map((row) => `
<tr>
<td>${escapeHtml(row.vendor)}</td>
<td>${escapeHtml(Array.from(row.people).slice(0, 4).join(", ") || "-")}</td>
<td class="number">${formatNumber(row.amount)}</td>
<td class="number">${formatNumber(row.count)}</td>
</tr>
`).join("")}
</tbody>
</table>
</div>
<button type="button" class="modal-detail-toggle" id="modalDetailToggle">상세 내역 열기</button>
<div class="modal-detail-panel" id="modalDetailPanel">
${renderDetailTable(rows, { showCategory: false })}
</div>
`;
} else {
modalBody.innerHTML = `
<p class="report-muted">총 ${rows.length.toLocaleString("ko-KR")}건 / ${formatNumber(total)}원</p>
${renderDetailTable(rows)}
`;
}
}
modal.classList.add("is-open");
modal.setAttribute("aria-hidden", "false");
}
function closeModal() {
modal.classList.remove("is-open");
modal.setAttribute("aria-hidden", "true");
}
function positionCounterpartTooltip(event) {
if (!counterpartTooltip || !counterpartTooltip.classList.contains("is-open")) return;
const margin = 14;
const rect = counterpartTooltip.getBoundingClientRect();
let left = event.clientX + margin;
let top = event.clientY + margin;
if (left + rect.width > window.innerWidth - margin) {
left = Math.max(margin, event.clientX - rect.width - margin);
}
if (top + rect.height > window.innerHeight - margin) {
top = Math.max(margin, event.clientY - rect.height - margin);
}
counterpartTooltip.style.left = `${left}px`;
counterpartTooltip.style.top = `${top}px`;
}
document.addEventListener("mouseover", (event) => {
const cell = event.target.closest(".counterpart[data-tooltip]");
if (!cell || !counterpartTooltip) return;
const text = cell.dataset.tooltip || "";
if (!text.trim()) return;
counterpartTooltip.textContent = text;
counterpartTooltip.classList.add("is-open");
counterpartTooltip.setAttribute("aria-hidden", "false");
positionCounterpartTooltip(event);
});
document.addEventListener("mousemove", (event) => {
if (event.target.closest(".counterpart[data-tooltip]")) {
positionCounterpartTooltip(event);
}
});
document.addEventListener("mouseout", (event) => {
const cell = event.target.closest(".counterpart[data-tooltip]");
if (!cell || !counterpartTooltip) return;
if (event.relatedTarget && cell.contains(event.relatedTarget)) return;
counterpartTooltip.classList.remove("is-open");
counterpartTooltip.setAttribute("aria-hidden", "true");
});
modalBody?.addEventListener("click", (event) => {
const toggle = event.target.closest("#modalDetailToggle");
if (!toggle) return;
const panel = document.getElementById("modalDetailPanel");
const isOpen = panel?.classList.toggle("is-open");
toggle.textContent = isOpen ? "상세 내역 접기" : "상세 내역 열기";
});
document.querySelectorAll("[data-summary-detail]").forEach((button) => {
button.addEventListener("click", () => {
const vendor = button.dataset.vendor || "";
const category = button.dataset.category || "";
const accountGroup = button.dataset.accountGroup || "";
const rows = (reportData.detail_rows || []).filter((row) =>
displayVendor(row.vendor_name) === vendor &&
String(row.category || "") === category &&
String(row.account_group || "") === accountGroup
);
openModal(rows, { vendor, category, accountGroup });
});
});
document.querySelectorAll("[data-category-year-detail]").forEach((button) => {
button.addEventListener("click", () => {
const accountGroup = button.dataset.accountGroup || "";
const category = button.dataset.category || "";
const year = button.dataset.year || "all";
const rows = (reportData.detail_rows || []).filter((row) =>
String(row.account_group || "") === accountGroup &&
String(row.category || "") === category &&
(year === "all" || String(row.year || "") === year)
);
const titleYear = year === "all" ? "전체연도" : `${year}`;
openModal(rows, {
title: `${accountGroup} / ${category} / ${titleYear} 상세`,
vendor: accountGroup,
category,
accountGroup,
mode: "categoryYear",
});
});
});
detailBody?.addEventListener("change", async (event) => {
const select = event.target.closest("[data-category-select]");
if (!select) return;
const rowId = select.dataset.rowId || "";
const category = select.value || "";
select.disabled = true;
try {
const response = await fetch("/wehago-benefit-entertainment/api/category", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ledger_row_id: rowId, category }),
});
const payload = await response.json();
if (!response.ok || !payload.ok) {
throw new Error(payload.error || "분류 저장 중 오류가 발생했습니다.");
}
window.location.reload();
} catch (error) {
alert(error.message || "분류 저장 중 오류가 발생했습니다.");
select.disabled = false;
}
});
detailMore?.addEventListener("click", appendDetailRows);
updateDetailControls();
modalClose?.addEventListener("click", closeModal);
modal?.addEventListener("click", (event) => {
if (event.target === modal) closeModal();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") closeModal();
});
})();
</script>
{% endblock %}
+42 -24
View File
@@ -466,6 +466,12 @@
margin-left: 0;
}
.status-card[data-count-pending="true"] .status-value {
color: #64748b;
font-size: 13px;
line-height: 1.15;
}
.status-card[data-status="matched"] {
background: #f7faf8;
border-color: #d2ddd6;
@@ -1640,15 +1646,17 @@
class="status-card"
data-target="detail-{{ section.key }}"
data-status="{{ section.key }}"
data-count-pending="{% if wehago_compare.pending and section.count == 0 %}true{% else %}false{% endif %}"
aria-expanded="false"
>
<span class="status-label">{{ section.label }}</span>
<span class="status-value">{{ "{:,}".format(section.count) }}</span>
<span class="status-value">
{% if wehago_compare.pending and section.count == 0 %}갱신 중{% else %}{{ "{:,}".format(section.count) }}{% endif %}
</span>
</button>
{% endif %}
{% endfor %}
</div>
<div class="detail-stack">
{% for section in wehago_compare.metric_sections %}
{% if section.key in ['voucher_matched', 'voucher_unmatched', 'voucher_recheck', 'voucher_excepted', 'hanmac_unconnected', 'erp_voucher_matched', 'erp_voucher_unmatched'] %}
@@ -2024,12 +2032,22 @@
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 = [
const getVoucherLineColumns = (statusKey = '') => {
if (statusKey === 'hanmac_unconnected') {
return [
['fiscal_year', '연도'],
['proof_date', 'Hanmac 일자'],
['voucher_no', '확정전표번호'],
['draft_no', '가전표번호'],
['voucher_account_name', 'Hanmac 계정'],
['voucher_vendor', 'Hanmac 거래처'],
['voucher_debit', 'Hanmac 차변'],
['voucher_credit', 'Hanmac 대변'],
['voucher_desc', 'Hanmac 적요'],
];
}
return [
['fiscal_year', '연도'],
['status_label', '구분'],
['ledger_date', 'WEHAGO 일자'],
['voucher_no', '전표번호'],
['draft_no', '가전표번호'],
@@ -2044,6 +2062,16 @@
['ledger_desc', 'WEHAGO 적요'],
['voucher_desc', 'ERP 적요'],
];
};
const renderVoucherDetailTable = (group) => {
if (!voucherDetailTableWrap) return;
const rows = Array.isArray(group?.rows) ? group.rows : [];
const isHanmacUnconnected = String(group?.summary?.status_label || '').toLowerCase() === 'hanmac unconnected';
const lineColumns = [
['status_label', '구분'],
...getVoucherLineColumns(isHanmacUnconnected ? 'hanmac_unconnected' : ''),
];
if (!rows.length) {
voucherDetailTableWrap.innerHTML = '<div class="table-placeholder">표시할 상세 행이 없습니다.</div>';
return;
@@ -2335,22 +2363,7 @@
});
});
};
const lineColumns = [
['fiscal_year', '연도'],
['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 적요'],
];
const lineColumns = getVoucherLineColumns(statusKey);
const stripAccountCodePrefix = (value) => {
const text = String(value ?? '');
return text
@@ -2746,8 +2759,13 @@
sections.forEach((section) => {
const card = document.querySelector(`.status-card[data-status="${escapeSelectorValue(section.key || '')}"]`);
const valueNode = card?.querySelector('.status-value');
const countValue = Number(section.count || 0);
const isPendingCount = Boolean(payload?.pending) && countValue === 0;
if (card) {
card.dataset.countPending = isPendingCount ? 'true' : 'false';
}
if (valueNode) {
valueNode.textContent = Number(section.count || 0).toLocaleString();
valueNode.textContent = isPendingCount ? '갱신 중' : countValue.toLocaleString();
}
});
const lastAction = payload?.last_action;