Files
HM_project_Viewer_Board/templates/wehago_compare.html
T

2604 lines
107 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;
padding-bottom: 14px;
border-bottom: 1px solid var(--line);
}
.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: 0;
border-top: 1px solid var(--line);
border-radius: 0;
background: transparent;
overflow: hidden;
box-shadow: none;
}
.voucher-page > .panel-shell:first-of-type {
border-top: 0;
}
.voucher-page > .panel-shell:first-of-type > .panel-header {
padding-top: 0;
}
.panel-header {
padding: 14px 0 10px;
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 0 16px;
min-width: 0;
}
.status-grid {
display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr));
gap: 4px;
align-items: stretch;
}
.status-card {
min-width: 0;
width: 100%;
border: 1px solid rgba(217, 221, 227, 0.92);
border-radius: 12px;
background: #fbfcfd;
padding: 7px 6px 8px;
text-align: left;
cursor: pointer;
transition: background-color 0.16s ease, color 0.16s ease, border-color 0.16s ease;
display: grid;
gap: 4px;
align-content: start;
justify-items: start;
box-shadow: none !important;
}
.status-card:hover,
.status-card[aria-expanded="true"] {
background: #111827;
border-color: #111827;
color: #fff;
box-shadow: none !important;
}
.status-label {
display: block;
width: 100%;
font-size: 9px;
font-weight: 700;
color: #475569;
margin-bottom: 0;
text-transform: none;
letter-spacing: 0;
text-align: left;
line-height: 1.1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.status-card:hover .status-label,
.status-card[aria-expanded="true"] .status-label {
color: rgba(255, 255, 255, 0.82);
}
.status-value {
display: block;
width: 100%;
font-size: 18px;
font-weight: 800;
line-height: 1.05;
color: #111;
text-align: left;
}
.status-card[data-status="matched"] {
background: #f7faf8;
border-color: #d2ddd6;
}
.status-card[data-status="ledger_only"],
.status-card[data-status="voucher_only"],
.status-card[data-status="amount_mismatch"] {
background: #f7faf8;
border-color: #d2ddd6;
}
.status-card[data-status="voucher_matched"],
.status-card[data-status="erp_voucher_matched"],
.status-card[data-status="voucher_unmatched"],
.status-card[data-status="voucher_recheck"] {
background: #f5f8fb;
border-color: #c8d7e6;
}
.status-card[data-status="matched"]:hover,
.status-card[data-status="matched"][aria-expanded="true"],
.status-card[data-status="ledger_only"]:hover,
.status-card[data-status="ledger_only"][aria-expanded="true"],
.status-card[data-status="voucher_only"]:hover,
.status-card[data-status="voucher_only"][aria-expanded="true"],
.status-card[data-status="amount_mismatch"]:hover,
.status-card[data-status="amount_mismatch"][aria-expanded="true"],
.status-card[data-status="voucher_matched"]:hover,
.status-card[data-status="voucher_matched"][aria-expanded="true"],
.status-card[data-status="erp_voucher_matched"]:hover,
.status-card[data-status="erp_voucher_matched"][aria-expanded="true"],
.status-card[data-status="voucher_unmatched"]:hover,
.status-card[data-status="voucher_unmatched"][aria-expanded="true"],
.status-card[data-status="voucher_recheck"]:hover,
.status-card[data-status="voucher_recheck"][aria-expanded="true"] {
background: #111827;
border-color: #111827;
color: #fff;
}
.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: 0;
border-top: 1px solid var(--line);
border-radius: 0;
background: transparent;
overflow: visible;
min-width: 0;
}
.detail-panel.active {
display: block;
}
.detail-head {
padding: 14px 0 10px;
border-bottom: 1px solid rgba(217, 221, 227, 0.9);
background: transparent;
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: 14px 0;
}
.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-x: hidden;
overflow-y: auto;
border-top: 1px solid rgba(217, 221, 227, 0.9);
background: transparent;
max-height: 460px;
box-sizing: border-box;
}
.detail-panel .table-wrap {
max-height: 360px;
overflow-x: hidden;
overflow-y: auto;
}
.table-footer {
display: flex;
justify-content: center;
padding: 10px 0 14px;
border-top: 1px solid rgba(217, 221, 227, 0.9);
background: transparent;
}
.load-more-button {
min-width: 120px;
}
.table-wrap table {
width: 100%;
min-width: 0;
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 td {
background: #fff;
}
.table-wrap th {
position: sticky;
top: 0;
z-index: 1;
background: #f8fafc;
font-size: 13px;
letter-spacing: 0.01em;
white-space: nowrap;
}
.table-wrap tr.bank-payable-case td,
.table-wrap tr.bank-payable-case:hover td,
.table-wrap tr.boundary-excluded-row td,
.table-wrap tr.boundary-excluded-row:hover td {
background: #fff;
color: #111827;
}
.voucher-group-list {
display: grid;
gap: 12px;
}
.voucher-group {
border: 1px solid rgba(217, 221, 227, 0.92);
border-radius: 12px;
overflow: hidden;
background: rgba(255, 255, 255, 0.86);
}
.voucher-group-head {
display: grid;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid rgba(217, 221, 227, 0.92);
background: #f8fafc;
}
.voucher-group-title {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.voucher-group-title strong {
font-size: 18px;
color: #111827;
}
.voucher-group-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.voucher-group-meta span {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid #d7e0ea;
background: #fff;
font-size: 12px;
color: #334155;
}
.voucher-group-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 8px;
font-size: 12px;
color: #475569;
}
.voucher-group-summary strong {
display: block;
margin-bottom: 4px;
color: #111827;
font-size: 12px;
}
.voucher-group-lines {
overflow: visible;
max-height: none;
}
.voucher-group-lines table {
width: 100%;
min-width: 0;
border-collapse: collapse;
table-layout: fixed;
}
.voucher-group-lines th,
.voucher-group-lines td {
padding: 7px 8px;
border-bottom: 1px solid rgba(148, 163, 184, 0.18);
font-size: 13px;
vertical-align: middle;
text-align: left;
line-height: 1.35;
}
/* Voucher body rows stay white; voucher grouping is expressed only by borders. */
.voucher-group-lines td {
background: #fff !important;
}
.voucher-group-lines th {
position: sticky;
top: 0;
z-index: 2;
background: #f8fafc;
color: inherit;
font-size: 13px;
letter-spacing: 0.01em;
white-space: nowrap;
}
.voucher-group-lines tr:last-child td {
border-bottom: 0;
}
.voucher-group-lines tr.bank-payable-case td,
.voucher-group-lines tr.bank-payable-case:hover td,
.voucher-group-lines tr.boundary-excluded-row td,
.voucher-group-lines tr.boundary-excluded-row:hover td {
background: #fff !important;
}
.voucher-table-group-start td {
border-top: 2px solid rgba(148, 163, 184, 0.62);
}
.case-stat {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 2px 8px;
border: 1px solid #bfd7c8;
border-radius: 999px;
background: #edf6f0;
color: #173522;
font-size: 12px;
font-weight: 800;
white-space: nowrap;
cursor: pointer;
box-shadow: none;
transform: none;
}
.case-stat:hover {
background: #dfeee4;
border-color: #9fc4ad;
}
.cell-ellipsis {
width: 100%;
min-width: 0;
max-width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
display: block;
}
.cell-tight {
max-width: clamp(56px, 5vw, 86px);
}
.cell-code {
max-width: clamp(72px, 7vw, 108px);
}
.cell-equal {
max-width: clamp(88px, 9vw, 148px);
}
.cell-desc {
max-width: clamp(116px, 14vw, 220px);
}
.col-fiscal_year {
width: 4.5%;
}
.col-ledger_date,
.col-proof_date {
width: 6%;
}
.col-voucher_no,
.col-confirmed_no {
width: 5.5%;
}
.col-draft_no,
.col-account_code {
width: 7%;
}
.col-ledger_account_name,
.col-voucher_account_name {
width: 8%;
}
.col-ledger_vendor,
.col-voucher_vendor {
width: 10%;
}
.col-ledger_debit,
.col-ledger_credit,
.col-voucher_debit,
.col-voucher_credit,
.col-amount {
width: 7%;
}
.col-ledger_desc,
.col-voucher_desc,
.col-desc1,
.col-desc2,
.col-description,
.col-notes,
.col-review_memo,
.col-review_reason {
width: 12%;
}
.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-wide,
.detail-panel[data-status-panel="amount_mismatch"] .filter-form .suggest-field-wide {
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-recommend-panel-open {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
align-items: start;
column-gap: 12px;
}
body[data-view-mode="dual"] .voucher-page.has-recommend-panel-open > .voucher-topbar {
grid-column: 1 / -1;
}
body[data-view-mode="dual"] .voucher-page.has-recommend-panel-open > .panel-shell:not(#pairRecommendPanel) {
grid-column: 1;
min-width: 0;
}
body[data-view-mode="dual"] .voucher-page.has-recommend-panel-open > #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);
}
body[data-view-mode="dual"] .cell-code {
max-width: clamp(90px, 8vw, 132px);
}
body[data-view-mode="dual"] .cell-equal {
max-width: clamp(110px, 11vw, 180px);
}
body[data-view-mode="dual"] .cell-desc {
max-width: clamp(150px, 15vw, 280px);
}
body[data-view-mode="dual"] .col-ledger_vendor,
body[data-view-mode="dual"] .col-voucher_vendor {
width: 11%;
}
body[data-view-mode="dual"] .col-ledger_desc,
body[data-view-mode="dual"] .col-voucher_desc,
body[data-view-mode="dual"] .col-desc1,
body[data-view-mode="dual"] .col-desc2,
body[data-view-mode="dual"] .col-description,
body[data-view-mode="dual"] .col-notes,
body[data-view-mode="dual"] .col-review_memo,
body[data-view-mode="dual"] .col-review_reason {
width: 13%;
}
@media (max-width: 720px) {
.voucher-topbar,
.panel-header,
.data-panel summary {
align-items: flex-start;
}
.status-card[data-status="voucher_matched"],
.status-card[data-status="erp_voucher_matched"] {
margin-left: 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>
<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 suggest-field-wide">
<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 suggest-field-wide">
<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 getColumnClass = (field) => `col-${String(field || '').replace(/[^a-zA-Z0-9_]/g, '_')}`;
const renderVoucherGroups = (container, payload, append = false, statusKey = '') => {
if (!container) return;
const groups = payload.groups || [];
if (!groups.length && !append) {
container.innerHTML = '<div class="table-placeholder">조건에 맞는 항목이 없습니다.</div>';
return;
}
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 formatValue = (field, raw) => {
if (field === 'fiscal_year') return String(raw ?? '');
if (typeof raw === 'number') {
return Number.isInteger(raw)
? raw.toLocaleString()
: raw.toLocaleString(undefined, { maximumFractionDigits: 2 });
}
return String(raw ?? '');
};
const lineHead = lineColumns.map(([field, label]) => `<th class="${getColumnClass(field)}">${escapeHtml(label)}</th>`).join('');
const lineBody = groups.map((group, groupIndex) => {
const rows = Array.isArray(group.rows) ? group.rows : [];
return rows.map((row, rowIndex) => {
const rowClasses = [];
if (rowIndex === 0) {
rowClasses.push('voucher-table-group-start');
}
if (row.matched_case === 'boundary_excluded' || row.boundary_excluded === '1') {
rowClasses.push('boundary-excluded-row');
} else if (row.matched_case === 'bank_payable' || row.review_reason === 'BANK_PAYABLE_MATCH') {
rowClasses.push('bank-payable-case');
}
const cells = lineColumns.map(([field]) => {
const display = formatValue(field, row[field]);
return `<td class="${getColumnClass(field)}" title="${escapeHtml(display)}"><span class="${getCellClass(field, ['voucher_no'])}">${escapeHtml(display)}</span></td>`;
}).join('');
return `<tr class="${rowClasses.join(' ')}">${cells}</tr>`;
}).join('');
}).join('');
const tableHtml = `
<table>
<thead><tr>${lineHead}</tr></thead>
<tbody>${lineBody}</tbody>
</table>
`;
if (!append || !container.querySelector('.voucher-group-lines')) {
container.innerHTML = `<div class="voucher-group-lines">${tableHtml}</div>`;
} else {
const tbody = container.querySelector('.voucher-group-lines tbody');
const temp = document.createElement('tbody');
temp.innerHTML = lineBody;
if (tbody) {
tbody.insertAdjacentHTML('beforeend', temp.innerHTML);
} else {
container.innerHTML = `<div class="voucher-group-lines">${tableHtml}</div>`;
}
}
};
const renderTable = (container, payload, monoFields = [], append = false, statusKey = '') => {
if (!container) {
return;
}
if (statusKey === 'voucher_matched' || statusKey === 'erp_voucher_matched' || statusKey === 'voucher_unmatched' || statusKey === 'voucher_recheck') {
renderVoucherGroups(container, payload, append, statusKey);
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(([field, label]) => `<th class="${getColumnClass(field)}">${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 class="${getColumnClass(field)}" 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>`
: '';
const rowClasses = [];
if (row.matched_case === 'bank_payable' || row.review_reason === 'BANK_PAYABLE_MATCH') {
rowClasses.push('bank-payable-case');
}
if (row.matched_case === 'boundary_excluded' || row.boundary_excluded === '1') {
rowClasses.push('boundary-excluded-row');
}
const rowClass = rowClasses.length ? ` class="${rowClasses.join(' ')}"` : '';
return `<tr${rowClass}>${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 = '', stats = {}) => {
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}` : '';
const bankPayableCount = Number(stats.bank_payable_case_count || 0);
const bankPayableText = key === 'matched' && bankPayableCount
? ` <button type="button" class="case-stat" data-case-filter="bank_payable">보통예금/미지급금 case : ${bankPayableCount.toLocaleString()}</button>`
: '';
const boundaryCount = Number(stats.boundary_excluded_count || 0);
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}`;
}
};
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 applySummaryPayload = (payload) => {
const sections = Array.isArray(payload?.metric_sections) ? payload.metric_sections : [];
sections.forEach((section) => {
const card = document.querySelector(`.status-card[data-status="${escapeSelectorValue(section.key || '')}"]`);
const valueNode = card?.querySelector('.status-value');
if (valueNode) {
valueNode.textContent = Number(section.count || 0).toLocaleString();
}
});
const lastAction = payload?.last_action;
if (lastActionMeta) {
if (lastAction && lastAction.id) {
lastActionMeta.textContent = `최근 작업: ${lastAction.action_type} / ${Number(lastAction.count || 0).toLocaleString()}건`;
} else {
lastActionMeta.textContent = '최근 작업: 없음';
}
}
if (undoLastActionBtn) {
undoLastActionBtn.disabled = !(lastAction && lastAction.id);
}
};
const loadDashboardSummary = async (attempt = 0) => {
const params = new URLSearchParams();
if (startYearValue) params.set('start_year', startYearValue);
if (endYearValue) params.set('end_year', endYearValue);
try {
const payload = await fetchJson(`/wehago-compare/api/summary?${params.toString()}`);
applySummaryPayload(payload);
if (payload?.pending && attempt < 6) {
window.setTimeout(() => loadDashboardSummary(attempt + 1), 1200);
}
} catch (_error) {
// Keep the page usable even if the summary loads later or fails once.
}
};
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 syncRecommendationPanelState = () => {
if (!page || !recommendPanel) return;
page.classList.toggle('has-recommend-panel-open', recommendPanel.classList.contains('active'));
};
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', 'voucher_matched', 'erp_voucher_matched', 'voucher_unmatched', 'voucher_recheck'].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: '적요',
review_reason: '검증근거',
boundary_excluded: '연초/연말 대체·이월',
};
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 syncFormWithActiveFilters = (statusKey, form) => {
if (!form) return;
const active = cumulativeFiltersByStatus.get(statusKey) || {};
form.querySelectorAll('input[name]').forEach((input) => {
input.value = active[input.name] || '';
});
};
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>가전표번호</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(pair.substitution_hint || pair.reason || '')}"><span class="cell-ellipsis cell-desc">${escapeHtml(pair.substitution_hint || pair.reason || '')}</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');
syncRecommendationPanelState();
recommendPanel.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
const closeRecommendationPanel = () => {
if (!recommendPanel) return;
recommendPanel.classList.remove('active');
syncRecommendationPanelState();
};
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 = 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();
const boundaryExcluded = String(selected.boundary_excluded || '').trim();
if (account) mapped.set('wehago_account', account);
if (vendor) mapped.set('wehago_vendor', vendor);
if (amount) mapped.set('wehago_amount', amount);
if (boundaryExcluded) mapped.set('boundary_excluded', boundaryExcluded);
} 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 if (statusKey === 'voucher_matched' || statusKey === 'erp_voucher_matched' || statusKey === 'voucher_unmatched' || statusKey === 'voucher_recheck') {
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);
mapped.set('erp_account', account);
}
if (vendor) {
mapped.set('wehago_vendor', vendor);
mapped.set('erp_vendor', vendor);
}
if (amount) {
mapped.set('wehago_amount', 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);
cumulativeFiltersByStatus.set(statusKey, selected);
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, payload);
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"]');
syncFormWithActiveFilters(statusKey, form);
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 caseButton = event.target.closest('[data-case-filter="bank_payable"], [data-case-filter="boundary_excluded"]');
if (caseButton) {
const caseFilter = caseButton.dataset.caseFilter || '';
const statusKey = caseFilter === 'boundary_excluded' ? 'ledger_only' : 'matched';
const current = { ...(cumulativeFiltersByStatus.get(statusKey) || {}) };
if (caseFilter === 'boundary_excluded') {
current.boundary_excluded = '1';
} else {
current.review_reason = 'BANK_PAYABLE_MATCH';
}
cumulativeFiltersByStatus.set(statusKey, current);
renderActiveFilterCards(statusKey);
const form = document.querySelector(`form[data-filter-kind="status"][data-status="${statusKey}"]`);
syncFormWithActiveFilters(statusKey, form);
loadStatusRows(statusKey, null, false);
return;
}
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');
syncRecommendationPanelState();
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();
}
window.setTimeout(() => {
loadDashboardSummary();
}, 400);
refreshLastActionMeta();
})();
</script>
{% endblock %}