Files
HM_project_Viewer_Board/templates/wehago_compare.html
T

2121 lines
90 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}전표비교{% endblock %}
{% block head_extra %}
<style>
.voucher-page {
display: grid;
gap: 14px;
}
.voucher-topbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.voucher-title-wrap h2 {
margin: 0;
}
.voucher-toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
overflow-x: auto;
}
.voucher-toolbar form,
.filter-form {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
margin: 0;
overflow: visible;
}
.voucher-toolbar label,
.filter-form label {
font-size: 13px;
color: var(--muted);
white-space: nowrap;
}
.voucher-toolbar select,
.voucher-toolbar button,
.filter-form input,
.filter-form button,
.upload-picker {
height: 38px;
box-sizing: border-box;
white-space: nowrap;
}
.voucher-toolbar select,
.filter-form input {
min-width: 104px;
padding: 0 30px 0 10px;
font-size: 14px;
line-height: 38px;
}
.filter-form input {
min-width: 120px;
padding-right: 10px;
}
.detail-head .filter-form {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 8px;
width: 100%;
max-width: 100%;
min-width: 0;
overflow: visible;
row-gap: 0;
}
.detail-head .filter-form input {
min-width: 98px;
}
.detail-panel[data-status-panel="amount_mismatch"] .detail-head {
display: grid;
grid-template-columns: minmax(0, 1fr);
align-items: start;
row-gap: 6px;
overflow: visible;
}
.detail-panel[data-status-panel="amount_mismatch"] .detail-head > strong {
grid-column: 1 / -1;
grid-row: 1;
white-space: nowrap;
}
.detail-panel[data-status-panel="amount_mismatch"] .detail-head > .filter-form {
grid-column: 1 / -1;
grid-row: 2;
width: 100%;
max-width: 100%;
min-width: 0;
overflow: visible;
padding-bottom: 2px;
}
.detail-panel[data-status-panel="amount_mismatch"] .detail-head > .result-meta {
grid-column: 1 / -1;
grid-row: 3;
}
.active-filter-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
gap: 8px;
margin-top: 8px;
}
.active-filter-card {
min-width: 0;
border: 1px solid #d3dee8;
border-radius: 10px;
padding: 6px 8px;
background: #f8fafc;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 6px;
align-items: center;
}
.active-filter-card .value {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 12px;
color: #0f172a;
font-weight: 700;
}
.active-filter-card .remove {
width: 18px;
height: 18px;
border-radius: 999px;
border: 0;
background: rgba(15, 23, 42, 0.08);
color: #111827;
box-shadow: none;
transform: none;
padding: 0;
cursor: pointer;
line-height: 1;
font-size: 13px;
font-weight: 800;
}
.active-filter-card .remove:hover {
background: rgba(15, 23, 42, 0.14);
}
.panel-shell {
border: 1px solid var(--line);
border-radius: 16px;
background: #fff;
overflow: hidden;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.05);
}
.panel-header {
padding: 12px 16px 8px;
display: flex;
justify-content: space-between;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.panel-header h2,
.summary-copy h2 {
margin: 0;
font-size: 18px;
}
.panel-meta,
.empty-note,
.result-meta {
color: var(--muted);
line-height: 1.5;
font-size: 13px;
}
.panel-body {
padding: 0 16px 16px;
min-width: 0;
}
.status-grid {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.status-card {
min-width: 130px;
width: auto;
border: 1px solid var(--line);
border-radius: 14px;
background: #fff;
padding: 10px 12px;
text-align: left;
cursor: pointer;
transition: background-color 0.16s ease, color 0.16s ease, border-color 0.16s ease;
}
.status-card:hover,
.status-card[aria-expanded="true"] {
background: #111827;
border-color: #111827;
color: #fff;
}
.status-label {
display: block;
font-size: 12px;
font-weight: 700;
color: var(--muted);
margin-bottom: 4px;
}
.status-card:hover .status-label,
.status-card[aria-expanded="true"] .status-label {
color: rgba(255, 255, 255, 0.82);
}
.status-value {
display: block;
font-size: 26px;
font-weight: 800;
line-height: 1;
color: #111;
}
.status-card:hover .status-value,
.status-card[aria-expanded="true"] .status-value {
color: #fff;
}
.detail-stack {
display: grid;
gap: 10px;
margin-top: 10px;
min-width: 0;
}
.detail-panel {
display: none;
border: 1px solid var(--line);
border-radius: 14px;
background: #fff;
overflow: visible;
min-width: 0;
}
.detail-panel.active {
display: block;
}
.detail-head {
padding: 10px 12px;
border-bottom: 1px solid var(--line);
background: #f8fafc;
display: grid;
gap: 10px;
position: relative;
z-index: 6;
overflow: visible;
}
.data-panel summary {
list-style: none;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 12px 16px;
}
.data-panel summary::-webkit-details-marker {
display: none;
}
.summary-badges {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.summary-badge {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 5px 9px;
background: #f3f4f6;
color: #111827;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.summary-toggle {
font-size: 12px;
color: var(--muted);
}
.data-panel[open] .summary-toggle::after {
content: "접기";
}
.data-panel:not([open]) .summary-toggle::after {
content: "펼치기";
}
.table-wrap {
width: 100%;
max-width: 100%;
overflow: auto;
border-top: 1px solid var(--line);
background: #fff;
max-height: 460px;
box-sizing: border-box;
}
.detail-panel .table-wrap {
max-height: 360px;
overflow-x: auto;
overflow-y: auto;
}
.table-footer {
display: flex;
justify-content: center;
padding: 10px 12px 14px;
border-top: 1px solid var(--line);
background: #fff;
}
.load-more-button {
min-width: 120px;
}
.table-wrap table {
width: max-content;
min-width: max(100%, 1040px);
border-collapse: collapse;
table-layout: fixed;
}
.table-wrap th,
.table-wrap td {
border-bottom: 1px solid rgba(148, 163, 184, 0.18);
padding: 7px 8px;
vertical-align: middle;
text-align: left;
font-size: 13px;
line-height: 1.35;
}
.table-wrap th {
position: sticky;
top: 0;
z-index: 1;
background: #f8fafc;
font-size: 13px;
letter-spacing: 0.01em;
}
.cell-ellipsis {
max-width: 180px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
display: block;
}
.cell-tight {
max-width: 86px;
}
.cell-code {
max-width: 108px;
}
.cell-equal {
max-width: 128px;
}
.cell-desc {
max-width: 220px;
}
.mono {
font-family: "Consolas", "Courier New", monospace;
font-size: 13px;
}
.upload-form {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: nowrap;
overflow-x: auto;
padding-bottom: 12px;
}
.upload-picker {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 12px;
border-radius: 10px;
border: 1px solid var(--line);
background: #fff;
cursor: pointer;
font-weight: 700;
transition: border-color 0.16s ease, box-shadow 0.16s ease;
}
.upload-picker:hover {
border-color: #9ca3af;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08);
}
.upload-picker::before {
content: "";
width: 15px;
height: 11px;
border: 2px solid currentColor;
border-top-left-radius: 3px;
border-top-right-radius: 3px;
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
box-sizing: border-box;
position: relative;
display: inline-block;
transform: translateY(1px);
clip-path: polygon(0 28%, 22% 28%, 30% 0, 100% 0, 100% 100%, 0 100%);
}
.hidden-input {
display: none;
}
.table-placeholder {
padding: 16px;
color: var(--muted);
}
.recommend-panel {
display: none;
}
.recommend-panel.active {
display: block;
}
.recommend-actions {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
white-space: normal;
word-break: keep-all;
overflow-wrap: break-word;
}
.recommend-sort {
display: inline-flex;
gap: 6px;
align-items: center;
white-space: normal;
word-break: keep-all;
overflow-wrap: break-word;
}
.recommend-sort button.active {
background: #111827;
border-color: #111827;
color: #fff;
}
.recommend-summary {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.recommend-badge {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 4px 10px;
font-size: 12px;
font-weight: 700;
background: #f3f4f6;
color: #111827;
}
.recommend-badge.good {
background: #ecfdf3;
color: #166534;
}
.recommend-badge.warn {
background: #fff7ed;
color: #9a3412;
}
.recommend-panel .panel-header h2 {
margin: 0;
white-space: normal;
word-break: keep-all;
overflow-wrap: break-word;
}
.suggest-field {
position: relative;
width: 132px;
min-width: 132px;
}
.suggest-field > input[type="text"] {
width: 100%;
min-width: 0;
}
.detail-panel[data-status-panel="matched"] .filter-form input[name="wehago_amount"],
.detail-panel[data-status-panel="matched"] .filter-form input[name="erp_amount"],
.detail-panel[data-status-panel="amount_mismatch"] .filter-form input[name="wehago_amount"],
.detail-panel[data-status-panel="amount_mismatch"] .filter-form input[name="erp_amount"] {
width: 106px;
min-width: 106px;
}
.detail-panel[data-status-panel="matched"] .filter-form .suggest-field:has(input[name="wehago_vendor"]),
.detail-panel[data-status-panel="matched"] .filter-form .suggest-field:has(input[name="erp_vendor"]),
.detail-panel[data-status-panel="amount_mismatch"] .filter-form .suggest-field:has(input[name="wehago_vendor"]),
.detail-panel[data-status-panel="amount_mismatch"] .filter-form .suggest-field:has(input[name="erp_vendor"]) {
flex: 1 1 220px;
width: auto;
min-width: 220px;
}
.detail-panel[data-status-panel="ledger_only"] .filter-form .suggest-field,
.detail-panel[data-status-panel="voucher_only"] .filter-form .suggest-field,
.detail-panel[data-status-panel="ledger_only"] .filter-form input[name="amount_keyword"],
.detail-panel[data-status-panel="voucher_only"] .filter-form input[name="amount_keyword"] {
width: 140px;
min-width: 140px;
}
.suggest-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: auto;
width: 100%;
min-width: 100%;
max-width: 100%;
box-sizing: border-box;
background: #fff;
border: 1px solid var(--line);
border-radius: 10px;
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.14);
max-height: 260px;
overflow: auto;
z-index: 50;
display: none;
}
.suggest-dropdown.open {
display: block;
}
.suggest-item {
width: 100%;
text-align: left;
padding: 3px 4px;
margin: 0;
border: 0;
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
background: #fff;
color: #111827;
box-shadow: none;
border-radius: 0;
min-height: 28px;
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
line-height: 1.2;
}
.suggest-item:last-child {
border-bottom: 0;
}
.suggest-item:hover {
background: #f3f4f6;
transform: none;
}
.suggest-item input[type="checkbox"] {
width: 12px !important;
height: 12px !important;
min-width: 12px !important;
min-height: 12px !important;
max-width: 12px !important;
max-height: 12px !important;
margin: 0 !important;
padding: 0 !important;
border: 0 !important;
box-shadow: none !important;
line-height: 1 !important;
flex: 0 0 12px !important;
accent-color: #111827;
pointer-events: none;
}
.suggest-item .text {
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.suggest-empty {
padding: 10px 12px;
color: var(--muted);
font-size: 13px;
}
body[data-view-mode="dual"] .voucher-page:has(#pairRecommendPanel.active) {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
align-items: start;
column-gap: 12px;
}
body[data-view-mode="dual"] .voucher-page:has(#pairRecommendPanel.active) > .voucher-topbar {
grid-column: 1 / -1;
}
body[data-view-mode="dual"] .voucher-page:has(#pairRecommendPanel.active) > .panel-shell:not(#pairRecommendPanel) {
grid-column: 1;
min-width: 0;
}
body[data-view-mode="dual"] .voucher-page:has(#pairRecommendPanel.active) > #pairRecommendPanel.active {
grid-column: 2;
grid-row: 2;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
position: sticky;
top: 10px;
max-height: calc(100vh - 90px);
min-width: 0;
}
body[data-view-mode="dual"] #pairRecommendPanel.active .table-wrap {
max-height: calc(100vh - 230px);
}
body[data-view-mode="dual"] .detail-panel .table-wrap {
max-height: clamp(300px, 44vh, 520px);
}
@media (max-width: 720px) {
.voucher-topbar,
.panel-header,
.data-panel summary {
align-items: flex-start;
}
.status-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.status-card {
min-width: 0;
}
.cell-ellipsis {
max-width: 180px;
}
}
</style>
{% endblock %}
{% block content %}
<section
class="voucher-page"
data-start-year="{{ wehago_compare.selected_start_year or '' }}"
data-end-year="{{ wehago_compare.selected_end_year or '' }}"
>
<datalist id="wehagoAccountOptions">
{% for item in wehago_compare.wehago_account_options %}
<option value="{{ item }}"></option>
{% endfor %}
</datalist>
<datalist id="erpAccountOptions">
{% for item in wehago_compare.erp_account_options %}
<option value="{{ item }}"></option>
{% endfor %}
</datalist>
<div class="voucher-topbar">
<div class="voucher-title-wrap">
<div class="section-title" style="margin-bottom: 0;">
<h2>{{ wehago_compare.page_title }}</h2>
</div>
</div>
<div class="voucher-toolbar">
<form method="get" action="/wehago-compare">
<label for="start_year">기간</label>
<select id="start_year" name="start_year">
{% for year in wehago_compare.available_years %}
<option
value="{{ year }}"
{% if wehago_compare.selected_start_year == year %}selected{% endif %}
{% if wehago_compare.selected_end_year and year > wehago_compare.selected_end_year %}disabled{% endif %}
>{{ year }}</option>
{% endfor %}
</select>
<span>~</span>
<select id="end_year" name="end_year">
{% for year in wehago_compare.available_years %}
<option
value="{{ year }}"
{% if wehago_compare.selected_end_year == year %}selected{% endif %}
{% if wehago_compare.selected_start_year and year < wehago_compare.selected_start_year %}disabled{% endif %}
>{{ year }}</option>
{% endfor %}
</select>
<button type="submit" class="button-secondary">적용</button>
</form>
<form method="post" action="/wehago-compare/refresh?start_year={{ wehago_compare.selected_start_year or '' }}&end_year={{ wehago_compare.selected_end_year or '' }}">
<button type="submit">WEHAGO 새로 읽기</button>
</form>
<button type="button" class="button-secondary" id="undoLastActionBtn">직전 작업 취소</button>
<span class="panel-meta" id="lastActionMeta">
{% if wehago_compare.last_action %}
최근 작업: {{ wehago_compare.last_action.action_type }} / {{ "{:,}".format(wehago_compare.last_action.count or 0) }}건
{% else %}
최근 작업: 없음
{% endif %}
</span>
</div>
</div>
<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>
<div class="panel-body">
<div class="status-grid">
{% for section in wehago_compare.metric_sections %}
<button
type="button"
class="status-card"
data-target="detail-{{ section.key }}"
data-status="{{ section.key }}"
aria-expanded="false"
>
<span class="status-label">{{ section.label }}</span>
<span class="status-value">{{ "{:,}".format(section.count) }}</span>
</button>
{% endfor %}
</div>
<div class="detail-stack">
{% for section in wehago_compare.metric_sections %}
<section class="detail-panel" id="detail-{{ section.key }}" data-status-panel="{{ section.key }}">
<div class="detail-head">
<strong>{{ section.label }}</strong>
{% if section.key in ['matched', 'amount_mismatch'] %}
<form class="filter-form" data-filter-kind="status" data-status="{{ section.key }}">
<div class="suggest-field">
<input type="text" name="wehago_account" placeholder="WEHAGO 계정코드" autocomplete="off">
<div class="suggest-dropdown" data-wehago-account-suggest="{{ section.key }}"></div>
</div>
<div class="suggest-field">
<input type="text" name="erp_account" placeholder="Hanmac 계정코드" autocomplete="off">
<div class="suggest-dropdown" data-erp-account-suggest="{{ section.key }}"></div>
</div>
<div class="suggest-field">
<input type="text" name="voucher_no" placeholder="전표번호" autocomplete="off">
<div class="suggest-dropdown" data-voucher-suggest="{{ section.key }}"></div>
</div>
<div class="suggest-field">
<input type="text" name="draft_no" placeholder="가전표번호" autocomplete="off">
<div class="suggest-dropdown" data-draft-suggest="{{ section.key }}"></div>
</div>
<input type="text" name="wehago_amount" placeholder="WEHAGO 금액">
<input type="text" name="erp_amount" placeholder="Hanmac 금액">
<div class="suggest-field">
<input type="text" name="wehago_vendor" placeholder="WEHAGO 거래처" autocomplete="off">
<div class="suggest-dropdown" data-wehago-vendor-suggest="{{ section.key }}"></div>
</div>
<div class="suggest-field">
<input type="text" name="erp_vendor" placeholder="Hanmac 거래처" autocomplete="off">
<div class="suggest-dropdown" data-erp-vendor-suggest="{{ section.key }}"></div>
</div>
<button type="submit">조회</button>
{% if section.key == 'amount_mismatch' %}
<button type="button" class="button-secondary" data-review-save>검토확인</button>
{% endif %}
</form>
{% else %}
<form class="filter-form" data-filter-kind="status" data-status="{{ section.key }}">
<div class="suggest-field">
<input type="text" name="voucher_no" placeholder="전표번호" autocomplete="off">
<div class="suggest-dropdown" data-voucher-suggest="{{ section.key }}"></div>
</div>
<div class="suggest-field">
<input type="text" name="account_keyword" placeholder="계정" autocomplete="off">
<div class="suggest-dropdown" data-account-suggest="{{ section.key }}"></div>
</div>
<div class="suggest-field">
<input type="text" name="vendor_keyword" placeholder="거래처" autocomplete="off">
<div class="suggest-dropdown" data-vendor-suggest="{{ section.key }}"></div>
</div>
<input type="text" name="amount_keyword" placeholder="금액">
<input type="text" name="desc_keyword" placeholder="적요">
<button type="submit">조회</button>
{% if section.key in ['ledger_only', 'voucher_only'] %}
<button type="button" class="button-secondary" data-pair-recommend>정확도 추천</button>
<button type="button" class="button-secondary" data-pair-save>개별 추천</button>
{% endif %}
</form>
{% endif %}
<div class="active-filter-cards" data-active-filters="{{ section.key }}"></div>
<div class="result-meta" data-result-meta="{{ section.key }}"></div>
</div>
<div class="table-wrap" data-table-wrap="{{ section.key }}">
<div class="table-placeholder">상태를 클릭하거나 조회를 실행하면 결과를 불러옵니다.</div>
</div>
<div class="table-footer" data-table-footer="{{ section.key }}" hidden>
<button type="button" class="button-secondary load-more-button" data-load-more="{{ section.key }}">더 보기</button>
</div>
</section>
{% endfor %}
</div>
</div>
</section>
<section class="panel-shell recommend-panel" id="pairRecommendPanel">
<div class="panel-header">
<div>
<h2 id="recommendPanelTitle">정확도 추천 워크벤치</h2>
<div class="panel-meta" id="recommendModeMeta"></div>
</div>
<div class="recommend-actions">
<div class="recommend-sort" id="recommendSortTools">
<button type="button" class="button-secondary active" data-recommend-sort="score">점수순</button>
<button type="button" class="button-secondary" data-recommend-sort="amount_gap">금액차이순</button>
<button type="button" class="button-secondary" data-recommend-sort="account_similarity">계정유사도순</button>
<button type="button" class="button-secondary" data-recommend-sort="vendor_similarity">거래처유사도순</button>
</div>
<label id="recommendHighOnlyWrap" style="display:inline-flex; align-items:center; gap:6px; font-size:13px; color:var(--muted);">
<input type="checkbox" id="recommendHighOnly">
고신뢰만 보기
</label>
<button type="button" class="button-secondary" id="recommendApplySelected">선택 저장</button>
<button type="button" class="button-secondary" id="recommendApplyIndividual" hidden>선택 매칭 저장</button>
<button type="button" class="button-secondary" id="recommendClose">닫기</button>
</div>
</div>
<div class="panel-body">
<div class="recommend-summary" id="recommendSummary"></div>
</div>
<div class="table-wrap" id="recommendTableWrap">
<div class="table-placeholder">정확도 추천 버튼을 누르면 결과가 표시됩니다.</div>
</div>
</section>
</section>
<script>
(() => {
const page = document.querySelector('.voucher-page');
const startYearValue = page?.dataset.startYear || '';
const endYearValue = page?.dataset.endYear || '';
const escapeHtml = (value) => String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const escapeSelectorValue = (value) => String(value ?? '').replace(/["\\]/g, '\\$&');
const detailState = new Map();
const recheckSelections = new Map();
const recheckRowStore = new Map();
const pairSelections = new Map();
const pairLedgerStore = new Map();
const pairVoucherStore = new Map();
const cumulativeFiltersByStatus = new Map();
let latestPairRecommendation = null;
const getCellClass = (field, monoFields = []) => {
const classes = ['cell-ellipsis'];
if (monoFields.includes(field)) {
classes.push('mono');
}
if (['fiscal_year'].includes(field)) {
classes.push('cell-tight');
} else if (['voucher_no', 'draft_no', 'ledger_date', 'proof_date', 'confirmed_no', 'account_code'].includes(field)) {
classes.push('cell-code');
} else if (
[
'ledger_account_name',
'voucher_account_name',
'ledger_vendor',
'voucher_vendor',
'ledger_debit',
'ledger_credit',
'voucher_debit',
'voucher_credit',
].includes(field)
) {
classes.push('cell-equal');
} else if (['ledger_desc', 'voucher_desc', 'desc1', 'desc2', 'description', 'review_reason', 'review_memo', 'notes'].includes(field)) {
classes.push('cell-desc');
}
return classes.join(' ');
};
const renderTable = (container, payload, monoFields = [], append = false, statusKey = '') => {
if (!container) {
return;
}
const columns = payload.columns || [];
const rows = payload.rows || [];
if (!rows.length && !append) {
container.innerHTML = '<div class="table-placeholder">조건에 맞는 항목이 없습니다.</div>';
return;
}
const includeReviewCheckbox = statusKey === 'amount_mismatch';
const includePairCheckbox = statusKey === 'ledger_only' || statusKey === 'voucher_only';
const head = [
(includeReviewCheckbox || includePairCheckbox) ? '<th></th>' : '',
...columns.map(([, label]) => `<th>${escapeHtml(label)}</th>`),
].join('');
const body = rows.map((row) => {
const reviewKey = row.review_key || '';
if (includeReviewCheckbox && reviewKey) {
recheckRowStore.set(reviewKey, row);
}
const pairKey = statusKey === 'ledger_only'
? (row.ledger_row_key || '')
: statusKey === 'voucher_only'
? (row.voucher_row_key || '')
: '';
if (includePairCheckbox && pairKey) {
if (statusKey === 'ledger_only') {
pairLedgerStore.set(pairKey, row);
} else {
pairVoucherStore.set(pairKey, row);
}
}
const cells = columns.map(([field]) => {
const raw = row[field] ?? '';
let display = '';
if (field === 'fiscal_year') {
display = String(raw ?? '');
} else if (typeof raw === 'number') {
display = Number.isInteger(raw)
? raw.toLocaleString()
: raw.toLocaleString(undefined, { maximumFractionDigits: 2 });
} else {
display = String(raw);
}
const className = getCellClass(field, monoFields);
return `<td title="${escapeHtml(display)}"><span class="${className}">${escapeHtml(display)}</span></td>`;
}).join('');
const checkboxCell = includeReviewCheckbox
? `<td><input type="checkbox" data-review-key="${escapeHtml(reviewKey)}" ${recheckSelections.get(reviewKey) ? 'checked' : ''}></td>`
: includePairCheckbox
? `<td><input type="checkbox" data-pair-key="${escapeHtml(pairKey)}" data-pair-status="${escapeHtml(statusKey)}" ${pairSelections.get(pairKey) ? 'checked' : ''}></td>`
: '';
return `<tr>${checkboxCell}${cells}</tr>`;
}).join('');
if (!append || !container.querySelector('table')) {
container.innerHTML = `<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
} else {
const tbody = container.querySelector('tbody');
if (tbody && body) {
tbody.insertAdjacentHTML('beforeend', body);
}
}
if (includeReviewCheckbox) {
container.querySelectorAll('input[data-review-key]').forEach((checkbox) => {
checkbox.addEventListener('change', () => {
const key = checkbox.dataset.reviewKey || '';
if (!key) return;
recheckSelections.set(key, checkbox.checked);
});
});
}
if (includePairCheckbox) {
container.querySelectorAll('input[data-pair-key]').forEach((checkbox) => {
checkbox.addEventListener('change', () => {
const key = checkbox.dataset.pairKey || '';
if (!key) return;
pairSelections.set(key, checkbox.checked);
});
});
}
};
const setMeta = (key, totalCount, shownCount, notice = '') => {
const target = document.querySelector(`[data-result-meta="${key}"]`);
if (target) {
const countText = `조회 결과 ${Number(totalCount || 0).toLocaleString()}건`;
const shownText = shownCount
? ` / 현재 ${Number(shownCount || 0).toLocaleString()}건`
: '';
const noticeText = notice ? ` / ${notice}` : '';
target.textContent = `${countText}${shownText}${noticeText}`;
}
};
const fetchJson = async (url) => {
const response = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || '조회 중 오류가 발생했습니다.');
}
return payload;
};
const buildQuery = (form, extra = {}) => {
const params = new URLSearchParams();
if (startYearValue) params.set('start_year', startYearValue);
if (endYearValue) params.set('end_year', endYearValue);
if (form) {
const data = new FormData(form);
for (const [key, value] of data.entries()) {
if (String(value).trim()) {
params.set(key, String(value).trim());
}
}
}
Object.entries(extra).forEach(([key, value]) => {
if (value) params.set(key, value);
});
return params.toString();
};
const getStatusForm = (statusKey) => document.querySelector(`form[data-filter-kind="status"][data-status="${statusKey}"]`);
const suggestState = new Map();
const recommendPanel = document.getElementById('pairRecommendPanel');
const recommendPanelTitle = document.getElementById('recommendPanelTitle');
const recommendModeMeta = document.getElementById('recommendModeMeta');
const recommendSummary = document.getElementById('recommendSummary');
const recommendTableWrap = document.getElementById('recommendTableWrap');
const recommendHighOnly = document.getElementById('recommendHighOnly');
const recommendHighOnlyWrap = document.getElementById('recommendHighOnlyWrap');
const recommendApplySelected = document.getElementById('recommendApplySelected');
const recommendApplyIndividual = document.getElementById('recommendApplyIndividual');
const recommendClose = document.getElementById('recommendClose');
const recommendSortTools = document.getElementById('recommendSortTools');
const recommendSortButtons = Array.from(document.querySelectorAll('[data-recommend-sort]'));
const undoLastActionBtn = document.getElementById('undoLastActionBtn');
const lastActionMeta = document.getElementById('lastActionMeta');
const recommendSelections = new Map();
let recommendSortKey = 'score';
let recommendMode = 'batch';
const individualState = {
sourceStatus: '',
sourceRow: null,
sourceRowKey: '',
rows: [],
totalCount: 0,
nextOffset: 0,
hasMore: false,
loading: false,
selectedPairKey: '',
};
const refreshLastActionMeta = async () => {
if (!lastActionMeta || !undoLastActionBtn) return;
try {
const payload = await fetchJson('/wehago-compare/api/last-action');
const hasAction = Boolean(payload && payload.id);
undoLastActionBtn.disabled = !hasAction;
if (!hasAction) {
lastActionMeta.textContent = '최근 작업: 없음';
return;
}
const actionTypeMap = {
recheck_save: 'recheck 저장',
pair_save: '쌍매칭 저장',
};
const actionLabel = actionTypeMap[payload.action_type] || payload.action_type || '작업';
lastActionMeta.textContent = `최근 작업: ${actionLabel} / ${formatNumber(payload.count || 0)}건`;
} catch (_error) {
undoLastActionBtn.disabled = true;
lastActionMeta.textContent = '최근 작업: 조회 실패';
}
};
const buildPairQuery = (extra = {}) => {
const ledgerForm = getStatusForm('ledger_only');
const voucherForm = getStatusForm('voucher_only');
const params = new URLSearchParams();
if (startYearValue) params.set('start_year', startYearValue);
if (endYearValue) params.set('end_year', endYearValue);
if (ledgerForm) {
const data = new FormData(ledgerForm);
params.set('ledger_voucher_no', String(data.get('voucher_no') || '').trim());
params.set('ledger_review_reason', String(data.get('desc_keyword') || '').trim());
}
if (voucherForm) {
const data = new FormData(voucherForm);
params.set('voucher_voucher_no', String(data.get('voucher_no') || '').trim());
params.set('voucher_review_reason', String(data.get('desc_keyword') || '').trim());
}
Object.entries(extra).forEach(([key, value]) => {
if (value !== undefined && value !== null && String(value).trim() !== '') {
params.set(key, String(value).trim());
}
});
return params;
};
const initStatusSuggest = (form, type) => {
if (!form) return;
const status = form.dataset.status || '';
if (!['matched', 'amount_mismatch', 'ledger_only', 'voucher_only'].includes(status)) return;
const inputName = type === 'account'
? 'account_keyword'
: type === 'wehago_account'
? 'wehago_account'
: type === 'erp_account'
? 'erp_account'
: type === 'draft'
? 'draft_no'
: type === 'vendor'
? 'vendor_keyword'
: type === 'wehago_vendor'
? 'wehago_vendor'
: type === 'erp_vendor'
? 'erp_vendor'
: 'voucher_no';
const fieldName = type === 'account'
? 'account'
: type === 'wehago_account'
? 'wehago_account'
: type === 'erp_account'
? 'erp_account'
: type === 'draft'
? 'draft_no'
: type === 'vendor'
? 'vendor'
: type === 'wehago_vendor'
? 'wehago_vendor'
: type === 'erp_vendor'
? 'erp_vendor'
: 'voucher_no';
const dropdownSelector = type === 'account'
? `[data-account-suggest="${status}"]`
: type === 'wehago_account'
? `[data-wehago-account-suggest="${status}"]`
: type === 'erp_account'
? `[data-erp-account-suggest="${status}"]`
: type === 'draft'
? `[data-draft-suggest="${status}"]`
: type === 'vendor'
? `[data-vendor-suggest="${status}"]`
: type === 'wehago_vendor'
? `[data-wehago-vendor-suggest="${status}"]`
: type === 'erp_vendor'
? `[data-erp-vendor-suggest="${status}"]`
: `[data-voucher-suggest="${status}"]`;
const input = form.querySelector(`input[name="${inputName}"]`);
const dropdown = form.querySelector(dropdownSelector);
if (!input || !dropdown) return;
const stateKey = `${status}:${type}`;
suggestState.set(stateKey, { offset: 0, hasMore: false, loading: false, keyword: '' });
const renderSuggest = (rows, append = false) => {
if (!append) {
dropdown.innerHTML = '';
}
if (!rows.length && !append) {
dropdown.innerHTML = '<div class="suggest-empty">검색 결과가 없습니다.</div>';
dropdown.classList.add('open');
return;
}
const active = cumulativeFiltersByStatus.get(status) || {};
const html = rows.map((row) => {
const value = type === 'voucher' ? (row.voucher_no || '') : (row.label || '');
const checked = String(active[inputName] || '') === String(value || '');
return `
<label class="suggest-item" title="${escapeHtml(row.label || '')}">
<input type="checkbox" data-suggest-check="${escapeHtml(inputName)}" data-suggest-value="${escapeHtml(value)}" ${checked ? 'checked' : ''}>
<span class="text">${escapeHtml(row.label || '')}</span>
</label>
`;
}).join('');
if (!append) {
dropdown.innerHTML = html;
} else {
dropdown.insertAdjacentHTML('beforeend', html);
}
dropdown.classList.add('open');
dropdown.querySelectorAll('input[type="checkbox"][data-suggest-check]').forEach((checkbox) => {
if (checkbox.dataset.bound === 'y') return;
checkbox.dataset.bound = 'y';
checkbox.addEventListener('change', () => {
const value = checkbox.dataset.suggestValue || '';
if (checkbox.checked) {
dropdown.querySelectorAll(`input[type="checkbox"][data-suggest-check="${escapeSelectorValue(inputName)}"]`).forEach((other) => {
if (other !== checkbox) other.checked = false;
});
input.value = value;
updateCumulativeFilter(status, inputName, value, true);
} else {
if (String(input.value || '').trim() === value) {
input.value = '';
}
updateCumulativeFilter(status, inputName, '', false);
}
});
});
};
const loadSuggest = async (append = false) => {
const state = suggestState.get(stateKey) || { offset: 0, hasMore: false, loading: false, keyword: '' };
if (state.loading) return;
const keyword = String(input.value || '').trim();
if (!keyword) {
dropdown.classList.remove('open');
dropdown.innerHTML = '';
suggestState.set(stateKey, { ...state, offset: 0, hasMore: false, keyword: '' });
return;
}
const nextOffset = append ? (state.offset || 0) : 0;
suggestState.set(stateKey, { ...state, loading: true, keyword });
try {
const params = new URLSearchParams();
if (startYearValue) params.set('start_year', startYearValue);
if (endYearValue) params.set('end_year', endYearValue);
params.set('status', status);
params.set('field', fieldName);
params.set('keyword', keyword);
params.set('offset', String(nextOffset));
params.set('limit', '10');
let payload = await fetchJson(`/wehago-compare/api/status-suggestions?${params.toString()}`);
let rows = payload.rows || [];
if (!append && rows.length === 0) {
const fallbackParams = new URLSearchParams();
if (startYearValue) fallbackParams.set('start_year', startYearValue);
if (endYearValue) fallbackParams.set('end_year', endYearValue);
fallbackParams.set('status', status);
fallbackParams.set('field', fieldName);
fallbackParams.set('keyword', '');
fallbackParams.set('offset', '0');
fallbackParams.set('limit', '10');
const fallback = await fetchJson(`/wehago-compare/api/status-suggestions?${fallbackParams.toString()}`);
rows = fallback.rows || [];
payload = rows.length ? fallback : payload;
}
renderSuggest(rows, append);
suggestState.set(stateKey, {
...state,
loading: false,
offset: Number(payload.next_offset || 0),
hasMore: Boolean(payload.has_more),
keyword,
});
} catch (_error) {
suggestState.set(stateKey, { ...state, loading: false });
}
};
let debounceTimer = null;
input.addEventListener('input', () => {
if (debounceTimer) {
window.clearTimeout(debounceTimer);
}
debounceTimer = window.setTimeout(() => {
loadSuggest(false);
}, 180);
});
input.addEventListener('focus', () => {
if (String(input.value || '').trim()) {
loadSuggest(false);
}
});
dropdown.addEventListener('scroll', () => {
const state = suggestState.get(stateKey);
if (!state || !state.hasMore || state.loading) return;
const remain = dropdown.scrollHeight - dropdown.scrollTop - dropdown.clientHeight;
if (remain <= 40) {
loadSuggest(true);
}
});
document.addEventListener('mousedown', (event) => {
if (!form.contains(event.target)) {
dropdown.classList.remove('open');
}
});
};
const formatNumber = (value) => {
const numberValue = Number(value || 0);
return Number.isInteger(numberValue)
? numberValue.toLocaleString()
: numberValue.toLocaleString(undefined, { maximumFractionDigits: 2 });
};
const prettyFilterLabels = {
voucher_no: '전표번호',
draft_no: '가전표번호',
wehago_account: 'WEHAGO 계정',
erp_account: 'Hanmac 계정',
wehago_amount: 'WEHAGO 금액',
erp_amount: 'Hanmac 금액',
wehago_vendor: 'WEHAGO 거래처',
erp_vendor: 'Hanmac 거래처',
account_keyword: '계정',
vendor_keyword: '거래처',
amount_keyword: '금액',
desc_keyword: '적요',
};
const getFormFieldValues = (form) => {
const values = {};
form.querySelectorAll('input[name]').forEach((input) => {
const name = input.name;
if (!name) return;
const value = String(input.value || '').trim();
if (!value) return;
values[name] = value;
});
return values;
};
const updateCumulativeFilter = (statusKey, fieldName, value, checked = true) => {
const current = { ...(cumulativeFiltersByStatus.get(statusKey) || {}) };
if (checked && String(value || '').trim()) {
current[fieldName] = String(value).trim();
} else {
delete current[fieldName];
}
cumulativeFiltersByStatus.set(statusKey, current);
renderActiveFilterCards(statusKey);
};
const renderActiveFilterCards = (statusKey) => {
const box = document.querySelector(`[data-active-filters="${statusKey}"]`);
if (!box) return;
const active = cumulativeFiltersByStatus.get(statusKey) || {};
const entries = Object.entries(active).filter(([, value]) => String(value || '').trim() !== '');
if (!entries.length) {
box.innerHTML = '';
return;
}
box.innerHTML = entries.map(([name, value]) => `
<div class="active-filter-card">
<div class="value" title="${escapeHtml(`${prettyFilterLabels[name] || name}: ${value}`)}">${escapeHtml(`${prettyFilterLabels[name] || name}: ${value}`)}</div>
<button type="button" class="remove" data-remove-active-filter="${escapeHtml(statusKey)}" data-filter-name="${escapeHtml(name)}" aria-label="조건 제거">×</button>
</div>
`).join('');
};
const getFilteredRecommendations = () => {
if (!latestPairRecommendation || !Array.isArray(latestPairRecommendation.pairs)) {
return [];
}
const onlyHigh = Boolean(recommendHighOnly?.checked);
const filtered = latestPairRecommendation.pairs.filter((pair) => !onlyHigh || pair.confidence_level === 'high');
filtered.sort((a, b) => {
if (recommendSortKey === 'amount_gap') {
const left = Number(a.amount_gap || 0);
const right = Number(b.amount_gap || 0);
return left - right;
}
if (recommendSortKey === 'account_similarity') {
const left = Number(a.account_similarity || 0);
const right = Number(b.account_similarity || 0);
return right - left;
}
if (recommendSortKey === 'vendor_similarity') {
const left = Number(a.vendor_similarity || 0);
const right = Number(b.vendor_similarity || 0);
return right - left;
}
return Number(b.score || 0) - Number(a.score || 0);
});
return filtered;
};
const renderRecommendationSummary = (stats = {}) => {
if (!recommendSummary) {
return;
}
recommendSummary.innerHTML = `
<span class="recommend-badge">추천 ${formatNumber(stats.recommended || 0)}건</span>
<span class="recommend-badge good">고신뢰 ${formatNumber(stats.high_confidence || 0)}건</span>
<span class="recommend-badge">WEHAGO 풀 ${formatNumber(stats.ledger_rows || 0)}건</span>
<span class="recommend-badge">ERP 풀 ${formatNumber(stats.voucher_rows || 0)}건</span>
`;
};
const renderRecommendationTable = () => {
if (!recommendTableWrap) {
return;
}
const pairs = getFilteredRecommendations();
if (!pairs.length) {
recommendTableWrap.innerHTML = '<div class="table-placeholder">표시할 추천 결과가 없습니다.</div>';
return;
}
const head = `
<tr>
<th><input type="checkbox" id="recommendSelectAll"></th>
<th>점수</th>
<th>금액차이</th>
<th>계정유사도</th>
<th>거래처유사도</th>
<th>신뢰도</th>
<th>연도</th>
<th>전표번호</th>
<th>일자</th>
<th>가전표번호</th>
<th>WEHAGO 계정</th>
<th>ERP 계정</th>
<th>WEHAGO 거래처</th>
<th>ERP 거래처</th>
<th>WEHAGO 금액</th>
<th>ERP 금액</th>
<th>WEHAGO 적요</th>
<th>ERP 적요</th>
</tr>
`;
const body = pairs.map((pair) => {
const key = pair.pair_key || '';
const ledger = pair.ledger_row || {};
const voucher = pair.voucher_row || {};
const ledgerAmount = Math.max(Number(ledger.ledger_debit || 0), Number(ledger.ledger_credit || 0));
const voucherAmount = Math.max(Number(voucher.voucher_debit || 0), Number(voucher.voucher_credit || 0));
if (!recommendSelections.has(key) && pair.auto_eligible) {
recommendSelections.set(key, true);
}
const checked = recommendSelections.get(key) ? 'checked' : '';
return `
<tr>
<td><input type="checkbox" data-recommend-key="${escapeHtml(key)}" ${checked}></td>
<td title="${escapeHtml(pair.score)}"><span class="cell-ellipsis cell-tight">${escapeHtml(pair.score)}</span></td>
<td title="${escapeHtml(formatNumber(pair.amount_gap || 0))}"><span class="cell-ellipsis cell-tight">${escapeHtml(formatNumber(pair.amount_gap || 0))}</span></td>
<td title="${escapeHtml((Number(pair.account_similarity || 0) * 100).toFixed(1) + '%')}"><span class="cell-ellipsis cell-tight">${escapeHtml((Number(pair.account_similarity || 0) * 100).toFixed(1) + '%')}</span></td>
<td title="${escapeHtml((Number(pair.vendor_similarity || 0) * 100).toFixed(1) + '%')}"><span class="cell-ellipsis cell-tight">${escapeHtml((Number(pair.vendor_similarity || 0) * 100).toFixed(1) + '%')}</span></td>
<td title="${escapeHtml(pair.confidence_level)}"><span class="cell-ellipsis cell-tight">${escapeHtml(pair.confidence_level)}</span></td>
<td title="${escapeHtml(ledger.fiscal_year || voucher.fiscal_year || '')}"><span class="cell-ellipsis cell-tight">${escapeHtml(ledger.fiscal_year || voucher.fiscal_year || '')}</span></td>
<td title="${escapeHtml(ledger.voucher_no || '')}"><span class="cell-ellipsis cell-code">${escapeHtml(ledger.voucher_no || '')}</span></td>
<td title="${escapeHtml(ledger.ledger_date || '')}"><span class="cell-ellipsis cell-code">${escapeHtml(ledger.ledger_date || '')}</span></td>
<td title="${escapeHtml(voucher.draft_no || '')}"><span class="cell-ellipsis cell-code">${escapeHtml(voucher.draft_no || '')}</span></td>
<td title="${escapeHtml(ledger.ledger_account_name || '')}"><span class="cell-ellipsis cell-equal">${escapeHtml(ledger.ledger_account_name || '')}</span></td>
<td title="${escapeHtml(voucher.voucher_account_name || '')}"><span class="cell-ellipsis cell-equal">${escapeHtml(voucher.voucher_account_name || '')}</span></td>
<td title="${escapeHtml(ledger.ledger_vendor || '')}"><span class="cell-ellipsis cell-equal">${escapeHtml(ledger.ledger_vendor || '')}</span></td>
<td title="${escapeHtml(voucher.voucher_vendor || '')}"><span class="cell-ellipsis cell-equal">${escapeHtml(voucher.voucher_vendor || '')}</span></td>
<td title="${escapeHtml(formatNumber(ledgerAmount))}"><span class="cell-ellipsis cell-equal">${escapeHtml(formatNumber(ledgerAmount))}</span></td>
<td title="${escapeHtml(formatNumber(voucherAmount))}"><span class="cell-ellipsis cell-equal">${escapeHtml(formatNumber(voucherAmount))}</span></td>
<td title="${escapeHtml(ledger.ledger_desc || '')}"><span class="cell-ellipsis cell-desc">${escapeHtml(ledger.ledger_desc || '')}</span></td>
<td title="${escapeHtml(voucher.voucher_desc || '')}"><span class="cell-ellipsis cell-desc">${escapeHtml(voucher.voucher_desc || '')}</span></td>
</tr>
`;
}).join('');
recommendTableWrap.innerHTML = `<table><thead>${head}</thead><tbody>${body}</tbody></table>`;
const selectAll = document.getElementById('recommendSelectAll');
const rowChecks = Array.from(recommendTableWrap.querySelectorAll('input[data-recommend-key]'));
const syncHeader = () => {
if (!selectAll) return;
const checkedCount = rowChecks.filter((el) => el.checked).length;
selectAll.checked = rowChecks.length > 0 && checkedCount === rowChecks.length;
selectAll.indeterminate = checkedCount > 0 && checkedCount < rowChecks.length;
};
rowChecks.forEach((checkbox) => {
checkbox.addEventListener('change', () => {
const key = checkbox.dataset.recommendKey || '';
if (!key) return;
recommendSelections.set(key, checkbox.checked);
syncHeader();
});
});
if (selectAll) {
selectAll.addEventListener('change', () => {
rowChecks.forEach((checkbox) => {
checkbox.checked = selectAll.checked;
const key = checkbox.dataset.recommendKey || '';
if (key) {
recommendSelections.set(key, selectAll.checked);
}
});
syncHeader();
});
}
syncHeader();
};
const setRecommendMode = (mode) => {
recommendMode = mode === 'individual' ? 'individual' : 'batch';
if (recommendPanelTitle) {
recommendPanelTitle.textContent = recommendMode === 'individual' ? '개별 추천' : '정확도 추천 워크벤치';
}
if (recommendModeMeta && recommendMode === 'batch') {
recommendModeMeta.textContent = '';
}
if (recommendSortTools) {
recommendSortTools.hidden = recommendMode === 'individual';
}
if (recommendHighOnlyWrap) {
recommendHighOnlyWrap.hidden = recommendMode === 'individual';
}
if (recommendApplySelected) {
recommendApplySelected.hidden = recommendMode === 'individual';
}
if (recommendApplyIndividual) {
recommendApplyIndividual.hidden = recommendMode !== 'individual';
}
if (recommendSummary) {
recommendSummary.style.display = recommendMode === 'individual' ? 'none' : 'flex';
}
};
const openRecommendationPanel = (mode = 'batch') => {
if (!recommendPanel) return;
setRecommendMode(mode);
recommendPanel.classList.add('active');
recommendPanel.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
const closeRecommendationPanel = () => {
if (!recommendPanel) return;
recommendPanel.classList.remove('active');
};
const getSelectedSourceForIndividual = (sourceStatus) => {
if (sourceStatus === 'ledger_only') {
return Array.from(pairSelections.entries())
.filter(([key, checked]) => checked && pairLedgerStore.has(key))
.map(([key]) => pairLedgerStore.get(key))
.filter(Boolean);
}
return Array.from(pairSelections.entries())
.filter(([key, checked]) => checked && pairVoucherStore.has(key))
.map(([key]) => pairVoucherStore.get(key))
.filter(Boolean);
};
const renderIndividualRows = (append = false) => {
if (!recommendTableWrap) return;
const rows = individualState.rows || [];
if (!rows.length && !append) {
recommendTableWrap.innerHTML = '<div class="table-placeholder">추천 항목이 없습니다.</div>';
return;
}
const head = `
<tr>
<th></th>
<th>점수</th>
<th>금액차이</th>
<th>계정유사도</th>
<th>거래처유사도</th>
<th>연도</th>
<th>전표번호</th>
<th>일자</th>
<th>가전표번호</th>
<th>WEHAGO 계정</th>
<th>ERP 계정</th>
<th>WEHAGO 거래처</th>
<th>ERP 거래처</th>
<th>WEHAGO 적요</th>
<th>ERP 적요</th>
</tr>
`;
const body = rows.map((item) => {
const target = item.target_row || {};
const isLedgerSource = individualState.sourceStatus === 'ledger_only';
const ledger = isLedgerSource ? (individualState.sourceRow || {}) : target;
const voucher = isLedgerSource ? target : (individualState.sourceRow || {});
const checked = individualState.selectedPairKey === item.pair_key ? 'checked' : '';
return `
<tr>
<td><input type="radio" name="individualPairRadio" value="${escapeHtml(item.pair_key || '')}" ${checked}></td>
<td title="${escapeHtml(item.score)}"><span class="cell-ellipsis cell-tight">${escapeHtml(item.score)}</span></td>
<td title="${escapeHtml(formatNumber(item.amount_gap || 0))}"><span class="cell-ellipsis cell-tight">${escapeHtml(formatNumber(item.amount_gap || 0))}</span></td>
<td title="${escapeHtml((Number(item.account_similarity || 0) * 100).toFixed(1) + '%')}"><span class="cell-ellipsis cell-tight">${escapeHtml((Number(item.account_similarity || 0) * 100).toFixed(1) + '%')}</span></td>
<td title="${escapeHtml((Number(item.vendor_similarity || 0) * 100).toFixed(1) + '%')}"><span class="cell-ellipsis cell-tight">${escapeHtml((Number(item.vendor_similarity || 0) * 100).toFixed(1) + '%')}</span></td>
<td title="${escapeHtml(ledger.fiscal_year || voucher.fiscal_year || '')}"><span class="cell-ellipsis cell-tight">${escapeHtml(ledger.fiscal_year || voucher.fiscal_year || '')}</span></td>
<td title="${escapeHtml(ledger.voucher_no || '')}"><span class="cell-ellipsis cell-code">${escapeHtml(ledger.voucher_no || '')}</span></td>
<td title="${escapeHtml(ledger.ledger_date || '')}"><span class="cell-ellipsis cell-code">${escapeHtml(ledger.ledger_date || '')}</span></td>
<td title="${escapeHtml(voucher.draft_no || '')}"><span class="cell-ellipsis cell-code">${escapeHtml(voucher.draft_no || '')}</span></td>
<td title="${escapeHtml(ledger.ledger_account_name || '')}"><span class="cell-ellipsis cell-equal">${escapeHtml(ledger.ledger_account_name || '')}</span></td>
<td title="${escapeHtml(voucher.voucher_account_name || '')}"><span class="cell-ellipsis cell-equal">${escapeHtml(voucher.voucher_account_name || '')}</span></td>
<td title="${escapeHtml(ledger.ledger_vendor || '')}"><span class="cell-ellipsis cell-equal">${escapeHtml(ledger.ledger_vendor || '')}</span></td>
<td title="${escapeHtml(voucher.voucher_vendor || '')}"><span class="cell-ellipsis cell-equal">${escapeHtml(voucher.voucher_vendor || '')}</span></td>
<td title="${escapeHtml(ledger.ledger_desc || '')}"><span class="cell-ellipsis cell-desc">${escapeHtml(ledger.ledger_desc || '')}</span></td>
<td title="${escapeHtml(voucher.voucher_desc || '')}"><span class="cell-ellipsis cell-desc">${escapeHtml(voucher.voucher_desc || '')}</span></td>
</tr>
`;
}).join('');
recommendTableWrap.innerHTML = `<table><thead>${head}</thead><tbody>${body}</tbody></table>`;
recommendTableWrap.querySelectorAll('input[name="individualPairRadio"]').forEach((radio) => {
radio.addEventListener('change', () => {
individualState.selectedPairKey = radio.value || '';
});
});
};
const loadIndividualRecommendations = async (append = false) => {
if (!individualState.sourceStatus || !individualState.sourceRowKey || individualState.loading) return;
individualState.loading = true;
if (!append && recommendTableWrap) {
recommendTableWrap.innerHTML = '<div class="table-placeholder">개별 추천 불러오는 중...</div>';
}
try {
const params = new URLSearchParams();
if (startYearValue) params.set('start_year', startYearValue);
if (endYearValue) params.set('end_year', endYearValue);
params.set('source_status', individualState.sourceStatus);
params.set('source_row_key', individualState.sourceRowKey);
params.set('offset', append ? String(individualState.nextOffset || 0) : '0');
params.set('limit', '10');
const payload = await fetchJson(`/wehago-compare/api/pair-individual-recommendations?${params.toString()}`);
if (!append) {
individualState.rows = payload.rows || [];
individualState.sourceRow = payload.source_row || null;
individualState.totalCount = Number(payload.total_count || 0);
} else {
individualState.rows = [...(individualState.rows || []), ...(payload.rows || [])];
}
individualState.nextOffset = Number(payload.next_offset || 0);
individualState.hasMore = Boolean(payload.has_more);
if (recommendModeMeta) {
const source = individualState.sourceRow || {};
const label = individualState.sourceStatus === 'ledger_only' ? 'Unmatched 기준' : 'ERP Unmatched 기준';
recommendModeMeta.textContent = `${label} / 전표번호 ${source.voucher_no || '-'} / 계정 ${source.ledger_account_name || source.voucher_account_name || '-'}`;
}
if (!individualState.selectedPairKey && individualState.rows.length) {
individualState.selectedPairKey = individualState.rows[0].pair_key || '';
}
renderIndividualRows(append);
} catch (error) {
if (recommendTableWrap) {
recommendTableWrap.innerHTML = `<div class="table-placeholder">${escapeHtml(error.message)}</div>`;
}
} finally {
individualState.loading = false;
}
};
const setLoadMoreState = (statusKey, hasMore, loading = false) => {
const footer = document.querySelector(`[data-table-footer="${statusKey}"]`);
const button = document.querySelector(`[data-load-more="${statusKey}"]`);
if (!footer || !button) return;
footer.hidden = !hasMore && !loading;
button.disabled = loading;
button.textContent = loading ? '불러오는 중...' : '더 보기';
};
const buildStatusQuery = (form, statusKey, extra = {}) => {
const selected = form ? (cumulativeFiltersByStatus.get(statusKey) || {}) : {};
const mapped = new URLSearchParams();
if (startYearValue) mapped.set('start_year', startYearValue);
if (endYearValue) mapped.set('end_year', endYearValue);
mapped.set('status', statusKey);
const rawVoucher = String(selected.voucher_no || '').trim();
const rawDesc = String(selected.desc_keyword || '').trim();
if (rawVoucher) mapped.set('voucher_no', rawVoucher);
if (rawDesc) mapped.set('desc_keyword', rawDesc);
if (statusKey === 'ledger_only') {
const account = String(selected.account_keyword || '').trim();
const vendor = String(selected.vendor_keyword || '').trim();
const amount = String(selected.amount_keyword || '').trim();
if (account) mapped.set('wehago_account', account);
if (vendor) mapped.set('wehago_vendor', vendor);
if (amount) mapped.set('wehago_amount', amount);
} else if (statusKey === 'voucher_only') {
const account = String(selected.account_keyword || '').trim();
const vendor = String(selected.vendor_keyword || '').trim();
const amount = String(selected.amount_keyword || '').trim();
if (account) mapped.set('erp_account', account);
if (vendor) mapped.set('erp_vendor', vendor);
if (amount) mapped.set('erp_amount', amount);
} else {
for (const [key, value] of Object.entries(selected)) {
if (String(value).trim()) {
mapped.set(key, String(value).trim());
}
}
}
Object.entries(extra).forEach(([key, value]) => {
if (value !== undefined && value !== null && String(value) !== '') {
mapped.set(key, String(value));
}
});
return mapped.toString();
};
const loadStatusRows = async (statusKey, form = null, append = false) => {
const wrap = document.querySelector(`[data-table-wrap="${statusKey}"]`);
if (!wrap) return;
const current = detailState.get(statusKey) || { offset: 0, totalCount: 0, loading: false };
if (current.loading) return;
if (!append && form) {
const selected = getFormFieldValues(form);
const merged = { ...(cumulativeFiltersByStatus.get(statusKey) || {}), ...selected };
cumulativeFiltersByStatus.set(statusKey, merged);
renderActiveFilterCards(statusKey);
}
const nextOffset = append ? (current.nextOffset || 0) : 0;
detailState.set(statusKey, { ...current, loading: true, form });
setLoadMoreState(statusKey, true, true);
if (!append) {
wrap.innerHTML = '<div class="table-placeholder">조회 중입니다...</div>';
}
try {
const payload = await fetchJson(`/wehago-compare/api/status-rows?${buildStatusQuery(form, statusKey, { offset: nextOffset, limit: 200 })}`);
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,
totalCount: payload.total_count,
hasMore: payload.has_more,
loading: false,
form,
shownCount,
});
setMeta(statusKey, payload.total_count, shownCount, payload.notice);
setLoadMoreState(statusKey, payload.has_more, false);
} catch (error) {
if (!append) {
wrap.innerHTML = `<div class="table-placeholder">${escapeHtml(error.message)}</div>`;
}
detailState.set(statusKey, { ...current, loading: false, form });
setLoadMoreState(statusKey, current.hasMore, false);
}
};
const cards = Array.from(document.querySelectorAll('.status-card'));
const panels = Array.from(document.querySelectorAll('.detail-panel'));
cards.forEach((card) => {
card.addEventListener('click', () => {
// 상태 탭 전환 시 추천/개별 추천 패널은 즉시 닫는다.
closeRecommendationPanel();
const targetId = card.dataset.target;
const statusKey = card.dataset.status;
const panel = targetId ? document.getElementById(targetId) : null;
const isActive = panel ? panel.classList.contains('active') : false;
cards.forEach((item) => item.setAttribute('aria-expanded', 'false'));
panels.forEach((item) => item.classList.remove('active'));
if (panel && !isActive) {
panel.classList.add('active');
card.setAttribute('aria-expanded', 'true');
const form = panel.querySelector('form[data-filter-kind="status"]');
loadStatusRows(statusKey, form, false);
}
});
});
document.querySelectorAll('form[data-filter-kind="status"]').forEach((form) => {
initStatusSuggest(form, 'voucher');
initStatusSuggest(form, 'draft');
initStatusSuggest(form, 'account');
initStatusSuggest(form, 'vendor');
initStatusSuggest(form, 'wehago_account');
initStatusSuggest(form, 'erp_account');
initStatusSuggest(form, 'wehago_vendor');
initStatusSuggest(form, 'erp_vendor');
form.addEventListener('submit', (event) => {
event.preventDefault();
const statusKey = form.dataset.status;
loadStatusRows(statusKey, form, false);
});
});
document.addEventListener('click', (event) => {
const removeButton = event.target.closest('[data-remove-active-filter]');
if (!removeButton) return;
const statusKey = removeButton.dataset.removeActiveFilter || '';
const filterName = removeButton.dataset.filterName || '';
if (!statusKey || !filterName) return;
const current = { ...(cumulativeFiltersByStatus.get(statusKey) || {}) };
delete current[filterName];
cumulativeFiltersByStatus.set(statusKey, current);
renderActiveFilterCards(statusKey);
const form = document.querySelector(`form[data-filter-kind="status"][data-status="${statusKey}"]`);
if (form) {
const safeName = escapeSelectorValue(filterName);
const input = form.querySelector(`input[name="${safeName}"]`);
if (input) input.value = '';
}
loadStatusRows(statusKey, form, false);
});
document.querySelectorAll('[data-load-more]').forEach((button) => {
button.addEventListener('click', () => {
const statusKey = button.dataset.loadMore;
const state = detailState.get(statusKey);
const form = state?.form || document.querySelector(`form[data-filter-kind="status"][data-status="${statusKey}"]`);
loadStatusRows(statusKey, form, true);
});
});
document.querySelectorAll('[data-review-save]').forEach((button) => {
button.addEventListener('click', async () => {
const selectedRows = Array.from(recheckSelections.entries())
.filter(([, checked]) => checked)
.map(([key]) => recheckRowStore.get(key))
.filter(Boolean);
if (!selectedRows.length) {
alert('저장할 검토 항목을 선택해주세요.');
return;
}
button.disabled = true;
try {
const response = await fetch('/wehago-compare/api/recheck-review-save', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({ rows: selectedRows }),
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || '검토 저장 중 오류가 발생했습니다.');
}
window.location.reload();
} catch (error) {
alert(error.message);
} finally {
button.disabled = false;
}
});
});
document.querySelectorAll('[data-pair-save]').forEach((button) => {
button.addEventListener('click', async () => {
const form = button.closest('form[data-status]');
const sourceStatus = form?.dataset.status || '';
if (!['ledger_only', 'voucher_only'].includes(sourceStatus)) {
alert('개별 추천은 Unmatched 또는 ERP Unmatched에서만 사용할 수 있습니다.');
return;
}
const selectedRows = getSelectedSourceForIndividual(sourceStatus);
if (!selectedRows.length) {
alert(sourceStatus === 'ledger_only' ? 'Unmatched에서 항목 1건을 체크해주세요.' : 'ERP Unmatched에서 항목 1건을 체크해주세요.');
return;
}
if (selectedRows.length > 1) {
alert('개별 추천은 한 번에 1건만 선택해서 실행해주세요.');
return;
}
const sourceRow = selectedRows[0];
individualState.sourceStatus = sourceStatus;
individualState.sourceRow = sourceRow;
individualState.sourceRowKey = sourceStatus === 'ledger_only'
? (sourceRow.ledger_row_key || '')
: (sourceRow.voucher_row_key || '');
individualState.rows = [];
individualState.nextOffset = 0;
individualState.hasMore = false;
individualState.selectedPairKey = '';
button.disabled = true;
try {
openRecommendationPanel('individual');
await loadIndividualRecommendations(false);
} catch (error) {
alert(error.message);
} finally {
button.disabled = false;
}
});
});
document.querySelectorAll('[data-pair-recommend]').forEach((button) => {
button.addEventListener('click', async () => {
button.disabled = true;
try {
const params = buildPairQuery({ limit: 300 });
const payload = await fetchJson(`/wehago-compare/api/pair-recommendations?${params.toString()}`);
recommendSelections.clear();
latestPairRecommendation = payload;
openRecommendationPanel('batch');
renderRecommendationSummary(payload.stats || {});
renderRecommendationTable();
const stats = payload.stats || {};
const pairs = payload.pairs || [];
if (!pairs.length) {
recommendTableWrap.innerHTML = '<div class="table-placeholder">추천 가능한 쌍이 없습니다. 조회 조건을 조금 더 좁혀주세요.</div>';
}
if (stats.recommended) {
setMeta('ledger_only', stats.ledger_rows, undefined, `추천 ${Number(stats.recommended).toLocaleString()}건 준비됨`);
setMeta('voucher_only', stats.voucher_rows, undefined, `추천 ${Number(stats.recommended).toLocaleString()}건 준비됨`);
}
} catch (error) {
alert(error.message);
} finally {
button.disabled = false;
}
});
});
if (recommendHighOnly) {
recommendHighOnly.addEventListener('change', () => {
renderRecommendationTable();
});
}
recommendSortButtons.forEach((button) => {
button.addEventListener('click', () => {
const key = button.dataset.recommendSort || 'score';
recommendSortKey = key;
recommendSortButtons.forEach((item) => {
item.classList.toggle('active', item === button);
});
renderRecommendationTable();
});
});
if (recommendApplySelected) {
recommendApplySelected.addEventListener('click', async () => {
const keys = Array.from(recommendSelections.entries())
.filter(([, checked]) => checked)
.map(([key]) => key);
if (!keys.length) {
alert('저장할 추천 항목을 선택해주세요.');
return;
}
recommendApplySelected.disabled = true;
try {
const params = buildPairQuery();
const response = await fetch('/wehago-compare/api/pair-recommendations/apply', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({
start_year: startYearValue || null,
end_year: endYearValue || null,
pair_keys: keys,
auto_only: false,
ledger_voucher_no: params.get('ledger_voucher_no') || '',
ledger_review_reason: params.get('ledger_review_reason') || '',
voucher_voucher_no: params.get('voucher_voucher_no') || '',
voucher_review_reason: params.get('voucher_review_reason') || '',
}),
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || '추천 선택 저장 중 오류가 발생했습니다.');
}
alert(`추천 선택 저장 완료: ${Number(payload.saved_count || 0).toLocaleString()}건`);
window.location.reload();
} catch (error) {
alert(error.message);
} finally {
recommendApplySelected.disabled = false;
}
});
}
if (recommendClose) {
recommendClose.addEventListener('click', () => {
closeRecommendationPanel();
});
}
if (recommendApplyIndividual) {
recommendApplyIndividual.addEventListener('click', async () => {
const picked = (individualState.rows || []).find((row) => row.pair_key === individualState.selectedPairKey);
if (!picked) {
alert('매칭할 추천 항목 1건을 선택해주세요.');
return;
}
recommendApplyIndividual.disabled = true;
try {
const isLedgerSource = individualState.sourceStatus === 'ledger_only';
const ledgerRows = isLedgerSource ? [individualState.sourceRow] : [picked.target_row];
const voucherRows = isLedgerSource ? [picked.target_row] : [individualState.sourceRow];
const response = await fetch('/wehago-compare/api/pair-match-save', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({ ledger_rows: ledgerRows, voucher_rows: voucherRows }),
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || '개별 매칭 저장 중 오류가 발생했습니다.');
}
alert(`개별 매칭 저장 완료: ${Number(payload.saved_count || 0).toLocaleString()}건`);
window.location.reload();
} catch (error) {
alert(error.message);
} finally {
recommendApplyIndividual.disabled = false;
}
});
}
if (recommendTableWrap) {
recommendTableWrap.addEventListener('scroll', () => {
if (recommendMode !== 'individual') return;
if (!individualState.hasMore || individualState.loading) return;
const remain = recommendTableWrap.scrollHeight - recommendTableWrap.scrollTop - recommendTableWrap.clientHeight;
if (remain <= 100) {
loadIndividualRecommendations(true);
}
});
}
if (undoLastActionBtn) {
undoLastActionBtn.addEventListener('click', async () => {
const confirmed = window.confirm('직전 작업을 취소할까요? 최근 저장 작업 1건만 되돌립니다.');
if (!confirmed) return;
undoLastActionBtn.disabled = true;
try {
const response = await fetch('/wehago-compare/api/undo-last-action', {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || '되돌리기 중 오류가 발생했습니다.');
}
if (!payload.undone) {
alert(payload.message || '되돌릴 작업이 없습니다.');
} else {
alert(`되돌리기 완료: ${formatNumber(payload.affected || 0)}건`);
window.location.reload();
}
} catch (error) {
alert(error.message);
} finally {
undoLastActionBtn.disabled = false;
}
});
}
document.querySelectorAll('[data-table-wrap]').forEach((wrap) => {
const statusKey = wrap.dataset.tableWrap;
if (!statusKey) {
return;
}
wrap.addEventListener('scroll', () => {
const state = detailState.get(statusKey);
if (!state || state.loading || !state.hasMore) {
return;
}
const threshold = 120;
const remain = wrap.scrollHeight - wrap.scrollTop - wrap.clientHeight;
if (remain <= threshold) {
const form = state.form || document.querySelector(`form[data-filter-kind="status"][data-status="${statusKey}"]`);
loadStatusRows(statusKey, form, true);
}
});
});
const startYear = document.getElementById('start_year');
const endYear = document.getElementById('end_year');
const syncYearOptions = () => {
if (!startYear || !endYear) {
return;
}
const startValue = Number(startYear.value || 0);
const endValue = Number(endYear.value || 0);
Array.from(startYear.options).forEach((option) => {
option.disabled = Boolean(endValue) && Number(option.value) > endValue;
});
Array.from(endYear.options).forEach((option) => {
option.disabled = Boolean(startValue) && Number(option.value) < startValue;
});
if (startValue && endValue && startValue > endValue) {
endYear.value = startYear.value;
}
};
if (startYear && endYear) {
startYear.addEventListener('change', syncYearOptions);
endYear.addEventListener('change', syncYearOptions);
syncYearOptions();
}
refreshLastActionMeta();
})();
</script>
{% endblock %}