Files
HM_project_Viewer_Board/templates/wehago_compare.html
T

4455 lines
193 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: 0;
}
.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;
}
.system-job-meta {
display: inline-flex;
align-items: center;
min-height: 28px;
max-width: 360px;
padding: 0 10px;
border: 1px solid #d8e2ec;
border-radius: 999px;
background: #f8fafc;
color: #475569;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.system-job-meta[data-state="running"],
.system-job-meta[data-state="queued"] {
color: #075985;
border-color: #bae6fd;
background: #f0f9ff;
}
.system-job-meta[data-state="done"] {
color: #166534;
border-color: #bbf7d0;
background: #f0fdf4;
}
.system-job-meta[data-state="failed"] {
color: #991b1b;
border-color: #fecaca;
background: #fef2f2;
}
.panel-body {
padding: 0 0 16px;
min-width: 0;
}
.status-grid {
display: grid;
grid-template-columns: repeat(7, minmax(132px, 1fr));
gap: 4px;
align-items: stretch;
overflow-x: auto;
padding-bottom: 4px;
}
.snapshot-panel {
margin-top: 10px;
border: 1px solid var(--line);
border-radius: 12px;
background: #fff;
padding: 10px 12px;
display: grid;
gap: 10px;
}
.snapshot-panel[hidden] {
display: none !important;
}
.snapshot-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
}
.snapshot-panel-title {
display: grid;
gap: 3px;
}
.snapshot-panel-title strong {
font-size: 14px;
}
.snapshot-policy {
color: var(--muted);
font-size: 12px;
line-height: 1.4;
}
.snapshot-summary-badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.snapshot-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 5px 8px;
border-radius: 999px;
border: 1px solid #dbe3eb;
background: #f8fafc;
font-size: 12px;
color: #334155;
}
.snapshot-badge strong {
color: #0f172a;
}
.snapshot-years {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(148px, 1fr));
gap: 8px;
}
.snapshot-year-card {
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
padding: 8px 9px;
display: grid;
gap: 5px;
}
.snapshot-year-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.snapshot-year-head strong {
font-size: 13px;
}
.snapshot-state-pill {
display: inline-flex;
align-items: center;
padding: 2px 7px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
border: 1px solid transparent;
white-space: nowrap;
}
.snapshot-state-pill.ready { background: #ecfdf5; border-color: #bbf7d0; color: #166534; }
.snapshot-state-pill.queued { background: #eff6ff; border-color: #bfdbfe; color: #1d4ed8; }
.snapshot-state-pill.running { background: #eff6ff; border-color: #93c5fd; color: #1d4ed8; }
.snapshot-state-pill.stale { background: #fff7ed; border-color: #fed7aa; color: #c2410c; }
.snapshot-state-pill.failed { background: #fef2f2; border-color: #fecaca; color: #b91c1c; }
.snapshot-state-pill.missing { background: #f8fafc; border-color: #cbd5e1; color: #475569; }
.snapshot-year-meta,
.snapshot-job-meta {
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.snapshot-jobs {
display: grid;
gap: 6px;
}
.snapshot-job-row {
border-top: 1px solid #eef2f7;
padding-top: 6px;
display: grid;
gap: 2px;
}
.snapshot-job-row:first-child {
border-top: 0;
padding-top: 0;
}
.snapshot-job-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 12px;
}
.icon-button-label {
display: inline-flex;
align-items: center;
justify-content: center;
}
.icon-button-label svg {
width: 16px;
height: 16px;
stroke: currentColor;
fill: none;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.status-card {
min-width: 0;
width: 100%;
border: 1px solid rgba(217, 221, 227, 0.92);
border-radius: 12px;
background: #fbfcfd;
padding: 7px 10px 8px 10px;
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;
padding-left: 0;
margin-left: 0;
}
.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;
padding-left: 0;
margin-left: 0;
}
.status-card[data-status="matched"] {
background: #f7faf8;
border-color: #d2ddd6;
}
.status-card[data-status="bridge_expense_review"] {
background: #f2faf7;
border-color: #b9d8cb;
}
.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="voucher_unmatched"],
.status-card[data-status="voucher_recheck"],
.status-card[data-status="voucher_excepted"],
.status-card[data-status="hanmac_unconnected"] {
background: #eff6ff;
border-color: #bfdbfe;
}
.status-card[data-status="voucher_matched"] .status-label,
.status-card[data-status="voucher_unmatched"] .status-label,
.status-card[data-status="voucher_recheck"] .status-label,
.status-card[data-status="voucher_excepted"] .status-label,
.status-card[data-status="hanmac_unconnected"] .status-label {
color: #1d4ed8;
}
.status-card[data-status="erp_voucher_matched"],
.status-card[data-status="erp_voucher_unmatched"] {
background: #ecfdf5;
border-color: #bbf7d0;
}
.status-card[data-status="erp_voucher_matched"] .status-label,
.status-card[data-status="erp_voucher_unmatched"] .status-label {
color: #166534;
}
.status-card[data-status="matched"]:hover,
.status-card[data-status="matched"][aria-expanded="true"],
.status-card[data-status="bridge_expense_review"]:hover,
.status-card[data-status="bridge_expense_review"][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_excepted"]:hover,
.status-card[data-status="voucher_excepted"][aria-expanded="true"],
.status-card[data-status="hanmac_unconnected"]:hover,
.status-card[data-status="hanmac_unconnected"][aria-expanded="true"],
.status-card[data-status="erp_voucher_unmatched"]:hover,
.status-card[data-status="erp_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;
}
.detail-heading-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
}
.detail-heading-tools {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.inline-note {
font-size: 12px;
color: var(--muted);
white-space: nowrap;
}
.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-group-lines th.selection-col,
.voucher-group-lines td.selection-col {
width: 44px;
min-width: 44px;
text-align: center;
padding-left: 10px;
padding-right: 10px;
}
.voucher-group-lines td.selection-col input[type="checkbox"],
.voucher-group-lines th.selection-col input[type="checkbox"] {
margin: 0;
display: inline-block;
vertical-align: middle;
}
/* 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;
}
.bridge-modal {
position: fixed;
inset: 0;
display: none;
align-items: center;
justify-content: center;
background: rgba(15, 23, 42, 0.38);
z-index: 80;
padding: 24px;
}
.bridge-modal.active {
display: flex;
}
.bridge-modal-dialog {
width: min(1120px, calc(100vw - 32px));
max-height: calc(100vh - 48px);
overflow: auto;
background: #fff;
border: 1px solid var(--line);
border-radius: 12px;
padding: 18px 18px 16px;
display: grid;
gap: 14px;
}
.bridge-modal-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.bridge-modal-title {
display: grid;
gap: 4px;
}
.bridge-modal-title strong {
font-size: 18px;
color: var(--text);
}
.bridge-modal-title p {
margin: 0;
color: var(--muted);
font-size: 13px;
line-height: 1.45;
}
.bridge-modal-grid {
display: grid;
grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
gap: 14px;
align-items: start;
}
.voucher-detail-wrap {
max-height: 65vh;
overflow: auto;
border: 1px solid var(--line);
border-radius: 12px;
background: #fff;
}
.voucher-detail-wrap table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.voucher-detail-wrap th,
.voucher-detail-wrap td {
padding: 10px 12px;
border-bottom: 1px solid #eef2f7;
text-align: left;
vertical-align: top;
white-space: nowrap;
}
.voucher-detail-wrap td.cell-desc,
.voucher-detail-wrap th.cell-desc {
white-space: normal;
}
.voucher-summary-action {
min-width: 64px;
}
.offset-review-dialog {
width: min(1460px, calc(100vw - 32px));
height: min(88vh, calc(100vh - 48px));
grid-template-rows: auto auto minmax(0, 1fr);
overflow: hidden;
}
.offset-review-actions {
display: inline-flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.offset-review-wrap {
min-height: 0;
overflow: auto;
border: 1px solid var(--line);
border-radius: 10px;
}
.offset-review-wrap table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.offset-review-wrap th,
.offset-review-wrap td {
padding: 9px 10px;
border-bottom: 1px solid #eef2f7;
vertical-align: top;
text-align: left;
}
.offset-review-wrap thead th {
position: sticky;
top: 0;
background: #f8fafc;
z-index: 1;
color: var(--muted);
font-weight: 600;
white-space: nowrap;
}
.offset-review-wrap .choice-cell {
width: 48px;
text-align: center;
}
.offset-review-wrap .voucher-cell {
white-space: nowrap;
}
.offset-review-wrap .long-cell {
min-width: 180px;
line-height: 1.45;
}
.offset-review-wrap .status-cell {
white-space: nowrap;
color: var(--muted);
}
.offset-review-wrap .offset-source-row td {
background: #f8fafc;
border-top: 2px solid #d8dee7;
}
.offset-review-wrap .offset-source-label {
width: 88px;
text-align: center;
font-weight: 800;
color: #111827;
white-space: nowrap;
}
.offset-review-wrap .offset-source-detail {
line-height: 1.5;
}
.offset-review-wrap .offset-source-line {
display: block;
margin: 1px 0;
}
.offset-review-wrap .offset-source-line strong {
display: inline-block;
min-width: 92px;
color: #475569;
font-weight: 800;
}
.offset-review-wrap tr.offset-group-start td {
border-top: 2px solid #d8dee7;
}
.bridge-card {
border: 1px solid var(--line);
border-radius: 10px;
padding: 14px;
background: #fff;
display: grid;
gap: 12px;
}
.bridge-card h3 {
margin: 0;
font-size: 14px;
color: var(--text);
}
.bridge-guide-list,
.bridge-setting-list {
margin: 0;
padding-left: 18px;
display: grid;
gap: 6px;
color: var(--text);
font-size: 13px;
line-height: 1.5;
}
.bridge-setting-fields {
display: grid;
gap: 10px;
}
.bridge-setting-fields label {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 13px;
color: var(--text);
line-height: 1.45;
}
.bridge-setting-fields input[type="checkbox"] {
margin-top: 2px;
accent-color: #111827;
}
.bridge-setting-fields select,
.bridge-setting-fields input[type="number"] {
width: 100%;
border: 1px solid var(--line);
border-radius: 10px;
padding: 8px 10px;
font-size: 13px;
color: var(--text);
background: #fff;
}
.bridge-setting-fields .field-block {
display: grid;
gap: 6px;
}
.bridge-setting-fields .field-block span {
font-size: 12px;
color: var(--muted);
}
.bridge-modal-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.bridge-modal-meta {
font-size: 12px;
color: var(--muted);
}
.bridge-preview-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.bridge-preview-actions {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.bridge-preview-wrap .table-wrap {
max-height: clamp(300px, 46vh, 540px);
}
@media (max-width: 960px) {
.bridge-modal-grid {
grid-template-columns: minmax(0, 1fr);
}
}
.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;
}
.detail-panel[data-status-panel="voucher_recheck"] .filter-form input[name="amount_keyword"] {
width: 94px;
min-width: 94px;
}
.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="snapshotToggleBtn" aria-expanded="false" aria-controls="snapshotPanel">스냅샷 상태</button>
<button type="button" class="button-secondary" id="queryCacheRebuildBtn">조회 캐시 재생성</button>
<span class="system-job-meta" id="systemJobMeta" data-state="">작업 상태 확인 전</span>
<button type="button" class="button-secondary button-icon" id="undoLastActionBtn" aria-label="직전 작업 취소" title="직전 작업 취소">
<span class="icon-button-label" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path d="M15 18l-6-6 6-6"></path>
</svg>
</span>
</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>기간 {{ wehago_compare.selected_start_year or '-' }} ~ {{ wehago_compare.selected_end_year or '-' }}</h2></div>
</div>
<div class="panel-body">
<div class="status-grid">
{% for section in wehago_compare.metric_sections %}
{% if section.key in ['voucher_matched', 'voucher_unmatched', 'voucher_recheck', 'voucher_excepted', 'hanmac_unconnected', 'erp_voucher_matched', 'erp_voucher_unmatched'] %}
<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>
{% endif %}
{% endfor %}
</div>
<div class="detail-stack">
{% for section in wehago_compare.metric_sections %}
{% if section.key in ['voucher_matched', 'voucher_unmatched', 'voucher_recheck', 'voucher_excepted', 'hanmac_unconnected', 'erp_voucher_matched', 'erp_voucher_unmatched'] %}
<section class="detail-panel" id="detail-{{ section.key }}" data-status-panel="{{ section.key }}">
<div class="detail-head">
{% set show_export_button = section.key in ['voucher_matched', 'voucher_unmatched', 'voucher_recheck', 'voucher_excepted', 'hanmac_unconnected', 'erp_voucher_matched', 'erp_voucher_unmatched'] %}
{% if section.key == 'matched' or show_export_button %}
<div class="detail-heading-row">
<strong>{{ section.label }}</strong>
<div class="detail-heading-tools">
{% if section.key == 'matched' %}
<button type="button" class="button-secondary" id="bridgeReviewOpenBtn">2단계 비교 설정</button>
{% endif %}
{% if show_export_button %}
<button type="button" class="button-secondary button-icon" data-export-status="{{ section.key }}" aria-label="엑셀 다운로드" title="엑셀 다운로드">
<span class="icon-button-label" aria-hidden="true">
<svg viewBox="0 0 64 64" aria-hidden="true">
<rect x="19" y="4" width="41" height="56" rx="6" fill="#21A366"></rect>
<rect x="40" y="4" width="20" height="56" rx="6" fill="#33C481"></rect>
<rect x="0" y="17" width="34" height="30" rx="4" fill="#185C37"></rect>
<path d="M8 24h6.2l4.1 7.1 4.1-7.1h5.8l-6.4 10 6.6 10h-6.1l-4.3-7.5-4.4 7.5H7.9l6.8-10.1L8 24z" fill="#FFFFFF"></path>
</svg>
</span>
</button>
{% endif %}
</div>
</div>
{% else %}
<strong>{{ section.label }}</strong>
{% endif %}
{% 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 == 'voucher_recheck' %}
<button type="button" class="button-secondary" id="offsetReviewOpenBtn">상계</button>
<button type="button" class="button-secondary" data-review-save>변경</button>
{% endif %}
{% 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>
{% endif %}
{% endfor %}
</div>
<section class="snapshot-panel" id="snapshotPanel" hidden>
<div class="snapshot-panel-head">
<div class="snapshot-panel-title">
<strong>스냅샷 상태</strong>
<div class="snapshot-policy" id="snapshotPolicyText">전표 스냅샷 상태를 불러오는 중입니다.</div>
</div>
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap;">
<div class="panel-meta" id="snapshotSummaryMeta"></div>
<button type="button" class="button-secondary" id="snapshotRebuildBtn">현재 기간 다시 준비</button>
</div>
</div>
<div class="snapshot-summary-badges" id="snapshotSummaryBadges"></div>
<div class="snapshot-years" id="snapshotYears"></div>
<div class="snapshot-jobs" id="snapshotJobs"></div>
</section>
</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>
<div class="bridge-modal" id="voucherDetailModal" aria-hidden="true">
<div class="bridge-modal-dialog" role="dialog" aria-modal="true" aria-labelledby="voucherDetailModalTitle">
<div class="bridge-modal-head">
<div class="bridge-modal-title">
<strong id="voucherDetailModalTitle">전표 상세</strong>
<p id="voucherDetailModalMeta">선택한 전표의 세부 행입니다.</p>
</div>
<button type="button" class="button-secondary" id="voucherDetailCloseBtn">닫기</button>
</div>
<div class="voucher-detail-wrap" id="voucherDetailTableWrap">
<div class="table-placeholder">상세 데이터를 불러오지 않았습니다.</div>
</div>
</div>
</div>
<div class="bridge-modal" id="offsetReviewModal" aria-hidden="true">
<div class="bridge-modal-dialog offset-review-dialog" role="dialog" aria-modal="true" aria-labelledby="offsetReviewModalTitle">
<div class="bridge-modal-head">
<div class="bridge-modal-title">
<strong id="offsetReviewModalTitle">상계 취소 후보</strong>
</div>
<div class="offset-review-actions">
<button type="button" id="offsetReviewMoveBtn" disabled>이동</button>
<button type="button" class="button-secondary" id="offsetReviewCloseBtn">닫기</button>
</div>
</div>
<div class="bridge-modal-meta" id="offsetReviewMeta">후보를 불러오지 않았습니다.</div>
<div class="offset-review-wrap" id="offsetReviewTableWrap">
<div class="table-placeholder">상계 버튼을 누르면 검토 대상이 표시됩니다.</div>
</div>
</div>
</div>
<div class="bridge-modal" id="bridgeReviewModal" aria-hidden="true">
<div class="bridge-modal-dialog" role="dialog" aria-modal="true" aria-labelledby="bridgeReviewModalTitle">
<div class="bridge-modal-head">
<div class="bridge-modal-title">
<strong id="bridgeReviewModalTitle">2단계 비교 설정</strong>
<p>보통예금 출금, 지급전표, 원가/판관비 전표를 연결해 WEHAGO 카드에 반영할 수 있는 후보를 다루는 설정입니다.</p>
</div>
<button type="button" class="button-secondary" id="bridgeReviewCloseBtn">닫기</button>
</div>
<div class="bridge-modal-grid">
<section class="bridge-card">
<div>
<h3>반영 기준</h3>
</div>
<ol class="bridge-guide-list">
<li>WEHAGO 보통예금 출금과 ERP 지급전표의 금액이 먼저 일치해야 합니다.</li>
<li>ERP 원 전표는 원가/판관비 성격이어야 하며, 자산·부채·부가세 계정은 자동 반영 대상에서 제외합니다.</li>
<li>적요와 거래처의 핵심 단어가 강하게 일치할수록 우선합니다.</li>
<li>같은 지급전표를 설명하는 원 비용 후보가 여러 개면 자동 매칭하지 않고 검토 대상으로 남깁니다.</li>
</ol>
<div class="bridge-modal-meta" id="bridgeReviewSettingsMeta">설정은 서버에 저장되며 WEHAGO 카드 계산에 반영됩니다.</div>
</section>
<section class="bridge-card">
<div>
<h3>설정</h3>
</div>
<form class="bridge-setting-fields" id="bridgeReviewSettingsForm">
<label>
<input type="checkbox" name="enable_promotion">
<span>2단계 비교에서 충분히 강한 후보는 WEHAGO 카드에 반영 후보로 표시</span>
</label>
<label>
<input type="checkbox" name="require_exact_amount" checked>
<span>WEHAGO 출금액, ERP 지급액, 원 비용 금액이 모두 같을 때만 자동 반영 검토</span>
</label>
<label>
<input type="checkbox" name="require_expense_nature" checked>
<span>원 비용 전표는 원가/판관비 계정만 허용하고, 자산/부채/VAT 계정은 제외</span>
</label>
<label>
<input type="checkbox" name="require_strong_text" checked>
<span>적요 또는 거래처 핵심어가 강하게 일치할 때만 자동 반영 검토</span>
</label>
<div class="field-block">
<label for="bridgeAmbiguousHandling">복수 후보 처리</label>
<select id="bridgeAmbiguousHandling" name="ambiguous_handling">
<option value="recheck">WEHAGO Recheck로 보냄</option>
<option value="review_only">2단계 비교 미리보기만 유지</option>
</select>
<span>지급전표 하나에 원 비용 후보가 둘 이상이면 자동 승격하지 않습니다.</span>
</div>
<div class="field-block">
<label for="bridgePreviewLimit">미리보기 개수</label>
<input type="number" id="bridgePreviewLimit" name="preview_limit" min="20" max="500" step="10" value="100">
<span>모달에서 불러올 2단계 비교 후보 건수입니다.</span>
</div>
</form>
<div class="bridge-modal-actions">
<div class="bridge-modal-meta">설정 저장 후 아래 미리보기를 다시 불러오면 같은 조건으로 검토할 수 있습니다.</div>
<button type="button" class="button-secondary" id="bridgeReviewSaveBtn">설정 저장</button>
</div>
</section>
</div>
<section class="bridge-card bridge-preview-wrap">
<div class="bridge-preview-head">
<div>
<h3>2단계 비교 미리보기</h3>
<div class="bridge-modal-meta" id="bridgeReviewPreviewMeta">아직 불러오지 않았습니다.</div>
</div>
<div class="bridge-preview-actions">
<button type="button" class="button-secondary" id="bridgeReviewLoadBtn">후보 불러오기</button>
</div>
</div>
<div class="table-wrap" id="bridgeReviewPreviewWrap">
<div class="table-placeholder">2단계 비교 후보를 모달에서 확인할 수 있습니다.</div>
</div>
</section>
</div>
</div>
</section>
<script>
(() => {
const page = document.querySelector('.voucher-page');
const initialStartYearValue = page?.dataset.startYear || '';
const initialEndYearValue = page?.dataset.endYear || '';
const getSelectedYearRange = () => {
const startSelect = document.getElementById('start_year');
const endSelect = document.getElementById('end_year');
return {
startYear: startSelect?.value || initialStartYearValue || '',
endYear: endSelect?.value || initialEndYearValue || '',
};
};
const appendSelectedYearRange = (params) => {
const { startYear, endYear } = getSelectedYearRange();
if (startYear) params.set('start_year', startYear);
if (endYear) params.set('end_year', endYear);
return params;
};
const selectedYearRangePayload = () => {
const { startYear, endYear } = getSelectedYearRange();
return {
start_year: startYear || null,
end_year: endYear || null,
};
};
const navigateToCompareRange = (startYear, endYear) => {
const params = new URLSearchParams(window.location.search);
if (startYear) params.set('start_year', startYear);
if (endYear) params.set('end_year', endYear);
const query = params.toString();
window.location.href = `${window.location.pathname}${query ? `?${query}` : ''}`;
};
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 voucherRecheckSelections = new Map();
const voucherRecheckSplitSelections = new Map();
const voucherRecheckRowStore = new Map();
const voucherGroupDetailStore = new Map();
const summaryRefreshState = { timerId: null, pending: false };
const detailRetryTimers = new Map();
const detailRequestControllers = new Map();
const queryCacheRebuildBtn = document.getElementById('queryCacheRebuildBtn');
const systemJobMeta = document.getElementById('systemJobMeta');
let systemJobPollTimer = null;
const voucherDetailModal = document.getElementById('voucherDetailModal');
const voucherDetailCloseBtn = document.getElementById('voucherDetailCloseBtn');
const voucherDetailTableWrap = document.getElementById('voucherDetailTableWrap');
const voucherDetailModalTitle = document.getElementById('voucherDetailModalTitle');
const voucherDetailModalMeta = document.getElementById('voucherDetailModalMeta');
const offsetReviewOpenBtn = document.getElementById('offsetReviewOpenBtn');
const offsetReviewModal = document.getElementById('offsetReviewModal');
const offsetReviewCloseBtn = document.getElementById('offsetReviewCloseBtn');
const offsetReviewMoveBtn = document.getElementById('offsetReviewMoveBtn');
const offsetReviewMeta = document.getElementById('offsetReviewMeta');
const offsetReviewTableWrap = document.getElementById('offsetReviewTableWrap');
const offsetReviewSelections = new Map();
let offsetReviewGroups = [];
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 renderVoucherDetailTable = (group) => {
if (!voucherDetailTableWrap) return;
const rows = Array.isArray(group?.rows) ? group.rows : [];
const lineColumns = [
['fiscal_year', '연도'],
['status_label', '구분'],
['ledger_date', 'WEHAGO 일자'],
['voucher_no', '전표번호'],
['draft_no', '가전표번호'],
['ledger_account_name', 'WEHAGO 계정'],
['voucher_account_name', 'ERP 계정'],
['ledger_vendor', 'WEHAGO 거래처'],
['voucher_vendor', 'ERP 거래처'],
['ledger_debit', 'WEHAGO 차변'],
['ledger_credit', 'WEHAGO 대변'],
['voucher_debit', 'ERP 차변'],
['voucher_credit', 'ERP 대변'],
['ledger_desc', 'WEHAGO 적요'],
['voucher_desc', 'ERP 적요'],
];
if (!rows.length) {
voucherDetailTableWrap.innerHTML = '<div class="table-placeholder">표시할 상세 행이 없습니다.</div>';
return;
}
const head = lineColumns.map(([field, label]) => `<th class="${getColumnClass(field)} ${getCellClass(field, ['voucher_no'])}">${escapeHtml(label)}</th>`).join('');
const body = rows.map((row) => {
const cells = lineColumns.map(([field]) => {
const raw = row[field];
const display = typeof raw === 'number'
? (Number.isInteger(raw) ? raw.toLocaleString() : raw.toLocaleString(undefined, { maximumFractionDigits: 2 }))
: String(raw ?? '');
return `<td class="${getColumnClass(field)} ${getCellClass(field, ['voucher_no'])}" title="${escapeHtml(display)}">${escapeHtml(display)}</td>`;
}).join('');
return `<tr>${cells}</tr>`;
}).join('');
voucherDetailTableWrap.innerHTML = `<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
};
const openVoucherDetailModal = (groupKey) => {
const group = voucherGroupDetailStore.get(groupKey);
if (!group || !voucherDetailModal) return;
const summary = group.summary || {};
if (voucherDetailModalTitle) {
voucherDetailModalTitle.textContent = `${summary.voucher_no || '전표'} 상세`;
}
if (voucherDetailModalMeta) {
const bits = [
summary.ledger_date || '',
summary.draft_no || '',
`WEHAGO ${Number(summary.ledger_row_count || 0).toLocaleString()}행`,
`ERP ${Number(summary.voucher_row_count || 0).toLocaleString()}행`,
].filter(Boolean);
voucherDetailModalMeta.textContent = bits.join(' / ');
}
renderVoucherDetailTable(group);
voucherDetailModal.classList.add('active');
voucherDetailModal.setAttribute('aria-hidden', 'false');
};
const closeVoucherDetailModal = () => {
if (!voucherDetailModal) return;
voucherDetailModal.classList.remove('active');
voucherDetailModal.setAttribute('aria-hidden', 'true');
};
voucherDetailCloseBtn?.addEventListener('click', closeVoucherDetailModal);
voucherDetailModal?.addEventListener('click', (event) => {
if (event.target === voucherDetailModal) {
closeVoucherDetailModal();
}
});
const formatOffsetAmount = (value) => Number(value || 0).toLocaleString(undefined, { maximumFractionDigits: 0 });
const setOffsetReviewModalOpen = (open) => {
if (!offsetReviewModal) return;
offsetReviewModal.classList.toggle('active', open);
offsetReviewModal.setAttribute('aria-hidden', open ? 'false' : 'true');
};
const updateOffsetMoveState = () => {
if (offsetReviewMoveBtn) {
offsetReviewMoveBtn.disabled = offsetReviewSelections.size === 0;
offsetReviewMoveBtn.textContent = offsetReviewSelections.size
? `이동 (${offsetReviewSelections.size.toLocaleString()})`
: '이동';
}
};
const renderOffsetReviewGroups = (payload) => {
if (!offsetReviewTableWrap) return;
offsetReviewGroups = Array.isArray(payload?.groups) ? payload.groups : [];
offsetReviewSelections.clear();
updateOffsetMoveState();
if (offsetReviewMeta) {
const missingYears = Array.isArray(payload?.missing_context_years) ? payload.missing_context_years : [];
offsetReviewMeta.textContent = missingYears.length
? `${missingYears.join(', ')}년 projection은 준비되지 않아 연도 경계 후보에서 제외됩니다.`
: '';
}
if (!offsetReviewGroups.length) {
offsetReviewTableWrap.innerHTML = '<div class="table-placeholder">현재 확인할 상계 취소 후보가 없습니다.</div>';
return;
}
const rows = offsetReviewGroups.map((group, sourceIndex) => {
const source = group.source || {};
const sourceRow = `
<tr class="offset-source-row">
<td class="choice-cell offset-source-label">취소 전표</td>
<td class="status-cell">${escapeHtml(source.status_key || 'voucher_recheck')}</td>
<td class="voucher-cell mono">${escapeHtml(source.ledger_date)}<br>${escapeHtml(source.voucher_no)}</td>
<td class="long-cell" title="${escapeHtml(source.ledger_accounts)}">${escapeHtml(source.ledger_vendors || '-')}<br>${escapeHtml(source.ledger_accounts || '-')}<br>${escapeHtml(formatOffsetAmount(source.ledger_debit))} / ${escapeHtml(formatOffsetAmount(source.ledger_credit))}</td>
<td class="long-cell" title="${escapeHtml(source.voucher_accounts || '')}">${escapeHtml(source.draft_no || '-')}<br>${escapeHtml(source.voucher_vendors || '-')}<br>${escapeHtml(source.voucher_accounts || '-')}</td>
<td class="long-cell" title="${escapeHtml(source.erp_management_items || '')}">${escapeHtml(source.erp_management_items || '-')}</td>
<td class="long-cell"></td>
</tr>
`;
const partnerRows = (group.partners || []).map((partner, partnerIndex) => {
const key = `${sourceIndex}:${partnerIndex}`;
return `
<tr class="${partnerIndex === 0 ? 'offset-group-start' : ''}">
<td class="choice-cell"><input type="checkbox" data-offset-choice="${escapeHtml(key)}" data-offset-source="${sourceIndex}"></td>
<td class="status-cell">${escapeHtml(partner.status_key)}</td>
<td class="voucher-cell mono">${escapeHtml(partner.ledger_date)}<br>${escapeHtml(partner.voucher_no)}</td>
<td class="long-cell" title="${escapeHtml(partner.ledger_accounts)}">${escapeHtml(partner.ledger_vendors)}<br>${escapeHtml(partner.ledger_accounts)}<br>${escapeHtml(formatOffsetAmount(partner.ledger_debit))} / ${escapeHtml(formatOffsetAmount(partner.ledger_credit))}</td>
<td class="long-cell" title="${escapeHtml(partner.voucher_accounts)}">${escapeHtml(partner.draft_no || '-')}<br>${escapeHtml(partner.voucher_vendors || '')}<br>${escapeHtml(partner.voucher_accounts || '')}</td>
<td class="long-cell" title="${escapeHtml(partner.erp_management_items || '')}">${escapeHtml(partner.erp_management_items || '-')}</td>
<td class="long-cell">${escapeHtml(partner.status_key === 'voucher_matched' ? 'Matched 원 전표 취소 여부 확인 후 함께 이동' : (group.recommendation || ''))}</td>
</tr>
`;
}).join('');
return sourceRow + partnerRows;
}).join('');
offsetReviewTableWrap.innerHTML = `
<table>
<thead>
<tr>
<th class="choice-cell">선택</th>
<th>상태</th>
<th>전표번호</th>
<th>반전 후보 정보</th>
<th>Hanmac ERP 연결</th>
<th>ERP 관리항목</th>
<th>판단</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
`;
};
const loadOffsetReviewGroups = async () => {
if (!offsetReviewTableWrap) return;
offsetReviewTableWrap.innerHTML = '<div class="table-placeholder">상계 취소 후보를 불러오는 중입니다.</div>';
if (offsetReviewMeta) offsetReviewMeta.textContent = '현재 projection의 Recheck와 반전 후보를 비교하고 있습니다.';
const params = appendSelectedYearRange(new URLSearchParams());
const response = await fetch(`/wehago-compare/api/offset-candidates?${params.toString()}`, {
headers: { 'X-Requested-With': 'XMLHttpRequest' },
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || '상계 후보를 불러오지 못했습니다.');
}
renderOffsetReviewGroups(payload);
};
offsetReviewTableWrap?.addEventListener('change', (event) => {
const checkbox = event.target.closest('input[data-offset-choice]');
if (!checkbox) return;
const key = checkbox.dataset.offsetChoice || '';
const [sourceIndexText, partnerIndexText] = key.split(':');
const sourceIndex = Number(sourceIndexText);
const partnerIndex = Number(partnerIndexText);
const group = offsetReviewGroups[sourceIndex];
const partner = group?.partners?.[partnerIndex];
if (!group || !partner) return;
if (checkbox.checked) {
offsetReviewTableWrap.querySelectorAll(`input[data-offset-source="${sourceIndex}"]`).forEach((other) => {
if (other !== checkbox) {
other.checked = false;
offsetReviewSelections.delete(other.dataset.offsetChoice || '');
}
});
for (const [selectedKey, selectedPair] of offsetReviewSelections.entries()) {
if (selectedPair.partner_identity !== partner.identity) continue;
const selectedCheckbox = offsetReviewTableWrap.querySelector(`input[data-offset-choice="${selectedKey}"]`);
if (selectedCheckbox) selectedCheckbox.checked = false;
offsetReviewSelections.delete(selectedKey);
}
offsetReviewSelections.set(key, {
source_identity: group.source.identity,
partner_identity: partner.identity,
});
} else {
offsetReviewSelections.delete(key);
}
updateOffsetMoveState();
});
offsetReviewOpenBtn?.addEventListener('click', async () => {
setOffsetReviewModalOpen(true);
try {
await loadOffsetReviewGroups();
} catch (error) {
if (offsetReviewTableWrap) {
offsetReviewTableWrap.innerHTML = '<div class="table-placeholder">후보 조회에 실패했습니다.</div>';
}
if (offsetReviewMeta) offsetReviewMeta.textContent = error.message;
}
});
offsetReviewCloseBtn?.addEventListener('click', () => setOffsetReviewModalOpen(false));
offsetReviewModal?.addEventListener('click', (event) => {
if (event.target === offsetReviewModal) setOffsetReviewModalOpen(false);
});
offsetReviewMoveBtn?.addEventListener('click', async () => {
if (!offsetReviewSelections.size) {
alert('Excepted로 이동할 상계쌍을 선택해주세요.');
return;
}
const confirmed = window.confirm(`선택한 ${offsetReviewSelections.size.toLocaleString()}개 상계쌍의 양 전표를 Excepted로 이동할까요?`);
if (!confirmed) return;
offsetReviewMoveBtn.disabled = true;
offsetReviewMoveBtn.textContent = '이동 중...';
try {
const response = await fetch('/wehago-compare/api/offset-except-save', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({ ...selectedYearRangePayload(), pairs: Array.from(offsetReviewSelections.values()) }),
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || '상계 전표 이동 중 오류가 발생했습니다.');
}
alert(`Excepted 이동 완료: ${Number(payload.saved_count || 0).toLocaleString()}개 상계쌍`);
window.location.reload();
} catch (error) {
alert(error.message);
updateOffsetMoveState();
}
});
const renderVoucherGroups = (container, payload, append = false, statusKey = '') => {
if (!container) return;
const groups = payload.groups || [];
const includeReviewCheckbox = statusKey === 'voucher_recheck';
const pendingPlaceholder = String(payload?.notice || '').trim() || '조건에 맞는 항목이 없습니다.';
if (includeReviewCheckbox && !append) {
voucherRecheckSelections.clear();
voucherRecheckSplitSelections.clear();
voucherRecheckRowStore.clear();
}
const buildVoucherRecheckGroupKey = (group, fallbackIndex = 0) => {
const summary = group?.summary || {};
return [
summary.fiscal_year || '',
summary.voucher_no || '',
summary.draft_no || '',
summary.ledger_date || '',
summary.proof_date || '',
fallbackIndex,
].join('|');
};
const buildVoucherRecheckRowKey = (row, groupKey = '', rowIndex = 0) => {
const reviewKey = String(row?.review_key || '').trim();
if (reviewKey) return reviewKey;
return [
groupKey,
rowIndex,
row?.ledger_row_key || '',
row?.voucher_row_key || '',
row?.ledger_account_name || '',
row?.voucher_account_name || '',
row?.ledger_desc || '',
row?.voucher_desc || '',
].join('|');
};
const bindVoucherRecheckCheckboxes = () => {
if (!includeReviewCheckbox) return;
container.querySelectorAll('input[data-voucher-recheck-row]').forEach((checkbox) => {
if (checkbox.dataset.bound === 'y') return;
checkbox.dataset.bound = 'y';
checkbox.addEventListener('change', () => {
const key = checkbox.dataset.voucherRecheckRow || '';
if (!key) return;
voucherRecheckSelections.set(key, checkbox.checked);
if (checkbox.checked) {
voucherRecheckSplitSelections.set(key, false);
const splitBox = container.querySelector(`input[data-voucher-recheck-split="${escapeSelectorValue(key)}"]`);
if (splitBox) splitBox.checked = false;
}
});
});
container.querySelectorAll('input[data-voucher-recheck-split]').forEach((checkbox) => {
if (checkbox.dataset.bound === 'y') return;
checkbox.dataset.bound = 'y';
checkbox.addEventListener('change', () => {
const key = checkbox.dataset.voucherRecheckSplit || '';
if (!key) return;
voucherRecheckSplitSelections.set(key, checkbox.checked);
if (checkbox.checked) {
voucherRecheckSelections.set(key, false);
const matchBox = container.querySelector(`input[data-voucher-recheck-row="${escapeSelectorValue(key)}"]`);
if (matchBox) matchBox.checked = false;
}
});
});
};
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 stripAccountCodePrefix = (value) => {
const text = String(value ?? '');
return text
.split(',')
.map((part) => part.trim().replace(/^\d+\s+/, ''))
.join(', ');
};
const formatValue = (field, raw) => {
if (field === 'fiscal_year') return String(raw ?? '');
if (['ledger_account_name', 'voucher_account_name', 'ledger_accounts', 'voucher_accounts'].includes(field)) {
return stripAccountCodePrefix(raw);
}
if (typeof raw === 'number') {
return Number.isInteger(raw)
? raw.toLocaleString()
: raw.toLocaleString(undefined, { maximumFractionDigits: 2 });
}
return String(raw ?? '');
};
if (!groups.length) {
const fallbackRows = Array.isArray(payload.rows) ? payload.rows : [];
if (!fallbackRows.length && !append) {
container.innerHTML = `<div class="table-placeholder">${escapeHtml(pendingPlaceholder)}</div>`;
return;
}
const lineHead = [
includeReviewCheckbox ? '<th class="selection-col">매칭</th><th class="selection-col">분리</th>' : '',
...lineColumns.map(([field, label]) => `<th class="${getColumnClass(field)}">${escapeHtml(label)}</th>`),
].join('');
const fallbackBody = fallbackRows.map((row, summaryIndex) => {
const groupKey = `${row.fiscal_year || ''}|${row.voucher_no || ''}|${row.draft_no || ''}|${row.ledger_date || ''}|${row.proof_date || ''}|${summaryIndex}`;
const cells = lineColumns.map(([field]) => {
const display = formatValue(field, row[field]);
return `<td class="${getColumnClass(field)}" title="${escapeHtml(display)}"><span class="${getCellClass(field, ['voucher_no'])}">${escapeHtml(display)}</span></td>`;
}).join('');
let checkboxCell = '';
if (includeReviewCheckbox) {
const rowKey = buildVoucherRecheckRowKey(row, groupKey, summaryIndex);
voucherRecheckRowStore.set(rowKey, { ...row, _recheck_group_key: groupKey, _recheck_group_rows: [row] });
checkboxCell = `<td class="selection-col"><input type="checkbox" data-voucher-recheck-row="${escapeHtml(rowKey)}" ${voucherRecheckSelections.get(rowKey) ? 'checked' : ''}></td><td class="selection-col"><input type="checkbox" data-voucher-recheck-split="${escapeHtml(rowKey)}" ${voucherRecheckSplitSelections.get(rowKey) ? 'checked' : ''}></td>`;
}
return `<tr>${checkboxCell}${cells}</tr>`;
}).join('');
const fallbackTable = `
<table>
<thead><tr>${lineHead}</tr></thead>
<tbody>${fallbackBody}</tbody>
</table>
`;
if (!append || !container.querySelector('.voucher-group-lines')) {
container.innerHTML = `<div class="voucher-group-lines">${fallbackTable}</div>`;
} else {
const tbody = container.querySelector('.voucher-group-lines tbody');
if (tbody) {
tbody.insertAdjacentHTML('beforeend', fallbackBody);
} else {
container.innerHTML = `<div class="voucher-group-lines">${fallbackTable}</div>`;
}
}
bindVoucherRecheckCheckboxes();
return;
}
const lineHead = [
includeReviewCheckbox ? '<th class="selection-col">매칭</th><th class="selection-col">분리</th>' : '',
...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 : [];
const groupKey = buildVoucherRecheckGroupKey(group, groupIndex);
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('');
let checkboxCell = '';
if (includeReviewCheckbox) {
const rowKey = buildVoucherRecheckRowKey(row, groupKey, rowIndex);
voucherRecheckRowStore.set(rowKey, { ...row, _recheck_group_key: groupKey, _recheck_group_summary: group.summary || {}, _recheck_group_rows: rows });
checkboxCell = `<td class="selection-col"><input type="checkbox" data-voucher-recheck-row="${escapeHtml(rowKey)}" ${voucherRecheckSelections.get(rowKey) ? 'checked' : ''}></td><td class="selection-col"><input type="checkbox" data-voucher-recheck-split="${escapeHtml(rowKey)}" ${voucherRecheckSplitSelections.get(rowKey) ? 'checked' : ''}></td>`;
}
return `<tr class="${rowClasses.join(' ')}">${checkboxCell}${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>`;
}
}
bindVoucherRecheckCheckboxes();
};
const renderTable = (container, payload, monoFields = [], append = false, statusKey = '') => {
if (!container) {
return;
}
if (statusKey === 'voucher_matched' || statusKey === 'voucher_unmatched' || statusKey === 'voucher_recheck' || statusKey === 'voucher_excepted' || statusKey === 'hanmac_unconnected' || statusKey === 'erp_voucher_matched' || statusKey === 'erp_voucher_unmatched') {
renderVoucherGroups(container, payload, append, statusKey);
return;
}
const columns = payload.columns || [];
const rows = payload.rows || [];
if (!rows.length && !append) {
container.innerHTML = `<div class="table-placeholder">${escapeHtml(String(payload?.notice || '').trim() || '조건에 맞는 항목이 없습니다.')}</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 readyYears = Array.isArray(stats.ready_years) ? stats.ready_years : [];
const pendingYears = Array.isArray(stats.pending_years) ? stats.pending_years : [];
const readyText = readyYears.length
? ` / 준비 연도 ${readyYears.join(', ')}`
: '';
const pendingText = pendingYears.length
? ` / 갱신 중 ${pendingYears.join(', ')}`
: '';
const noticeText = notice ? ` / ${notice}` : '';
const bankPayableCount = Number(stats.bank_payable_case_count || 0);
const bankPayableText = key === 'matched' && bankPayableCount
? ` <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}${readyText}${pendingText}${noticeText}`)}${bankPayableText}${boundaryText}`;
}
};
const fetchJson = async (url, options = {}) => {
const timeoutMs = Number(options.timeoutMs || 30000);
const controller = options.signal ? null : new AbortController();
let timeoutId = null;
if (controller && timeoutMs > 0) {
timeoutId = window.setTimeout(() => controller.abort(), timeoutMs);
}
try {
const response = await fetch(url, {
cache: 'no-store',
headers: {
'Cache-Control': 'no-cache',
'X-Requested-With': 'XMLHttpRequest',
},
signal: options.signal || controller?.signal,
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || '조회 중 오류가 발생했습니다.');
}
return payload;
} finally {
if (timeoutId) window.clearTimeout(timeoutId);
}
};
const loadBridgeReviewSettings = async () => {
try {
const payload = await fetchJson('/wehago-compare/api/bridge-review-settings');
return { ...defaultBridgeReviewSettings, ...(payload && typeof payload === 'object' ? payload : {}) };
} catch (_error) {
return { ...defaultBridgeReviewSettings };
}
};
const getBridgeReviewSettingsFromForm = () => {
if (!bridgeReviewSettingsForm) return { ...defaultBridgeReviewSettings };
const formData = new FormData(bridgeReviewSettingsForm);
return {
enable_promotion: formData.get('enable_promotion') === 'on',
require_exact_amount: formData.get('require_exact_amount') === 'on',
require_expense_nature: formData.get('require_expense_nature') === 'on',
require_strong_text: formData.get('require_strong_text') === 'on',
ambiguous_handling: String(formData.get('ambiguous_handling') || defaultBridgeReviewSettings.ambiguous_handling),
preview_limit: Math.max(20, Math.min(500, Number(formData.get('preview_limit') || defaultBridgeReviewSettings.preview_limit))),
};
};
const applyBridgeReviewSettingsToForm = (settings) => {
if (!bridgeReviewSettingsForm) return;
const merged = { ...defaultBridgeReviewSettings, ...(settings || {}) };
const assignCheckbox = (name, checked) => {
const input = bridgeReviewSettingsForm.querySelector(`input[name="${name}"]`);
if (input) input.checked = Boolean(checked);
};
const assignValue = (name, value) => {
const input = bridgeReviewSettingsForm.querySelector(`[name="${name}"]`);
if (input) input.value = String(value ?? '');
};
assignCheckbox('enable_promotion', merged.enable_promotion);
assignCheckbox('require_exact_amount', merged.require_exact_amount);
assignCheckbox('require_expense_nature', merged.require_expense_nature);
assignCheckbox('require_strong_text', merged.require_strong_text);
assignValue('ambiguous_handling', merged.ambiguous_handling);
assignValue('preview_limit', merged.preview_limit);
};
const updateBridgeReviewSettingsMeta = (settings) => {
if (!bridgeReviewSettingsMeta) return;
const merged = { ...defaultBridgeReviewSettings, ...(settings || {}) };
const promotionText = merged.enable_promotion ? 'WEHAGO 반영 후보 사용' : 'WEHAGO 자동 반영 후보 미사용';
const handlingText = merged.ambiguous_handling === 'recheck' ? '복수 후보는 WEHAGO Recheck' : '복수 후보는 2단계 비교에만 유지';
bridgeReviewSettingsMeta.textContent = `${promotionText} / ${handlingText}`;
};
const refreshBridgeAffectedPanels = async () => {
const refreshStatuses = ['voucher_matched', 'voucher_recheck'];
for (const statusKey of refreshStatuses) {
const panel = document.getElementById(`detail-${statusKey}`);
if (!panel || !panel.classList.contains('active')) continue;
const state = detailState.get(statusKey);
const form = state?.form || panel.querySelector('form[data-filter-kind="status"]');
await loadStatusRows(statusKey, form, false);
}
};
const saveBridgeReviewSettings = async () => {
const settings = getBridgeReviewSettingsFromForm();
const response = await fetch('/wehago-compare/api/bridge-review-settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify(settings),
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || '2단계 비교 설정 저장 중 오류가 발생했습니다.');
}
const saved = { ...defaultBridgeReviewSettings, ...((payload && payload.settings) || {}) };
applyBridgeReviewSettingsToForm(saved);
updateBridgeReviewSettingsMeta(saved);
if (bridgeReviewPreviewMeta) {
bridgeReviewPreviewMeta.textContent = '설정을 저장했습니다. 미리보기를 다시 불러오면 같은 기준으로 검토할 수 있습니다.';
}
return saved;
};
const setBridgeReviewModalOpen = async (open) => {
if (!bridgeReviewModal) return;
bridgeReviewModal.classList.toggle('active', open);
bridgeReviewModal.setAttribute('aria-hidden', open ? 'false' : 'true');
if (open) {
const settings = await loadBridgeReviewSettings();
applyBridgeReviewSettingsToForm(settings);
updateBridgeReviewSettingsMeta(settings);
}
};
const loadBridgeReviewPreview = async () => {
if (!bridgeReviewPreviewWrap) return;
const settings = getBridgeReviewSettingsFromForm();
if (bridgeReviewPreviewMeta) {
bridgeReviewPreviewMeta.textContent = '2단계 비교 후보를 불러오는 중입니다...';
}
bridgeReviewPreviewWrap.innerHTML = '<div class="table-placeholder">2단계 비교 후보를 불러오는 중입니다...</div>';
try {
const params = appendSelectedYearRange(new URLSearchParams());
params.set('status', 'bridge_expense_review');
params.set('limit', String(settings.preview_limit || defaultBridgeReviewSettings.preview_limit));
params.set('offset', '0');
const payload = await fetchJson(`/wehago-compare/api/status-rows?${params.toString()}`);
renderTable(bridgeReviewPreviewWrap, payload, ['voucher_no'], false, 'bridge_expense_review');
if (bridgeReviewPreviewMeta) {
bridgeReviewPreviewMeta.textContent = `후보 ${Number(payload.total_count || 0).toLocaleString()}건 / 현재 ${Number(payload.shown_count || 0).toLocaleString()}건`;
}
} catch (error) {
bridgeReviewPreviewWrap.innerHTML = `<div class="table-placeholder">${escapeHtml(error.message)}</div>`;
if (bridgeReviewPreviewMeta) {
bridgeReviewPreviewMeta.textContent = '후보를 불러오지 못했습니다.';
}
}
};
const stateLabelMap = {
ready: '준비됨',
queued: '대기중',
running: '생성중',
stale: '구버전',
failed: '실패',
missing: '없음',
};
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 pending = Boolean(payload?.pending);
summaryRefreshState.pending = pending;
if (summaryRefreshState.timerId) {
clearTimeout(summaryRefreshState.timerId);
summaryRefreshState.timerId = null;
}
if (pending) {
summaryRefreshState.timerId = window.setTimeout(() => {
loadDashboardSummary();
}, 4000);
}
};
const renderSnapshotStatus = (payload) => {
const snapshotPolicyText = document.getElementById('snapshotPolicyText');
const snapshotSummaryMeta = document.getElementById('snapshotSummaryMeta');
const snapshotSummaryBadges = document.getElementById('snapshotSummaryBadges');
const snapshotYears = document.getElementById('snapshotYears');
const snapshotJobs = document.getElementById('snapshotJobs');
if (!snapshotPolicyText || !snapshotSummaryMeta || !snapshotSummaryBadges || !snapshotYears || !snapshotJobs) {
return;
}
const aggregate = payload?.aggregate || payload?.snapshot_aggregate || {};
const policy = payload?.snapshot_policy || {};
const years = Array.isArray(payload?.years || payload?.snapshot_years) ? (payload.years || payload.snapshot_years) : [];
const recentJobs = Array.isArray(payload?.recent_jobs) ? payload.recent_jobs : [];
const modeSummary = Array.isArray(policy.year_modes)
? policy.year_modes.map((item) => `${item.year}:${item.mode}`).join(', ')
: '';
snapshotPolicyText.textContent = `백필 시작 ${policy.backfill_hint_start_year || '-'} / 운영 연도 ${policy.rolling_update_start_year || '-'}+ / 월별 반영 기준 익월 ${policy.monthly_expected_ready_day || '-'}일 이후`;
snapshotSummaryMeta.textContent = modeSummary || '연도별 스냅샷 정책 정보가 없습니다.';
const badgeOrder = ['ready', 'queued', 'running', 'stale', 'failed', 'missing'];
snapshotSummaryBadges.innerHTML = badgeOrder.map((key) => {
const label = stateLabelMap[key] || key;
const value = Number(aggregate[key] || 0).toLocaleString();
return `<span class="snapshot-badge"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></span>`;
}).join('');
snapshotYears.innerHTML = years.length
? years.map((item) => {
const stateKey = String(item.state || 'missing');
const counts = item.row_counts || {};
const summaryBits = [
`M ${Number(counts.matched || 0).toLocaleString()}`,
`U ${Number(counts.ledger_only || 0).toLocaleString()}`,
`R ${Number(counts.amount_mismatch || 0).toLocaleString()}`,
`E ${Number(counts.voucher_only || 0).toLocaleString()}`,
].join(' / ');
const metaBits = [
item.mode ? `mode ${item.mode}` : '',
item.last_built_at ? `built ${item.last_built_at}` : '',
item.last_requested_at ? `req ${item.last_requested_at}` : '',
item.cache_state ? `cache ${item.cache_state}` : '',
].filter(Boolean).join(' / ');
return `
<article class="snapshot-year-card">
<div class="snapshot-year-head">
<strong>${escapeHtml(item.fiscal_year)}</strong>
<span class="snapshot-state-pill ${escapeHtml(stateKey)}">${escapeHtml(stateLabelMap[stateKey] || stateKey)}</span>
</div>
<div class="snapshot-year-meta">${escapeHtml(summaryBits)}</div>
<div class="snapshot-year-meta">${escapeHtml(metaBits)}</div>
${item.error_message ? `<div class="snapshot-year-meta" style="color:#b91c1c;">${escapeHtml(item.error_message)}</div>` : ''}
</article>
`;
}).join('')
: '<div class="panel-meta">표시할 연도 상태가 없습니다.</div>';
snapshotJobs.innerHTML = recentJobs.length
? recentJobs.slice(0, 8).map((job) => {
const bits = [];
if (job.job_type === 'year_snapshot_rebuild' && job.payload?.fiscal_year) {
bits.push(`연도 ${job.payload.fiscal_year}`);
}
if (job.job_type === 'metric_counts_rebuild') {
bits.push(`범위 ${job.payload?.start_year ?? '-'}~${job.payload?.end_year ?? '-'}`);
}
bits.push(`우선순위 ${job.priority ?? 0}`);
return `
<div class="snapshot-job-row">
<div class="snapshot-job-head">
<strong>${escapeHtml(job.job_type)}</strong>
<span class="snapshot-state-pill ${escapeHtml(job.state || 'missing')}">${escapeHtml(stateLabelMap[job.state] || job.state || '')}</span>
</div>
<div class="snapshot-job-meta">${escapeHtml(bits.join(' / '))}</div>
<div class="snapshot-job-meta">${escapeHtml([job.created_at, job.started_at, job.finished_at].filter(Boolean).join(' / '))}</div>
${job.error_message ? `<div class="snapshot-job-meta" style="color:#b91c1c;">${escapeHtml(job.error_message)}</div>` : ''}
</div>
`;
}).join('')
: '<div class="panel-meta">최근 재생성 이력이 없습니다.</div>';
};
const loadDashboardSummary = async () => {
const params = appendSelectedYearRange(new URLSearchParams());
try {
const payload = await fetchJson(`/wehago-compare/api/summary?${params.toString()}`);
applySummaryPayload(payload);
if (snapshotStatusLoaded && !snapshotPanel?.hidden) {
renderSnapshotStatus(payload?.snapshot_status_payload || null);
}
} catch (_error) {
// Keep the page usable even if the summary loads later or fails once.
}
};
const loadSnapshotStatus = async (force = false) => {
const params = appendSelectedYearRange(new URLSearchParams());
if (force) params.set('force', '1');
try {
const payload = await fetchJson(`/wehago-compare/api/snapshot-status?${params.toString()}`);
renderSnapshotStatus(payload);
snapshotStatusLoaded = true;
return payload;
} catch (_error) {
return null;
}
};
const describeSystemJob = (job) => {
if (!job) return { text: '최근 작업 없음', state: '' };
const status = String(job.status || '');
const range = job.start_year && job.end_year ? `${job.start_year}~${job.end_year}` : '';
const message = job.error_message || job.message || '';
if (status === 'queued') return { text: `${range} 작업 대기 중`, state: status };
if (status === 'running') return { text: `${range} 계산 중... ${message}`.trim(), state: status };
if (status === 'done') return { text: `${range} 계산 완료`, state: status };
if (status === 'failed') return { text: `${range} 실패: ${message || '오류'}`, state: status };
return { text: `${range} ${message || status || '작업 상태 확인 전'}`.trim(), state: status };
};
const renderSystemJob = (job) => {
if (!systemJobMeta) return;
const view = describeSystemJob(job);
systemJobMeta.textContent = view.text;
systemJobMeta.dataset.state = view.state || '';
if (queryCacheRebuildBtn) {
queryCacheRebuildBtn.disabled = view.state === 'queued' || view.state === 'running';
}
if (view.state === 'done') {
window.setTimeout(() => {
loadDashboardSummary();
}, 400);
}
};
const loadLatestSystemJob = async () => {
const params = new URLSearchParams({
page_key: 'wehago_compare',
job_type: 'wehago_compare_project_range',
});
appendSelectedYearRange(params);
const payload = await fetchJson(`/api/system-jobs/latest?${params.toString()}`, { timeoutMs: 10000 });
renderSystemJob(payload.job || null);
return payload.job || null;
};
const pollSystemJob = (jobId) => {
if (!jobId) return;
if (systemJobPollTimer) window.clearInterval(systemJobPollTimer);
systemJobPollTimer = window.setInterval(async () => {
try {
const payload = await fetchJson(`/api/system-jobs/${encodeURIComponent(jobId)}`, { timeoutMs: 10000 });
const job = payload.job || null;
renderSystemJob(job);
if (!job || ['done', 'failed', 'cancelled'].includes(String(job.status || ''))) {
window.clearInterval(systemJobPollTimer);
systemJobPollTimer = null;
if (job && job.status === 'done') {
window.setTimeout(() => {
const result = job.result && typeof job.result === 'object' ? job.result : {};
const fallback = getSelectedYearRange();
const targetStart = job.start_year || result.start_year || fallback.startYear;
const targetEnd = job.end_year || result.end_year || fallback.endYear;
if (targetStart && targetEnd) {
navigateToCompareRange(targetStart, targetEnd);
} else {
window.location.reload();
}
}, 600);
}
}
} catch (_error) {
// Keep polling; transient server/database locks should not freeze the screen.
}
}, 2500);
};
const requestQueryCacheRebuild = async () => {
if (!queryCacheRebuildBtn) return;
queryCacheRebuildBtn.disabled = true;
if (systemJobMeta) {
systemJobMeta.textContent = '작업 등록 중...';
systemJobMeta.dataset.state = 'queued';
}
try {
const response = await fetch('/wehago-compare/api/rebuild-query-cache', {
method: 'POST',
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-cache',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({
...selectedYearRangePayload(),
}),
});
const payload = await response.json();
if (!response.ok || !payload.ok) {
throw new Error(payload.error || '작업 등록 중 오류가 발생했습니다.');
}
renderSystemJob(payload.job || null);
pollSystemJob(payload.job?.id);
} catch (error) {
if (systemJobMeta) {
systemJobMeta.textContent = error.message || '작업 등록 실패';
systemJobMeta.dataset.state = 'failed';
}
queryCacheRebuildBtn.disabled = false;
}
};
const setSnapshotPanelOpen = async (open) => {
if (!snapshotPanel || !snapshotToggleBtn) return;
snapshotPanel.hidden = !open;
snapshotToggleBtn.setAttribute('aria-expanded', open ? 'true' : 'false');
snapshotToggleBtn.textContent = open ? '스냅샷 상태 숨기기' : '스냅샷 상태';
if (open && !snapshotStatusLoaded) {
await loadSnapshotStatus();
}
};
const buildQuery = (form, extra = {}) => {
const params = appendSelectedYearRange(new URLSearchParams());
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 snapshotToggleBtn = document.getElementById('snapshotToggleBtn');
const snapshotPanel = document.getElementById('snapshotPanel');
const undoLastActionBtn = document.getElementById('undoLastActionBtn');
const lastActionMeta = document.getElementById('lastActionMeta');
const snapshotRebuildBtn = document.getElementById('snapshotRebuildBtn');
const bridgeReviewOpenBtn = document.getElementById('bridgeReviewOpenBtn');
const bridgeReviewModal = document.getElementById('bridgeReviewModal');
const bridgeReviewCloseBtn = document.getElementById('bridgeReviewCloseBtn');
const bridgeReviewSaveBtn = document.getElementById('bridgeReviewSaveBtn');
const bridgeReviewLoadBtn = document.getElementById('bridgeReviewLoadBtn');
const bridgeReviewSettingsForm = document.getElementById('bridgeReviewSettingsForm');
const bridgeReviewPreviewWrap = document.getElementById('bridgeReviewPreviewWrap');
const bridgeReviewPreviewMeta = document.getElementById('bridgeReviewPreviewMeta');
const bridgeReviewSettingsMeta = document.getElementById('bridgeReviewSettingsMeta');
const recommendSelections = new Map();
let recommendSortKey = 'score';
let recommendMode = 'batch';
let snapshotStatusLoaded = false;
const defaultBridgeReviewSettings = {
enable_promotion: false,
require_exact_amount: true,
require_expense_nature: true,
require_strong_text: true,
ambiguous_handling: 'recheck',
preview_limit: 100,
};
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 = appendSelectedYearRange(new URLSearchParams());
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', 'voucher_unmatched', 'voucher_recheck', 'voucher_excepted', 'hanmac_unconnected', 'erp_voucher_matched', 'erp_voucher_unmatched'].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 = appendSelectedYearRange(new URLSearchParams());
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 = appendSelectedYearRange(new URLSearchParams());
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 = appendSelectedYearRange(new URLSearchParams());
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 = appendSelectedYearRange(new URLSearchParams());
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 === 'bridge_expense_review') {
const wehagoAccount = String(selected.wehago_account || '').trim();
const erpAccount = String(selected.erp_account || '').trim();
const draftNo = String(selected.draft_no || '').trim();
const wehagoAmount = String(selected.wehago_amount || '').trim();
const erpAmount = String(selected.erp_amount || '').trim();
const wehagoVendor = String(selected.wehago_vendor || '').trim();
const erpVendor = String(selected.erp_vendor || '').trim();
if (wehagoAccount) mapped.set('wehago_account', wehagoAccount);
if (erpAccount) mapped.set('erp_account', erpAccount);
if (draftNo) mapped.set('draft_no', draftNo);
if (wehagoAmount) mapped.set('wehago_amount', wehagoAmount);
if (erpAmount) mapped.set('erp_amount', erpAmount);
if (wehagoVendor) mapped.set('wehago_vendor', wehagoVendor);
if (erpVendor) mapped.set('erp_vendor', erpVendor);
} 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 === 'voucher_recheck' || statusKey === 'voucher_excepted' || statusKey === 'erp_voucher_matched') {
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 if (statusKey === 'voucher_unmatched') {
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 === 'erp_voucher_unmatched' || statusKey === 'hanmac_unconnected') {
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 exportPollingByStatus = new Map();
const setExportButtonBusy = (statusKey, busy) => {
const button = document.querySelector(`[data-export-status="${statusKey}"]`);
if (!button) return;
button.disabled = busy;
button.dataset.loading = busy ? 'true' : 'false';
button.title = busy ? '엑셀 준비 중입니다...' : '엑셀 다운로드';
button.setAttribute('aria-label', busy ? '엑셀 준비 중' : '엑셀 다운로드');
};
const pollVoucherExportJob = async (statusKey, jobKey) => {
if (!statusKey || !jobKey) return;
exportPollingByStatus.set(statusKey, jobKey);
const maxAttempts = 360;
let transientErrors = 0;
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
try {
const payload = await fetchJson(`/wehago-compare/api/status-export-jobs/${encodeURIComponent(jobKey)}`);
transientErrors = 0;
if (payload.state === 'ready' && payload.download_url) {
setExportButtonBusy(statusKey, false);
exportPollingByStatus.delete(statusKey);
window.location.assign(payload.download_url);
return;
}
if (payload.state === 'failed') {
setExportButtonBusy(statusKey, false);
exportPollingByStatus.delete(statusKey);
throw new Error(payload.error_message || '엑셀 준비 중 오류가 발생했습니다.');
}
} catch (error) {
transientErrors += 1;
if (transientErrors >= 8) {
setExportButtonBusy(statusKey, false);
exportPollingByStatus.delete(statusKey);
throw error;
}
}
await new Promise((resolve) => window.setTimeout(resolve, 1500));
}
setExportButtonBusy(statusKey, false);
exportPollingByStatus.delete(statusKey);
throw new Error('엑셀 준비 시간이 길어지고 있습니다. 잠시 후 다시 시도해주세요.');
};
const triggerVoucherExport = async (statusKey) => {
if (!statusKey) return;
const current = detailState.get(statusKey) || {};
const form = current.form || document.querySelector(`.filter-form[data-status="${statusKey}"]`);
const query = buildStatusQuery(form, statusKey);
const payload = Object.fromEntries(new URLSearchParams(query).entries());
setExportButtonBusy(statusKey, true);
try {
const response = await fetch('/wehago-compare/api/status-export-jobs', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify(payload),
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || '엑셀 준비 요청 중 오류가 발생했습니다.');
}
if (result.state === 'ready' && result.download_url) {
setExportButtonBusy(statusKey, false);
window.location.assign(result.download_url);
return;
}
await pollVoucherExportJob(statusKey, result.job_key || '');
} catch (error) {
setExportButtonBusy(statusKey, false);
alert(error.message || '엑셀 다운로드 준비 중 오류가 발생했습니다.');
}
};
const loadStatusRows = async (statusKey, form = null, append = false) => {
const wrap = document.querySelector(`[data-table-wrap="${statusKey}"]`);
if (!wrap) return;
if (detailRetryTimers.has(statusKey)) {
clearTimeout(detailRetryTimers.get(statusKey));
detailRetryTimers.delete(statusKey);
}
const current = detailState.get(statusKey) || { offset: 0, totalCount: 0, loading: false };
if (current.loading) {
const activeController = detailRequestControllers.get(statusKey);
if (activeController) activeController.abort();
}
if (!append && form) {
const selected = getFormFieldValues(form);
cumulativeFiltersByStatus.set(statusKey, selected);
renderActiveFilterCards(statusKey);
}
const usesCursorPaging = ['voucher_matched', 'voucher_unmatched', 'voucher_recheck', 'voucher_excepted', 'hanmac_unconnected', 'erp_voucher_matched', 'erp_voucher_unmatched'].includes(statusKey);
const nextOffset = append ? (current.nextOffset || 0) : 0;
const nextCursor = append ? String(current.nextCursor || '') : '';
detailState.set(statusKey, { ...current, loading: true, form });
setLoadMoreState(statusKey, true, true);
if (!append) {
wrap.innerHTML = '<div class="table-placeholder">조회 중입니다...</div>';
setMeta(statusKey, current.totalCount || 0, 0, '조회 중입니다...');
}
const requestController = new AbortController();
const requestTimeoutId = window.setTimeout(() => requestController.abort(), 20000);
detailRequestControllers.set(statusKey, requestController);
try {
const pageLimit = usesCursorPaging ? 24 : 60;
const extra = usesCursorPaging
? { offset: append ? nextOffset : 0, cursor: nextCursor, limit: pageLimit }
: { offset: nextOffset, limit: pageLimit };
const payload = await fetchJson(
`/wehago-compare/api/status-rows?${buildStatusQuery(form, statusKey, extra)}`,
{ signal: requestController.signal, timeoutMs: 20000 },
);
if (detailRequestControllers.get(statusKey) !== requestController) return;
renderTable(wrap, payload, ['voucher_no'], append, statusKey);
const shownCount = append ? nextOffset + payload.shown_count : payload.shown_count;
detailState.set(statusKey, {
offset: payload.offset,
nextOffset: payload.next_offset,
nextCursor: payload.next_cursor || '',
totalCount: payload.total_count,
hasMore: payload.has_more,
loading: false,
form,
shownCount,
});
setMeta(statusKey, payload.total_count, shownCount, payload.notice, payload);
setLoadMoreState(statusKey, payload.has_more, false);
const rebuildingNotice = Boolean(payload?.pending) || String(payload.notice || '').includes('최신 전표 스냅샷을 갱신 중');
if (!append && rebuildingNotice) {
detailRetryTimers.set(statusKey, window.setTimeout(() => {
detailRetryTimers.delete(statusKey);
loadStatusRows(statusKey, form, false);
}, 4000));
}
} catch (error) {
if (error.name === 'AbortError') {
if (detailRequestControllers.get(statusKey) === requestController) {
if (!append) {
wrap.innerHTML = '<div class="table-placeholder">조회 시간이 길어져 중단했습니다. 조건을 줄여 다시 조회해주세요.</div>';
setMeta(statusKey, current.totalCount || 0, 0, '조회 시간이 길어져 중단했습니다.');
}
detailState.set(statusKey, { ...current, loading: false, form });
setLoadMoreState(statusKey, current.hasMore, false);
}
return;
}
if (!append) {
wrap.innerHTML = `<div class="table-placeholder">${escapeHtml(error.message)}</div>`;
}
detailState.set(statusKey, { ...current, loading: false, form });
setLoadMoreState(statusKey, current.hasMore, false);
} finally {
window.clearTimeout(requestTimeoutId);
if (detailRequestControllers.get(statusKey) === requestController) {
detailRequestControllers.delete(statusKey);
}
}
};
document.querySelectorAll('[data-export-status]').forEach((button) => {
button.addEventListener('click', async () => {
const statusKey = button.dataset.exportStatus || '';
if (!statusKey || exportPollingByStatus.has(statusKey)) {
return;
}
await triggerVoucherExport(statusKey);
});
});
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 panel = button.closest('.detail-panel');
const statusKey = panel?.dataset.statusPanel || '';
let selectedRows = [];
let splitRows = [];
if (statusKey === 'voucher_recheck') {
panel?.querySelectorAll('input[data-voucher-recheck-row]:checked').forEach((checkbox) => {
const key = checkbox.dataset.voucherRecheckRow || '';
if (key) {
voucherRecheckSelections.set(key, true);
}
});
panel?.querySelectorAll('input[data-voucher-recheck-split]:checked').forEach((checkbox) => {
const key = checkbox.dataset.voucherRecheckSplit || '';
if (key) {
voucherRecheckSplitSelections.set(key, true);
}
});
selectedRows = Array.from(voucherRecheckSelections.entries())
.filter(([, checked]) => checked)
.map(([key]) => voucherRecheckRowStore.get(key))
.filter(Boolean);
splitRows = Array.from(voucherRecheckSplitSelections.entries())
.filter(([, checked]) => checked)
.map(([key]) => voucherRecheckRowStore.get(key))
.filter(Boolean);
} else {
panel?.querySelectorAll('input[data-review-key]:checked').forEach((checkbox) => {
const key = checkbox.dataset.reviewKey || '';
if (key) {
recheckSelections.set(key, true);
}
});
selectedRows = Array.from(recheckSelections.entries())
.filter(([, checked]) => checked)
.map(([key]) => recheckRowStore.get(key))
.filter(Boolean);
}
if (!selectedRows.length && !splitRows.length) {
alert('변경할 WEHAGO Recheck 항목을 선택해주세요.');
return;
}
button.disabled = true;
const originalText = button.textContent;
button.textContent = '변경 중...';
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, match_rows: selectedRows, split_rows: splitRows }),
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || '매칭 처리 중 오류가 발생했습니다.');
}
if (!Number(payload.saved_count || 0)) {
alert('선택 항목을 저장하지 못했습니다. 목록을 새로 조회한 뒤 다시 선택해주세요.');
return;
}
if (statusKey === 'voucher_recheck') {
panel?.querySelectorAll('input[data-voucher-recheck-row]:checked').forEach((checkbox) => {
const key = checkbox.dataset.voucherRecheckRow || '';
if (key) {
voucherRecheckSelections.delete(key);
voucherRecheckRowStore.delete(key);
}
checkbox.closest('tr')?.remove();
});
panel?.querySelectorAll('input[data-voucher-recheck-split]:checked').forEach((checkbox) => {
const key = checkbox.dataset.voucherRecheckSplit || '';
if (key) {
voucherRecheckSplitSelections.delete(key);
}
checkbox.checked = false;
});
alert(`변경 완료: ${Number(payload.saved_count || 0).toLocaleString()}건`);
window.location.reload();
return;
}
window.location.reload();
} catch (error) {
alert(error.message);
} finally {
button.disabled = false;
button.textContent = originalText;
}
});
});
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({
...selectedYearRangePayload(),
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;
}
});
}
if (snapshotRebuildBtn) {
snapshotRebuildBtn.addEventListener('click', async () => {
const confirmed = window.confirm('현재 기간의 전표 스냅샷과 현황 캐시를 다시 준비할까요?');
if (!confirmed) return;
snapshotRebuildBtn.disabled = true;
try {
const response = await fetch('/wehago-compare/api/snapshot-rebuild', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({
...selectedYearRangePayload(),
include_metric_counts: true,
}),
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || '스냅샷 재생성 요청 중 오류가 발생했습니다.');
}
await loadSnapshotStatus();
window.setTimeout(() => loadDashboardSummary(), 1200);
} catch (error) {
alert(error.message);
} finally {
snapshotRebuildBtn.disabled = false;
}
});
}
if (snapshotToggleBtn) {
snapshotToggleBtn.addEventListener('click', async () => {
const nextOpen = snapshotPanel ? snapshotPanel.hidden : true;
await setSnapshotPanelOpen(nextOpen);
});
}
if (queryCacheRebuildBtn) {
queryCacheRebuildBtn.addEventListener('click', async () => {
const confirmed = window.confirm('현재 기간의 전표비교 조회 캐시를 서버에서 다시 계산할까요? 계산 중에도 다른 화면을 사용할 수 있습니다.');
if (!confirmed) return;
await requestQueryCacheRebuild();
});
}
if (bridgeReviewOpenBtn) {
bridgeReviewOpenBtn.addEventListener('click', async () => {
await setBridgeReviewModalOpen(true);
});
}
if (bridgeReviewCloseBtn) {
bridgeReviewCloseBtn.addEventListener('click', () => {
setBridgeReviewModalOpen(false);
});
}
if (bridgeReviewModal) {
bridgeReviewModal.addEventListener('click', (event) => {
if (event.target === bridgeReviewModal) {
setBridgeReviewModalOpen(false);
}
});
}
if (bridgeReviewSaveBtn) {
bridgeReviewSaveBtn.addEventListener('click', async () => {
try {
await saveBridgeReviewSettings();
await loadDashboardSummary();
await refreshBridgeAffectedPanels();
} catch (error) {
alert(error.message);
}
});
}
if (bridgeReviewLoadBtn) {
bridgeReviewLoadBtn.addEventListener('click', async () => {
await loadBridgeReviewPreview();
});
}
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && offsetReviewModal?.classList.contains('active')) {
setOffsetReviewModalOpen(false);
}
if (event.key === 'Escape' && bridgeReviewModal?.classList.contains('active')) {
setBridgeReviewModalOpen(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();
}
updateBridgeReviewSettingsMeta(defaultBridgeReviewSettings);
window.setTimeout(() => {
loadDashboardSummary();
}, 400);
loadLatestSystemJob()
.then((job) => {
if (job && ['queued', 'running'].includes(String(job.status || ''))) {
pollSystemJob(job.id);
}
})
.catch(() => {
if (systemJobMeta) {
systemJobMeta.textContent = '작업 상태 조회 실패';
systemJobMeta.dataset.state = 'failed';
}
});
refreshLastActionMeta();
})();
</script>
{% endblock %}