Files
HM_project_Viewer_Board/templates/hanmac_browser.html
T

1860 lines
70 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: 10px 10px;
border-bottom: 1px solid #eceff3;
text-align: left;
vertical-align: top;
font-size: 12px;
line-height: 1.5;
white-space: nowrap;
}
.hanmac-aggregate-table th {
position: sticky;
top: 0;
z-index: 1;
background: #f7f9fc;
color: var(--ink);
font-weight: 800;
}
.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-detail-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 24px;
padding: 0 8px;
border: 1px solid #d8dfeb;
border-radius: 999px;
background: #f5f8fc;
color: #1f4d8f;
font-size: 11px;
font-weight: 700;
cursor: pointer;
}
.hanmac-aggregate-detail-link:hover {
background: #ebf2fb;
}
.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-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>
</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">
<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 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 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 || !multiEntryModal || !closeMultiEntryButton || !multiEntryTitle || !multiEntryMeta || !multiEntryBody || !previewValueColumn || !previewValueSearch || !previewValueOptions || !previewValueReset || !previewLimit || !refreshPreviewButton || !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 currentAggregateRows = [];
let currentAggregateAllRows = [];
let currentAggregateColumns = [];
let currentAggregateView = "member";
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 csvEscape = (value) => {
const textValue = value == null ? "" : String(value);
return `"${textValue.replace(/"/g, '""')}"`;
};
const exportPreviewToExcel = () => {
if (!currentPreviewColumns.length || !currentPreviewRows.length) return;
const header = currentPreviewColumns.map((column) => csvEscape(getColumnLabel(column))).join(",");
const body = currentPreviewRows.map((row) => (
currentPreviewColumns.map((column) => csvEscape(row[column])).join(",")
));
const csvContent = ["\uFEFF" + header, ...body].join("\r\n");
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
const today = new Date().toISOString().slice(0, 10);
anchor.href = url;
anchor.download = `${selectedSchema || "hanmac"}_${selectedTable || "preview"}_${today}.csv`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
};
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 = [];
previewValueColumn.innerHTML = `<option value="">전체 컬럼</option>`;
previewValueSearch.value = "";
previewValueOptions.innerHTML = "";
summaryColumns.textContent = "0";
summaryKey.textContent = "-";
};
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 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);
previewMeta.textContent = searchValue
? `최근 ${Number(filteredRows.length).toLocaleString("ko-KR")}건 표시`
: `최근 ${Number(currentPreviewAllRows.length).toLocaleString("ko-KR")}건 표시`;
};
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 renderAggregateTableRows = (columns, rows, view) => {
currentAggregateColumns = columns;
currentAggregateRows = rows;
aggregateHead.innerHTML = `<tr>${columns.map((column) => `<th>${column.label}</th>`).join("")}</tr>`;
aggregateBody.innerHTML = rows.map((row, rowIndex) => (
`<tr>${columns.map((column) => {
if (view === "member" && column.key === "regular_hours") {
const duplicateDays = Number(row.multi_entry_days || 0);
return `<td><div class="hanmac-aggregate-regular-cell"><span>${formatNumberText(row[column.key])}</span>${duplicateDays > 0 ? `<button type="button" class="hanmac-aggregate-detail-link" data-aggregate-duplicate-index="${rowIndex}">${duplicateDays}일</button>` : ""}</div></td>`;
}
return `<td>${row[column.key] == null ? "" : escapeHtml(String(row[column.key]))}</td>`;
}).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, 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>${escapeHtml(entry.project_code || "(미지정)")}</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 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;
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;
previewValueColumn.innerHTML = `<option value="">전체 컬럼</option>${columns.map((column) => `<option value="${escapeHtml(column)}">${getColumnLabel(column)}</option>`).join("")}`;
previewValueSearch.value = "";
renderPreviewValueOptions();
renderPreviewTableRows(columns, rows);
previewMeta.textContent = `최근 ${Number(payload.shown_count || 0).toLocaleString("ko-KR")}건 표시`;
updateSelectedSummary(activeItem, columns);
};
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_days || 0).toLocaleString("ko-KR");
aggregateProjectCount.textContent = Number(summary.project_count || 0).toLocaleString("ko-KR");
aggregateMeta.textContent = `${payload.start_date || ""} ~ ${payload.end_date || ""}`;
if (!columns.length || !rows.length) {
setAggregateEmpty("");
return;
}
aggregateValueColumn.innerHTML = `<option value="">전체 컬럼</option>${columns.map((column) => `<option value="${escapeHtml(column.key)}">${escapeHtml(column.label)}</option>`).join("")}`;
aggregateValueSearch.value = "";
renderAggregateValueOptions();
renderAggregateTableRows(columns, rows, payload.view || "member");
};
const loadPreview = async () => {
if (!selectedTable || !selectedSchema) {
setPreviewEmpty("");
previewMeta.textContent = "";
return;
}
refreshPreviewButton.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,
}),
});
const payload = await response.json();
if (!response.ok || payload.status !== "ok") {
throw new Error(payload.message || "테이블 미리보기에 실패했습니다.");
}
renderPreview(payload);
} catch (error) {
setPreviewEmpty(error.message || "테이블 미리보기 중 오류가 발생했습니다.");
previewMeta.textContent = "조회 실패";
} finally {
refreshPreviewButton.disabled = false;
}
};
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 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]);
});
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();
if (getPayload().password) {
loadAggregate().catch(() => {});
}
});
});
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);
exportPreviewButton.addEventListener("click", exportPreviewToExcel);
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);
const hasSavedPassword = restoreCredentials();
if (hasSavedPassword) {
loadTables().catch(() => {});
loadAggregate().catch(() => {});
}
})();
</script>
{% endblock %}