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
+189 -18
View File
@@ -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>`;