Files
HM_project_Viewer_Board/templates/hanmac_browser.html
T

3511 lines
144 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-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-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%);
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);
}
.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: grid;
grid-template-columns: auto auto minmax(0, 1fr);
gap: 10px;
align-items: center;
}
@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,
.hanmac-modal-actions {
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-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>
<button type="button" class="hanmac-close-button" id="hanmacCloseConfigButton" aria-label="닫기">×</button>
</div>
<form id="hanmacConnectionForm">
<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>
</form>
<div class="hanmac-modal-actions">
<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 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 loadTablesButton = document.getElementById("hanmacLoadTablesButton");
const loadGradeCodesButton = document.getElementById("hanmacLoadGradeCodesButton");
const gradeCodesPanel = document.getElementById("hanmacGradeCodesPanel");
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 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 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 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([
"regular_hours",
"overtime_hours",
"total_hours",
"legal_leave_days",
"project_count",
]);
const aggregateDetailLabels = {
regular_hours: "정규근로",
overtime_hours: "연장근로",
total_hours: "총근로",
legal_leave_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) : [];
return equivalentCodes.length ? equivalentCodes.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 detailButton = duplicateDays > 0
? `<button type="button" class="hanmac-aggregate-detail-link" data-aggregate-duplicate-index="${rowIndex}">중복 ${duplicateDays.toLocaleString("ko-KR")}일</button>`
: "";
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">원본근로 ${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("")}
</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})` : ""}`,
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 === "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 diagnosticText = diagnostics
? ` · 공식연장 ${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.center_excluded_member_count || 0).toLocaleString("ko-KR")}명 (동일사번 ${Number(diagnostics.center_same_member_hidden_count || 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);
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;
}
});
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();
aggregateStartDate.value = startOfYear.toISOString().slice(0, 10);
aggregateEndDate.value = today.toISOString().slice(0, 10);
const restoredAggregateState = restoreAggregateState();
loadLatestSystemJob();
if (restoredAggregateState) {
loadAggregate();
}
loadHolidays();
})();
</script>
{% endblock %}