Files
HM_project_Viewer_Board/templates/hanmac_browser.html
T

2420 lines
96 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-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: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
white-space: nowrap;
}
.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-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;
}
.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="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>
</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="period">특정시기근무자</option>
<option value="retired">퇴사자</option>
</select>
<button type="button" class="hanmac-button secondary" id="hanmacAggregateLoadButton">집계 조회</button>
<button type="button" class="hanmac-button secondary hanmac-emoji-button" id="hanmacExportAggregateButton" title="집계 엑셀 다운로드" aria-label="집계 엑셀 다운로드">📥</button>
</div>
</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="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="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>
<div class="hanmac-inline-status" id="hanmacConnectionStatus"></div>
</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>
{% 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 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 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 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 exportAggregateButton = document.getElementById("hanmacExportAggregateButton");
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 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 || !tableListMeta || !tableList || !tableSearch || !aggregateStartDate || !aggregateEndDate || !aggregateEmployment || !aggregateLoadButton || !aggregateMeta || !aggregateMemberCount || !aggregateRegularHours || !aggregateOvertimeHours || !aggregateTotalHours || !aggregateLeaveDays || !aggregateProjectCount || !aggregateEmpty || !aggregateTable || !aggregateHead || !aggregateBody || !aggregateValueColumn || !aggregateValueSearch || !aggregateValueOptions || !aggregateValueReset || !exportAggregateButton || !multiEntryModal || !closeMultiEntryButton || !multiEntryTitle || !multiEntryMeta || !multiEntryBody || !previewValueColumn || !previewValueSearch || !previewValueOptions || !previewValueReset || !previewLimit || !refreshPreviewButton || !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 currentAggregateRows = [];
let currentAggregateAllRows = [];
let currentAggregateColumns = [];
let currentAggregateView = "member";
let currentAggregateSort = { key: "", direction: "desc" };
const credentialStorageKey = "hanmac-db-external-credentials-v1";
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 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 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,
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";
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 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 = a.schema === "hanmac_manhour" ? 0 : 1;
const bSchemaPriority = b.schema === "hanmac_manhour" ? 0 : 1;
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) => {
aggregateEmpty.hidden = false;
aggregateEmpty.textContent = message;
aggregateTable.hidden = true;
aggregateHead.innerHTML = "";
aggregateBody.innerHTML = "";
currentAggregateColumns = [];
currentAggregateRows = [];
currentAggregateAllRows = [];
currentAggregateView = "member";
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 formatNumberText = (value) => {
const number = Number(value || 0);
if (!Number.isFinite(number)) return value == null ? "" : String(value);
return number.toLocaleString("ko-KR", { maximumFractionDigits: 2 });
};
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 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) => {
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 (view === "member" && column.key === "regular_hours") {
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}일</button>`
: "";
return `<td class="is-numeric"><div class="hanmac-aggregate-metric-cell">${valueHtml}${dayBadge}${detailButton}</div></td>`;
}
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 renderAggregateTableRows = (columns, rows, view) => {
currentAggregateColumns = columns;
currentAggregateRows = rows;
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 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>
</div>
<table class="hanmac-detail-table">
<thead>
<tr>
<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>
</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 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>
`;
}
if (detailKey === "legal_leave_days") {
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>
${details.map((detail) => `
<tr>
<td>${escapeHtml(detail.work_date || "-")}</td>
<td>${escapeHtml(detail.leave_type || "법정휴가")}</td>
<td class="is-numeric">${formatNumberText(detail.leave_days)}</td>
<td class="is-numeric">${formatNumberText(detail.leave_hours)}</td>
</tr>
`).join("")}
</tbody>
</table>
`;
}
if (detailKey === "overtime_hours") {
return `
<table class="hanmac-detail-table">
<thead>
<tr>
<th>일자</th>
<th>${currentAggregateView === "project" ? "사원" : "프로젝트"}</th>
<th class="is-numeric">시간</th>
</tr>
</thead>
<tbody>
${details.map((detail) => `
<tr>
<td>${escapeHtml(detail.work_date || "-")}</td>
<td>${escapeHtml(currentAggregateView === "project" ? `${detail.member_name || detail.member_no || ""} ${detail.member_no ? `(${detail.member_no})` : ""}`.trim() : `${detail.project_name || detail.project_code || "(미지정)"} ${detail.project_code ? `(${detail.project_code})` : ""}`.trim())}</td>
<td class="is-numeric">${formatNumberText(detail.overtime_hours)}</td>
</tr>
`).join("")}
</tbody>
</table>
`;
}
return 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>
${detail.detail_type ? `<span class="hanmac-detail-badge">${escapeHtml(detail.detail_type)}</span>` : ""}
${detail.member_name || detail.member_no ? `<span class="hanmac-detail-badge">${escapeHtml(`${detail.member_name || detail.member_no || ""} ${detail.member_no ? `(${detail.member_no})` : ""}`.trim())}</span>` : ""}
${detail.regular_hours != null ? `<span class="hanmac-detail-badge">정규 ${formatNumberText(detail.regular_hours)}시간</span>` : ""}
${detail.holiday_hours != null ? `<span class="hanmac-detail-badge">휴일 ${formatNumberText(detail.holiday_hours)}시간</span>` : ""}
${detail.overtime_hours != null ? `<span class="hanmac-detail-badge">연장 ${formatNumberText(detail.overtime_hours)}시간</span>` : ""}
${detail.raw_total_hours != null ? `<span class="hanmac-detail-badge">원본 ${formatNumberText(detail.raw_total_hours)}시간</span>` : ""}
${detail.leave_days ? `<span class="hanmac-detail-badge">휴가 ${formatNumberText(detail.leave_days)}일</span>` : ""}
${detail.leave_hours ? `<span class="hanmac-detail-badge">휴가 ${formatNumberText(detail.leave_hours)}시간</span>` : ""}
</div>
${renderDetailProjectList(detail.projects)}
</section>
`).join("");
};
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) => {
const columns = Array.isArray(payload.columns) ? payload.columns : [];
const rows = Array.isArray(payload.rows) ? payload.rows : [];
const summary = payload.summary || {};
currentAggregateAllRows = rows;
currentAggregateView = payload.view || "member";
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
? ` · HolidayTime ${Number(diagnostics.holiday_time_rows || 0).toLocaleString("ko-KR")}행 · 휴가 ${Number(diagnostics.leave_matched_rows || 0).toLocaleString("ko-KR")}/${Number(diagnostics.tardy_candidate_rows || 0).toLocaleString("ko-KR")}행`
: "";
aggregateMeta.textContent = `${payload.start_date || ""} ~ ${payload.end_date || ""}${diagnosticText}`;
if (!columns.length || !rows.length) {
setAggregateEmpty("");
return;
}
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 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 테이블 목록을 불러왔습니다.", "success");
} catch (error) {
tableListMeta.textContent = error.message || "테이블 목록 조회 실패";
tableList.innerHTML = "";
setPreviewEmpty("");
updateSelectedSummary(null);
setStatus(error.message || "테이블 목록 조회 중 오류가 발생했습니다.", "error");
} finally {
loadTablesButton.disabled = false;
}
};
const loadAggregate = async () => {
aggregateLoadButton.disabled = true;
aggregateMeta.textContent = "집계 조회 중...";
try {
const response = await fetch("/hanmac-browser/api/aggregate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...getPayload(),
start_date: aggregateStartDate.value,
end_date: aggregateEndDate.value,
employment: aggregateEmployment.value,
view: getAggregateView(),
}),
});
const payload = await response.json();
if (!response.ok || payload.status !== "ok") {
throw new Error(payload.message || "집계 조회에 실패했습니다.");
}
renderAggregate(payload);
} catch (error) {
setAggregateEmpty(error.message || "집계 조회 중 오류가 발생했습니다.");
} finally {
aggregateLoadButton.disabled = false;
}
};
openConfigButton.addEventListener("click", openModal);
closeConfigButton.addEventListener("click", closeModal);
modal.addEventListener("click", (event) => {
if (event.target === modal) closeModal();
});
multiEntryModal.addEventListener("click", (event) => {
if (event.target === multiEntryModal) closeMultiEntryModal();
});
closeMultiEntryButton.addEventListener("click", closeMultiEntryModal);
tableSearch.addEventListener("input", renderTableList);
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);
aggregateLoadButton.addEventListener("click", loadAggregate);
refreshPreviewButton.addEventListener("click", () => loadPreview(currentPreviewCursor, true));
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("");
setPreviewEmpty("");
setStatus("");
const today = new Date();
const startOfYear = new Date(today.getFullYear(), 0, 1);
aggregateStartDate.value = startOfYear.toISOString().slice(0, 10);
aggregateEndDate.value = today.toISOString().slice(0, 10);
restoreCredentials();
})();
</script>
{% endblock %}