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