3830 lines
162 KiB
HTML
3830 lines
162 KiB
HTML
{% 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;
|
||
}
|
||
|
||
.panel-body {
|
||
padding: 0 0 16px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.status-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(9, minmax(92px, 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="erp_voucher_matched"],
|
||
.status-card[data-status="voucher_unmatched"],
|
||
.status-card[data-status="erp_voucher_unmatched"],
|
||
.status-card[data-status="voucher_recheck"] {
|
||
background: #f5f8fb;
|
||
border-color: #c8d7e6;
|
||
}
|
||
|
||
.status-card[data-status="matched"]:hover,
|
||
.status-card[data-status="matched"][aria-expanded="true"],
|
||
.status-card[data-status="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="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;
|
||
}
|
||
|
||
.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 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 != 'bridge_expense_review' %}
|
||
<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 != 'bridge_expense_review' %}
|
||
<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', 'erp_voucher_matched', 'voucher_unmatched', '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" 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="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 startYearValue = page?.dataset.startYear || '';
|
||
const endYearValue = page?.dataset.endYear || '';
|
||
|
||
const escapeHtml = (value) => String(value ?? '')
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
|
||
const escapeSelectorValue = (value) => String(value ?? '').replace(/["\\]/g, '\\$&');
|
||
|
||
const detailState = new Map();
|
||
const recheckSelections = new Map();
|
||
const recheckRowStore = new Map();
|
||
const voucherRecheckSelections = new Map();
|
||
const voucherRecheckRowStore = new Map();
|
||
const voucherGroupDetailStore = new Map();
|
||
const summaryRefreshState = { timerId: null, pending: false };
|
||
const detailRetryTimers = new Map();
|
||
const voucherDetailModal = document.getElementById('voucherDetailModal');
|
||
const voucherDetailCloseBtn = document.getElementById('voucherDetailCloseBtn');
|
||
const voucherDetailTableWrap = document.getElementById('voucherDetailTableWrap');
|
||
const voucherDetailModalTitle = document.getElementById('voucherDetailModalTitle');
|
||
const voucherDetailModalMeta = document.getElementById('voucherDetailModalMeta');
|
||
const pairSelections = new Map();
|
||
const pairLedgerStore = new Map();
|
||
const pairVoucherStore = new Map();
|
||
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 renderVoucherGroups = (container, payload, append = false, statusKey = '') => {
|
||
if (!container) return;
|
||
const groups = payload.groups || [];
|
||
const includeReviewCheckbox = statusKey === 'voucher_recheck';
|
||
const pendingPlaceholder = String(payload?.notice || '').trim() || '조건에 맞는 항목이 없습니다.';
|
||
if (includeReviewCheckbox && !append) {
|
||
voucherRecheckSelections.clear();
|
||
voucherRecheckRowStore.clear();
|
||
}
|
||
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);
|
||
});
|
||
});
|
||
};
|
||
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>' : '',
|
||
...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);
|
||
checkboxCell = `<td class="selection-col"><input type="checkbox" data-voucher-recheck-row="${escapeHtml(rowKey)}" ${voucherRecheckSelections.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>' : '',
|
||
...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);
|
||
const representativeReviewRow = rows.find((item) => String(item?.review_key || '').trim()) || group.summary || null;
|
||
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, String(row?.review_key || '').trim() ? row : (representativeReviewRow || row));
|
||
checkboxCell = `<td class="selection-col"><input type="checkbox" data-voucher-recheck-row="${escapeHtml(rowKey)}" ${voucherRecheckSelections.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 === 'erp_voucher_matched' || statusKey === 'voucher_unmatched' || statusKey === 'erp_voucher_unmatched' || statusKey === 'voucher_recheck') {
|
||
renderVoucherGroups(container, payload, append, statusKey);
|
||
return;
|
||
}
|
||
const columns = payload.columns || [];
|
||
const rows = payload.rows || [];
|
||
if (!rows.length && !append) {
|
||
container.innerHTML = `<div class="table-placeholder">${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) => {
|
||
const response = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
|
||
const payload = await response.json();
|
||
if (!response.ok) {
|
||
throw new Error(payload.error || '조회 중 오류가 발생했습니다.');
|
||
}
|
||
return payload;
|
||
};
|
||
|
||
const 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 = new URLSearchParams();
|
||
if (startYearValue) params.set('start_year', startYearValue);
|
||
if (endYearValue) params.set('end_year', endYearValue);
|
||
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 = new URLSearchParams();
|
||
if (startYearValue) params.set('start_year', startYearValue);
|
||
if (endYearValue) params.set('end_year', endYearValue);
|
||
try {
|
||
const payload = await fetchJson(`/wehago-compare/api/summary?${params.toString()}`);
|
||
applySummaryPayload(payload);
|
||
if (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 = new URLSearchParams();
|
||
if (startYearValue) params.set('start_year', startYearValue);
|
||
if (endYearValue) params.set('end_year', endYearValue);
|
||
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 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 = new URLSearchParams();
|
||
if (startYearValue) params.set('start_year', startYearValue);
|
||
if (endYearValue) params.set('end_year', endYearValue);
|
||
if (form) {
|
||
const data = new FormData(form);
|
||
for (const [key, value] of data.entries()) {
|
||
if (String(value).trim()) {
|
||
params.set(key, String(value).trim());
|
||
}
|
||
}
|
||
}
|
||
Object.entries(extra).forEach(([key, value]) => {
|
||
if (value) params.set(key, value);
|
||
});
|
||
return params.toString();
|
||
};
|
||
|
||
const getStatusForm = (statusKey) => document.querySelector(`form[data-filter-kind="status"][data-status="${statusKey}"]`);
|
||
const suggestState = new Map();
|
||
const recommendPanel = document.getElementById('pairRecommendPanel');
|
||
const recommendPanelTitle = document.getElementById('recommendPanelTitle');
|
||
const recommendModeMeta = document.getElementById('recommendModeMeta');
|
||
const recommendSummary = document.getElementById('recommendSummary');
|
||
const recommendTableWrap = document.getElementById('recommendTableWrap');
|
||
const recommendHighOnly = document.getElementById('recommendHighOnly');
|
||
const recommendHighOnlyWrap = document.getElementById('recommendHighOnlyWrap');
|
||
const recommendApplySelected = document.getElementById('recommendApplySelected');
|
||
const recommendApplyIndividual = document.getElementById('recommendApplyIndividual');
|
||
const recommendClose = document.getElementById('recommendClose');
|
||
const recommendSortTools = document.getElementById('recommendSortTools');
|
||
const recommendSortButtons = Array.from(document.querySelectorAll('[data-recommend-sort]'));
|
||
const 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 = new URLSearchParams();
|
||
if (startYearValue) params.set('start_year', startYearValue);
|
||
if (endYearValue) params.set('end_year', endYearValue);
|
||
if (ledgerForm) {
|
||
const data = new FormData(ledgerForm);
|
||
params.set('ledger_voucher_no', String(data.get('voucher_no') || '').trim());
|
||
params.set('ledger_review_reason', String(data.get('desc_keyword') || '').trim());
|
||
}
|
||
if (voucherForm) {
|
||
const data = new FormData(voucherForm);
|
||
params.set('voucher_voucher_no', String(data.get('voucher_no') || '').trim());
|
||
params.set('voucher_review_reason', String(data.get('desc_keyword') || '').trim());
|
||
}
|
||
Object.entries(extra).forEach(([key, value]) => {
|
||
if (value !== undefined && value !== null && String(value).trim() !== '') {
|
||
params.set(key, String(value).trim());
|
||
}
|
||
});
|
||
return params;
|
||
};
|
||
|
||
const initStatusSuggest = (form, type) => {
|
||
if (!form) return;
|
||
const status = form.dataset.status || '';
|
||
if (!['matched', 'amount_mismatch', 'ledger_only', 'voucher_only', 'voucher_matched', 'erp_voucher_matched', 'voucher_unmatched', 'erp_voucher_unmatched', 'voucher_recheck'].includes(status)) return;
|
||
const inputName = type === 'account'
|
||
? 'account_keyword'
|
||
: type === 'wehago_account'
|
||
? 'wehago_account'
|
||
: type === 'erp_account'
|
||
? 'erp_account'
|
||
: type === 'draft'
|
||
? 'draft_no'
|
||
: type === 'vendor'
|
||
? 'vendor_keyword'
|
||
: type === 'wehago_vendor'
|
||
? 'wehago_vendor'
|
||
: type === 'erp_vendor'
|
||
? 'erp_vendor'
|
||
: 'voucher_no';
|
||
const fieldName = type === 'account'
|
||
? 'account'
|
||
: type === 'wehago_account'
|
||
? 'wehago_account'
|
||
: type === 'erp_account'
|
||
? 'erp_account'
|
||
: type === 'draft'
|
||
? 'draft_no'
|
||
: type === 'vendor'
|
||
? 'vendor'
|
||
: type === 'wehago_vendor'
|
||
? 'wehago_vendor'
|
||
: type === 'erp_vendor'
|
||
? 'erp_vendor'
|
||
: 'voucher_no';
|
||
const dropdownSelector = type === 'account'
|
||
? `[data-account-suggest="${status}"]`
|
||
: type === 'wehago_account'
|
||
? `[data-wehago-account-suggest="${status}"]`
|
||
: type === 'erp_account'
|
||
? `[data-erp-account-suggest="${status}"]`
|
||
: type === 'draft'
|
||
? `[data-draft-suggest="${status}"]`
|
||
: type === 'vendor'
|
||
? `[data-vendor-suggest="${status}"]`
|
||
: type === 'wehago_vendor'
|
||
? `[data-wehago-vendor-suggest="${status}"]`
|
||
: type === 'erp_vendor'
|
||
? `[data-erp-vendor-suggest="${status}"]`
|
||
: `[data-voucher-suggest="${status}"]`;
|
||
const input = form.querySelector(`input[name="${inputName}"]`);
|
||
const dropdown = form.querySelector(dropdownSelector);
|
||
if (!input || !dropdown) return;
|
||
const stateKey = `${status}:${type}`;
|
||
suggestState.set(stateKey, { offset: 0, hasMore: false, loading: false, keyword: '' });
|
||
|
||
const renderSuggest = (rows, append = false) => {
|
||
if (!append) {
|
||
dropdown.innerHTML = '';
|
||
}
|
||
if (!rows.length && !append) {
|
||
dropdown.innerHTML = '<div class="suggest-empty">검색 결과가 없습니다.</div>';
|
||
dropdown.classList.add('open');
|
||
return;
|
||
}
|
||
const active = cumulativeFiltersByStatus.get(status) || {};
|
||
const html = rows.map((row) => {
|
||
const value = type === 'voucher' ? (row.voucher_no || '') : (row.label || '');
|
||
const checked = String(active[inputName] || '') === String(value || '');
|
||
return `
|
||
<label class="suggest-item" title="${escapeHtml(row.label || '')}">
|
||
<input type="checkbox" data-suggest-check="${escapeHtml(inputName)}" data-suggest-value="${escapeHtml(value)}" ${checked ? 'checked' : ''}>
|
||
<span class="text">${escapeHtml(row.label || '')}</span>
|
||
</label>
|
||
`;
|
||
}).join('');
|
||
if (!append) {
|
||
dropdown.innerHTML = html;
|
||
} else {
|
||
dropdown.insertAdjacentHTML('beforeend', html);
|
||
}
|
||
dropdown.classList.add('open');
|
||
dropdown.querySelectorAll('input[type="checkbox"][data-suggest-check]').forEach((checkbox) => {
|
||
if (checkbox.dataset.bound === 'y') return;
|
||
checkbox.dataset.bound = 'y';
|
||
checkbox.addEventListener('change', () => {
|
||
const value = checkbox.dataset.suggestValue || '';
|
||
if (checkbox.checked) {
|
||
dropdown.querySelectorAll(`input[type="checkbox"][data-suggest-check="${escapeSelectorValue(inputName)}"]`).forEach((other) => {
|
||
if (other !== checkbox) other.checked = false;
|
||
});
|
||
input.value = value;
|
||
updateCumulativeFilter(status, inputName, value, true);
|
||
} else {
|
||
if (String(input.value || '').trim() === value) {
|
||
input.value = '';
|
||
}
|
||
updateCumulativeFilter(status, inputName, '', false);
|
||
}
|
||
});
|
||
});
|
||
};
|
||
|
||
const loadSuggest = async (append = false) => {
|
||
const state = suggestState.get(stateKey) || { offset: 0, hasMore: false, loading: false, keyword: '' };
|
||
if (state.loading) return;
|
||
const keyword = String(input.value || '').trim();
|
||
if (!keyword) {
|
||
dropdown.classList.remove('open');
|
||
dropdown.innerHTML = '';
|
||
suggestState.set(stateKey, { ...state, offset: 0, hasMore: false, keyword: '' });
|
||
return;
|
||
}
|
||
const nextOffset = append ? (state.offset || 0) : 0;
|
||
suggestState.set(stateKey, { ...state, loading: true, keyword });
|
||
try {
|
||
const params = new URLSearchParams();
|
||
if (startYearValue) params.set('start_year', startYearValue);
|
||
if (endYearValue) params.set('end_year', endYearValue);
|
||
params.set('status', status);
|
||
params.set('field', fieldName);
|
||
params.set('keyword', keyword);
|
||
params.set('offset', String(nextOffset));
|
||
params.set('limit', '10');
|
||
let payload = await fetchJson(`/wehago-compare/api/status-suggestions?${params.toString()}`);
|
||
let rows = payload.rows || [];
|
||
if (!append && rows.length === 0) {
|
||
const fallbackParams = new URLSearchParams();
|
||
if (startYearValue) fallbackParams.set('start_year', startYearValue);
|
||
if (endYearValue) fallbackParams.set('end_year', endYearValue);
|
||
fallbackParams.set('status', status);
|
||
fallbackParams.set('field', fieldName);
|
||
fallbackParams.set('keyword', '');
|
||
fallbackParams.set('offset', '0');
|
||
fallbackParams.set('limit', '10');
|
||
const fallback = await fetchJson(`/wehago-compare/api/status-suggestions?${fallbackParams.toString()}`);
|
||
rows = fallback.rows || [];
|
||
payload = rows.length ? fallback : payload;
|
||
}
|
||
renderSuggest(rows, append);
|
||
suggestState.set(stateKey, {
|
||
...state,
|
||
loading: false,
|
||
offset: Number(payload.next_offset || 0),
|
||
hasMore: Boolean(payload.has_more),
|
||
keyword,
|
||
});
|
||
} catch (_error) {
|
||
suggestState.set(stateKey, { ...state, loading: false });
|
||
}
|
||
};
|
||
|
||
let debounceTimer = null;
|
||
input.addEventListener('input', () => {
|
||
if (debounceTimer) {
|
||
window.clearTimeout(debounceTimer);
|
||
}
|
||
debounceTimer = window.setTimeout(() => {
|
||
loadSuggest(false);
|
||
}, 180);
|
||
});
|
||
input.addEventListener('focus', () => {
|
||
if (String(input.value || '').trim()) {
|
||
loadSuggest(false);
|
||
}
|
||
});
|
||
dropdown.addEventListener('scroll', () => {
|
||
const state = suggestState.get(stateKey);
|
||
if (!state || !state.hasMore || state.loading) return;
|
||
const remain = dropdown.scrollHeight - dropdown.scrollTop - dropdown.clientHeight;
|
||
if (remain <= 40) {
|
||
loadSuggest(true);
|
||
}
|
||
});
|
||
document.addEventListener('mousedown', (event) => {
|
||
if (!form.contains(event.target)) {
|
||
dropdown.classList.remove('open');
|
||
}
|
||
});
|
||
};
|
||
|
||
const formatNumber = (value) => {
|
||
const numberValue = Number(value || 0);
|
||
return Number.isInteger(numberValue)
|
||
? numberValue.toLocaleString()
|
||
: numberValue.toLocaleString(undefined, { maximumFractionDigits: 2 });
|
||
};
|
||
|
||
const prettyFilterLabels = {
|
||
voucher_no: '전표번호',
|
||
draft_no: '가전표번호',
|
||
wehago_account: 'WEHAGO 계정',
|
||
erp_account: 'Hanmac 계정',
|
||
wehago_amount: 'WEHAGO 금액',
|
||
erp_amount: 'Hanmac 금액',
|
||
wehago_vendor: 'WEHAGO 거래처',
|
||
erp_vendor: 'Hanmac 거래처',
|
||
account_keyword: '계정',
|
||
vendor_keyword: '거래처',
|
||
amount_keyword: '금액',
|
||
desc_keyword: '적요',
|
||
review_reason: '검증근거',
|
||
boundary_excluded: '연초/연말 대체·이월',
|
||
};
|
||
|
||
const getFormFieldValues = (form) => {
|
||
const values = {};
|
||
form.querySelectorAll('input[name]').forEach((input) => {
|
||
const name = input.name;
|
||
if (!name) return;
|
||
const value = String(input.value || '').trim();
|
||
if (!value) return;
|
||
values[name] = value;
|
||
});
|
||
return values;
|
||
};
|
||
|
||
const syncFormWithActiveFilters = (statusKey, form) => {
|
||
if (!form) return;
|
||
const active = cumulativeFiltersByStatus.get(statusKey) || {};
|
||
form.querySelectorAll('input[name]').forEach((input) => {
|
||
input.value = active[input.name] || '';
|
||
});
|
||
};
|
||
|
||
const updateCumulativeFilter = (statusKey, fieldName, value, checked = true) => {
|
||
const current = { ...(cumulativeFiltersByStatus.get(statusKey) || {}) };
|
||
if (checked && String(value || '').trim()) {
|
||
current[fieldName] = String(value).trim();
|
||
} else {
|
||
delete current[fieldName];
|
||
}
|
||
cumulativeFiltersByStatus.set(statusKey, current);
|
||
renderActiveFilterCards(statusKey);
|
||
};
|
||
|
||
const renderActiveFilterCards = (statusKey) => {
|
||
const box = document.querySelector(`[data-active-filters="${statusKey}"]`);
|
||
if (!box) return;
|
||
const active = cumulativeFiltersByStatus.get(statusKey) || {};
|
||
const entries = Object.entries(active).filter(([, value]) => String(value || '').trim() !== '');
|
||
if (!entries.length) {
|
||
box.innerHTML = '';
|
||
return;
|
||
}
|
||
box.innerHTML = entries.map(([name, value]) => `
|
||
<div class="active-filter-card">
|
||
<div class="value" title="${escapeHtml(`${prettyFilterLabels[name] || name}: ${value}`)}">${escapeHtml(`${prettyFilterLabels[name] || name}: ${value}`)}</div>
|
||
<button type="button" class="remove" data-remove-active-filter="${escapeHtml(statusKey)}" data-filter-name="${escapeHtml(name)}" aria-label="조건 제거">×</button>
|
||
</div>
|
||
`).join('');
|
||
};
|
||
|
||
const getFilteredRecommendations = () => {
|
||
if (!latestPairRecommendation || !Array.isArray(latestPairRecommendation.pairs)) {
|
||
return [];
|
||
}
|
||
const onlyHigh = Boolean(recommendHighOnly?.checked);
|
||
const filtered = latestPairRecommendation.pairs.filter((pair) => !onlyHigh || pair.confidence_level === 'high');
|
||
filtered.sort((a, b) => {
|
||
if (recommendSortKey === 'amount_gap') {
|
||
const left = Number(a.amount_gap || 0);
|
||
const right = Number(b.amount_gap || 0);
|
||
return left - right;
|
||
}
|
||
if (recommendSortKey === 'account_similarity') {
|
||
const left = Number(a.account_similarity || 0);
|
||
const right = Number(b.account_similarity || 0);
|
||
return right - left;
|
||
}
|
||
if (recommendSortKey === 'vendor_similarity') {
|
||
const left = Number(a.vendor_similarity || 0);
|
||
const right = Number(b.vendor_similarity || 0);
|
||
return right - left;
|
||
}
|
||
return Number(b.score || 0) - Number(a.score || 0);
|
||
});
|
||
return filtered;
|
||
};
|
||
|
||
const renderRecommendationSummary = (stats = {}) => {
|
||
if (!recommendSummary) {
|
||
return;
|
||
}
|
||
recommendSummary.innerHTML = `
|
||
<span class="recommend-badge">추천 ${formatNumber(stats.recommended || 0)}건</span>
|
||
<span class="recommend-badge good">고신뢰 ${formatNumber(stats.high_confidence || 0)}건</span>
|
||
<span class="recommend-badge">WEHAGO 풀 ${formatNumber(stats.ledger_rows || 0)}건</span>
|
||
<span class="recommend-badge">ERP 풀 ${formatNumber(stats.voucher_rows || 0)}건</span>
|
||
`;
|
||
};
|
||
|
||
const renderRecommendationTable = () => {
|
||
if (!recommendTableWrap) {
|
||
return;
|
||
}
|
||
const pairs = getFilteredRecommendations();
|
||
if (!pairs.length) {
|
||
recommendTableWrap.innerHTML = '<div class="table-placeholder">표시할 추천 결과가 없습니다.</div>';
|
||
return;
|
||
}
|
||
const head = `
|
||
<tr>
|
||
<th><input type="checkbox" id="recommendSelectAll"></th>
|
||
<th>점수</th>
|
||
<th>금액차이</th>
|
||
<th>계정유사도</th>
|
||
<th>거래처유사도</th>
|
||
<th>신뢰도</th>
|
||
<th>검토</th>
|
||
<th>연도</th>
|
||
<th>전표번호</th>
|
||
<th>일자</th>
|
||
<th>가전표번호</th>
|
||
<th>WEHAGO 계정</th>
|
||
<th>ERP 계정</th>
|
||
<th>WEHAGO 거래처</th>
|
||
<th>ERP 거래처</th>
|
||
<th>WEHAGO 금액</th>
|
||
<th>ERP 금액</th>
|
||
<th>WEHAGO 적요</th>
|
||
<th>ERP 적요</th>
|
||
</tr>
|
||
`;
|
||
const body = pairs.map((pair) => {
|
||
const key = pair.pair_key || '';
|
||
const ledger = pair.ledger_row || {};
|
||
const voucher = pair.voucher_row || {};
|
||
const ledgerAmount = Math.max(Number(ledger.ledger_debit || 0), Number(ledger.ledger_credit || 0));
|
||
const voucherAmount = Math.max(Number(voucher.voucher_debit || 0), Number(voucher.voucher_credit || 0));
|
||
if (!recommendSelections.has(key) && pair.auto_eligible) {
|
||
recommendSelections.set(key, true);
|
||
}
|
||
const checked = recommendSelections.get(key) ? 'checked' : '';
|
||
return `
|
||
<tr>
|
||
<td><input type="checkbox" data-recommend-key="${escapeHtml(key)}" ${checked}></td>
|
||
<td title="${escapeHtml(pair.score)}"><span class="cell-ellipsis cell-tight">${escapeHtml(pair.score)}</span></td>
|
||
<td title="${escapeHtml(formatNumber(pair.amount_gap || 0))}"><span class="cell-ellipsis cell-tight">${escapeHtml(formatNumber(pair.amount_gap || 0))}</span></td>
|
||
<td title="${escapeHtml((Number(pair.account_similarity || 0) * 100).toFixed(1) + '%')}"><span class="cell-ellipsis cell-tight">${escapeHtml((Number(pair.account_similarity || 0) * 100).toFixed(1) + '%')}</span></td>
|
||
<td title="${escapeHtml((Number(pair.vendor_similarity || 0) * 100).toFixed(1) + '%')}"><span class="cell-ellipsis cell-tight">${escapeHtml((Number(pair.vendor_similarity || 0) * 100).toFixed(1) + '%')}</span></td>
|
||
<td title="${escapeHtml(pair.confidence_level)}"><span class="cell-ellipsis cell-tight">${escapeHtml(pair.confidence_level)}</span></td>
|
||
<td title="${escapeHtml(pair.substitution_hint || pair.reason || '')}"><span class="cell-ellipsis cell-desc">${escapeHtml(pair.substitution_hint || pair.reason || '')}</span></td>
|
||
<td title="${escapeHtml(ledger.fiscal_year || voucher.fiscal_year || '')}"><span class="cell-ellipsis cell-tight">${escapeHtml(ledger.fiscal_year || voucher.fiscal_year || '')}</span></td>
|
||
<td title="${escapeHtml(ledger.voucher_no || '')}"><span class="cell-ellipsis cell-code">${escapeHtml(ledger.voucher_no || '')}</span></td>
|
||
<td title="${escapeHtml(ledger.ledger_date || '')}"><span class="cell-ellipsis cell-code">${escapeHtml(ledger.ledger_date || '')}</span></td>
|
||
<td title="${escapeHtml(voucher.draft_no || '')}"><span class="cell-ellipsis cell-code">${escapeHtml(voucher.draft_no || '')}</span></td>
|
||
<td title="${escapeHtml(ledger.ledger_account_name || '')}"><span class="cell-ellipsis cell-equal">${escapeHtml(ledger.ledger_account_name || '')}</span></td>
|
||
<td title="${escapeHtml(voucher.voucher_account_name || '')}"><span class="cell-ellipsis cell-equal">${escapeHtml(voucher.voucher_account_name || '')}</span></td>
|
||
<td title="${escapeHtml(ledger.ledger_vendor || '')}"><span class="cell-ellipsis cell-equal">${escapeHtml(ledger.ledger_vendor || '')}</span></td>
|
||
<td title="${escapeHtml(voucher.voucher_vendor || '')}"><span class="cell-ellipsis cell-equal">${escapeHtml(voucher.voucher_vendor || '')}</span></td>
|
||
<td title="${escapeHtml(formatNumber(ledgerAmount))}"><span class="cell-ellipsis cell-equal">${escapeHtml(formatNumber(ledgerAmount))}</span></td>
|
||
<td title="${escapeHtml(formatNumber(voucherAmount))}"><span class="cell-ellipsis cell-equal">${escapeHtml(formatNumber(voucherAmount))}</span></td>
|
||
<td title="${escapeHtml(ledger.ledger_desc || '')}"><span class="cell-ellipsis cell-desc">${escapeHtml(ledger.ledger_desc || '')}</span></td>
|
||
<td title="${escapeHtml(voucher.voucher_desc || '')}"><span class="cell-ellipsis cell-desc">${escapeHtml(voucher.voucher_desc || '')}</span></td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
recommendTableWrap.innerHTML = `<table><thead>${head}</thead><tbody>${body}</tbody></table>`;
|
||
|
||
const selectAll = document.getElementById('recommendSelectAll');
|
||
const rowChecks = Array.from(recommendTableWrap.querySelectorAll('input[data-recommend-key]'));
|
||
const syncHeader = () => {
|
||
if (!selectAll) return;
|
||
const checkedCount = rowChecks.filter((el) => el.checked).length;
|
||
selectAll.checked = rowChecks.length > 0 && checkedCount === rowChecks.length;
|
||
selectAll.indeterminate = checkedCount > 0 && checkedCount < rowChecks.length;
|
||
};
|
||
rowChecks.forEach((checkbox) => {
|
||
checkbox.addEventListener('change', () => {
|
||
const key = checkbox.dataset.recommendKey || '';
|
||
if (!key) return;
|
||
recommendSelections.set(key, checkbox.checked);
|
||
syncHeader();
|
||
});
|
||
});
|
||
if (selectAll) {
|
||
selectAll.addEventListener('change', () => {
|
||
rowChecks.forEach((checkbox) => {
|
||
checkbox.checked = selectAll.checked;
|
||
const key = checkbox.dataset.recommendKey || '';
|
||
if (key) {
|
||
recommendSelections.set(key, selectAll.checked);
|
||
}
|
||
});
|
||
syncHeader();
|
||
});
|
||
}
|
||
syncHeader();
|
||
};
|
||
|
||
const setRecommendMode = (mode) => {
|
||
recommendMode = mode === 'individual' ? 'individual' : 'batch';
|
||
if (recommendPanelTitle) {
|
||
recommendPanelTitle.textContent = recommendMode === 'individual' ? '개별 추천' : '정확도 추천 워크벤치';
|
||
}
|
||
if (recommendModeMeta && recommendMode === 'batch') {
|
||
recommendModeMeta.textContent = '';
|
||
}
|
||
if (recommendSortTools) {
|
||
recommendSortTools.hidden = recommendMode === 'individual';
|
||
}
|
||
if (recommendHighOnlyWrap) {
|
||
recommendHighOnlyWrap.hidden = recommendMode === 'individual';
|
||
}
|
||
if (recommendApplySelected) {
|
||
recommendApplySelected.hidden = recommendMode === 'individual';
|
||
}
|
||
if (recommendApplyIndividual) {
|
||
recommendApplyIndividual.hidden = recommendMode !== 'individual';
|
||
}
|
||
if (recommendSummary) {
|
||
recommendSummary.style.display = recommendMode === 'individual' ? 'none' : 'flex';
|
||
}
|
||
};
|
||
|
||
const openRecommendationPanel = (mode = 'batch') => {
|
||
if (!recommendPanel) return;
|
||
setRecommendMode(mode);
|
||
recommendPanel.classList.add('active');
|
||
syncRecommendationPanelState();
|
||
recommendPanel.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
};
|
||
|
||
const closeRecommendationPanel = () => {
|
||
if (!recommendPanel) return;
|
||
recommendPanel.classList.remove('active');
|
||
syncRecommendationPanelState();
|
||
};
|
||
|
||
const getSelectedSourceForIndividual = (sourceStatus) => {
|
||
if (sourceStatus === 'ledger_only') {
|
||
return Array.from(pairSelections.entries())
|
||
.filter(([key, checked]) => checked && pairLedgerStore.has(key))
|
||
.map(([key]) => pairLedgerStore.get(key))
|
||
.filter(Boolean);
|
||
}
|
||
return Array.from(pairSelections.entries())
|
||
.filter(([key, checked]) => checked && pairVoucherStore.has(key))
|
||
.map(([key]) => pairVoucherStore.get(key))
|
||
.filter(Boolean);
|
||
};
|
||
|
||
const renderIndividualRows = (append = false) => {
|
||
if (!recommendTableWrap) return;
|
||
const rows = individualState.rows || [];
|
||
if (!rows.length && !append) {
|
||
recommendTableWrap.innerHTML = '<div class="table-placeholder">추천 항목이 없습니다.</div>';
|
||
return;
|
||
}
|
||
const head = `
|
||
<tr>
|
||
<th></th>
|
||
<th>점수</th>
|
||
<th>금액차이</th>
|
||
<th>계정유사도</th>
|
||
<th>거래처유사도</th>
|
||
<th>연도</th>
|
||
<th>전표번호</th>
|
||
<th>일자</th>
|
||
<th>가전표번호</th>
|
||
<th>WEHAGO 계정</th>
|
||
<th>ERP 계정</th>
|
||
<th>WEHAGO 거래처</th>
|
||
<th>ERP 거래처</th>
|
||
<th>WEHAGO 적요</th>
|
||
<th>ERP 적요</th>
|
||
</tr>
|
||
`;
|
||
const body = rows.map((item) => {
|
||
const target = item.target_row || {};
|
||
const isLedgerSource = individualState.sourceStatus === 'ledger_only';
|
||
const ledger = isLedgerSource ? (individualState.sourceRow || {}) : target;
|
||
const voucher = isLedgerSource ? target : (individualState.sourceRow || {});
|
||
const checked = individualState.selectedPairKey === item.pair_key ? 'checked' : '';
|
||
return `
|
||
<tr>
|
||
<td><input type="radio" name="individualPairRadio" value="${escapeHtml(item.pair_key || '')}" ${checked}></td>
|
||
<td title="${escapeHtml(item.score)}"><span class="cell-ellipsis cell-tight">${escapeHtml(item.score)}</span></td>
|
||
<td title="${escapeHtml(formatNumber(item.amount_gap || 0))}"><span class="cell-ellipsis cell-tight">${escapeHtml(formatNumber(item.amount_gap || 0))}</span></td>
|
||
<td title="${escapeHtml((Number(item.account_similarity || 0) * 100).toFixed(1) + '%')}"><span class="cell-ellipsis cell-tight">${escapeHtml((Number(item.account_similarity || 0) * 100).toFixed(1) + '%')}</span></td>
|
||
<td title="${escapeHtml((Number(item.vendor_similarity || 0) * 100).toFixed(1) + '%')}"><span class="cell-ellipsis cell-tight">${escapeHtml((Number(item.vendor_similarity || 0) * 100).toFixed(1) + '%')}</span></td>
|
||
<td title="${escapeHtml(ledger.fiscal_year || voucher.fiscal_year || '')}"><span class="cell-ellipsis cell-tight">${escapeHtml(ledger.fiscal_year || voucher.fiscal_year || '')}</span></td>
|
||
<td title="${escapeHtml(ledger.voucher_no || '')}"><span class="cell-ellipsis cell-code">${escapeHtml(ledger.voucher_no || '')}</span></td>
|
||
<td title="${escapeHtml(ledger.ledger_date || '')}"><span class="cell-ellipsis cell-code">${escapeHtml(ledger.ledger_date || '')}</span></td>
|
||
<td title="${escapeHtml(voucher.draft_no || '')}"><span class="cell-ellipsis cell-code">${escapeHtml(voucher.draft_no || '')}</span></td>
|
||
<td title="${escapeHtml(ledger.ledger_account_name || '')}"><span class="cell-ellipsis cell-equal">${escapeHtml(ledger.ledger_account_name || '')}</span></td>
|
||
<td title="${escapeHtml(voucher.voucher_account_name || '')}"><span class="cell-ellipsis cell-equal">${escapeHtml(voucher.voucher_account_name || '')}</span></td>
|
||
<td title="${escapeHtml(ledger.ledger_vendor || '')}"><span class="cell-ellipsis cell-equal">${escapeHtml(ledger.ledger_vendor || '')}</span></td>
|
||
<td title="${escapeHtml(voucher.voucher_vendor || '')}"><span class="cell-ellipsis cell-equal">${escapeHtml(voucher.voucher_vendor || '')}</span></td>
|
||
<td title="${escapeHtml(ledger.ledger_desc || '')}"><span class="cell-ellipsis cell-desc">${escapeHtml(ledger.ledger_desc || '')}</span></td>
|
||
<td title="${escapeHtml(voucher.voucher_desc || '')}"><span class="cell-ellipsis cell-desc">${escapeHtml(voucher.voucher_desc || '')}</span></td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
recommendTableWrap.innerHTML = `<table><thead>${head}</thead><tbody>${body}</tbody></table>`;
|
||
recommendTableWrap.querySelectorAll('input[name="individualPairRadio"]').forEach((radio) => {
|
||
radio.addEventListener('change', () => {
|
||
individualState.selectedPairKey = radio.value || '';
|
||
});
|
||
});
|
||
};
|
||
|
||
const loadIndividualRecommendations = async (append = false) => {
|
||
if (!individualState.sourceStatus || !individualState.sourceRowKey || individualState.loading) return;
|
||
individualState.loading = true;
|
||
if (!append && recommendTableWrap) {
|
||
recommendTableWrap.innerHTML = '<div class="table-placeholder">개별 추천 불러오는 중...</div>';
|
||
}
|
||
try {
|
||
const params = new URLSearchParams();
|
||
if (startYearValue) params.set('start_year', startYearValue);
|
||
if (endYearValue) params.set('end_year', endYearValue);
|
||
params.set('source_status', individualState.sourceStatus);
|
||
params.set('source_row_key', individualState.sourceRowKey);
|
||
params.set('offset', append ? String(individualState.nextOffset || 0) : '0');
|
||
params.set('limit', '10');
|
||
const payload = await fetchJson(`/wehago-compare/api/pair-individual-recommendations?${params.toString()}`);
|
||
if (!append) {
|
||
individualState.rows = payload.rows || [];
|
||
individualState.sourceRow = payload.source_row || null;
|
||
individualState.totalCount = Number(payload.total_count || 0);
|
||
} else {
|
||
individualState.rows = [...(individualState.rows || []), ...(payload.rows || [])];
|
||
}
|
||
individualState.nextOffset = Number(payload.next_offset || 0);
|
||
individualState.hasMore = Boolean(payload.has_more);
|
||
if (recommendModeMeta) {
|
||
const source = individualState.sourceRow || {};
|
||
const label = individualState.sourceStatus === 'ledger_only' ? 'Unmatched 기준' : 'ERP Unmatched 기준';
|
||
recommendModeMeta.textContent = `${label} / 전표번호 ${source.voucher_no || '-'} / 계정 ${source.ledger_account_name || source.voucher_account_name || '-'}`;
|
||
}
|
||
if (!individualState.selectedPairKey && individualState.rows.length) {
|
||
individualState.selectedPairKey = individualState.rows[0].pair_key || '';
|
||
}
|
||
renderIndividualRows(append);
|
||
} catch (error) {
|
||
if (recommendTableWrap) {
|
||
recommendTableWrap.innerHTML = `<div class="table-placeholder">${escapeHtml(error.message)}</div>`;
|
||
}
|
||
} finally {
|
||
individualState.loading = false;
|
||
}
|
||
};
|
||
|
||
const setLoadMoreState = (statusKey, hasMore, loading = false) => {
|
||
const footer = document.querySelector(`[data-table-footer="${statusKey}"]`);
|
||
const button = document.querySelector(`[data-load-more="${statusKey}"]`);
|
||
if (!footer || !button) return;
|
||
footer.hidden = !hasMore && !loading;
|
||
button.disabled = loading;
|
||
button.textContent = loading ? '불러오는 중...' : '더 보기';
|
||
};
|
||
|
||
const buildStatusQuery = (form, statusKey, extra = {}) => {
|
||
const selected = cumulativeFiltersByStatus.get(statusKey) || {};
|
||
const mapped = new URLSearchParams();
|
||
if (startYearValue) mapped.set('start_year', startYearValue);
|
||
if (endYearValue) mapped.set('end_year', endYearValue);
|
||
mapped.set('status', statusKey);
|
||
const rawVoucher = String(selected.voucher_no || '').trim();
|
||
const rawDesc = String(selected.desc_keyword || '').trim();
|
||
if (rawVoucher) mapped.set('voucher_no', rawVoucher);
|
||
if (rawDesc) mapped.set('desc_keyword', rawDesc);
|
||
if (statusKey === 'ledger_only') {
|
||
const account = String(selected.account_keyword || '').trim();
|
||
const vendor = String(selected.vendor_keyword || '').trim();
|
||
const amount = String(selected.amount_keyword || '').trim();
|
||
const boundaryExcluded = String(selected.boundary_excluded || '').trim();
|
||
if (account) mapped.set('wehago_account', account);
|
||
if (vendor) mapped.set('wehago_vendor', vendor);
|
||
if (amount) mapped.set('wehago_amount', amount);
|
||
if (boundaryExcluded) mapped.set('boundary_excluded', boundaryExcluded);
|
||
} else if (statusKey === '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 === 'erp_voucher_matched' || statusKey === 'voucher_unmatched' || statusKey === 'erp_voucher_unmatched' || statusKey === 'voucher_recheck') {
|
||
const account = String(selected.account_keyword || '').trim();
|
||
const vendor = String(selected.vendor_keyword || '').trim();
|
||
const amount = String(selected.amount_keyword || '').trim();
|
||
if (account) {
|
||
mapped.set('wehago_account', account);
|
||
mapped.set('erp_account', account);
|
||
}
|
||
if (vendor) {
|
||
mapped.set('wehago_vendor', vendor);
|
||
mapped.set('erp_vendor', vendor);
|
||
}
|
||
if (amount) {
|
||
mapped.set('wehago_amount', amount);
|
||
mapped.set('erp_amount', amount);
|
||
}
|
||
} else {
|
||
for (const [key, value] of Object.entries(selected)) {
|
||
if (String(value).trim()) {
|
||
mapped.set(key, String(value).trim());
|
||
}
|
||
}
|
||
}
|
||
Object.entries(extra).forEach(([key, value]) => {
|
||
if (value !== undefined && value !== null && String(value) !== '') {
|
||
mapped.set(key, String(value));
|
||
}
|
||
});
|
||
return mapped.toString();
|
||
};
|
||
|
||
const 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) return;
|
||
if (!append && form) {
|
||
const selected = getFormFieldValues(form);
|
||
cumulativeFiltersByStatus.set(statusKey, selected);
|
||
renderActiveFilterCards(statusKey);
|
||
}
|
||
const usesCursorPaging = ['voucher_matched', 'erp_voucher_matched', 'voucher_unmatched', 'erp_voucher_unmatched', 'voucher_recheck'].includes(statusKey);
|
||
const nextOffset = append ? (current.nextOffset || 0) : 0;
|
||
const nextCursor = append ? String(current.nextCursor || '') : '';
|
||
detailState.set(statusKey, { ...current, loading: true, form });
|
||
setLoadMoreState(statusKey, true, true);
|
||
if (!append) {
|
||
wrap.innerHTML = '<div class="table-placeholder">조회 중입니다...</div>';
|
||
}
|
||
try {
|
||
const pageLimit = usesCursorPaging ? 24 : 60;
|
||
const extra = usesCursorPaging
|
||
? { offset: append ? nextOffset : 0, cursor: nextCursor, limit: pageLimit }
|
||
: { offset: nextOffset, limit: pageLimit };
|
||
const payload = await fetchJson(`/wehago-compare/api/status-rows?${buildStatusQuery(form, statusKey, extra)}`);
|
||
renderTable(wrap, payload, ['voucher_no'], append, statusKey);
|
||
const shownCount = append ? nextOffset + payload.shown_count : payload.shown_count;
|
||
detailState.set(statusKey, {
|
||
offset: payload.offset,
|
||
nextOffset: payload.next_offset,
|
||
nextCursor: payload.next_cursor || '',
|
||
totalCount: payload.total_count,
|
||
hasMore: payload.has_more,
|
||
loading: false,
|
||
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 (!append) {
|
||
wrap.innerHTML = `<div class="table-placeholder">${escapeHtml(error.message)}</div>`;
|
||
}
|
||
detailState.set(statusKey, { ...current, loading: false, form });
|
||
setLoadMoreState(statusKey, current.hasMore, false);
|
||
}
|
||
};
|
||
|
||
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 = [];
|
||
if (statusKey === 'voucher_recheck') {
|
||
selectedRows = Array.from(voucherRecheckSelections.entries())
|
||
.filter(([, checked]) => checked)
|
||
.map(([key]) => voucherRecheckRowStore.get(key))
|
||
.filter(Boolean);
|
||
} else {
|
||
selectedRows = Array.from(recheckSelections.entries())
|
||
.filter(([, checked]) => checked)
|
||
.map(([key]) => recheckRowStore.get(key))
|
||
.filter(Boolean);
|
||
}
|
||
if (!selectedRows.length) {
|
||
alert('매칭할 WEHAGO Recheck 항목을 선택해주세요.');
|
||
return;
|
||
}
|
||
button.disabled = true;
|
||
try {
|
||
const response = await fetch('/wehago-compare/api/recheck-review-save', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-Requested-With': 'XMLHttpRequest',
|
||
},
|
||
body: JSON.stringify({ rows: selectedRows }),
|
||
});
|
||
const payload = await response.json();
|
||
if (!response.ok) {
|
||
throw new Error(payload.error || '매칭 처리 중 오류가 발생했습니다.');
|
||
}
|
||
window.location.reload();
|
||
} catch (error) {
|
||
alert(error.message);
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll('[data-pair-save]').forEach((button) => {
|
||
button.addEventListener('click', async () => {
|
||
const form = button.closest('form[data-status]');
|
||
const sourceStatus = form?.dataset.status || '';
|
||
if (!['ledger_only', 'voucher_only'].includes(sourceStatus)) {
|
||
alert('개별 추천은 Unmatched 또는 ERP Unmatched에서만 사용할 수 있습니다.');
|
||
return;
|
||
}
|
||
const selectedRows = getSelectedSourceForIndividual(sourceStatus);
|
||
if (!selectedRows.length) {
|
||
alert(sourceStatus === 'ledger_only' ? 'Unmatched에서 항목 1건을 체크해주세요.' : 'ERP Unmatched에서 항목 1건을 체크해주세요.');
|
||
return;
|
||
}
|
||
if (selectedRows.length > 1) {
|
||
alert('개별 추천은 한 번에 1건만 선택해서 실행해주세요.');
|
||
return;
|
||
}
|
||
const sourceRow = selectedRows[0];
|
||
individualState.sourceStatus = sourceStatus;
|
||
individualState.sourceRow = sourceRow;
|
||
individualState.sourceRowKey = sourceStatus === 'ledger_only'
|
||
? (sourceRow.ledger_row_key || '')
|
||
: (sourceRow.voucher_row_key || '');
|
||
individualState.rows = [];
|
||
individualState.nextOffset = 0;
|
||
individualState.hasMore = false;
|
||
individualState.selectedPairKey = '';
|
||
button.disabled = true;
|
||
try {
|
||
openRecommendationPanel('individual');
|
||
await loadIndividualRecommendations(false);
|
||
} catch (error) {
|
||
alert(error.message);
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll('[data-pair-recommend]').forEach((button) => {
|
||
button.addEventListener('click', async () => {
|
||
button.disabled = true;
|
||
try {
|
||
const params = buildPairQuery({ limit: 300 });
|
||
const payload = await fetchJson(`/wehago-compare/api/pair-recommendations?${params.toString()}`);
|
||
recommendSelections.clear();
|
||
latestPairRecommendation = payload;
|
||
openRecommendationPanel('batch');
|
||
renderRecommendationSummary(payload.stats || {});
|
||
renderRecommendationTable();
|
||
const stats = payload.stats || {};
|
||
const pairs = payload.pairs || [];
|
||
if (!pairs.length) {
|
||
recommendTableWrap.innerHTML = '<div class="table-placeholder">추천 가능한 쌍이 없습니다. 조회 조건을 조금 더 좁혀주세요.</div>';
|
||
}
|
||
if (stats.recommended) {
|
||
setMeta('ledger_only', stats.ledger_rows, undefined, `추천 ${Number(stats.recommended).toLocaleString()}건 준비됨`);
|
||
setMeta('voucher_only', stats.voucher_rows, undefined, `추천 ${Number(stats.recommended).toLocaleString()}건 준비됨`);
|
||
}
|
||
} catch (error) {
|
||
alert(error.message);
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
});
|
||
});
|
||
|
||
if (recommendHighOnly) {
|
||
recommendHighOnly.addEventListener('change', () => {
|
||
renderRecommendationTable();
|
||
});
|
||
}
|
||
|
||
recommendSortButtons.forEach((button) => {
|
||
button.addEventListener('click', () => {
|
||
const key = button.dataset.recommendSort || 'score';
|
||
recommendSortKey = key;
|
||
recommendSortButtons.forEach((item) => {
|
||
item.classList.toggle('active', item === button);
|
||
});
|
||
renderRecommendationTable();
|
||
});
|
||
});
|
||
|
||
if (recommendApplySelected) {
|
||
recommendApplySelected.addEventListener('click', async () => {
|
||
const keys = Array.from(recommendSelections.entries())
|
||
.filter(([, checked]) => checked)
|
||
.map(([key]) => key);
|
||
if (!keys.length) {
|
||
alert('저장할 추천 항목을 선택해주세요.');
|
||
return;
|
||
}
|
||
recommendApplySelected.disabled = true;
|
||
try {
|
||
const params = buildPairQuery();
|
||
const response = await fetch('/wehago-compare/api/pair-recommendations/apply', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-Requested-With': 'XMLHttpRequest',
|
||
},
|
||
body: JSON.stringify({
|
||
start_year: startYearValue || null,
|
||
end_year: endYearValue || null,
|
||
pair_keys: keys,
|
||
auto_only: false,
|
||
ledger_voucher_no: params.get('ledger_voucher_no') || '',
|
||
ledger_review_reason: params.get('ledger_review_reason') || '',
|
||
voucher_voucher_no: params.get('voucher_voucher_no') || '',
|
||
voucher_review_reason: params.get('voucher_review_reason') || '',
|
||
}),
|
||
});
|
||
const payload = await response.json();
|
||
if (!response.ok) {
|
||
throw new Error(payload.error || '추천 선택 저장 중 오류가 발생했습니다.');
|
||
}
|
||
alert(`추천 선택 저장 완료: ${Number(payload.saved_count || 0).toLocaleString()}건`);
|
||
window.location.reload();
|
||
} catch (error) {
|
||
alert(error.message);
|
||
} finally {
|
||
recommendApplySelected.disabled = false;
|
||
}
|
||
});
|
||
}
|
||
|
||
if (recommendClose) {
|
||
recommendClose.addEventListener('click', () => {
|
||
closeRecommendationPanel();
|
||
});
|
||
}
|
||
|
||
if (recommendApplyIndividual) {
|
||
recommendApplyIndividual.addEventListener('click', async () => {
|
||
const picked = (individualState.rows || []).find((row) => row.pair_key === individualState.selectedPairKey);
|
||
if (!picked) {
|
||
alert('매칭할 추천 항목 1건을 선택해주세요.');
|
||
return;
|
||
}
|
||
recommendApplyIndividual.disabled = true;
|
||
try {
|
||
const isLedgerSource = individualState.sourceStatus === 'ledger_only';
|
||
const ledgerRows = isLedgerSource ? [individualState.sourceRow] : [picked.target_row];
|
||
const voucherRows = isLedgerSource ? [picked.target_row] : [individualState.sourceRow];
|
||
const response = await fetch('/wehago-compare/api/pair-match-save', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-Requested-With': 'XMLHttpRequest',
|
||
},
|
||
body: JSON.stringify({ ledger_rows: ledgerRows, voucher_rows: voucherRows }),
|
||
});
|
||
const payload = await response.json();
|
||
if (!response.ok) {
|
||
throw new Error(payload.error || '개별 매칭 저장 중 오류가 발생했습니다.');
|
||
}
|
||
alert(`개별 매칭 저장 완료: ${Number(payload.saved_count || 0).toLocaleString()}건`);
|
||
window.location.reload();
|
||
} catch (error) {
|
||
alert(error.message);
|
||
} finally {
|
||
recommendApplyIndividual.disabled = false;
|
||
}
|
||
});
|
||
}
|
||
|
||
if (recommendTableWrap) {
|
||
recommendTableWrap.addEventListener('scroll', () => {
|
||
if (recommendMode !== 'individual') return;
|
||
if (!individualState.hasMore || individualState.loading) return;
|
||
const remain = recommendTableWrap.scrollHeight - recommendTableWrap.scrollTop - recommendTableWrap.clientHeight;
|
||
if (remain <= 100) {
|
||
loadIndividualRecommendations(true);
|
||
}
|
||
});
|
||
}
|
||
|
||
if (undoLastActionBtn) {
|
||
undoLastActionBtn.addEventListener('click', async () => {
|
||
const confirmed = window.confirm('직전 작업을 취소할까요? 최근 저장 작업 1건만 되돌립니다.');
|
||
if (!confirmed) return;
|
||
undoLastActionBtn.disabled = true;
|
||
try {
|
||
const response = await fetch('/wehago-compare/api/undo-last-action', {
|
||
method: 'POST',
|
||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||
});
|
||
const payload = await response.json();
|
||
if (!response.ok) {
|
||
throw new Error(payload.error || '되돌리기 중 오류가 발생했습니다.');
|
||
}
|
||
if (!payload.undone) {
|
||
alert(payload.message || '되돌릴 작업이 없습니다.');
|
||
} else {
|
||
alert(`되돌리기 완료: ${formatNumber(payload.affected || 0)}건`);
|
||
window.location.reload();
|
||
}
|
||
} catch (error) {
|
||
alert(error.message);
|
||
} finally {
|
||
undoLastActionBtn.disabled = false;
|
||
}
|
||
});
|
||
}
|
||
|
||
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({
|
||
start_year: startYearValue || null,
|
||
end_year: endYearValue || null,
|
||
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 (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' && 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);
|
||
refreshLastActionMeta();
|
||
})();
|
||
</script>
|
||
{% endblock %}
|