Files
HM_project_Viewer_Board/templates/hanmac_browser.html
T

4158 lines
177 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}hanmac DB_external{% endblock %}
{% block head_extra %}
<style>
.hanmac-page {
display: grid;
gap: 18px;
min-height: calc(100vh - 150px);
}
.hanmac-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 40px;
padding: 0 14px;
border: 1px solid var(--line);
border-radius: 999px;
background: #111111;
color: #ffffff;
font-size: 13px;
font-weight: 700;
cursor: pointer;
box-shadow: none;
}
.hanmac-button.secondary {
background: #ffffff;
color: var(--ink);
}
.hanmac-inline-status {
min-height: 40px;
padding: 9px 14px;
border-left: 3px solid #111111;
border-radius: 12px;
background: rgba(255, 255, 255, 0.9);
color: var(--muted);
font-size: 13px;
line-height: 1.6;
}
.hanmac-inline-status.is-success {
border-left-color: #2a7f45;
background: #eef8f1;
color: #245a34;
}
.hanmac-inline-status.is-error {
border-left-color: #b23b3b;
background: #fff3f3;
color: #8e2e2e;
}
.hanmac-grade-panel {
display: none;
border: 1px solid var(--line);
border-radius: 10px;
background: #ffffff;
overflow: auto;
max-height: 360px;
}
.hanmac-grade-panel.is-open {
display: block;
}
.hanmac-connection-section {
display: grid;
gap: 12px;
padding: 14px 0;
border-top: 1px solid var(--line);
}
.hanmac-connection-section:first-child {
padding-top: 0;
border-top: 0;
}
.hanmac-connection-section-title {
display: grid;
gap: 3px;
}
.hanmac-connection-section-title strong {
font-size: 14px;
}
.hanmac-connection-section-title span {
color: var(--muted);
font-size: 12px;
line-height: 1.5;
}
.hanmac-connection-section.is-erp {
margin: 0 -10px;
padding: 14px 10px;
border: 1px solid #cfd9e8;
border-radius: 14px;
background: #f6f9fd;
}
.hanmac-connection-tabs {
display: flex;
gap: 8px;
padding-bottom: 2px;
overflow-x: auto;
}
.hanmac-connection-tab {
min-width: 104px;
min-height: 38px;
padding: 0 16px;
border: 1px solid var(--line);
border-radius: 999px;
background: #ffffff;
color: var(--ink);
font-weight: 800;
white-space: nowrap;
cursor: pointer;
}
.hanmac-connection-tab.is-active {
border-color: #111111;
background: #111111;
color: #ffffff;
}
.hanmac-connection-section[hidden],
.hanmac-connection-actions[hidden] {
display: none;
}
.hanmac-erp-server-note {
padding: 10px 12px;
border-radius: 12px;
background: #eaf1fb;
color: #344054;
font-size: 12px;
line-height: 1.6;
}
.hanmac-grade-panel table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.hanmac-grade-panel th,
.hanmac-grade-panel td {
padding: 8px 9px;
border-bottom: 1px solid #edf1f5;
text-align: left;
vertical-align: top;
}
.hanmac-grade-panel th {
position: sticky;
top: 0;
background: #f8fafc;
color: var(--muted);
font-weight: 800;
white-space: nowrap;
}
.hanmac-panel {
display: grid;
gap: 14px;
min-height: 0;
padding: 16px 0 0;
border-top: 1px solid var(--line);
background: transparent;
}
.hanmac-layout {
display: grid;
grid-template-columns: minmax(250px, 300px) minmax(0, 1fr);
gap: 20px;
align-items: start;
min-height: calc(100vh - 220px);
}
.hanmac-panel h3 {
margin: 0;
font-size: 18px;
letter-spacing: -0.03em;
}
.hanmac-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.hanmac-panel-header .section-title {
margin-bottom: 0;
}
.hanmac-panel-meta {
color: var(--muted);
font-size: 13px;
line-height: 1.6;
}
.hanmac-quick-grid {
display: flex;
flex-wrap: nowrap;
gap: 10px;
overflow-x: auto;
padding-bottom: 2px;
}
.hanmac-quick-card {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 38px;
padding: 0 12px;
border: 1px solid #d7dde5;
border-radius: 999px;
background: transparent;
color: var(--ink);
cursor: pointer;
text-align: left;
flex: 0 0 auto;
white-space: nowrap;
box-shadow: none;
}
.hanmac-quick-card.is-active {
border-color: #111111;
background: #111111;
color: #ffffff;
}
.hanmac-quick-card strong {
font-size: 14px;
}
.hanmac-wehago-audit {
display: grid;
gap: 10px;
padding: 12px 0;
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
}
.hanmac-wehago-audit-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.hanmac-wehago-audit-title strong {
font-size: 14px;
}
.hanmac-wehago-audit-title a,
.hanmac-wehago-audit a {
color: var(--ink);
font-size: 12px;
font-weight: 800;
text-decoration: none;
}
.hanmac-wehago-audit-list {
display: grid;
gap: 7px;
}
.hanmac-wehago-audit-item {
display: grid;
gap: 3px;
padding: 8px 0;
border-top: 1px solid #edf1f5;
}
.hanmac-wehago-audit-item:first-child {
border-top: 0;
padding-top: 0;
}
.hanmac-wehago-audit-item span {
color: var(--muted);
font-size: 12px;
line-height: 1.5;
}
.hanmac-wehago-year-links {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.hanmac-wehago-year-links a {
min-width: 48px;
padding: 5px 7px;
border: 1px solid var(--line);
border-radius: 999px;
background: #ffffff;
text-align: center;
}
.hanmac-filter-bar {
display: grid;
gap: 10px;
}
.hanmac-filter-bar input {
width: 100%;
min-height: 42px;
padding: 0 14px;
border: 1px solid var(--line);
border-radius: 14px;
background: #ffffff;
color: var(--ink);
font: inherit;
}
.hanmac-filter-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.hanmac-filter-tab {
min-height: 32px;
padding: 0 12px;
border: 1px solid #d7dde5;
border-radius: 999px;
background: #ffffff;
color: #4a5565;
font-size: 12px;
font-weight: 700;
cursor: pointer;
box-shadow: none;
}
.hanmac-filter-tab.is-active {
border-color: #111111;
background: #111111;
color: #ffffff;
}
.hanmac-table-list {
display: grid;
gap: 0;
max-height: calc(100vh - 410px);
overflow: auto;
padding-right: 4px;
}
.hanmac-table-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
width: 100%;
padding: 12px 0;
border: 0;
border-bottom: 1px solid #e4e9f1;
border-radius: 0;
background: transparent;
text-align: left;
cursor: pointer;
box-shadow: none;
}
.hanmac-table-item.is-active {
border-bottom-color: #111111;
background: transparent;
}
.hanmac-table-topline {
display: contents;
}
.hanmac-table-name {
font-weight: 800;
color: var(--ink);
font-size: 13px;
line-height: 1.35;
word-break: break-word;
text-align: left;
flex: 1 1 auto;
min-width: 0;
}
.hanmac-table-meta {
color: var(--muted);
font-size: 11px;
text-align: right;
flex: 0 0 auto;
white-space: nowrap;
}
.hanmac-schema-chip {
display: inline-flex;
align-items: center;
width: fit-content;
min-height: 20px;
padding: 0 7px;
border-radius: 999px;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.02em;
line-height: 1;
}
.hanmac-schema-chip {
background: #eef3f8;
color: #465466;
text-transform: uppercase;
}
.hanmac-preview-header {
display: grid;
gap: 12px;
min-width: 0;
padding-bottom: 10px;
border-bottom: 1px solid #e4e9f1;
}
.hanmac-aggregate-section {
display: grid;
gap: 12px;
padding-bottom: 14px;
border-bottom: 1px solid #e4e9f1;
}
.hanmac-aggregate-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
white-space: normal;
}
.hanmac-aggregate-toolbar-left,
.hanmac-aggregate-toolbar-right {
display: inline-flex;
align-items: center;
gap: 10px;
min-width: 0;
flex-wrap: wrap;
}
.hanmac-aggregate-toolbar-right {
order: 1;
margin-left: auto;
justify-content: flex-end;
width: 100%;
}
.hanmac-aggregate-toolbar-left {
order: 2;
width: 100%;
justify-content: flex-start;
}
.hanmac-aggregate-filter-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
white-space: nowrap;
width: 100%;
}
#hanmacOpenHolidayButton {
margin-left: auto;
flex: 0 0 auto;
}
.hanmac-joint-content {
min-width: 340px;
max-width: 620px;
white-space: pre-wrap;
line-height: 1.55;
}
#hanmacJointModal {
padding: 12px;
}
#hanmacJointModal .hanmac-modal-card {
width: min(1360px, 100%);
height: calc(100vh - 24px);
max-height: calc(100vh - 24px);
grid-template-rows: auto minmax(0, 1fr);
overflow: hidden;
}
#hanmacJointModal .hanmac-center-panel {
min-height: 0;
grid-template-rows: auto minmax(0, 1fr);
}
#hanmacJointModal .hanmac-center-shell {
min-height: 0;
max-height: none;
}
#hanmacJointModal .hanmac-center-table {
min-width: 1080px;
}
#hanmacJointModal .hanmac-center-table td {
vertical-align: top;
}
#hanmacJointModal .hanmac-center-table th:nth-child(6),
#hanmacJointModal .hanmac-center-table td:nth-child(6) {
min-width: 420px;
white-space: normal;
}
.hanmac-joint-line-list {
display: grid;
gap: 6px;
}
.hanmac-joint-line {
display: grid;
grid-template-columns: 116px minmax(0, 1fr);
gap: 8px;
align-items: start;
padding: 7px 8px;
border: 1px solid #e4e9f1;
background: #fbfcfe;
line-height: 1.45;
white-space: normal;
}
.hanmac-joint-date {
color: #475467;
font-weight: 800;
white-space: normal;
}
.hanmac-joint-text {
color: var(--ink);
overflow-wrap: anywhere;
word-break: keep-all;
}
#hanmacAggregateValueColumn {
width: 112px;
min-width: 112px;
max-width: 112px;
flex: 0 0 112px;
text-overflow: ellipsis;
}
.hanmac-aggregate-toolbar input,
.hanmac-aggregate-toolbar select {
min-height: 38px;
padding: 0 12px;
border: 1px solid var(--line);
border-radius: 12px;
background: #ffffff;
color: var(--ink);
font: inherit;
flex: 0 0 auto;
}
#hanmacAggregateStartDate,
#hanmacAggregateEndDate {
width: 150px;
}
#hanmacAggregateEmployment {
width: 160px;
}
#hanmacAggregateLoadButton {
flex: 0 0 auto;
}
#hanmacAggregateMeta {
min-width: 0;
text-align: left;
}
.hanmac-aggregate-summary {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 10px;
overflow-x: auto;
}
.hanmac-aggregate-card {
display: grid;
gap: 4px;
padding: 8px 0 10px;
border-bottom: 1px solid #e4e9f1;
}
.hanmac-aggregate-card span {
color: var(--muted);
font-size: 12px;
line-height: 1.3;
}
.hanmac-aggregate-card strong {
font-size: 18px;
line-height: 1.1;
word-break: break-word;
}
.hanmac-aggregate-shell {
overflow: auto;
border-top: 1px solid #e4e9f1;
border-bottom: 1px solid #e4e9f1;
background: #ffffff;
max-height: 320px;
}
.hanmac-center-panel {
display: grid;
gap: 8px;
border-top: 1px solid #e4e9f1;
padding-top: 10px;
}
.hanmac-center-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
}
.hanmac-center-head strong {
font-size: 14px;
}
.hanmac-center-shell {
overflow: auto;
max-height: 180px;
border-top: 1px solid #e4e9f1;
border-bottom: 1px solid #e4e9f1;
background: #ffffff;
}
#hanmacCenterModal {
padding: 12px;
}
#hanmacCenterModal .hanmac-modal-card {
width: min(1160px, 100%);
height: calc(100vh - 24px);
max-height: calc(100vh - 24px);
grid-template-rows: auto minmax(0, 1fr);
overflow: hidden;
}
#hanmacCenterModal .hanmac-center-panel {
min-height: 0;
grid-template-rows: auto minmax(0, 1fr);
}
#hanmacCenterModal .hanmac-center-shell {
min-height: 0;
max-height: none;
}
#hanmacMultiEntryModal .hanmac-modal-card {
width: min(1120px, calc(100vw - 32px));
max-height: calc(100vh - 32px);
overflow: hidden;
}
#hanmacMultiEntryModal .hanmac-detail-list {
overflow-y: auto;
overflow-x: hidden;
}
#hanmacMultiEntryModal .hanmac-detail-table {
width: 100%;
min-width: 0;
table-layout: fixed;
}
#hanmacMultiEntryModal .hanmac-detail-table th:nth-child(1) {
width: 124px;
}
#hanmacMultiEntryModal .hanmac-detail-table th:nth-child(3),
#hanmacMultiEntryModal .hanmac-detail-table th:nth-child(4) {
width: 108px;
}
#hanmacMultiEntryModal .hanmac-detail-table td:nth-child(2) {
white-space: normal;
word-break: keep-all;
overflow-wrap: anywhere;
}
.hanmac-center-table {
width: 100%;
min-width: 720px;
border-collapse: collapse;
font-size: 12px;
}
.hanmac-center-table th,
.hanmac-center-table td {
padding: 8px 10px;
border-bottom: 1px solid #edf1f6;
text-align: left;
vertical-align: middle;
white-space: nowrap;
}
.hanmac-center-table th {
color: #465466;
font-weight: 800;
background: #f7f9fc;
}
.hanmac-holiday-panel {
display: grid;
gap: 10px;
padding: 0;
}
.hanmac-holiday-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.hanmac-holiday-form {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.hanmac-holiday-form input,
.hanmac-holiday-form select {
min-height: 30px;
padding: 0 9px;
border: 1px solid var(--line);
border-radius: 8px;
background: #ffffff;
color: var(--ink);
font: inherit;
}
.hanmac-holiday-form .hanmac-button {
min-height: 30px;
padding: 0 10px;
border-radius: 8px;
}
.hanmac-holiday-list {
display: flex;
gap: 6px;
flex-wrap: wrap;
max-height: 360px;
overflow: auto;
}
.hanmac-holiday-chip {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 24px;
padding: 2px 7px;
border: 1px solid #d7e2f0;
border-radius: 8px;
background: #f8fbff;
font-size: 12px;
line-height: 1.35;
color: var(--ink);
box-shadow: none;
}
.hanmac-aggregate-table {
width: 100%;
border-collapse: collapse;
min-width: 880px;
}
.hanmac-aggregate-table th,
.hanmac-aggregate-table td {
padding: 7px 10px;
border-bottom: 1px solid #eceff3;
text-align: left;
vertical-align: middle;
font-size: 12px;
line-height: 1.35;
white-space: nowrap;
}
.hanmac-aggregate-table th {
position: sticky;
top: 0;
z-index: 1;
background: #f7f9fc;
color: var(--ink);
font-weight: 800;
text-align: center;
box-shadow: none;
}
.hanmac-aggregate-sort-button {
all: unset;
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
min-height: 20px;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
color: inherit;
font: inherit;
font-weight: 800;
line-height: 1.2;
text-align: center;
cursor: pointer;
box-shadow: none;
transform: none;
transition: none;
}
.hanmac-aggregate-sort-button:hover,
.hanmac-aggregate-sort-button:focus {
background: transparent;
box-shadow: none;
transform: none;
}
.hanmac-aggregate-sort-button::after {
content: "↕";
color: #8a96a8;
font-size: 11px;
line-height: 1;
}
.hanmac-aggregate-sort-button.is-sorted-asc::after {
content: "↑";
color: #111111;
}
.hanmac-aggregate-sort-button.is-sorted-desc::after {
content: "↓";
color: #111111;
}
.hanmac-aggregate-table td.is-numeric {
text-align: right;
}
.hanmac-aggregate-table th.is-numeric {
text-align: center;
}
.hanmac-aggregate-table th.is-numeric .hanmac-aggregate-sort-button {
justify-content: center;
text-align: center;
}
.hanmac-aggregate-table tbody tr:nth-child(even) td {
background: #fbfcfe;
}
.hanmac-aggregate-regular-cell {
display: inline-flex;
align-items: center;
gap: 8px;
}
.hanmac-aggregate-metric-cell {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
width: 100%;
min-height: 18px;
font-variant-numeric: tabular-nums;
}
.hanmac-aggregate-metric-value {
display: inline-flex;
justify-content: flex-end;
min-width: 64px;
color: inherit;
font: inherit;
font-weight: 700;
line-height: 18px;
text-align: right;
font-variant-numeric: tabular-nums;
}
.hanmac-aggregate-metric-button {
all: unset;
box-sizing: border-box;
display: inline-flex;
justify-content: flex-end;
min-width: 64px;
color: inherit;
font: inherit;
font-weight: 700;
line-height: 18px;
text-align: right;
font-variant-numeric: tabular-nums;
border: 0;
border-bottom: 1px solid transparent;
border-radius: 0;
padding: 0;
background: transparent;
cursor: pointer;
box-shadow: none;
min-height: 0;
transform: none;
transition: none;
}
.hanmac-aggregate-metric-button:hover {
background: transparent;
border-bottom-color: #1f4d8f;
color: #1f4d8f;
box-shadow: none;
transform: none;
}
.hanmac-aggregate-day-badge {
display: inline-flex;
align-items: center;
justify-content: flex-end;
width: 42px;
min-height: 0;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
color: #4a5565;
font-size: 11px;
font-weight: 800;
line-height: 18px;
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
box-shadow: none;
}
.hanmac-aggregate-detail-link {
all: unset;
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 0;
padding: 0;
border: 0;
border-bottom: 1px solid transparent;
border-radius: 0;
background: transparent;
color: #1f4d8f;
font-size: 11px;
font-weight: 700;
line-height: 18px;
cursor: pointer;
box-shadow: none;
transform: none;
transition: none;
}
.hanmac-aggregate-detail-link:hover {
background: transparent;
border-bottom-color: #1f4d8f;
box-shadow: none;
transform: none;
}
.hanmac-modal-copy {
color: var(--muted);
font-size: 13px;
line-height: 1.5;
}
.hanmac-detail-list {
display: grid;
gap: 14px;
max-height: 58vh;
overflow: auto;
}
.hanmac-detail-day {
padding: 0 0 14px;
border-bottom: 1px solid #e8edf4;
}
.hanmac-detail-day:last-child {
border-bottom: none;
padding-bottom: 0;
}
.hanmac-detail-day-head {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.hanmac-detail-day-title {
font-weight: 800;
}
.hanmac-detail-badge {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0 8px;
border-radius: 999px;
background: #f4f6fa;
color: var(--muted);
font-size: 11px;
font-weight: 700;
}
.hanmac-detail-table {
width: 100%;
border-collapse: collapse;
}
.hanmac-detail-table th,
.hanmac-detail-table td {
padding: 8px 10px;
border-bottom: 1px solid #edf1f6;
text-align: left;
vertical-align: top;
font-size: 12px;
line-height: 1.45;
}
.hanmac-detail-table th {
background: #f8fafc;
font-weight: 800;
}
.hanmac-detail-table th.is-numeric,
.hanmac-detail-table td.is-numeric {
text-align: right;
font-variant-numeric: tabular-nums;
}
.hanmac-preview-hero {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
flex: 1 1 auto;
min-width: 0;
padding: 0;
border-bottom: 0;
background: transparent;
}
.hanmac-preview-hero .section-title {
flex: 0 0 auto;
margin-bottom: 0;
}
.hanmac-preview-hero h3 {
font-size: 16px;
line-height: 1.35;
white-space: nowrap;
}
.hanmac-preview-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
min-width: 0;
flex: 1 1 auto;
}
.hanmac-summary-card {
display: grid;
gap: 4px;
padding: 8px 0 10px;
border-bottom: 1px solid #e4e9f1;
background: transparent;
min-width: 0;
}
.hanmac-summary-card span {
color: var(--muted);
font-size: 12px;
line-height: 1.4;
}
.hanmac-summary-card strong {
font-size: 18px;
line-height: 1.1;
white-space: nowrap;
}
.hanmac-preview-toolbar {
display: flex;
gap: 10px;
align-items: center;
justify-content: space-between;
flex-wrap: nowrap;
white-space: nowrap;
}
.hanmac-preview-toolbar-left,
.hanmac-preview-toolbar-right {
display: inline-flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.hanmac-preview-toolbar-right {
margin-left: auto;
}
.hanmac-preview-filter-wrap {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.hanmac-preview-controls {
display: grid;
gap: 12px;
}
.hanmac-preview-toolbar select {
min-height: 38px;
padding: 0 12px;
border: 1px solid var(--line);
border-radius: 12px;
background: #ffffff;
color: var(--ink);
font: inherit;
width: 130px;
}
.hanmac-emoji-button {
width: 38px;
min-width: 38px;
min-height: 38px;
padding: 0;
font-size: 18px;
line-height: 1;
}
.hanmac-preview-toolbar input {
width: 120px;
min-height: 38px;
padding: 0 12px;
border: 1px solid var(--line);
border-radius: 12px;
background: #ffffff;
color: var(--ink);
font: inherit;
}
#hanmacPreviewValueSearch {
width: 180px;
}
#hanmacAggregateValueSearch {
width: 180px;
min-width: 120px;
flex: 0 1 180px;
}
.hanmac-collapse-button[aria-expanded="false"] {
background: #f4f6fa;
color: var(--muted);
}
.hanmac-button,
.hanmac-button:hover,
.hanmac-button:focus,
.hanmac-button:active,
.hanmac-quick-card,
.hanmac-quick-card:hover,
.hanmac-quick-card:focus,
.hanmac-filter-tab,
.hanmac-filter-tab:hover,
.hanmac-filter-tab:focus,
.hanmac-table-item,
.hanmac-table-item:hover,
.hanmac-table-item:focus,
.hanmac-aggregate-detail-link,
.hanmac-aggregate-detail-link:hover,
.hanmac-close-button,
.hanmac-close-button:hover,
.hanmac-close-button:focus {
box-shadow: none !important;
}
.hanmac-preview-shell.is-collapsed {
display: none;
}
.hanmac-preview-controls.is-collapsed {
display: none;
}
.hanmac-preview-shell {
overflow: auto;
border-top: 1px solid #e4e9f1;
border-bottom: 1px solid #e4e9f1;
border-radius: 0;
background: #ffffff;
max-height: calc(100vh - 380px);
}
.hanmac-preview-table {
width: 100%;
border-collapse: collapse;
min-width: 920px;
}
.hanmac-preview-table th,
.hanmac-preview-table td {
padding: 10px 10px;
border-bottom: 1px solid #eceff3;
text-align: left;
vertical-align: top;
font-size: 11px;
line-height: 1.5;
}
.hanmac-preview-table th {
position: sticky;
top: 0;
z-index: 1;
background: #f7f9fc;
color: var(--ink);
font-weight: 800;
white-space: nowrap;
min-width: 88px;
word-break: normal;
}
.hanmac-preview-table th.is-priority {
background: #eef4ff;
color: #173a7a;
}
.hanmac-preview-table tbody tr:nth-child(even) td {
background: #fbfcfe;
}
.hanmac-preview-table td {
word-break: break-word;
white-space: normal;
min-width: 88px;
max-width: 240px;
}
.hanmac-preview-empty {
padding: 28px 22px;
color: var(--muted);
line-height: 1.8;
}
.hanmac-modal[hidden] {
display: none;
}
.hanmac-modal {
position: fixed;
inset: 0;
z-index: 40;
display: grid;
place-items: center;
padding: 24px;
background: rgba(17, 17, 17, 0.34);
}
.hanmac-modal-card {
width: min(760px, 100%);
max-height: calc(100vh - 48px);
display: grid;
gap: 14px;
padding: 20px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.99);
box-shadow: 0 20px 50px rgba(17, 17, 17, 0.18);
overflow-y: auto;
}
#hanmacConfigModal .hanmac-modal-card {
width: min(860px, 100%);
}
.hanmac-modal-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.hanmac-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.hanmac-field {
display: grid;
gap: 6px;
}
.hanmac-field label {
color: var(--muted);
font-size: 13px;
font-weight: 700;
}
.hanmac-field input,
.hanmac-field select {
width: 100%;
min-height: 42px;
padding: 0 12px;
border: 1px solid var(--line);
border-radius: 12px;
background: #ffffff;
color: var(--ink);
font: inherit;
}
.hanmac-close-button {
width: 40px;
min-width: 40px;
min-height: 40px;
padding: 0;
border: 1px solid var(--line);
border-radius: 999px;
background: #ffffff;
color: var(--ink);
font-size: 18px;
cursor: pointer;
}
.hanmac-modal-actions {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: nowrap;
overflow-x: auto;
padding-bottom: 2px;
}
.hanmac-modal-actions .hanmac-button,
.hanmac-connection-actions {
flex: 0 0 auto;
white-space: nowrap;
}
.hanmac-connection-actions {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: nowrap;
}
.hanmac-modal-actions .hanmac-inline-status {
flex: 1 0 360px;
min-width: 360px;
max-width: none;
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
}
@media (max-width: 1080px) {
.hanmac-layout,
.hanmac-preview-hero {
grid-template-columns: 1fr;
}
.hanmac-table-list,
.hanmac-preview-shell {
max-height: none;
}
.hanmac-preview-header,
.hanmac-preview-hero {
flex-wrap: wrap;
}
.hanmac-preview-toolbar,
.hanmac-aggregate-toolbar,
.hanmac-aggregate-toolbar-left,
.hanmac-aggregate-toolbar-right,
.hanmac-preview-toolbar-left,
.hanmac-preview-toolbar-right {
flex-wrap: wrap;
}
.hanmac-aggregate-summary {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.hanmac-form-grid,
.hanmac-aggregate-summary,
.hanmac-preview-summary {
grid-template-columns: 1fr;
}
.hanmac-panel-header,
.hanmac-preview-toolbar {
align-items: stretch;
}
.hanmac-preview-filter-wrap {
flex-wrap: wrap;
}
.hanmac-inline-status {
width: 100%;
}
.hanmac-layout {
grid-template-columns: 1fr;
}
.hanmac-aggregate-toolbar {
flex-wrap: wrap;
white-space: normal;
}
#hanmacAggregateMeta {
text-align: left;
flex-basis: 100%;
}
}
</style>
{% endblock %}
{% block content %}
<section class="panel hanmac-page">
<div class="hanmac-layout">
<section class="hanmac-panel">
<div class="hanmac-panel-header">
<div class="section-title" style="margin-bottom: 0;">
<h3>테이블 목록</h3>
</div>
<button type="button" class="hanmac-button secondary" id="hanmacOpenConfigButton">접속 정보</button>
</div>
<div class="hanmac-quick-grid">
<button type="button" class="hanmac-quick-card is-active" data-focus="all">
<strong>전체</strong>
</button>
<button type="button" class="hanmac-quick-card" data-focus="yearly">
<strong>연도</strong>
</button>
<button type="button" class="hanmac-quick-card" data-focus="member">
<strong>사원</strong>
</button>
<button type="button" class="hanmac-quick-card" data-focus="project">
<strong>프로젝트</strong>
</button>
</div>
<div class="hanmac-wehago-audit">
<div class="hanmac-wehago-audit-title">
<strong>WEHAGO 검증자료</strong>
<a href="{{ hanmac_wehago_audit_sources.smarta_home_url }}" target="_blank" rel="noreferrer">SmartA</a>
</div>
<div class="hanmac-wehago-audit-list">
{% for menu in hanmac_wehago_audit_sources.menus %}
<div class="hanmac-wehago-audit-item">
<a href="{{ hanmac_wehago_audit_sources.smarta_home_url }}" target="_blank" rel="noreferrer">{{ menu.name }}{% if menu.program %} · {{ menu.program }}{% endif %}</a>
<span>{{ menu.basis }} · 메뉴 검색어: {{ menu.keyword }}</span>
</div>
{% endfor %}
</div>
<div class="hanmac-wehago-year-links" aria-label="계정별원장 연도별 바로가기">
{% for item in hanmac_wehago_audit_sources.ledger_urls %}
<a href="{{ item.url }}" target="_blank" rel="noreferrer">{{ item.year }}<br>{{ item.gisu }}기</a>
{% endfor %}
</div>
<div class="hanmac-panel-meta">
검토 계정: {{ hanmac_wehago_audit_sources.account_codes|join(", ") }}
</div>
</div>
<div class="hanmac-filter-bar">
<input id="hanmacTableSearch" type="search" placeholder="테이블 검색">
<div class="hanmac-filter-tabs">
<button type="button" class="hanmac-filter-tab is-active" data-schema-filter="all">전체</button>
<button type="button" class="hanmac-filter-tab" data-schema-filter="hanmac_manhour">hanmac_manhour</button>
<button type="button" class="hanmac-filter-tab" data-schema-filter="baron_manhour">baron_manhour</button>
<button type="button" class="hanmac-filter-tab" data-schema-filter="hanmac">hanmac</button>
</div>
</div>
<div class="hanmac-panel-meta" id="hanmacTableListMeta"></div>
<div class="hanmac-table-list" id="hanmacTableList"></div>
</section>
<section class="hanmac-panel">
<div class="hanmac-aggregate-section">
<div class="hanmac-aggregate-toolbar">
<div class="hanmac-aggregate-toolbar-left">
<span class="hanmac-panel-meta" id="hanmacAggregateMeta"></span>
<div class="hanmac-aggregate-filter-row">
<select id="hanmacAggregateValueColumn">
<option value="">전체 컬럼</option>
</select>
<input id="hanmacAggregateValueSearch" type="search" placeholder="집계 값 검색" list="hanmacAggregateValueOptions">
<datalist id="hanmacAggregateValueOptions"></datalist>
<button type="button" class="hanmac-button secondary" id="hanmacAggregateValueReset">초기화</button>
<button type="button" class="hanmac-button secondary" id="hanmacOpenHolidayButton">휴일정보</button>
<button type="button" class="hanmac-button secondary" id="hanmacOpenJointButton">합사정보</button>
<button type="button" class="hanmac-button secondary" id="hanmacOpenCenterButton">센터/총괄</button>
</div>
</div>
<div class="hanmac-aggregate-toolbar-right">
<input id="hanmacAggregateStartDate" type="date" min="1900-01-01" max="9999-12-31">
<input id="hanmacAggregateEndDate" type="date" min="1900-01-01" max="9999-12-31">
<select id="hanmacAggregateEmployment">
<option value="all">전체</option>
<option value="current">재직자</option>
<option value="retired">퇴사자</option>
</select>
<button type="button" class="hanmac-button secondary" id="hanmacAggregateLoadButton">집계 조회</button>
<button type="button" class="hanmac-button secondary" id="hanmacAggregateRebuildCacheButton">캐시 재계산</button>
<button type="button" class="hanmac-button secondary hanmac-emoji-button" id="hanmacExportAggregateButton" title="집계 엑셀 다운로드" aria-label="집계 엑셀 다운로드">📥</button>
</div>
</div>
<div class="hanmac-inline-status" id="hanmacSystemJobStatus">작업 상태 확인 전</div>
<div class="hanmac-aggregate-summary">
<div class="hanmac-aggregate-card">
<span>대상 인원</span>
<strong id="hanmacAggregateMemberCount">0</strong>
</div>
<div class="hanmac-aggregate-card">
<span>정규근로</span>
<strong id="hanmacAggregateRegularHours">0</strong>
</div>
<div class="hanmac-aggregate-card">
<span>연장근로</span>
<strong id="hanmacAggregateOvertimeHours">0</strong>
</div>
<div class="hanmac-aggregate-card">
<span>총근로</span>
<strong id="hanmacAggregateTotalHours">0</strong>
</div>
<div class="hanmac-aggregate-card">
<span>법정휴가</span>
<strong id="hanmacAggregateLeaveDays">0</strong>
</div>
<div class="hanmac-aggregate-card">
<span>프로젝트 수</span>
<strong id="hanmacAggregateProjectCount">0</strong>
</div>
</div>
<div class="hanmac-aggregate-shell">
<div class="hanmac-preview-empty" id="hanmacAggregateEmpty"></div>
<table class="hanmac-aggregate-table" id="hanmacAggregateTable" hidden>
<thead id="hanmacAggregateHead"></thead>
<tbody id="hanmacAggregateBody"></tbody>
</table>
</div>
</div>
<div class="hanmac-preview-header">
<div class="hanmac-preview-hero">
<div class="section-title" style="margin-bottom: 0;">
<h3 id="hanmacSelectedTitle">원본 테이블</h3>
</div>
<div class="hanmac-preview-summary">
<div class="hanmac-summary-card">
<span>선택 스키마</span>
<strong id="hanmacSummarySchema">-</strong>
</div>
<div class="hanmac-summary-card">
<span>행 수</span>
<strong id="hanmacSummaryRows">0</strong>
</div>
<div class="hanmac-summary-card">
<span>컬럼 수</span>
<strong id="hanmacSummaryColumns">0</strong>
</div>
<div class="hanmac-summary-card">
<span>기준 키</span>
<strong id="hanmacSummaryKey">-</strong>
</div>
</div>
<button type="button" class="hanmac-button secondary hanmac-collapse-button" id="hanmacPreviewToggleButton" aria-expanded="true">접기</button>
</div>
<div class="hanmac-preview-controls" id="hanmacPreviewControls">
<div class="hanmac-preview-toolbar">
<div class="hanmac-preview-toolbar-left">
<div class="hanmac-preview-filter-wrap">
<select id="hanmacPreviewValueColumn">
<option value="">전체 컬럼</option>
</select>
<input id="hanmacPreviewValueSearch" type="search" placeholder="표 값 검색" list="hanmacPreviewValueOptions">
<datalist id="hanmacPreviewValueOptions"></datalist>
<button type="button" class="hanmac-button secondary" id="hanmacPreviewValueReset">초기화</button>
</div>
<span class="hanmac-panel-meta" id="hanmacPreviewMeta"></span>
</div>
<div class="hanmac-preview-toolbar-right">
<button type="button" class="hanmac-button secondary" id="hanmacPreviewRebuildCacheButton">캐시 재계산</button>
<button type="button" class="hanmac-button secondary" id="hanmacPreviewPrevButton">이전</button>
<button type="button" class="hanmac-button secondary" id="hanmacPreviewNextButton">다음</button>
<input id="hanmacPreviewLimit" type="number" min="1" max="300" value="100">
<button type="button" class="hanmac-button secondary" id="hanmacRefreshPreviewButton">다시 조회</button>
<button type="button" class="hanmac-button secondary hanmac-emoji-button" id="hanmacExportPreviewButton" title="엑셀 다운로드" aria-label="엑셀 다운로드">📥</button>
</div>
</div>
<div class="hanmac-preview-shell">
<div class="hanmac-preview-empty" id="hanmacPreviewEmpty"></div>
<table class="hanmac-preview-table" id="hanmacPreviewTable" hidden>
<thead id="hanmacPreviewHead"></thead>
<tbody id="hanmacPreviewBody"></tbody>
</table>
</div>
</div>
</div>
</section>
</div>
</section>
<div class="hanmac-modal" id="hanmacConfigModal" hidden>
<div class="hanmac-modal-card">
<div class="hanmac-modal-head">
<div class="section-title" style="margin-bottom: 0;">
<h2>서버 접속 정보</h2>
</div>
<div class="hanmac-connection-tabs" role="tablist" aria-label="서버 접속 정보 구분">
<button type="button" class="hanmac-connection-tab is-active" data-connection-tab="work" role="tab" aria-selected="true">근무DB</button>
<button type="button" class="hanmac-connection-tab" data-connection-tab="erp" role="tab" aria-selected="false">관리DB</button>
</div>
<button type="button" class="hanmac-close-button" id="hanmacCloseConfigButton" aria-label="닫기">×</button>
</div>
<form id="hanmacConnectionForm">
<section class="hanmac-connection-section" data-connection-panel="work">
<div class="hanmac-connection-section-title">
<strong>근무 DB 접속</strong>
<span>hanmac DB_external 조회와 집계에 사용하는 MySQL 계정입니다.</span>
</div>
<div class="hanmac-form-grid">
<div class="hanmac-field">
<label for="hanmac-host">서버 IP</label>
<input id="hanmac-host" name="host" type="text" value="172.16.42.111" autocomplete="off">
</div>
<div class="hanmac-field">
<label for="hanmac-port">포트</label>
<input id="hanmac-port" name="port" type="text" value="3306" autocomplete="off">
</div>
<div class="hanmac-field">
<label for="hanmac-user">아이디</label>
<input id="hanmac-user" name="user" type="text" value="root" autocomplete="off">
</div>
<div class="hanmac-field">
<label for="hanmac-password">비밀번호</label>
<input id="hanmac-password" name="password" type="password" value="" placeholder="여기에 비밀번호 입력">
</div>
<div class="hanmac-field">
<label for="hanmac-database">기본 연결 DB</label>
<select id="hanmac-database" name="database">
<option value="hanmac_manhour">hanmac_manhour</option>
<option value="baron_manhour">baron_manhour</option>
<option value="hanmac">hanmac</option>
</select>
</div>
<div class="hanmac-field">
<label for="hanmac-engine">DB 종류</label>
<input id="hanmac-engine" type="text" value="MySQL" readonly>
</div>
</div>
</section>
<section class="hanmac-connection-section is-erp" data-connection-panel="erp" hidden>
<div class="hanmac-connection-section-title">
<strong>관리DB / 관리 ERP 접속</strong>
<span>입력한 아이디와 비밀번호는 현재 브라우저 탭이 열려 있는 동안만 저장됩니다.</span>
</div>
<div class="hanmac-erp-server-note">
웹 주소: http://erp.hanmaceng.co.kr/planning_mng/<br>
MySQL 직접 경로: erp.hanmaceng.co.kr:3306 · 서버: MySQL 5.1.41-community-log
</div>
<div class="hanmac-form-grid">
<div class="hanmac-field">
<label for="hanmac-erp-host">관리DB 서버</label>
<input id="hanmac-erp-host" type="text" value="erp.hanmaceng.co.kr" readonly>
</div>
<div class="hanmac-field">
<label for="hanmac-erp-port">MySQL 포트</label>
<input id="hanmac-erp-port" type="text" value="3306" readonly>
</div>
<div class="hanmac-field">
<label for="hanmac-erp-engine">DB 서버</label>
<input id="hanmac-erp-engine" type="text" value="MySQL 5.1.41-community-log" readonly>
</div>
<div class="hanmac-field">
<label for="hanmac-erp-user">관리 ERP 아이디</label>
<input id="hanmac-erp-user" name="erp_user" type="text" value="" autocomplete="username" placeholder="관리 ERP 아이디 입력">
</div>
<div class="hanmac-field">
<label for="hanmac-erp-password">관리 ERP 비밀번호</label>
<input id="hanmac-erp-password" name="erp_password" type="password" value="" autocomplete="current-password" placeholder="관리 ERP 비밀번호 입력">
</div>
<div class="hanmac-field">
<label for="hanmac-erp-db-user">관리DB MySQL 아이디</label>
<input id="hanmac-erp-db-user" name="erp_db_user" type="text" value="" autocomplete="off" placeholder="DB 계정이 ERP 계정과 다르면 입력">
</div>
<div class="hanmac-field">
<label for="hanmac-erp-db-password">관리DB MySQL 비밀번호</label>
<input id="hanmac-erp-db-password" name="erp_db_password" type="password" value="" autocomplete="off" placeholder="MySQL 직접 접속 비밀번호 입력">
</div>
</div>
</section>
</form>
<div class="hanmac-modal-actions">
<div class="hanmac-connection-actions" data-connection-actions="work">
<button type="button" class="hanmac-button secondary" id="hanmacTestConnectionButton">연결 확인</button>
<button type="button" class="hanmac-button" id="hanmacLoadTablesButton">테이블 목록 보기</button>
<button type="button" class="hanmac-button secondary" id="hanmacLoadGradeCodesButton">직급 코드 조회</button>
</div>
<div class="hanmac-connection-actions" data-connection-actions="erp" hidden>
<button type="button" class="hanmac-button secondary" id="hanmacTestErpButton">관리 ERP·MySQL 확인</button>
<button type="button" class="hanmac-button" id="hanmacDiscoverSatisBudgetButton">Satis 예산 후보 탐색</button>
<button type="button" class="hanmac-button" id="hanmacSyncSatisBudgetButton">Satis 예산 금액 저장</button>
<button type="button" class="hanmac-button" id="hanmacNormalizeSatisBudgetButton">Satis 예산 정규화</button>
<button type="button" class="hanmac-button" id="hanmacProjectSatisBudgetButton">승인 최신 차수 반영</button>
<button type="button" class="hanmac-button" id="hanmacRunSatisBudgetFullSyncButton">Satis 예산 전체 실행</button>
<button type="button" class="hanmac-button" id="hanmacCollectSatisBudgetWebButton">Satis 웹로그인 예산 수집</button>
</div>
<div class="hanmac-inline-status" id="hanmacConnectionStatus"></div>
</div>
<div class="hanmac-grade-panel" id="hanmacGradeCodesPanel"></div>
</div>
</div>
<div class="hanmac-modal" id="hanmacMultiEntryModal" hidden>
<div class="hanmac-modal-card">
<div class="hanmac-modal-head">
<div class="section-title" style="margin-bottom: 0;">
<h2 id="hanmacMultiEntryTitle">중복 근무 상세</h2>
</div>
<button type="button" class="hanmac-close-button" id="hanmacCloseMultiEntryButton" aria-label="닫기">×</button>
</div>
<div class="hanmac-modal-copy" id="hanmacMultiEntryMeta"></div>
<div class="hanmac-detail-list" id="hanmacMultiEntryBody"></div>
</div>
</div>
<div class="hanmac-modal" id="hanmacHolidayModal" hidden>
<div class="hanmac-modal-card">
<div class="hanmac-modal-head">
<div class="section-title" style="margin-bottom: 0;">
<h2>휴일정보</h2>
</div>
<button type="button" class="hanmac-close-button" id="hanmacCloseHolidayButton" aria-label="닫기">×</button>
</div>
<div class="hanmac-holiday-panel">
<div class="hanmac-holiday-toolbar">
<span class="hanmac-panel-meta" id="hanmacHolidayMeta">휴일 기준을 불러오는 중입니다.</span>
<div class="hanmac-holiday-form">
<input id="hanmacHolidayDate" type="date" min="1900-01-01" max="9999-12-31" aria-label="휴일 날짜">
<select id="hanmacHolidayType" aria-label="휴일 구분">
<option value="legal">법정공휴일</option>
<option value="substitute">대체공휴일</option>
<option value="company">선거/기타휴무</option>
</select>
<input id="hanmacHolidayName" type="text" placeholder="휴일명" aria-label="휴일명">
<button type="button" class="hanmac-button secondary" id="hanmacHolidaySaveButton">휴일 저장</button>
</div>
</div>
<div class="hanmac-holiday-list" id="hanmacHolidayList"></div>
</div>
</div>
</div>
<div class="hanmac-modal" id="hanmacJointModal" hidden>
<div class="hanmac-modal-card">
<div class="hanmac-modal-head">
<div class="section-title" style="margin-bottom: 0;">
<h2>합사정보</h2>
</div>
<button type="button" class="hanmac-close-button" id="hanmacCloseJointButton" aria-label="닫기">×</button>
</div>
<div class="hanmac-center-panel" id="hanmacJointPanel" hidden>
<div class="hanmac-center-head">
<span class="hanmac-panel-meta" id="hanmacJointMeta"></span>
</div>
<div class="hanmac-center-shell">
<table class="hanmac-center-table">
<thead>
<tr>
<th>사번</th>
<th>이름</th>
<th>직급</th>
<th>입사일</th>
<th>퇴사일</th>
<th>내용</th>
<th>일자</th>
</tr>
</thead>
<tbody id="hanmacJointBody"></tbody>
</table>
</div>
</div>
<div class="hanmac-preview-empty" id="hanmacJointEmpty" hidden></div>
</div>
</div>
<div class="hanmac-modal" id="hanmacCenterModal" hidden>
<div class="hanmac-modal-card">
<div class="hanmac-modal-head">
<div class="section-title" style="margin-bottom: 0;">
<h2>센터/총괄</h2>
</div>
<button type="button" class="hanmac-close-button" id="hanmacCloseCenterButton" aria-label="닫기">×</button>
</div>
<div class="hanmac-center-panel" id="hanmacCenterPanel" hidden>
<div class="hanmac-center-head">
<span class="hanmac-panel-meta" id="hanmacCenterMeta"></span>
<button type="button" class="hanmac-button secondary" id="hanmacCenterApplyButton">선택 복구 적용</button>
</div>
<div class="hanmac-center-shell">
<table class="hanmac-center-table">
<thead>
<tr>
<th>복구</th>
<th>사번</th>
<th>센터 사번</th>
<th>이름</th>
<th>부서</th>
<th>매칭</th>
<th>상태</th>
</tr>
</thead>
<tbody id="hanmacCenterBody"></tbody>
</table>
</div>
</div>
<div class="hanmac-preview-empty" id="hanmacCenterEmpty">집계를 조회하면 센터/총괄 명단이 표시됩니다.</div>
</div>
</div>
{% endblock %}
{% block script %}
<script>
(() => {
const form = document.getElementById("hanmacConnectionForm");
const modal = document.getElementById("hanmacConfigModal");
const openConfigButton = document.getElementById("hanmacOpenConfigButton");
const closeConfigButton = document.getElementById("hanmacCloseConfigButton");
const statusBox = document.getElementById("hanmacConnectionStatus");
const testButton = document.getElementById("hanmacTestConnectionButton");
const testErpButton = document.getElementById("hanmacTestErpButton");
const discoverSatisBudgetButton = document.getElementById("hanmacDiscoverSatisBudgetButton");
const syncSatisBudgetButton = document.getElementById("hanmacSyncSatisBudgetButton");
const normalizeSatisBudgetButton = document.getElementById("hanmacNormalizeSatisBudgetButton");
const projectSatisBudgetButton = document.getElementById("hanmacProjectSatisBudgetButton");
const runSatisBudgetFullSyncButton = document.getElementById("hanmacRunSatisBudgetFullSyncButton");
const collectSatisBudgetWebButton = document.getElementById("hanmacCollectSatisBudgetWebButton");
const loadTablesButton = document.getElementById("hanmacLoadTablesButton");
const loadGradeCodesButton = document.getElementById("hanmacLoadGradeCodesButton");
const gradeCodesPanel = document.getElementById("hanmacGradeCodesPanel");
const connectionTabs = Array.from(document.querySelectorAll("[data-connection-tab]"));
const connectionPanels = Array.from(document.querySelectorAll("[data-connection-panel]"));
const connectionActions = Array.from(document.querySelectorAll("[data-connection-actions]"));
const tableListMeta = document.getElementById("hanmacTableListMeta");
const tableList = document.getElementById("hanmacTableList");
const tableSearch = document.getElementById("hanmacTableSearch");
const aggregateStartDate = document.getElementById("hanmacAggregateStartDate");
const aggregateEndDate = document.getElementById("hanmacAggregateEndDate");
const aggregateEmployment = document.getElementById("hanmacAggregateEmployment");
const aggregateLoadButton = document.getElementById("hanmacAggregateLoadButton");
const aggregateRebuildCacheButton = document.getElementById("hanmacAggregateRebuildCacheButton");
const systemJobStatus = document.getElementById("hanmacSystemJobStatus");
const aggregateMeta = document.getElementById("hanmacAggregateMeta");
const aggregateMemberCount = document.getElementById("hanmacAggregateMemberCount");
const aggregateRegularHours = document.getElementById("hanmacAggregateRegularHours");
const aggregateOvertimeHours = document.getElementById("hanmacAggregateOvertimeHours");
const aggregateTotalHours = document.getElementById("hanmacAggregateTotalHours");
const aggregateLeaveDays = document.getElementById("hanmacAggregateLeaveDays");
const aggregateProjectCount = document.getElementById("hanmacAggregateProjectCount");
const jointPanel = document.getElementById("hanmacJointPanel");
const jointMeta = document.getElementById("hanmacJointMeta");
const jointBody = document.getElementById("hanmacJointBody");
const jointEmpty = document.getElementById("hanmacJointEmpty");
const jointModal = document.getElementById("hanmacJointModal");
const openJointButton = document.getElementById("hanmacOpenJointButton");
const closeJointButton = document.getElementById("hanmacCloseJointButton");
const centerPanel = document.getElementById("hanmacCenterPanel");
const centerMeta = document.getElementById("hanmacCenterMeta");
const centerApplyButton = document.getElementById("hanmacCenterApplyButton");
const centerBody = document.getElementById("hanmacCenterBody");
const centerEmpty = document.getElementById("hanmacCenterEmpty");
const centerModal = document.getElementById("hanmacCenterModal");
const openCenterButton = document.getElementById("hanmacOpenCenterButton");
const closeCenterButton = document.getElementById("hanmacCloseCenterButton");
const aggregateEmpty = document.getElementById("hanmacAggregateEmpty");
const aggregateTable = document.getElementById("hanmacAggregateTable");
const aggregateHead = document.getElementById("hanmacAggregateHead");
const aggregateBody = document.getElementById("hanmacAggregateBody");
const aggregateValueColumn = document.getElementById("hanmacAggregateValueColumn");
const aggregateValueSearch = document.getElementById("hanmacAggregateValueSearch");
const aggregateValueOptions = document.getElementById("hanmacAggregateValueOptions");
const aggregateValueReset = document.getElementById("hanmacAggregateValueReset");
const openHolidayButton = document.getElementById("hanmacOpenHolidayButton");
const exportAggregateButton = document.getElementById("hanmacExportAggregateButton");
const holidayModal = document.getElementById("hanmacHolidayModal");
const closeHolidayButton = document.getElementById("hanmacCloseHolidayButton");
const holidayMeta = document.getElementById("hanmacHolidayMeta");
const holidayList = document.getElementById("hanmacHolidayList");
const holidayDateInput = document.getElementById("hanmacHolidayDate");
const holidayTypeSelect = document.getElementById("hanmacHolidayType");
const holidayNameInput = document.getElementById("hanmacHolidayName");
const holidaySaveButton = document.getElementById("hanmacHolidaySaveButton");
const multiEntryModal = document.getElementById("hanmacMultiEntryModal");
const closeMultiEntryButton = document.getElementById("hanmacCloseMultiEntryButton");
const multiEntryTitle = document.getElementById("hanmacMultiEntryTitle");
const multiEntryMeta = document.getElementById("hanmacMultiEntryMeta");
const multiEntryBody = document.getElementById("hanmacMultiEntryBody");
const previewValueColumn = document.getElementById("hanmacPreviewValueColumn");
const previewValueSearch = document.getElementById("hanmacPreviewValueSearch");
const previewValueOptions = document.getElementById("hanmacPreviewValueOptions");
const previewValueReset = document.getElementById("hanmacPreviewValueReset");
const previewLimit = document.getElementById("hanmacPreviewLimit");
const refreshPreviewButton = document.getElementById("hanmacRefreshPreviewButton");
const previewRebuildCacheButton = document.getElementById("hanmacPreviewRebuildCacheButton");
const previewPrevButton = document.getElementById("hanmacPreviewPrevButton");
const previewNextButton = document.getElementById("hanmacPreviewNextButton");
const exportPreviewButton = document.getElementById("hanmacExportPreviewButton");
const previewMeta = document.getElementById("hanmacPreviewMeta");
const previewEmpty = document.getElementById("hanmacPreviewEmpty");
const previewControls = document.getElementById("hanmacPreviewControls");
const previewTable = document.getElementById("hanmacPreviewTable");
const previewHead = document.getElementById("hanmacPreviewHead");
const previewBody = document.getElementById("hanmacPreviewBody");
const previewToggleButton = document.getElementById("hanmacPreviewToggleButton");
const selectedTitle = document.getElementById("hanmacSelectedTitle");
const summarySchema = document.getElementById("hanmacSummarySchema");
const summaryRows = document.getElementById("hanmacSummaryRows");
const summaryColumns = document.getElementById("hanmacSummaryColumns");
const summaryKey = document.getElementById("hanmacSummaryKey");
const schemaTabs = Array.from(document.querySelectorAll("[data-schema-filter]"));
const focusButtons = Array.from(document.querySelectorAll("[data-focus]"));
if (!form || !modal || !openConfigButton || !closeConfigButton || !statusBox || !testButton || !loadTablesButton || !loadGradeCodesButton || !gradeCodesPanel || !tableListMeta || !tableList || !tableSearch || !aggregateStartDate || !aggregateEndDate || !aggregateEmployment || !aggregateLoadButton || !aggregateRebuildCacheButton || !systemJobStatus || !aggregateMeta || !aggregateMemberCount || !aggregateRegularHours || !aggregateOvertimeHours || !aggregateTotalHours || !aggregateLeaveDays || !aggregateProjectCount || !jointPanel || !jointMeta || !jointBody || !jointEmpty || !jointModal || !openJointButton || !closeJointButton || !centerPanel || !centerMeta || !centerApplyButton || !centerBody || !centerEmpty || !centerModal || !openCenterButton || !closeCenterButton || !aggregateEmpty || !aggregateTable || !aggregateHead || !aggregateBody || !aggregateValueColumn || !aggregateValueSearch || !aggregateValueOptions || !aggregateValueReset || !openHolidayButton || !holidayModal || !closeHolidayButton || !exportAggregateButton || !multiEntryModal || !closeMultiEntryButton || !multiEntryTitle || !multiEntryMeta || !multiEntryBody || !previewValueColumn || !previewValueSearch || !previewValueOptions || !previewValueReset || !previewLimit || !refreshPreviewButton || !previewRebuildCacheButton || !previewPrevButton || !previewNextButton || !exportPreviewButton || !previewMeta || !previewEmpty || !previewControls || !previewTable || !previewHead || !previewBody || !previewToggleButton || !selectedTitle || !summarySchema || !summaryRows || !summaryColumns || !summaryKey) return;
let allTables = [];
let visibleTables = [];
let selectedTable = "";
let selectedSchema = "";
let schemaFilter = "all";
let focusFilter = "all";
let currentPreviewColumns = [];
let currentPreviewRows = [];
let currentPreviewAllRows = [];
let currentPreviewCursor = "";
let currentPreviewNextCursor = "";
let currentPreviewCursorHistory = [];
let currentPreviewExportJobKey = "";
let currentAggregateExportJobKey = "";
let systemJobPollTimer = null;
let currentAggregateRows = [];
let currentAggregateAllRows = [];
let currentCenterMembers = [];
let currentJointMembers = [];
let restoredCenterMemberNos = new Set();
let currentAggregateColumns = [];
let currentAggregateView = "member";
let currentAggregateSort = { key: "", direction: "desc" };
let currentAggregateResultKey = "";
let currentAggregateRenderSignature = "";
let aggregateInFlightKey = "";
let aggregateLoadPromise = null;
let aggregateRequestSeq = 0;
const credentialStorageKey = "hanmac-db-external-credentials-v1";
const erpCredentialStorageKey = "hanmac-management-erp-session-credentials-v1";
const aggregateStateStorageKey = "hanmac-db-external-aggregate-state-v1";
const jointMembersFallbackUrl = "/static/hanmac-joint-members-cache.json";
const priorityTableNames = ["dallyproject_tbl", "dallyproject_addwork_tbl", "member_tbl", "project_tbl", "worker_tardy_tbl"];
const focusColumnKeywords = {
all: [],
yearly: ["date", "time", "year", "month", "entry", "leave", "over", "work", "holiday", "tardy"],
member: ["member", "user", "emp", "name", "dept", "team", "worker"],
project: ["project", "job", "code", "key", "pj"],
};
const tableProfiles = {
dallyproject_tbl: {
key: "EntryDate",
},
dallyproject_addwork_tbl: {
key: "MemberNo",
},
member_tbl: {
key: "MemberNo",
},
project_tbl: {
key: "project_code",
},
worker_tardy_tbl: {
key: "work_date",
},
};
const columnLabelMap = {
MemberNo: "사번",
MemberName: "사원명",
Name: "이름",
EntryDate: "입사일",
LeaveDate: "퇴사일",
EntryTime: "출근시간",
LeaveTime: "퇴근시간",
OverTime: "연장근로",
HolidayTime: "휴일근로",
WorkTime: "정규근로",
WorkHour: "근로시간",
ProjectCode: "프로젝트코드",
ProjectKey: "프로젝트키",
ProjectName: "프로젝트명",
DeptName: "부서명",
DeptCode: "부서코드",
};
const selectConnectionTab = (tabName) => {
const selectedTab = tabName === "erp" ? "erp" : "work";
connectionTabs.forEach((button) => {
const active = button.dataset.connectionTab === selectedTab;
button.classList.toggle("is-active", active);
button.setAttribute("aria-selected", active ? "true" : "false");
});
connectionPanels.forEach((panel) => {
panel.hidden = panel.dataset.connectionPanel !== selectedTab;
});
connectionActions.forEach((actions) => {
actions.hidden = actions.dataset.connectionActions !== selectedTab;
});
gradeCodesPanel.classList.toggle("is-open", selectedTab === "work" && gradeCodesPanel.innerHTML.trim() !== "");
setStatus("");
};
const openModal = () => {
modal.hidden = false;
};
const closeModal = () => {
modal.hidden = true;
};
const openHolidayModal = () => {
holidayModal.hidden = false;
loadHolidays();
};
const closeHolidayModal = () => {
holidayModal.hidden = true;
};
const openJointModal = async () => {
jointModal.hidden = false;
jointEmpty.hidden = true;
if (!currentJointMembers.length) {
jointPanel.hidden = false;
jointMeta.textContent = "합사 정보 불러오는 중...";
jointBody.innerHTML = "";
const payload = await loadAggregate();
if (!currentJointMembers.length) {
await loadJointMembersFallback();
}
if (!payload && !currentJointMembers.length) {
jointMeta.textContent = "합사 정보를 불러오지 못했습니다.";
}
}
};
const closeJointModal = () => {
jointModal.hidden = true;
};
const openCenterModal = () => {
centerModal.hidden = false;
};
const closeCenterModal = () => {
centerModal.hidden = true;
};
const closeMultiEntryModal = () => {
multiEntryModal.hidden = true;
};
const setStatus = (message, tone = "") => {
statusBox.textContent = message;
statusBox.classList.remove("is-success", "is-error");
if (tone === "success") statusBox.classList.add("is-success");
if (tone === "error") statusBox.classList.add("is-error");
};
const getPayload = () => {
const formData = new FormData(form);
return {
host: String(formData.get("host") || "").trim(),
port: String(formData.get("port") || "").trim(),
user: String(formData.get("user") || "").trim(),
password: String(formData.get("password") || ""),
database: String(formData.get("database") || "").trim(),
};
};
const getErpPayload = () => {
const formData = new FormData(form);
return {
erp_user: String(formData.get("erp_user") || "").trim(),
erp_password: String(formData.get("erp_password") || ""),
erp_db_user: String(formData.get("erp_db_user") || "").trim(),
erp_db_password: String(formData.get("erp_db_password") || ""),
};
};
const saveErpCredentials = () => {
try {
window.sessionStorage.setItem(erpCredentialStorageKey, JSON.stringify(getErpPayload()));
} catch (_error) {
// Keep the form usable when session storage is unavailable.
}
};
const restoreErpCredentials = () => {
try {
const raw = window.sessionStorage.getItem(erpCredentialStorageKey);
if (!raw) return false;
const saved = JSON.parse(raw);
["erp_user", "erp_password", "erp_db_user", "erp_db_password"].forEach((key) => {
const field = form.elements.namedItem(key);
if (field && key in saved) field.value = String(saved[key] || "");
});
return Boolean(saved.erp_user || saved.erp_password || saved.erp_db_user || saved.erp_db_password);
} catch (_error) {
return false;
}
};
const fetchJson = async (url, options = {}) => {
const response = await fetch(url, {
...options,
cache: "no-store",
credentials: "same-origin",
headers: {
"Accept": "application/json",
"Cache-Control": "no-cache",
"X-Requested-With": "XMLHttpRequest",
...(options.headers || {}),
},
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload?.error || payload?.ok === false) {
throw new Error(payload?.error || payload?.message || `HTTP ${response.status}`);
}
return payload;
};
const describeSystemJob = (job) => {
if (!job) return { text: "최근 작업 없음", tone: "" };
const status = String(job.status || "");
const message = job.error_message || job.message || "";
const type = String(job.job_type || "").includes("aggregate") ? "집계" : "미리보기";
if (status === "queued") return { text: `${type} 캐시 대기 중`, tone: "" };
if (status === "running") return { text: `${type} 캐시 계산 중... ${message}`.trim(), tone: "" };
if (status === "done") return { text: `${type} 캐시 계산 완료`, tone: "success" };
if (status === "failed") return { text: `${type} 캐시 실패: ${message || "오류"}`, tone: "error" };
return { text: `${type} ${message || status || "작업 상태 확인 전"}`.trim(), tone: "" };
};
const renderSystemJob = (job) => {
const view = describeSystemJob(job);
systemJobStatus.textContent = view.text;
systemJobStatus.classList.remove("is-success", "is-error");
if (view.tone === "success") systemJobStatus.classList.add("is-success");
if (view.tone === "error") systemJobStatus.classList.add("is-error");
const busy = ["queued", "running"].includes(String(job?.status || ""));
aggregateRebuildCacheButton.disabled = busy;
previewRebuildCacheButton.disabled = busy;
};
const pollSystemJob = (jobId, afterDone) => {
if (!jobId) return;
if (systemJobPollTimer) window.clearInterval(systemJobPollTimer);
systemJobPollTimer = window.setInterval(async () => {
try {
const payload = await fetchJson(`/api/system-jobs/${encodeURIComponent(jobId)}`);
const job = payload.job || null;
renderSystemJob(job);
if (!job || ["done", "failed", "cancelled"].includes(String(job.status || ""))) {
window.clearInterval(systemJobPollTimer);
systemJobPollTimer = null;
if (job && job.status === "done" && typeof afterDone === "function") {
await afterDone();
}
}
} catch (_error) {
// Keep the page usable during transient connection delays.
}
}, 2500);
};
const loadLatestSystemJob = async () => {
try {
const payload = await fetchJson("/api/system-jobs/latest?page_key=hanmac_browser");
const job = payload.job || null;
renderSystemJob(job);
if (job && ["queued", "running"].includes(String(job.status || ""))) {
pollSystemJob(job.id);
}
} catch (_error) {
systemJobStatus.textContent = "작업 상태 조회 실패";
systemJobStatus.classList.add("is-error");
}
};
const normalizeDateYear = (input) => {
const rawValue = String(input?.value || "");
if (!rawValue) return;
const match = rawValue.match(/^(\d+)-(\d{2})-(\d{2})$/);
if (!match) return;
let [, year, month, day] = match;
if (year.length > 4) {
input.value = `${year.slice(0, 4)}-${month}-${day}`;
}
};
const saveCredentials = () => {
try {
window.localStorage.setItem(credentialStorageKey, JSON.stringify(getPayload()));
} catch (error) {
// ignore storage failure
}
};
const restoreCredentials = () => {
try {
const raw = window.localStorage.getItem(credentialStorageKey);
if (!raw) return false;
const saved = JSON.parse(raw);
if (!saved || typeof saved !== "object") return false;
["host", "port", "user", "password", "database"].forEach((key) => {
const field = form.elements.namedItem(key);
if (field && key in saved) {
field.value = String(saved[key] || "");
}
});
return Boolean(saved.password);
} catch (error) {
return false;
}
};
const getTableProfile = (item) => {
const profile = tableProfiles[item.name] || {};
return {
key: profile.key || "-",
};
};
const getColumnLabel = (column) => {
return columnLabelMap[String(column)] || String(column);
};
const setPreviewExportBusy = (busy) => {
exportPreviewButton.disabled = busy;
exportPreviewButton.dataset.loading = busy ? "true" : "false";
exportPreviewButton.title = busy ? "파일 준비 중입니다..." : "엑셀 다운로드";
exportPreviewButton.setAttribute("aria-label", busy ? "엑셀 준비 중" : "엑셀 다운로드");
};
const setAggregateExportBusy = (busy) => {
exportAggregateButton.disabled = busy;
exportAggregateButton.dataset.loading = busy ? "true" : "false";
exportAggregateButton.title = busy ? "집계 파일 준비 중입니다..." : "집계 엑셀 다운로드";
exportAggregateButton.setAttribute("aria-label", busy ? "집계 엑셀 준비 중" : "집계 엑셀 다운로드");
};
const pollPreviewExportJob = async (jobKey) => {
currentPreviewExportJobKey = jobKey;
for (let attempt = 0; attempt < 240; attempt += 1) {
const response = await fetch(`/hanmac-browser/api/preview-export-jobs/${encodeURIComponent(jobKey)}`);
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || "파일 준비 상태를 확인하지 못했습니다.");
}
if (payload.state === "ready" && payload.download_url) {
setPreviewExportBusy(false);
currentPreviewExportJobKey = "";
window.location.assign(payload.download_url);
return;
}
if (payload.state === "failed") {
setPreviewExportBusy(false);
currentPreviewExportJobKey = "";
throw new Error(payload.error_message || "파일 준비 중 오류가 발생했습니다.");
}
await new Promise((resolve) => window.setTimeout(resolve, 1500));
}
setPreviewExportBusy(false);
currentPreviewExportJobKey = "";
throw new Error("파일 준비 시간이 길어지고 있습니다. 잠시 후 다시 시도해주세요.");
};
const exportPreviewToExcel = async () => {
if (!selectedTable || !selectedSchema || !currentPreviewColumns.length) return;
setPreviewExportBusy(true);
try {
const response = await fetch("/hanmac-browser/api/preview-export-jobs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...getPayload(),
schema: selectedSchema,
table: selectedTable,
limit: previewLimit.value,
cursor: currentPreviewCursor,
value_column: previewValueColumn.value,
value_search: previewValueSearch.value,
}),
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || "파일 준비 요청에 실패했습니다.");
}
if (payload.state === "ready" && payload.download_url) {
setPreviewExportBusy(false);
window.location.assign(payload.download_url);
return;
}
await pollPreviewExportJob(payload.job_key || "");
} catch (error) {
setPreviewExportBusy(false);
alert(error.message || "엑셀 다운로드 준비 중 오류가 발생했습니다.");
}
};
const pollAggregateExportJob = async (jobKey) => {
currentAggregateExportJobKey = jobKey;
for (let attempt = 0; attempt < 240; attempt += 1) {
const response = await fetch(`/hanmac-browser/api/preview-export-jobs/${encodeURIComponent(jobKey)}`);
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || "집계 파일 준비 상태를 확인하지 못했습니다.");
}
if (payload.state === "ready" && payload.download_url) {
setAggregateExportBusy(false);
currentAggregateExportJobKey = "";
window.location.assign(payload.download_url);
return;
}
if (payload.state === "failed") {
setAggregateExportBusy(false);
currentAggregateExportJobKey = "";
throw new Error(payload.error_message || "집계 파일 준비 중 오류가 발생했습니다.");
}
await new Promise((resolve) => window.setTimeout(resolve, 1500));
}
setAggregateExportBusy(false);
currentAggregateExportJobKey = "";
throw new Error("집계 파일 준비 시간이 길어지고 있습니다. 잠시 후 다시 시도해주세요.");
};
const exportAggregateToExcel = async () => {
setAggregateExportBusy(true);
try {
const response = await fetch("/hanmac-browser/api/aggregate-export-jobs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...getPayload(),
start_date: aggregateStartDate.value,
end_date: aggregateEndDate.value,
employment: aggregateEmployment.value,
view: currentAggregateView,
include_center_member_nos: getRestoredCenterMemberNos(),
value_column: aggregateValueColumn.value,
value_search: aggregateValueSearch.value,
sort_key: currentAggregateSort.key,
sort_direction: currentAggregateSort.direction,
}),
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || "집계 파일 준비 요청에 실패했습니다.");
}
if (payload.state === "ready" && payload.download_url) {
setAggregateExportBusy(false);
window.location.assign(payload.download_url);
return;
}
await pollAggregateExportJob(payload.job_key || "");
} catch (error) {
setAggregateExportBusy(false);
alert(error.message || "집계 엑셀 다운로드 준비 중 오류가 발생했습니다.");
}
};
const getFocusTitle = () => {
if (focusFilter === "yearly") return "연도";
if (focusFilter === "member") return "사원";
if (focusFilter === "project") return "프로젝트";
return "전체";
};
const getAggregateView = () => {
return focusFilter === "project" ? "project" : "member";
};
const prioritizeColumns = (columns) => {
const keywords = focusColumnKeywords[focusFilter] || [];
const scored = [...columns].map((column, index) => {
const normalized = String(column || "").toLowerCase();
const hitIndex = keywords.findIndex((keyword) => normalized.includes(keyword));
return {
column,
index,
score: hitIndex === -1 ? 999 : hitIndex,
};
});
scored.sort((a, b) => a.score - b.score || a.index - b.index);
return scored.map((item) => item.column);
};
const isPriorityColumn = (column) => {
const normalized = String(column || "").toLowerCase();
return (focusColumnKeywords[focusFilter] || []).some((keyword) => normalized.includes(keyword));
};
const isMatchFocus = (item) => {
const joined = `${item.schema} ${item.name}`.toLowerCase();
if (focusFilter === "all") return true;
if (focusFilter === "yearly") return joined.includes("dallyproject") || joined.includes("tardy") || item.schema === "hanmac_manhour" || item.schema === "baron_manhour";
if (focusFilter === "member") return joined.includes("member") || joined.includes("worker") || joined.includes("user") || joined.includes("tardy");
if (focusFilter === "project") return joined.includes("project") || joined.includes("dallyproject");
return true;
};
const getSchemaPriority = (schema) => {
if (schema === "hanmac_manhour") return 0;
if (schema === "baron_manhour") return 1;
if (schema === "hanmac") return 2;
return 9;
};
const getVisibleTables = () => {
const keyword = String(tableSearch.value || "").trim().toLowerCase();
return allTables
.filter((item) => schemaFilter === "all" || item.schema === schemaFilter)
.filter((item) => isMatchFocus(item))
.filter((item) => {
if (!keyword) return true;
const profile = getTableProfile(item);
return `${item.schema} ${item.name} ${profile.key}`.toLowerCase().includes(keyword);
})
.sort((a, b) => {
const aSchemaPriority = getSchemaPriority(a.schema);
const bSchemaPriority = getSchemaPriority(b.schema);
const aPriority = priorityTableNames.indexOf(a.name);
const bPriority = priorityTableNames.indexOf(b.name);
const aFocusBoost = focusFilter === "yearly" && a.name.includes("dallyproject") ? -1 : 0;
const bFocusBoost = focusFilter === "yearly" && b.name.includes("dallyproject") ? -1 : 0;
const aMemberBoost = focusFilter === "member" && (a.name.includes("member") || a.name.includes("worker")) ? -1 : 0;
const bMemberBoost = focusFilter === "member" && (b.name.includes("member") || b.name.includes("worker")) ? -1 : 0;
const aProjectBoost = focusFilter === "project" && (a.name.includes("project") || a.name.includes("dallyproject")) ? -1 : 0;
const bProjectBoost = focusFilter === "project" && (b.name.includes("project") || b.name.includes("dallyproject")) ? -1 : 0;
return (
aFocusBoost - bFocusBoost ||
aMemberBoost - bMemberBoost ||
aProjectBoost - bProjectBoost ||
aSchemaPriority - bSchemaPriority ||
(aPriority === -1 ? 999 : aPriority) - (bPriority === -1 ? 999 : bPriority) ||
(b.row_count || 0) - (a.row_count || 0) ||
a.name.localeCompare(b.name)
);
});
};
const setPreviewEmpty = (message) => {
previewEmpty.hidden = false;
previewEmpty.textContent = message;
previewTable.hidden = true;
previewHead.innerHTML = "";
previewBody.innerHTML = "";
currentPreviewColumns = [];
currentPreviewRows = [];
currentPreviewAllRows = [];
currentPreviewCursor = "";
currentPreviewNextCursor = "";
currentPreviewCursorHistory = [];
previewValueColumn.innerHTML = `<option value="">전체 컬럼</option>`;
previewValueSearch.value = "";
previewValueOptions.innerHTML = "";
summaryColumns.textContent = "0";
summaryKey.textContent = "-";
previewPrevButton.disabled = true;
previewNextButton.disabled = true;
};
const updatePreviewPager = () => {
previewPrevButton.disabled = !currentPreviewCursorHistory.length;
previewNextButton.disabled = !currentPreviewNextCursor;
};
const setAggregateEmpty = (message, options = {}) => {
const forceClear = options.forceClear === true;
const shouldPreserveExisting = currentAggregateAllRows.length > 0 && !forceClear;
if (options.preserveExisting || shouldPreserveExisting) {
aggregateMeta.textContent = message || aggregateMeta.textContent || "이전 조회 결과를 유지하고 있습니다.";
aggregateEmpty.hidden = true;
aggregateTable.hidden = false;
return;
}
aggregateEmpty.hidden = false;
aggregateEmpty.textContent = message;
aggregateTable.hidden = true;
aggregateHead.innerHTML = "";
aggregateBody.innerHTML = "";
currentAggregateColumns = [];
currentAggregateRows = [];
currentAggregateAllRows = [];
currentCenterMembers = [];
centerPanel.hidden = true;
centerEmpty.hidden = false;
centerMeta.textContent = "";
centerBody.innerHTML = "";
currentAggregateView = "member";
currentAggregateRenderSignature = "";
aggregateValueColumn.innerHTML = `<option value="">전체 컬럼</option>`;
aggregateValueSearch.value = "";
aggregateValueOptions.innerHTML = "";
aggregateMeta.textContent = "";
aggregateMemberCount.textContent = "0";
aggregateRegularHours.textContent = "0";
aggregateOvertimeHours.textContent = "0";
aggregateTotalHours.textContent = "0";
aggregateLeaveDays.textContent = "0";
aggregateProjectCount.textContent = "0";
};
const getAggregateStateKey = (payload) => JSON.stringify({
start_date: String(payload?.start_date || ""),
end_date: String(payload?.end_date || ""),
employment: String(payload?.employment || "all"),
view: String(payload?.view || "member"),
include_center_member_nos: Array.isArray(payload?.include_center_member_nos)
? [...payload.include_center_member_nos].map(String).sort()
: [],
});
const formatNumberText = (value) => {
const number = Number(value || 0);
if (!Number.isFinite(number)) return value == null ? "" : String(value);
return number.toLocaleString("ko-KR", { maximumFractionDigits: 2 });
};
const formatActualHoursText = (value) => {
if (value == null || value === "") return "-";
const number = Number(value);
if (!Number.isFinite(number)) return "-";
return number.toLocaleString("ko-KR", { minimumFractionDigits: 1, maximumFractionDigits: 1 });
};
const aggregateDayColumnMap = {
regular_hours: "regular_work_days",
overtime_hours: "overtime_work_days",
legal_leave_days: "legal_leave_days",
};
const aggregateValueColumnMap = {
legal_leave_days: "legal_leave_hours",
};
const aggregateNumericColumns = new Set([
"member_count",
"regular_hours",
"overtime_hours",
"total_hours",
"legal_leave_days",
"project_count",
]);
const aggregateDetailColumns = new Set([
"population_review",
"regular_hours",
"overtime_hours",
"total_hours",
"legal_leave_days",
"missing_regular_days",
"project_count",
]);
const aggregateDetailLabels = {
population_review: "소속검토",
regular_hours: "정규근로",
overtime_hours: "연장근로",
total_hours: "총근로",
legal_leave_days: "법정휴가",
missing_regular_days: "정규근로 부족",
project_count: "프로젝트수",
};
const escapeHtml = (value) => String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;")
.replaceAll("'", "&#39;");
const getRowValueText = (row, column) => {
const value = row?.[column];
return value == null ? "" : String(value);
};
const renderGradeCodes = (payload) => {
const schemas = Array.isArray(payload?.schemas) ? payload.schemas : [];
const rows = schemas.flatMap((schema) => {
const schemaRows = Array.isArray(schema.rows) ? schema.rows : [];
if (!schemaRows.length) {
return [{
schema: schema.schema || "-",
grade_col: schema.grade_col || "-",
grade_code: "-",
mapped_name: "",
normalized_name: "",
member_count: 0,
active_member_count: 0,
examples: schema.grade_col ? "직급 코드 없음" : "직급 컬럼 없음",
}];
}
return schemaRows.map((row) => ({
schema: schema.schema || "-",
grade_col: schema.grade_col || "-",
grade_code: row.grade_code || "",
mapped_name: row.mapped_name || "",
normalized_name: row.normalized_name || "",
member_count: row.member_count || 0,
active_member_count: row.active_member_count || 0,
examples: Array.isArray(row.examples) ? row.examples.join(", ") : "",
}));
});
gradeCodesPanel.classList.add("is-open");
gradeCodesPanel.innerHTML = `
<table>
<thead>
<tr>
<th>스키마</th>
<th>직급 컬럼</th>
<th>직급 코드</th>
<th>코드명</th>
<th>정규화명</th>
<th>전체 인원</th>
<th>재직 인원</th>
<th>예시</th>
</tr>
</thead>
<tbody>
${rows.map((row) => `
<tr>
<td>${escapeHtml(row.schema)}</td>
<td>${escapeHtml(row.grade_col)}</td>
<td class="mono">${escapeHtml(row.grade_code)}</td>
<td>${escapeHtml(row.mapped_name || "-")}</td>
<td>${escapeHtml(row.normalized_name || "-")}</td>
<td>${formatNumberText(row.member_count)}</td>
<td>${formatNumberText(row.active_member_count)}</td>
<td>${escapeHtml(row.examples || "")}</td>
</tr>
`).join("")}
</tbody>
</table>
`;
};
const renderPreviewTableRows = (columns, rows) => {
currentPreviewColumns = columns;
currentPreviewRows = rows;
previewHead.innerHTML = `<tr>${columns.map((column) => `<th class="${isPriorityColumn(column) ? "is-priority" : ""}" title="${column}">${getColumnLabel(column)}</th>`).join("")}</tr>`;
previewBody.innerHTML = rows.map((row) => (
`<tr>${columns.map((column) => `<td>${escapeHtml(getRowValueText(row, column))}</td>`).join("")}</tr>`
)).join("");
previewEmpty.hidden = true;
previewTable.hidden = false;
};
const renderPreviewValueOptions = () => {
const selectedColumn = previewValueColumn.value;
const keyword = String(previewValueSearch.value || "").trim().toLowerCase();
const sourceRows = currentPreviewAllRows;
const sourceColumns = currentPreviewColumns;
const values = new Set();
sourceRows.forEach((row) => {
const columnsToRead = selectedColumn ? [selectedColumn] : sourceColumns;
columnsToRead.forEach((column) => {
const text = getRowValueText(row, column).trim();
if (!text) return;
if (keyword && !text.toLowerCase().includes(keyword)) return;
values.add(text);
});
});
previewValueOptions.innerHTML = Array.from(values).slice(0, 120).sort((a, b) => a.localeCompare(b, "ko")).map((value) => (
`<option value="${escapeHtml(value)}"></option>`
)).join("");
};
const applyPreviewValueFilter = () => {
if (!currentPreviewColumns.length) {
setPreviewEmpty("");
return;
}
const selectedColumn = previewValueColumn.value;
const searchValue = String(previewValueSearch.value || "").trim().toLowerCase();
const filteredRows = !searchValue ? currentPreviewAllRows : currentPreviewAllRows.filter((row) => {
const columnsToRead = selectedColumn ? [selectedColumn] : currentPreviewColumns;
return columnsToRead.some((column) => getRowValueText(row, column).toLowerCase().includes(searchValue));
});
renderPreviewTableRows(currentPreviewColumns, filteredRows);
const countText = searchValue
? `현재 ${Number(filteredRows.length).toLocaleString("ko-KR")}건 표시`
: `현재 ${Number(currentPreviewAllRows.length).toLocaleString("ko-KR")}건 표시`;
const pageStart = Number(currentPreviewCursor || 0) + 1;
const pageEnd = Number(currentPreviewCursor || 0) + Number(currentPreviewAllRows.length || 0);
const pageText = currentPreviewAllRows.length ? `${pageStart.toLocaleString("ko-KR")}~${pageEnd.toLocaleString("ko-KR")}행` : "";
previewMeta.textContent = [countText, pageText].filter(Boolean).join(" / ");
};
const renderAggregateValueOptions = () => {
const selectedColumn = aggregateValueColumn.value;
const keyword = String(aggregateValueSearch.value || "").trim().toLowerCase();
const values = new Set();
currentAggregateAllRows.forEach((row) => {
const columnsToRead = selectedColumn ? [selectedColumn] : currentAggregateColumns.map((column) => column.key);
columnsToRead.forEach((columnKey) => {
const text = getRowValueText(row, columnKey).trim();
if (!text) return;
if (keyword && !text.toLowerCase().includes(keyword)) return;
values.add(text);
});
});
aggregateValueOptions.innerHTML = Array.from(values).slice(0, 120).sort((a, b) => a.localeCompare(b, "ko")).map((value) => (
`<option value="${escapeHtml(value)}"></option>`
)).join("");
};
const getAggregateSortValue = (row, key) => {
const valueKey = aggregateValueColumnMap[key] || key;
const value = row?.[valueKey];
const numeric = Number(value);
if (value !== null && value !== "" && Number.isFinite(numeric)) return numeric;
return String(value ?? "").toLowerCase();
};
const getSortedAggregateRows = (rows) => {
if (!currentAggregateSort.key) return rows;
const direction = currentAggregateSort.direction === "asc" ? 1 : -1;
return [...rows].sort((left, right) => {
const leftValue = getAggregateSortValue(left, currentAggregateSort.key);
const rightValue = getAggregateSortValue(right, currentAggregateSort.key);
if (typeof leftValue === "number" && typeof rightValue === "number") {
return (leftValue - rightValue) * direction;
}
return String(leftValue).localeCompare(String(rightValue), "ko", { numeric: true }) * direction;
});
};
const getEquivalentProjectTitle = (row) => {
const equivalentCodes = Array.isArray(row?.equivalent_project_codes) ? row.equivalent_project_codes.filter(Boolean) : [];
const sourceCodes = Array.isArray(row?.source_project_codes) ? row.source_project_codes.filter(Boolean) : [];
const parts = [];
if (sourceCodes.length) parts.push(`원천코드: ${sourceCodes.join(", ")}`);
if (equivalentCodes.length) parts.push(`연결코드: ${equivalentCodes.join(", ")}`);
return parts.join(" / ");
};
const renderProjectCodeText = (row, fallbackKey = "project_code") => {
const text = row?.[fallbackKey] || "(미지정)";
const title = getEquivalentProjectTitle(row);
return `<span${title ? ` title="${escapeHtml(title)}"` : ""}>${escapeHtml(text)}</span>`;
};
const canOpenAggregateDetail = (row, columnKey) => {
if (!aggregateDetailColumns.has(columnKey)) return false;
const details = row?.aggregate_details?.[columnKey];
return Array.isArray(details) && details.length > 0;
};
const renderAggregateMetricCell = (row, column, rowIndex, view) => {
if (view === "member" && column.key === "remarks") {
const duplicateDays = Number(row.multi_entry_days || 0);
const overlapCounts = row.overlap_type_counts && typeof row.overlap_type_counts === "object"
? Object.entries(row.overlap_type_counts).filter(([, count]) => Number(count || 0) > 0)
: [];
const overlapLabel = overlapCounts.length
? overlapCounts.map(([label, count]) => `${label} ${Number(count).toLocaleString("ko-KR")}일`).join(" · ")
: `검토 필요 ${duplicateDays.toLocaleString("ko-KR")}일`;
const duplicateButton = duplicateDays > 0
? `<button type="button" class="hanmac-aggregate-detail-link" data-aggregate-duplicate-index="${rowIndex}">${escapeHtml(overlapLabel)}</button>`
: "";
const missingDays = Number(row.missing_regular_days || 0);
const missingHours = Number(row.missing_regular_hours || 0);
const missingButton = missingHours > 0
? `<button type="button" class="hanmac-aggregate-detail-link" data-aggregate-detail-index="${rowIndex}" data-aggregate-detail-key="missing_regular_days">정규근로 ${formatNumberText(missingDays)}일 부족 (${formatNumberText(missingHours)}시간)</button>`
: "";
return `<td>${[missingButton, duplicateButton].filter(Boolean).join(" · ")}</td>`;
}
if (view === "member" && column.key === "population_review") {
const label = String(row.population_review || "");
const detailButton = label && canOpenAggregateDetail(row, column.key)
? `<button type="button" class="hanmac-aggregate-detail-link" data-aggregate-detail-index="${rowIndex}" data-aggregate-detail-key="population_review">${escapeHtml(label)}</button>`
: escapeHtml(label);
return `<td>${detailButton}</td>`;
}
const isNumeric = aggregateNumericColumns.has(column.key);
const valueKey = aggregateValueColumnMap[column.key] || column.key;
const valueText = formatNumberText(row[valueKey]);
const valueHtml = canOpenAggregateDetail(row, column.key)
? `<button type="button" class="hanmac-aggregate-metric-value hanmac-aggregate-metric-button" data-aggregate-detail-index="${rowIndex}" data-aggregate-detail-key="${escapeHtml(column.key)}">${valueText}</button>`
: `<span class="hanmac-aggregate-metric-value">${valueText}</span>`;
const dayKey = aggregateDayColumnMap[column.key];
const dayValue = Number(row[dayKey] || 0);
const dayBadge = dayKey ? `<span class="hanmac-aggregate-day-badge">${formatNumberText(dayValue)}일</span>` : "";
if (dayKey) {
return `<td class="is-numeric"><div class="hanmac-aggregate-metric-cell">${valueHtml}${dayBadge}</div></td>`;
}
if (isNumeric) {
return `<td class="is-numeric"><div class="hanmac-aggregate-metric-cell">${valueHtml}</div></td>`;
}
if (column.key === "project_code") {
return `<td>${renderProjectCodeText(row)}</td>`;
}
return `<td>${row[column.key] == null ? "" : escapeHtml(String(row[column.key]))}</td>`;
};
const getAggregateRowIdentity = (row) => {
if (!row || typeof row !== "object") return "";
const identityKeys = [
"member_no",
"member_name",
"member_grade",
"project_code",
"year",
"regular_hours",
"overtime_hours",
"holiday_hours",
"total_hours",
"legal_leave_hours",
"project_count",
"remarks",
];
return identityKeys.map((key) => `${key}:${String(row[key] ?? "")}`).join("|");
};
const getAggregateRenderSignature = (columns, rows, view) => JSON.stringify({
view,
sort: currentAggregateSort,
filter_column: aggregateValueColumn.value || "",
filter_value: aggregateValueSearch.value || "",
columns: columns.map((column) => column.key),
row_count: rows.length,
first: getAggregateRowIdentity(rows[0]),
last: getAggregateRowIdentity(rows[rows.length - 1]),
});
const renderAggregateTableRows = (columns, rows, view) => {
currentAggregateColumns = columns;
currentAggregateRows = rows;
const renderSignature = getAggregateRenderSignature(columns, rows, view);
if (renderSignature === currentAggregateRenderSignature && !aggregateTable.hidden) {
aggregateEmpty.hidden = true;
aggregateTable.hidden = false;
return;
}
currentAggregateRenderSignature = renderSignature;
aggregateHead.innerHTML = `<tr>${columns.map((column) => {
const sortedClass = currentAggregateSort.key === column.key
? ` is-sorted-${currentAggregateSort.direction}`
: "";
const numericClass = aggregateNumericColumns.has(column.key) ? " class=\"is-numeric\"" : "";
return `<th${numericClass}><button type="button" class="hanmac-aggregate-sort-button${sortedClass}" data-aggregate-sort="${escapeHtml(column.key)}">${escapeHtml(column.label)}</button></th>`;
}).join("")}</tr>`;
aggregateBody.innerHTML = rows.map((row, rowIndex) => (
`<tr>${columns.map((column) => {
return renderAggregateMetricCell(row, column, rowIndex, view);
}).join("")}</tr>`
)).join("");
aggregateEmpty.hidden = true;
aggregateTable.hidden = false;
};
const applyAggregateValueFilter = () => {
if (!currentAggregateColumns.length) {
setAggregateEmpty("");
return;
}
const selectedColumn = aggregateValueColumn.value;
const searchValue = String(aggregateValueSearch.value || "").trim().toLowerCase();
const filteredRows = !searchValue ? currentAggregateAllRows : currentAggregateAllRows.filter((row) => {
const columnsToRead = selectedColumn ? [selectedColumn] : currentAggregateColumns.map((column) => column.key);
return columnsToRead.some((columnKey) => getRowValueText(row, columnKey).toLowerCase().includes(searchValue));
});
renderAggregateTableRows(currentAggregateColumns, getSortedAggregateRows(filteredRows), currentAggregateView);
};
const getRestoredCenterMemberNos = () => Array.from(restoredCenterMemberNos).filter(Boolean).sort((a, b) => a.localeCompare(b, "ko", { numeric: true }));
const renderCenterMembers = (members = []) => {
currentCenterMembers = Array.isArray(members) ? members : [];
if (!currentCenterMembers.length) {
centerPanel.hidden = true;
centerEmpty.hidden = false;
centerMeta.textContent = "";
centerBody.innerHTML = "";
return;
}
const restoreEligible = currentCenterMembers.filter((member) => member.can_restore !== false && String(member.member_no || ""));
const restoredCount = restoreEligible.filter((member) => restoredCenterMemberNos.has(String(member.member_no || ""))).length;
const excludedCount = restoreEligible.length - restoredCount;
const centerOnlyCount = currentCenterMembers.length - restoreEligible.length;
centerPanel.hidden = false;
centerEmpty.hidden = true;
centerMeta.textContent = `전체 ${Number(currentCenterMembers.length).toLocaleString("ko-KR")}명 · 한맥 중복 제외 ${Number(excludedCount).toLocaleString("ko-KR")}명 · 복구 ${Number(restoredCount).toLocaleString("ko-KR")}명 · 센터 전용 ${Number(centerOnlyCount).toLocaleString("ko-KR")}명`;
centerBody.innerHTML = currentCenterMembers.map((member) => {
const memberNo = String(member.member_no || "");
const canRestore = member.can_restore !== false && Boolean(memberNo);
const checked = restoredCenterMemberNos.has(memberNo);
return `
<tr>
<td><input type="checkbox" data-center-member-no="${escapeHtml(memberNo)}" ${checked ? "checked" : ""} ${canRestore ? "" : "disabled"} aria-label="${escapeHtml(member.member_name || memberNo)} 복구"></td>
<td>${escapeHtml(memberNo || "-")}</td>
<td>${escapeHtml(member.center_member_no || "")}</td>
<td>${escapeHtml(member.member_name || "")}</td>
<td>${escapeHtml(member.dept_name || "")}</td>
<td>${escapeHtml(member.matched_by || "")}</td>
<td>${escapeHtml(canRestore ? (checked ? "복구" : "기본 제외") : (member.status || "센터/총괄 전용"))}</td>
</tr>
`;
}).join("");
};
const getJointContentLines = (member) => {
const sourceLines = Array.isArray(member?.contents) && member.contents.length
? member.contents
: String(member?.content || "").split(/\n+/);
return sourceLines.map((line) => {
const normalized = String(line || "").trim();
if (!normalized) return null;
const parts = normalized.split(/\s+\/\s+/);
const dateText = parts.shift() || "";
return {
dateText,
bodyText: parts.join(" / ") || normalized,
rawText: normalized,
};
}).filter(Boolean);
};
const renderJointContentLines = (member) => {
const lines = getJointContentLines(member);
if (!lines.length) return "";
return `
<div class="hanmac-joint-line-list">
${lines.map((line) => `
<div class="hanmac-joint-line">
<span class="hanmac-joint-date">${escapeHtml(line.dateText || "-")}</span>
<span class="hanmac-joint-text">${escapeHtml(line.bodyText || line.rawText || "")}</span>
</div>
`).join("")}
</div>
`;
};
const getJointDateCount = (member) => {
const dates = new Set(getJointContentLines(member).map((line) => line.dateText).filter(Boolean));
return dates.size;
};
const renderJointMembers = (members = []) => {
currentJointMembers = Array.isArray(members) ? members : [];
if (!currentJointMembers.length) {
jointPanel.hidden = false;
jointEmpty.hidden = true;
jointMeta.textContent = "해당 조건의 합사 정보가 없습니다.";
jointBody.innerHTML = "";
return;
}
jointPanel.hidden = false;
jointEmpty.hidden = true;
jointMeta.textContent = `전체 ${Number(currentJointMembers.length).toLocaleString("ko-KR")}명`;
jointBody.innerHTML = currentJointMembers.map((member) => {
const dateCount = getJointDateCount(member);
return `
<tr>
<td>${escapeHtml(member.member_no || "")}</td>
<td>${escapeHtml(member.member_name || "")}</td>
<td>${escapeHtml(member.member_grade || "")}</td>
<td>${escapeHtml(member.entry_date || "")}</td>
<td>${escapeHtml(member.leave_date || "")}</td>
<td class="hanmac-joint-content">${renderJointContentLines(member)}</td>
<td class="is-numeric">${Number(dateCount || 0).toLocaleString("ko-KR")}</td>
</tr>
`;
}).join("");
};
const loadJointMembersFallback = async () => {
try {
const response = await fetch(`${jointMembersFallbackUrl}?v=${Date.now()}`, { cache: "no-store" });
if (!response.ok) return false;
const payload = await response.json();
const requestKey = getAggregateStateKey(getAggregateRequestPayload());
const exact = payload?.by_key?.[requestKey];
const fallback = exact || payload?.latest || null;
if (!fallback || !Array.isArray(fallback.joint_members)) return false;
renderJointMembers(fallback.joint_members);
const label = exact ? "현재 조건" : `${fallback.start_date || ""} ~ ${fallback.end_date || ""}`;
jointMeta.textContent = `전체 ${Number(fallback.joint_members.length).toLocaleString("ko-KR")}명 · ${label}`;
return true;
} catch (_error) {
return false;
}
};
const openMultiEntryModal = (row) => {
const details = Array.isArray(row?.multi_entry_details) ? row.multi_entry_details : [];
multiEntryTitle.textContent = `${row?.member_name || row?.member_no || "사원"} 근무기록 검토 상세`;
multiEntryMeta.textContent = `${row?.member_no || ""} · ${Number(row?.multi_entry_days || details.length || 0).toLocaleString("ko-KR")}일 검토 필요`;
if (!details.length) {
multiEntryBody.innerHTML = `<div class="hanmac-preview-empty">검토할 근무기록이 없습니다.</div>`;
} else {
multiEntryBody.innerHTML = details.map((detail) => `
<section class="hanmac-detail-day">
<div class="hanmac-detail-day-head">
<strong class="hanmac-detail-day-title">${escapeHtml(detail.work_date || "-")}</strong>
<span class="hanmac-detail-badge">${escapeHtml(detail.overlap_type || "근무기록 검토")}</span>
<span class="hanmac-detail-badge">원본근로 ${formatNumberText(detail.raw_total_hours)}시간</span>
<span class="hanmac-detail-badge">집계반영 ${formatNumberText(detail.capped_regular_hours)}시간</span>
<span class="hanmac-detail-badge">${Number(detail.row_count || 0).toLocaleString("ko-KR")}행</span>
<span class="hanmac-detail-badge">표시 ${Number(detail.display_row_count || detail.row_count || 0).toLocaleString("ko-KR")}행</span>
</div>
<table class="hanmac-detail-table">
<thead>
<tr>
<th>프로젝트코드</th>
<th>프로젝트명</th>
<th>행 원본근로</th>
<th>비고</th>
</tr>
</thead>
<tbody>
${(Array.isArray(detail.entries) ? detail.entries : []).map((entry) => `
<tr>
<td>${renderProjectCodeText(entry)}</td>
<td>${escapeHtml(entry.project_name || entry.project_code || "(미지정)")}</td>
<td>${formatNumberText(entry.regular_hours)}</td>
<td>${Number(entry.row_count || 0) > 1 ? `원본 ${Number(entry.row_count).toLocaleString("ko-KR")}행` : ""}</td>
</tr>
`).join("")}
${(Array.isArray(detail.activity_evidence) ? detail.activity_evidence : []).map((entry) => `
<tr>
<td>${renderProjectCodeText(entry)}</td>
<td>${escapeHtml(entry.project_name || entry.project_code || "공통/미지정")}</td>
<td>-</td>
<td>${escapeHtml([entry.source_label || "활동 근거", entry.note || ""].filter(Boolean).join(" · "))}</td>
</tr>
`).join("")}
</tbody>
</table>
</section>
`).join("");
}
multiEntryModal.hidden = false;
};
const renderDetailProjectList = (projects) => {
const list = Array.isArray(projects) ? projects : [];
if (!list.length) return "";
return `
<table class="hanmac-detail-table">
<thead>
<tr>
<th>프로젝트코드</th>
<th>프로젝트명</th>
<th class="is-numeric">시간</th>
</tr>
</thead>
<tbody>
${list.map((project) => `
<tr>
<td>${renderProjectCodeText(project)}</td>
<td>${escapeHtml(project.project_name || project.project_code || "(미지정)")}</td>
<td class="is-numeric">${formatNumberText(project.hours)}</td>
</tr>
`).join("")}
</tbody>
</table>
`;
};
const getDetailOwnerText = (detail, fallback = "(미지정)") => currentAggregateView === "project"
? `${detail.member_name || detail.member_no || fallback} ${detail.member_no ? `(${detail.member_no})` : ""}`.trim()
: `${detail.project_name || detail.project_code || fallback} ${detail.project_code ? `(${detail.project_code})` : ""}`.trim();
const flattenTimeDetails = (detailKey, details) => details.flatMap((detail) => {
const prefix = detail.detail_type ? `[${detail.detail_type}] ` : "";
const sourcePrefix = (value) => value ? `[${value}] ` : prefix;
if (detailKey === "legal_leave_days") {
return [{
workDate: detail.work_date,
project: getDetailOwnerText(detail, detail.leave_type || "법정휴가"),
recognizedHours: detail.leave_hours,
actualHours: null,
}];
}
if (detailKey === "regular_hours" || (detailKey === "total_hours" && detail.detail_type === "정규근로")) {
if (currentAggregateView !== "project" && Array.isArray(detail.projects) && detail.projects.length) {
return detail.projects.map((project) => ({
workDate: detail.work_date,
project: `${sourcePrefix(project.source_label)}${project.project_name || project.project_code || "(미지정)"}${project.project_code ? ` (${project.project_code})` : ""}${Array.isArray(project.source_project_codes) && project.source_project_codes.length ? ` · 원천 ${project.source_project_codes.join(", ")}` : ""}`,
recognizedHours: project.recognized_hours ?? (detail.projects.length === 1 ? detail.regular_hours : 0),
actualHours: project.hours,
}));
}
return [{
workDate: detail.work_date,
project: `${sourcePrefix(detail.source_label)}${getDetailOwnerText(detail)}`,
recognizedHours: detail.regular_hours,
actualHours: detail.raw_project_hours ?? detail.raw_total_hours,
}];
}
if (detailKey === "holiday_hours" || (detailKey === "total_hours" && detail.detail_type === "휴일근로")) {
if (currentAggregateView !== "project" && Array.isArray(detail.projects) && detail.projects.length) {
return detail.projects.map((project) => ({
workDate: detail.work_date,
project: `${prefix}${project.project_name || project.project_code || "(미지정)"}${project.project_code ? ` (${project.project_code})` : ""}`,
recognizedHours: project.recognized_hours ?? (detail.projects.length === 1 ? detail.holiday_hours : 0),
actualHours: project.hours,
}));
}
return [{
workDate: detail.work_date,
project: `${prefix}${getDetailOwnerText(detail)}`,
recognizedHours: detail.holiday_hours,
actualHours: detail.raw_project_hours ?? detail.raw_holiday_hours,
}];
}
return [{
workDate: detail.work_date,
project: `${sourcePrefix(detail.source === "합사" ? "합사" : detail.source_label)}${getDetailOwnerText(detail)}`,
recognizedHours: detail.overtime_hours,
actualHours: detail.raw_overtime_hours,
}];
});
const renderAggregateDetailRows = (detailKey, details) => {
if (detailKey === "population_review") {
return `
<table class="hanmac-detail-table">
<thead>
<tr>
<th>판정</th>
<th>사유</th>
<th>Company</th>
<th>WorkCompany</th>
<th>부서</th>
</tr>
</thead>
<tbody>
${details.map((detail) => `
<tr>
<td>${escapeHtml(detail.decision || "소속검토필요")}</td>
<td>${escapeHtml(detail.reason || "")}</td>
<td>${escapeHtml(detail.company || "")}</td>
<td>${escapeHtml(detail.work_company || "")}</td>
<td>${escapeHtml(detail.dept_name || "")}</td>
</tr>
`).join("")}
</tbody>
</table>
`;
}
if (detailKey === "missing_regular_days") {
return `
<table class="hanmac-detail-table">
<thead>
<tr>
<th>일자</th>
<th class="is-numeric">근무의무</th>
<th class="is-numeric">휴가·시차</th>
<th class="is-numeric">인정근로</th>
<th class="is-numeric">부족</th>
<th>판정</th>
</tr>
</thead>
<tbody>
${details.map((detail) => `
<tr>
<td>${escapeHtml(detail.work_date || "-")}</td>
<td class="is-numeric">${formatNumberText(detail.expected_hours)}</td>
<td class="is-numeric">${formatNumberText(detail.leave_hours)}</td>
<td class="is-numeric">${formatNumberText(detail.recognized_hours)}</td>
<td class="is-numeric">${formatNumberText(detail.missing_hours)}</td>
<td>${escapeHtml(detail.reason || "")}</td>
</tr>
`).join("")}
</tbody>
</table>
`;
}
if (detailKey === "project_count") {
return `
<table class="hanmac-detail-table">
<thead>
<tr>
<th>프로젝트코드</th>
<th>프로젝트명</th>
</tr>
</thead>
<tbody>
${details.map((detail) => `
<tr>
<td>${renderProjectCodeText(detail)}</td>
<td>${escapeHtml(detail.project_name || detail.project_code || "(미지정)")}</td>
</tr>
`).join("")}
</tbody>
</table>
`;
}
const detailRows = flattenTimeDetails(detailKey, details);
return `
<table class="hanmac-detail-table">
<thead>
<tr>
<th>일자</th>
<th>프로젝트</th>
<th class="is-numeric">집계반영</th>
<th class="is-numeric">원본근로</th>
</tr>
</thead>
<tbody>
${detailRows.map((detail) => `
<tr>
<td>${escapeHtml(detail.workDate || "-")}</td>
<td>${escapeHtml(detail.project || "(미지정)")}</td>
<td class="is-numeric">${formatNumberText(detail.recognizedHours)}</td>
<td class="is-numeric">${formatActualHoursText(detail.actualHours)}</td>
</tr>
`).join("")}
</tbody>
</table>
`;
};
const openAggregateDetailModal = (row, detailKey) => {
const details = Array.isArray(row?.aggregate_details?.[detailKey]) ? row.aggregate_details[detailKey] : [];
const label = aggregateDetailLabels[detailKey] || detailKey;
const owner = currentAggregateView === "project"
? `${row?.project_name || row?.project_code || "프로젝트"}`
: `${row?.member_name || row?.member_no || "사원"}`;
multiEntryTitle.textContent = `${owner} ${label} 상세`;
multiEntryMeta.textContent = `${Number(details.length).toLocaleString("ko-KR")}건 · ${label} ${formatNumberText(row?.[detailKey])}`;
multiEntryBody.innerHTML = details.length
? renderAggregateDetailRows(detailKey, details)
: `<div class="hanmac-preview-empty">상세 내역이 없습니다.</div>`;
multiEntryModal.hidden = false;
};
const updateSelectedSummary = (tableItem, columns = []) => {
if (!tableItem) {
selectedTitle.textContent = `${getFocusTitle()} · 선택 대기`;
summarySchema.textContent = "-";
summaryRows.textContent = "0";
summaryColumns.textContent = "0";
summaryKey.textContent = "-";
return;
}
const profile = getTableProfile(tableItem);
selectedTitle.textContent = `${getFocusTitle()} · ${tableItem.name}`;
summarySchema.textContent = tableItem.schema;
summaryRows.textContent = tableItem.row_count == null ? "-" : Number(tableItem.row_count).toLocaleString("ko-KR");
summaryColumns.textContent = Number(columns.length || 0).toLocaleString("ko-KR");
summaryKey.textContent = profile.key;
};
const renderTableList = () => {
visibleTables = getVisibleTables();
tableList.innerHTML = "";
if (!visibleTables.length) {
tableListMeta.textContent = "";
setPreviewEmpty("");
updateSelectedSummary(null);
return;
}
tableListMeta.textContent = `${Number(visibleTables.length).toLocaleString("ko-KR")}개 테이블 표시 중`;
visibleTables.forEach((item) => {
const button = document.createElement("button");
button.type = "button";
button.className = "hanmac-table-item";
if (item.name === selectedTable && item.schema === selectedSchema) button.classList.add("is-active");
button.innerHTML = `
<span class="hanmac-table-name">${item.name}</span>
<span class="hanmac-table-meta">${item.row_count == null ? "확인 불가" : `${Number(item.row_count).toLocaleString("ko-KR")} 행`}</span>
`;
button.addEventListener("click", () => {
selectedSchema = item.schema;
selectedTable = item.name;
currentPreviewCursor = "";
currentPreviewNextCursor = "";
currentPreviewCursorHistory = [];
renderTableList();
loadPreview();
});
tableList.appendChild(button);
});
};
const renderPreview = (payload) => {
const originalColumns = Array.isArray(payload.columns) ? payload.columns : [];
const columns = prioritizeColumns(originalColumns);
const rows = Array.isArray(payload.rows) ? payload.rows : [];
const activeItem = allTables.find((item) => item.schema === payload.schema && item.name === payload.table) || null;
if (!columns.length) {
setPreviewEmpty("");
previewMeta.textContent = "";
updateSelectedSummary(activeItem, []);
return;
}
currentPreviewAllRows = rows;
currentPreviewCursor = String(payload.cursor || "");
currentPreviewNextCursor = String(payload.next_cursor || "");
previewValueColumn.innerHTML = `<option value="">전체 컬럼</option>${columns.map((column) => `<option value="${escapeHtml(column)}">${getColumnLabel(column)}</option>`).join("")}`;
previewValueSearch.value = "";
renderPreviewValueOptions();
renderPreviewTableRows(columns, rows);
const shownText = `현재 ${Number(payload.shown_count || 0).toLocaleString("ko-KR")}건 표시`;
const pageStart = Number(payload.cursor || 0) + 1;
const pageEnd = Number(payload.cursor || 0) + Number(payload.shown_count || 0);
const pageText = Number(payload.shown_count || 0) > 0 ? `${pageStart.toLocaleString("ko-KR")}~${pageEnd.toLocaleString("ko-KR")}행` : "";
const pendingText = payload.cache_meta?.pending_refresh ? " · 캐시 갱신 중" : "";
previewMeta.textContent = [shownText, pageText].filter(Boolean).join(" / ") + pendingText;
updateSelectedSummary(activeItem, columns);
updatePreviewPager();
};
const renderAggregate = (payload, options = {}) => {
const columns = Array.isArray(payload.columns) ? payload.columns : [];
const rows = Array.isArray(payload.rows) ? payload.rows : [];
if (!columns.length || !rows.length) {
setAggregateEmpty(
"새 조회 응답이 비어 있어 이전 결과를 유지합니다.",
{ preserveExisting: currentAggregateAllRows.length > 0 || options.preserveExistingOnEmpty },
);
return;
}
const summary = payload.summary || {};
currentAggregateAllRows = rows;
currentAggregateView = payload.view || "member";
currentAggregateResultKey = options.requestKey || getAggregateStateKey(payload);
renderCenterMembers(payload.center_members || []);
renderJointMembers(payload.joint_members || []);
aggregateMemberCount.textContent = Number(summary.member_count || 0).toLocaleString("ko-KR");
aggregateRegularHours.textContent = Number(summary.regular_hours || 0).toLocaleString("ko-KR");
aggregateOvertimeHours.textContent = Number(summary.overtime_hours || 0).toLocaleString("ko-KR");
aggregateTotalHours.textContent = Number(summary.total_hours || 0).toLocaleString("ko-KR");
aggregateLeaveDays.textContent = Number(summary.legal_leave_hours || 0).toLocaleString("ko-KR");
aggregateProjectCount.textContent = Number(summary.project_count || 0).toLocaleString("ko-KR");
const diagnostics = payload.source_diagnostics || {};
const logicVersionText = diagnostics.logic_version ? ` · 로직 ${diagnostics.logic_version}` : "";
const diagnosticText = diagnostics
? `${logicVersionText} · 공식연장 ${Number(diagnostics.official_overtime_rows || 0).toLocaleString("ko-KR")}행 · addwork ${Number(diagnostics.addwork_parsed_rows || 0).toLocaleString("ko-KR")}/${Number(diagnostics.addwork_source_rows || 0).toLocaleString("ko-KR")}행 · 합사 ${Number(diagnostics.joint_assignment_records || 0).toLocaleString("ko-KR")}건(코드 ${Number(diagnostics.joint_assignment_code_matched_rows || 0).toLocaleString("ko-KR")}, 문구 ${Number(diagnostics.joint_assignment_text_matched_rows || 0).toLocaleString("ko-KR")}건)/${Number(diagnostics.joint_assignment_regular_rows || 0).toLocaleString("ko-KR")}일 · 합사연장 ${Number(diagnostics.joint_assignment_overtime_rows || 0).toLocaleString("ko-KR")}일 · 사번통합 ${Number(diagnostics.canonical_member_no_rows || 0).toLocaleString("ko-KR")}행 · 임계 제외 ${Number((diagnostics.addwork_weekday_threshold_filtered_rows || 0) + (diagnostics.addwork_holiday_threshold_filtered_rows || 0)).toLocaleString("ko-KR")}행 · 휴가 ${Number(diagnostics.leave_matched_rows || 0).toLocaleString("ko-KR")}/${Number(diagnostics.tardy_candidate_rows || 0).toLocaleString("ko-KR")}행 · 탄력 제외 ${Number(diagnostics.leave_flexible_work_excluded_rows || 0).toLocaleString("ko-KR")}행 · 부서 연결 ${Number(diagnostics.dept_mapped_rows || 0).toLocaleString("ko-KR")}명 · 계열사스키마 ${Number(diagnostics.affiliate_schema_count || 0).toLocaleString("ko-KR")}개 · 계열사/소속 제외 ${Number((diagnostics.population_owner_excluded_member_count || 0) + (diagnostics.affiliate_actual_work_excluded_count || diagnostics.center_excluded_member_count || 0)).toLocaleString("ko-KR")}명 · 소속코드 검토 ${Number(diagnostics.company_review_member_count || 0).toLocaleString("ko-KR")}명 · 근무기록 없음 검토 ${Number(diagnostics.no_hanmac_work_review_member_count || 0).toLocaleString("ko-KR")}명 · 시스템 제외 ${Number(diagnostics.system_member_filtered_rows || 0).toLocaleString("ko-KR")}건`
: "";
aggregateMeta.textContent = `${payload.start_date || ""} ~ ${payload.end_date || ""}${diagnosticText}`;
if (currentAggregateSort.key && !columns.some((column) => column.key === currentAggregateSort.key)) {
currentAggregateSort = { key: "", direction: "desc" };
}
aggregateValueColumn.innerHTML = `<option value="">전체 컬럼</option>${columns.map((column) => `<option value="${escapeHtml(column.key)}">${escapeHtml(column.label)}</option>`).join("")}`;
aggregateValueSearch.value = "";
renderAggregateValueOptions();
renderAggregateTableRows(columns, getSortedAggregateRows(rows), payload.view || "member");
};
const getAggregateRequestPayload = () => ({
...getPayload(),
start_date: aggregateStartDate.value,
end_date: aggregateEndDate.value,
employment: aggregateEmployment.value,
view: getAggregateView(),
include_center_member_nos: getRestoredCenterMemberNos(),
});
const persistAggregateState = () => {
try {
window.sessionStorage.setItem(aggregateStateStorageKey, JSON.stringify({
start_date: aggregateStartDate.value,
end_date: aggregateEndDate.value,
employment: aggregateEmployment.value,
view: getAggregateView(),
include_center_member_nos: getRestoredCenterMemberNos(),
saved_at: new Date().toISOString(),
}));
} catch (_error) {
// The page remains usable even when session storage is blocked.
}
};
const restoreAggregateState = () => {
try {
const raw = window.sessionStorage.getItem(aggregateStateStorageKey);
if (!raw) return false;
const state = JSON.parse(raw);
if (!state || typeof state !== "object") return false;
if (state.start_date) aggregateStartDate.value = state.start_date;
if (state.end_date) aggregateEndDate.value = state.end_date;
if (state.employment) aggregateEmployment.value = state.employment;
if (Array.isArray(state.include_center_member_nos)) {
restoredCenterMemberNos = new Set(state.include_center_member_nos.map(String).filter(Boolean));
}
if (state.view) {
focusFilter = state.view === "project" ? "project" : focusFilter;
focusButtons.forEach((button) => {
button.classList.toggle("is-active", button.dataset.focus === focusFilter);
});
}
return true;
} catch (_error) {
// Ignore corrupt saved state and use the default view.
}
return false;
};
const getHolidayTypeLabel = (value) => {
if (value === "substitute") return "대체";
if (value === "company") return "선거/기타";
return "법정";
};
const renderHolidays = (rows = []) => {
if (!holidayList || !holidayMeta) return;
const sortedRows = [...rows].sort((a, b) => String(a.holiday_date || "").localeCompare(String(b.holiday_date || "")));
holidayMeta.textContent = sortedRows.length
? `휴일 기준 ${Number(sortedRows.length).toLocaleString("ko-KR")}건`
: "등록된 휴일 기준이 없습니다.";
holidayList.innerHTML = sortedRows.map((row) => `
<span class="hanmac-holiday-chip" title="${escapeHtml(row.memo || "")}">
<strong>${escapeHtml(row.holiday_date || "")}</strong>
<span>${escapeHtml(row.holiday_name || "")}</span>
<em>${escapeHtml(getHolidayTypeLabel(row.holiday_type || ""))}</em>
</span>
`).join("");
};
const loadHolidays = async () => {
if (!holidayList || !holidayMeta) return;
try {
const payload = await fetchJson("/hanmac-browser/api/holidays");
renderHolidays(Array.isArray(payload.rows) ? payload.rows : []);
} catch (error) {
holidayMeta.textContent = error.message || "휴일 기준을 불러오지 못했습니다.";
}
};
const saveHoliday = async () => {
if (!holidayDateInput || !holidayNameInput || !holidayTypeSelect || !holidaySaveButton) return;
const holidayDate = holidayDateInput.value || "";
const holidayName = holidayNameInput.value.trim();
if (!holidayDate || !holidayName) {
window.alert("휴일 날짜와 휴일명을 입력해주세요.");
return;
}
holidaySaveButton.disabled = true;
try {
await fetchJson("/hanmac-browser/api/holidays", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
holiday_date: holidayDate,
holiday_name: holidayName,
holiday_type: holidayTypeSelect.value || "company",
}),
});
holidayNameInput.value = "";
await loadHolidays();
persistAggregateState();
} catch (error) {
window.alert(error.message || "휴일 기준을 저장하지 못했습니다.");
} finally {
holidaySaveButton.disabled = false;
}
};
const loadPreview = async (cursor = "", preserveHistory = false) => {
if (!selectedTable || !selectedSchema) {
setPreviewEmpty("");
previewMeta.textContent = "";
return;
}
refreshPreviewButton.disabled = true;
previewPrevButton.disabled = true;
previewNextButton.disabled = true;
previewMeta.textContent = "조회 중...";
try {
const response = await fetch("/hanmac-browser/api/preview", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...getPayload(),
schema: selectedSchema,
table: selectedTable,
limit: previewLimit.value,
cursor,
}),
});
const payload = await response.json();
if (!response.ok || payload.status !== "ok") {
throw new Error(payload.message || "테이블 미리보기에 실패했습니다.");
}
if (!preserveHistory) {
currentPreviewCursorHistory = cursor ? [...currentPreviewCursorHistory, currentPreviewCursor] : [];
}
renderPreview(payload);
} catch (error) {
setPreviewEmpty(error.message || "테이블 미리보기 중 오류가 발생했습니다.");
previewMeta.textContent = "조회 실패";
} finally {
refreshPreviewButton.disabled = false;
updatePreviewPager();
}
};
const loadTables = async () => {
loadTablesButton.disabled = true;
tableListMeta.textContent = "테이블 목록을 불러오는 중입니다...";
try {
saveCredentials();
const response = await fetch("/hanmac-browser/api/tables", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(getPayload()),
});
const payload = await response.json();
if (!response.ok || payload.status !== "ok") {
throw new Error(payload.message || "테이블 목록 조회에 실패했습니다.");
}
allTables = Array.isArray(payload.tables) ? payload.tables : [];
if ((!selectedTable || !selectedSchema) && allTables.length) {
selectedSchema = allTables[0].schema;
selectedTable = allTables[0].name;
}
renderTableList();
closeModal();
if (selectedTable && selectedSchema) {
await loadPreview();
} else {
setPreviewEmpty("");
}
setStatus("hanmac / hanmac_manhour / baron_manhour 테이블 목록을 불러왔습니다.", "success");
} catch (error) {
tableListMeta.textContent = error.message || "테이블 목록 조회 실패";
tableList.innerHTML = "";
setPreviewEmpty("");
updateSelectedSummary(null);
setStatus(error.message || "테이블 목록 조회 중 오류가 발생했습니다.", "error");
} finally {
loadTablesButton.disabled = false;
}
};
const loadAggregate = async () => {
const requestPayload = getAggregateRequestPayload();
const requestKey = getAggregateStateKey(requestPayload);
if (aggregateInFlightKey === requestKey) {
aggregateMeta.textContent = currentAggregateAllRows.length
? (aggregateMeta.textContent || "같은 조건의 조회를 유지하고 있습니다.")
: "같은 조건으로 조회 중입니다...";
return aggregateLoadPromise;
}
aggregateInFlightKey = requestKey;
const requestSeq = ++aggregateRequestSeq;
const preserveExisting = currentAggregateAllRows.length > 0 && currentAggregateResultKey === requestKey;
persistAggregateState();
aggregateLoadButton.disabled = true;
aggregateMeta.textContent = preserveExisting
? `${aggregateMeta.textContent || ""} · 갱신 중...`.trim()
: "집계 조회 중...";
aggregateLoadPromise = (async () => {
try {
const response = await fetch("/hanmac-browser/api/aggregate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(requestPayload),
});
const payload = await response.json();
if (requestSeq !== aggregateRequestSeq) return;
if (!response.ok || payload.status !== "ok") {
throw new Error(payload.message || "집계 조회에 실패했습니다.");
}
renderAggregate(payload, {
requestKey,
preserveExistingOnEmpty: preserveExisting,
});
persistAggregateState();
return payload;
} catch (error) {
if (requestSeq !== aggregateRequestSeq) return;
setAggregateEmpty(
preserveExisting
? `새 조회에 실패했습니다. 이전 결과를 유지합니다. (${error.message || "집계 조회 중 오류가 발생했습니다."})`
: (error.message || "집계 조회 중 오류가 발생했습니다."),
{ preserveExisting },
);
return null;
} finally {
if (requestSeq === aggregateRequestSeq) {
aggregateLoadButton.disabled = false;
}
if (aggregateInFlightKey === requestKey) {
aggregateInFlightKey = "";
}
if (aggregateLoadPromise && aggregateInFlightKey === "") {
aggregateLoadPromise = null;
}
}
})();
return aggregateLoadPromise;
};
const requestPreviewCacheRebuild = async () => {
if (!selectedTable || !selectedSchema) {
throw new Error("캐시를 다시 계산할 테이블을 먼저 선택해주세요.");
}
previewRebuildCacheButton.disabled = true;
systemJobStatus.textContent = "미리보기 캐시 작업 등록 중...";
systemJobStatus.classList.remove("is-success", "is-error");
const payload = await fetchJson("/hanmac-browser/api/preview/rebuild-cache", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...getPayload(),
schema: selectedSchema,
table: selectedTable,
limit: previewLimit.value,
cursor: currentPreviewCursor,
}),
});
renderSystemJob(payload.job || null);
pollSystemJob(payload.job?.id, async () => {
await loadPreview(currentPreviewCursor, true);
});
};
const requestAggregateCacheRebuild = async () => {
aggregateRebuildCacheButton.disabled = true;
systemJobStatus.textContent = "집계 캐시 작업 등록 중...";
systemJobStatus.classList.remove("is-success", "is-error");
const payload = await fetchJson("/hanmac-browser/api/aggregate/rebuild-cache", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...getPayload(),
start_date: aggregateStartDate.value,
end_date: aggregateEndDate.value,
employment: aggregateEmployment.value,
view: getAggregateView(),
include_center_member_nos: getRestoredCenterMemberNos(),
}),
});
renderSystemJob(payload.job || null);
pollSystemJob(payload.job?.id, loadAggregate);
};
openConfigButton.addEventListener("click", openModal);
closeConfigButton.addEventListener("click", closeModal);
connectionTabs.forEach((button) => {
button.addEventListener("click", () => selectConnectionTab(button.dataset.connectionTab || "work"));
});
["erp_user", "erp_password", "erp_db_user", "erp_db_password"].forEach((name) => {
form.elements.namedItem(name)?.addEventListener("input", saveErpCredentials);
});
modal.addEventListener("click", (event) => {
if (event.target === modal) closeModal();
});
openHolidayButton.addEventListener("click", openHolidayModal);
closeHolidayButton.addEventListener("click", closeHolidayModal);
holidayModal.addEventListener("click", (event) => {
if (event.target === holidayModal) closeHolidayModal();
});
openJointButton.addEventListener("click", openJointModal);
closeJointButton.addEventListener("click", closeJointModal);
jointModal.addEventListener("click", (event) => {
if (event.target === jointModal) closeJointModal();
});
openCenterButton.addEventListener("click", openCenterModal);
closeCenterButton.addEventListener("click", closeCenterModal);
centerModal.addEventListener("click", (event) => {
if (event.target === centerModal) closeCenterModal();
});
multiEntryModal.addEventListener("click", (event) => {
if (event.target === multiEntryModal) closeMultiEntryModal();
});
closeMultiEntryButton.addEventListener("click", closeMultiEntryModal);
tableSearch.addEventListener("input", renderTableList);
centerBody.addEventListener("change", (event) => {
const checkbox = event.target.closest("[data-center-member-no]");
if (!checkbox) return;
const memberNo = String(checkbox.dataset.centerMemberNo || "");
if (!memberNo) return;
if (checkbox.checked) {
restoredCenterMemberNos.add(memberNo);
} else {
restoredCenterMemberNos.delete(memberNo);
}
renderCenterMembers(currentCenterMembers);
persistAggregateState();
});
centerApplyButton.addEventListener("click", loadAggregate);
aggregateBody.addEventListener("click", (event) => {
const detailButton = event.target.closest("[data-aggregate-detail-index]");
if (detailButton) {
const rowIndex = Number(detailButton.dataset.aggregateDetailIndex || -1);
const detailKey = detailButton.dataset.aggregateDetailKey || "";
if (rowIndex >= 0 && rowIndex < currentAggregateRows.length && detailKey) {
openAggregateDetailModal(currentAggregateRows[rowIndex], detailKey);
}
return;
}
const button = event.target.closest("[data-aggregate-duplicate-index]");
if (!button || currentAggregateView !== "member") return;
const rowIndex = Number(button.dataset.aggregateDuplicateIndex || -1);
if (rowIndex < 0 || rowIndex >= currentAggregateRows.length) return;
openMultiEntryModal(currentAggregateRows[rowIndex]);
});
aggregateHead.addEventListener("click", (event) => {
const button = event.target.closest("[data-aggregate-sort]");
if (!button) return;
const key = button.dataset.aggregateSort || "";
if (!key) return;
currentAggregateSort = currentAggregateSort.key === key
? { key, direction: currentAggregateSort.direction === "asc" ? "desc" : "asc" }
: { key, direction: "desc" };
applyAggregateValueFilter();
});
schemaTabs.forEach((button) => {
button.addEventListener("click", () => {
schemaFilter = button.dataset.schemaFilter || "all";
schemaTabs.forEach((item) => item.classList.toggle("is-active", item === button));
renderTableList();
});
});
focusButtons.forEach((button) => {
button.addEventListener("click", () => {
focusFilter = button.dataset.focus || "all";
focusButtons.forEach((item) => item.classList.toggle("is-active", item === button));
renderTableList();
});
});
testButton.addEventListener("click", async () => {
testButton.disabled = true;
setStatus("MySQL 연결을 확인하고 있습니다...");
try {
saveCredentials();
const response = await fetch("/hanmac-browser/api/test-connection", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(getPayload()),
});
const payload = await response.json();
if (!response.ok || payload.status !== "ok") {
throw new Error(payload.message || "연결 확인에 실패했습니다.");
}
setStatus(`${payload.message} 접속 사용자: ${payload.user || "-"} / 서버 버전: ${payload.server_version || "-"}`, "success");
} catch (error) {
setStatus(error.message || "연결 확인 중 오류가 발생했습니다.", "error");
} finally {
testButton.disabled = false;
}
});
testErpButton.addEventListener("click", async () => {
testErpButton.disabled = true;
setStatus("관리 ERP 로그인과 내부 접근 범위를 확인하고 있습니다...");
try {
saveErpCredentials();
const payload = await fetchJson("/hanmac-browser/api/test-management-erp", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(getErpPayload()),
});
setStatus(
`${payload.message} 내부 링크 ${Number(payload.internal_link_count || 0).toLocaleString("ko-KR")}개 확인 · ${payload.direct_db_message || "DB 직접 접속정보 미확인"} · ${payload.transport_warning || ""}`,
"success",
);
} catch (error) {
setStatus(error.message || "관리 ERP 접근 확인 중 오류가 발생했습니다.", "error");
} finally {
testErpButton.disabled = false;
}
});
discoverSatisBudgetButton.addEventListener("click", async () => {
discoverSatisBudgetButton.disabled = true;
setStatus("Satis 예산 후보 DB·테이블을 탐색하고 로컬 이력 저장소를 준비하고 있습니다...");
try {
saveErpCredentials();
const payload = await fetchJson("/hanmac-browser/api/discover-satis-budget", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(getErpPayload()),
});
const dbCount = Number(payload.direct_db_database_count || 0).toLocaleString("ko-KR");
const tableCount = Number(payload.candidate_table_count || 0).toLocaleString("ko-KR");
const firstTables = Array.isArray(payload.candidate_tables)
? payload.candidate_tables.slice(0, 3).map((row) => `${row.database}.${row.table}`).join(", ")
: "";
const preparedTables = Array.isArray(payload.prepared_local_tables)
? payload.prepared_local_tables.length
: 0;
setStatus(
`${payload.message} DB ${dbCount}개 · 후보 테이블 ${tableCount}개 · 로컬 이력 테이블 ${preparedTables}개 준비${firstTables ? ` · 예: ${firstTables}` : ""}`,
payload.direct_db_access || payload.web_access ? "success" : "error",
);
if (payload.transport_warning) {
console.warn(payload.transport_warning);
}
if (payload.candidate_tables?.length) {
console.table(payload.candidate_tables.slice(0, 20));
}
} catch (error) {
setStatus(error.message || "Satis 예산 후보 탐색 중 오류가 발생했습니다.", "error");
} finally {
discoverSatisBudgetButton.disabled = false;
}
});
syncSatisBudgetButton.addEventListener("click", async () => {
syncSatisBudgetButton.disabled = true;
setStatus("Satis 후보 테이블에서 실제 예산 금액 행을 읽어 원본 저장소에 저장하고 있습니다...");
try {
saveErpCredentials();
const payload = await fetchJson("/hanmac-browser/api/sync-satis-budget-raw", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...getErpPayload(),
max_tables: 12,
row_limit: 500,
}),
});
const rowCount = Number(payload.inserted_or_updated_rows || 0).toLocaleString("ko-KR");
const tableCount = Array.isArray(payload.table_results)
? payload.table_results.filter((row) => row.status === "synced").length
: 0;
const firstTables = Array.isArray(payload.table_results)
? payload.table_results
.filter((row) => row.status === "synced" && Number(row.row_count || 0) > 0)
.slice(0, 3)
.map((row) => `${row.database}.${row.table} ${Number(row.row_count || 0).toLocaleString("ko-KR")}건`)
.join(", ")
: "";
setStatus(
`${payload.message} 저장 테이블: ${payload.raw_table || "satis_project_budget_raw_rows"} · 처리 테이블 ${tableCount.toLocaleString("ko-KR")}${firstTables ? ` · 예: ${firstTables}` : ""}`,
Number(payload.inserted_or_updated_rows || 0) > 0 ? "success" : "error",
);
if (payload.table_results?.length) {
console.table(payload.table_results);
}
} catch (error) {
setStatus(error.message || "Satis 예산 금액 저장 중 오류가 발생했습니다.", "error");
} finally {
syncSatisBudgetButton.disabled = false;
}
});
normalizeSatisBudgetButton.addEventListener("click", async () => {
normalizeSatisBudgetButton.disabled = true;
setStatus("저장된 Satis 원본 금액을 차수/상세 예산 테이블로 정규화하고 있습니다...");
try {
const payload = await fetchJson("/hanmac-browser/api/normalize-satis-budget", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const revisionCount = Number(payload.normalized_revisions || 0).toLocaleString("ko-KR");
const taskLineCount = Number(payload.task_lines || 0).toLocaleString("ko-KR");
const execLineCount = Number(payload.exec_lines || 0).toLocaleString("ko-KR");
const firstSummary = Array.isArray(payload.summary)
? payload.summary
.slice(0, 3)
.map((row) => `${row.budget_type}: ${Number(row.revision_count || 0).toLocaleString("ko-KR")}차수`)
.join(", ")
: "";
setStatus(
`${payload.message} 차수 ${revisionCount}건 · 과업수행계획 ${taskLineCount}행 · 실행예산 ${execLineCount}${firstSummary ? ` · ${firstSummary}` : ""}`,
Number(payload.normalized_revisions || 0) > 0 ? "success" : "error",
);
if (payload.summary?.length) {
console.table(payload.summary);
}
if (payload.source_tables?.length) {
console.table(payload.source_tables);
}
} catch (error) {
setStatus(error.message || "Satis 예산 정규화 중 오류가 발생했습니다.", "error");
} finally {
normalizeSatisBudgetButton.disabled = false;
}
});
projectSatisBudgetButton.addEventListener("click", async () => {
projectSatisBudgetButton.disabled = true;
setStatus("프로젝트별 승인 최신 차수를 기존 예산 입력 테이블에 반영하고 있습니다. 승인 중 최신 차수는 가반영합니다...");
try {
const payload = await fetchJson("/hanmac-browser/api/project-satis-budget-current", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ include_pending: true }),
});
const projectCount = Number(payload.project_count || 0).toLocaleString("ko-KR");
const taskRows = Number(payload.task_rows_inserted || 0).toLocaleString("ko-KR");
const execRows = Number(payload.exec_rows_inserted || 0).toLocaleString("ko-KR");
const provisionalCount = Number(payload.provisional_count || 0).toLocaleString("ko-KR");
const firstProjected = Array.isArray(payload.projected)
? payload.projected
.slice(0, 3)
.map((row) => `${row.support_dept_code}/${row.budget_type}/${row.projection_mode}`)
.join(", ")
: "";
setStatus(
`${payload.message} 과업수행계획 ${taskRows}행 · 실행예산 ${execRows}행 · 가반영 ${provisionalCount}${firstProjected ? ` · 예: ${firstProjected}` : ""}`,
Number(payload.project_count || 0) > 0 ? "success" : "error",
);
if (payload.projected?.length) {
console.table(payload.projected);
}
if (payload.skipped?.length) {
console.table(payload.skipped);
}
} catch (error) {
setStatus(error.message || "Satis 예산 기존 입력 테이블 반영 중 오류가 발생했습니다.", "error");
} finally {
projectSatisBudgetButton.disabled = false;
}
});
runSatisBudgetFullSyncButton.addEventListener("click", async () => {
runSatisBudgetFullSyncButton.disabled = true;
setStatus("Satis 예산 전체 실행 중입니다. 원본 저장 → 정규화 → 승인 최신 차수 반영을 순차 처리합니다...");
try {
saveErpCredentials();
const payload = await fetchJson("/hanmac-browser/api/run-satis-budget-full-sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...getErpPayload(),
max_tables: 12,
row_limit: 500,
}),
});
const rawRows = Number(payload.inserted_or_updated_rows || 0).toLocaleString("ko-KR");
const revisions = Number(payload.normalized_revisions || 0).toLocaleString("ko-KR");
const projects = Number(payload.project_count || 0).toLocaleString("ko-KR");
const taskRows = Number(payload.task_rows_inserted || 0).toLocaleString("ko-KR");
const execRows = Number(payload.exec_rows_inserted || 0).toLocaleString("ko-KR");
const provisional = Number(payload.provisional_count || 0).toLocaleString("ko-KR");
setStatus(
`${payload.message} 원본 ${rawRows}건 · 차수 ${revisions}건 · 프로젝트 ${projects}개 · 과업 ${taskRows}행 · 실행 ${execRows}행 · 가반영 ${provisional}건`,
Number(payload.project_count || 0) > 0 ? "success" : "error",
);
console.log("Satis full sync", payload);
} catch (error) {
setStatus(error.message || "Satis 예산 전체 실행 중 오류가 발생했습니다.", "error");
} finally {
runSatisBudgetFullSyncButton.disabled = false;
}
});
collectSatisBudgetWebButton.addEventListener("click", async () => {
collectSatisBudgetWebButton.disabled = true;
setStatus("Satis 웹로그인 세션으로 예산 관련 메뉴/컨트롤러 응답을 수집하고 있습니다...");
try {
saveErpCredentials();
const payload = await fetchJson("/hanmac-browser/api/collect-satis-budget-web", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...getErpPayload(),
max_pages: 40,
}),
});
const capturedCount = Number(payload.captured_count || 0).toLocaleString("ko-KR");
const matchedCount = Number(payload.matched_count || 0).toLocaleString("ko-KR");
const errorCount = Number(payload.error_count || 0).toLocaleString("ko-KR");
const firstExamples = Array.isArray(payload.matched_examples)
? payload.matched_examples
.slice(0, 2)
.map((row) => `${row.title || row.url || "응답"} (${Number(row.amount_candidate_count || 0).toLocaleString("ko-KR")}개 금액후보)`)
.join(", ")
: "";
setStatus(
`${payload.message} 매칭 ${matchedCount}건 · 오류 ${errorCount}${firstExamples ? ` · 예: ${firstExamples}` : ""}`,
Number(payload.captured_count || 0) > 0 ? "success" : "error",
);
console.log("Satis web collection", payload);
if (payload.matched_examples?.length) {
console.table(payload.matched_examples);
}
} catch (error) {
setStatus(error.message || "Satis 웹로그인 예산 수집 중 오류가 발생했습니다.", "error");
} finally {
collectSatisBudgetWebButton.disabled = false;
}
});
loadTablesButton.addEventListener("click", loadTables);
loadGradeCodesButton.addEventListener("click", async () => {
loadGradeCodesButton.disabled = true;
gradeCodesPanel.classList.add("is-open");
gradeCodesPanel.innerHTML = '<div class="empty">직급 코드를 조회하고 있습니다.</div>';
setStatus("직급 코드를 조회하고 있습니다...");
try {
saveCredentials();
const payload = await fetchJson("/hanmac-browser/api/grade-codes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(getPayload()),
});
renderGradeCodes(payload);
setStatus("직급 코드 조회가 완료되었습니다.", "success");
} catch (error) {
gradeCodesPanel.classList.add("is-open");
gradeCodesPanel.innerHTML = `<div class="empty">${escapeHtml(error.message || "직급 코드 조회 중 오류가 발생했습니다.")}</div>`;
setStatus(error.message || "직급 코드 조회 중 오류가 발생했습니다.", "error");
} finally {
loadGradeCodesButton.disabled = false;
}
});
aggregateLoadButton.addEventListener("click", loadAggregate);
aggregateRebuildCacheButton.addEventListener("click", async () => {
const confirmed = window.confirm("현재 hanmac 집계 조건의 캐시를 서버에서 다시 계산할까요? 계산 중에도 다른 화면을 사용할 수 있습니다.");
if (!confirmed) return;
try {
await requestAggregateCacheRebuild();
} catch (error) {
systemJobStatus.textContent = error.message || "집계 캐시 작업 등록 실패";
systemJobStatus.classList.add("is-error");
aggregateRebuildCacheButton.disabled = false;
}
});
holidaySaveButton?.addEventListener("click", saveHoliday);
refreshPreviewButton.addEventListener("click", () => loadPreview(currentPreviewCursor, true));
previewRebuildCacheButton.addEventListener("click", async () => {
const confirmed = window.confirm("현재 원본 테이블 미리보기 캐시를 서버에서 다시 계산할까요? 계산 중에도 다른 화면을 사용할 수 있습니다.");
if (!confirmed) return;
try {
await requestPreviewCacheRebuild();
} catch (error) {
systemJobStatus.textContent = error.message || "미리보기 캐시 작업 등록 실패";
systemJobStatus.classList.add("is-error");
previewRebuildCacheButton.disabled = false;
}
});
previewPrevButton.addEventListener("click", async () => {
if (!currentPreviewCursorHistory.length) return;
const previousCursor = currentPreviewCursorHistory[currentPreviewCursorHistory.length - 1] || "";
currentPreviewCursorHistory = currentPreviewCursorHistory.slice(0, -1);
await loadPreview(previousCursor, true);
});
previewNextButton.addEventListener("click", async () => {
if (!currentPreviewNextCursor) return;
await loadPreview(currentPreviewNextCursor);
});
exportPreviewButton.addEventListener("click", exportPreviewToExcel);
exportAggregateButton.addEventListener("click", exportAggregateToExcel);
aggregateValueColumn.addEventListener("change", () => {
renderAggregateValueOptions();
applyAggregateValueFilter();
});
aggregateValueSearch.addEventListener("input", () => {
renderAggregateValueOptions();
applyAggregateValueFilter();
});
aggregateValueReset.addEventListener("click", () => {
aggregateValueColumn.value = "";
aggregateValueSearch.value = "";
renderAggregateValueOptions();
applyAggregateValueFilter();
});
previewValueColumn.addEventListener("change", () => {
renderPreviewValueOptions();
applyPreviewValueFilter();
});
previewValueSearch.addEventListener("input", () => {
renderPreviewValueOptions();
applyPreviewValueFilter();
});
previewValueReset.addEventListener("click", () => {
previewValueColumn.value = "";
previewValueSearch.value = "";
renderPreviewValueOptions();
applyPreviewValueFilter();
});
previewToggleButton.addEventListener("click", () => {
const collapsed = previewControls.classList.toggle("is-collapsed");
previewToggleButton.setAttribute("aria-expanded", collapsed ? "false" : "true");
previewToggleButton.textContent = collapsed ? "열기" : "접기";
});
[aggregateStartDate, aggregateEndDate].forEach((input) => {
input.addEventListener("input", () => normalizeDateYear(input));
input.addEventListener("change", () => normalizeDateYear(input));
input.addEventListener("blur", () => normalizeDateYear(input));
});
updateSelectedSummary(null);
setAggregateEmpty("", { forceClear: true });
setPreviewEmpty("");
setStatus("");
const today = new Date();
const startOfYear = new Date(today.getFullYear(), 0, 1);
restoreCredentials();
restoreErpCredentials();
selectConnectionTab("work");
aggregateStartDate.value = startOfYear.toISOString().slice(0, 10);
aggregateEndDate.value = today.toISOString().slice(0, 10);
const restoredAggregateState = restoreAggregateState();
loadLatestSystemJob();
if (restoredAggregateState) {
loadAggregate();
}
loadHolidays();
})();
</script>
{% endblock %}