2014 lines
72 KiB
HTML
2014 lines
72 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="ko">
|
|
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>MH 조직현황 관리 - 레이아웃 및 정렬 최적화</title>
|
|
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<style>
|
|
@import url('https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;900&display=swap');
|
|
|
|
body {
|
|
margin: 0;
|
|
background: #f1f5f9;
|
|
font-family: 'Pretendard', sans-serif;
|
|
color: #1e293b;
|
|
overflow-x: hidden;
|
|
}
|
|
|
|
.top-wrap {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 1000;
|
|
background: white;
|
|
border-bottom: 1px solid #e2e8f0;
|
|
padding: 10px 20px;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.btn-primary {
|
|
background: #4f46e5;
|
|
color: white;
|
|
padding: 7px 16px;
|
|
border-radius: 8px;
|
|
font-weight: 800;
|
|
font-size: 12px;
|
|
cursor: pointer;
|
|
border: none;
|
|
}
|
|
|
|
.org-canvas {
|
|
padding: 40px 20px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 40px;
|
|
width: 100%;
|
|
position: relative;
|
|
}
|
|
|
|
.dept-section {
|
|
width: 100%;
|
|
max-width: 1900px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
margin-bottom: 40px;
|
|
position: relative;
|
|
}
|
|
|
|
.dept-box {
|
|
width: fit-content;
|
|
min-width: 320px;
|
|
background: white;
|
|
border: 1px solid #cbd5e1;
|
|
border-radius: 10px;
|
|
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
|
|
position: relative;
|
|
z-index: 20;
|
|
margin-bottom: 40px;
|
|
}
|
|
|
|
.dept-header {
|
|
background: #1e293b;
|
|
color: white;
|
|
padding: 12px;
|
|
text-align: center;
|
|
font-size: 17px;
|
|
font-weight: 900;
|
|
border-radius: 10px;
|
|
}
|
|
|
|
.dept-header.has-members {
|
|
border-radius: 10px 10px 0 0;
|
|
border-bottom: none;
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.node-group {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
justify-content: center;
|
|
width: 100%;
|
|
position: relative;
|
|
gap: 12px;
|
|
}
|
|
|
|
.node-item {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
position: relative;
|
|
}
|
|
|
|
.box {
|
|
width: fit-content;
|
|
min-width: 112px;
|
|
background: white;
|
|
border: 1px solid #cbd5e1;
|
|
border-radius: 8px;
|
|
padding: 6px;
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
|
position: relative;
|
|
z-index: 10;
|
|
margin-bottom: 40px;
|
|
}
|
|
|
|
.box-name {
|
|
font-size: 13px;
|
|
font-weight: 800;
|
|
color: #475569;
|
|
text-align: center;
|
|
border-bottom: 1px solid #f1f5f9;
|
|
padding-bottom: 4px;
|
|
margin-bottom: 6px;
|
|
word-break: keep-all;
|
|
}
|
|
|
|
/* 위계 단계별 가로 최적화 */
|
|
.box-level-그룹 {
|
|
min-width: 250px;
|
|
}
|
|
|
|
.box-level-그룹 .box-name {
|
|
background: #3f516a;
|
|
color: #ffffff;
|
|
padding: 8px;
|
|
border-radius: 6px 6px 0 0;
|
|
margin: -6px -6px 8px -6px;
|
|
border-bottom: none;
|
|
}
|
|
|
|
.box-level-디비전 {
|
|
min-width: 150px;
|
|
}
|
|
|
|
.box-level-디비전 .box-name {
|
|
background: #869fb7;
|
|
color: #ffffff;
|
|
padding: 8px;
|
|
border-radius: 6px 6px 0 0;
|
|
margin: -6px -6px 8px -6px;
|
|
border-bottom: none;
|
|
}
|
|
|
|
.box-level-팀 {
|
|
width: auto;
|
|
min-width: 120px;
|
|
}
|
|
|
|
.box-team {
|
|
width: auto;
|
|
min-width: 120px;
|
|
}
|
|
|
|
.member-grid {
|
|
display: grid;
|
|
grid-template-rows: repeat(10, auto);
|
|
grid-auto-flow: column;
|
|
gap: 3px;
|
|
column-gap: 8px;
|
|
}
|
|
|
|
.cell-label {
|
|
grid-column: span 1;
|
|
background: #e2e8f0;
|
|
color: #475569;
|
|
font-size: 10px;
|
|
font-weight: 900;
|
|
text-align: center;
|
|
padding: 3px;
|
|
border-radius: 4px;
|
|
margin: 2px 0;
|
|
height: fit-content;
|
|
}
|
|
|
|
/* 공백 박스 스타일 */
|
|
.spacer-box {
|
|
width: 100px;
|
|
height: 26px;
|
|
visibility: hidden;
|
|
}
|
|
|
|
.member-card {
|
|
width: 100px;
|
|
padding: 4px 6px;
|
|
border-radius: 4px;
|
|
font-size: 11.5px;
|
|
text-align: left;
|
|
border: 1px solid #f1f5f9;
|
|
border-left: 4px solid #94a3b8;
|
|
background: #f8fafc;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease-in-out;
|
|
display: flex;
|
|
flex-direction: column;
|
|
position: relative;
|
|
}
|
|
|
|
.member-card.full-width {
|
|
width: 100% !important;
|
|
}
|
|
|
|
.member-card:hover {
|
|
background: white;
|
|
border-color: #4f46e5;
|
|
box-shadow: 0 4px 10px rgba(79, 70, 229, 0.2);
|
|
transform: translateY(-2px);
|
|
z-index: 50;
|
|
}
|
|
|
|
.drop-left::before {
|
|
content: '';
|
|
position: absolute;
|
|
left: -6px;
|
|
top: 0;
|
|
width: 4px;
|
|
height: 100%;
|
|
background: #4f46e5;
|
|
border-radius: 4px;
|
|
z-index: 20;
|
|
}
|
|
|
|
.drop-right::after {
|
|
content: '';
|
|
position: absolute;
|
|
right: -6px;
|
|
top: 0;
|
|
width: 4px;
|
|
height: 100%;
|
|
background: #4f46e5;
|
|
border-radius: 4px;
|
|
z-index: 20;
|
|
}
|
|
|
|
.co-삼안 {
|
|
border-left-color: #ffb366 !important;
|
|
}
|
|
|
|
.co-한맥 {
|
|
border-left-color: #ef4444 !important;
|
|
}
|
|
|
|
.co-피티씨 {
|
|
border-left-color: #a855f7 !important;
|
|
}
|
|
|
|
.co-바론 {
|
|
border-left-color: #3b82f6 !important;
|
|
}
|
|
|
|
.m-top {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 4px;
|
|
}
|
|
|
|
.m-name {
|
|
font-weight: 900;
|
|
color: #1e293b;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.m-rank {
|
|
color: #94a3b8;
|
|
font-size: 8px;
|
|
font-weight: 500;
|
|
margin-left: auto; /* 우측 정렬 */
|
|
}
|
|
|
|
.m-role {
|
|
color: #4f46e5;
|
|
font-weight: 800;
|
|
font-size: 8.5px;
|
|
margin-left: 3px;
|
|
}
|
|
|
|
#modal {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(15, 23, 42, 0.75); /* 조금 더 어둡게 하여 모달 집중도 향상 */
|
|
backdrop-filter: blur(6px);
|
|
z-index: 2000;
|
|
display: none;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.modal-content {
|
|
background: white;
|
|
width: 100%;
|
|
max-width: 650px;
|
|
padding: 35px;
|
|
border-radius: 20px;
|
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
|
position: relative;
|
|
z-index: 2010;
|
|
}
|
|
#last-updated { z-index: 4000; }
|
|
|
|
@media print {
|
|
@page {
|
|
size: A3 landscape;
|
|
margin: 10mm;
|
|
}
|
|
body {
|
|
background: white !important;
|
|
-webkit-print-color-adjust: exact !important;
|
|
print-color-adjust: exact !important;
|
|
}
|
|
.top-wrap, .search-container, .tab-container, .stat-section,
|
|
.fab-container, .admin-mode-btn, #last-updated, .admin-mode-btn {
|
|
display: none !important;
|
|
}
|
|
.main-content {
|
|
padding: 0 !important;
|
|
margin: 0 !important;
|
|
overflow: visible !important;
|
|
width: 100% !important;
|
|
}
|
|
.dept-container {
|
|
page-break-inside: avoid;
|
|
margin-bottom: 20px !important;
|
|
}
|
|
.member-card {
|
|
box-shadow: none !important;
|
|
border: 1px solid #e2e8f0 !important;
|
|
}
|
|
}
|
|
|
|
.modal-content.wide {
|
|
max-width: 1200px;
|
|
}
|
|
|
|
/* 리스트 테이블 스타일 */
|
|
.list-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 11px;
|
|
}
|
|
.list-table th {
|
|
background: #f8fafc;
|
|
color: #64748b;
|
|
font-weight: 800;
|
|
padding: 10px;
|
|
border: 1px solid #e2e8f0;
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 20;
|
|
text-align: center;
|
|
}
|
|
.list-table td {
|
|
padding: 8px 10px;
|
|
border: 1px solid #e2e8f0;
|
|
text-align: center;
|
|
background: white;
|
|
vertical-align: middle;
|
|
}
|
|
.col-name { width: 90px; }
|
|
.col-rank { width: 80px; }
|
|
.col-pos { width: 80px; }
|
|
.col-unit-sm { width: 70px; }
|
|
.col-unit-lg { width: 100px; }
|
|
.col-corp { width: 110px; }
|
|
.col-action { width: 90px; }
|
|
|
|
/* 그룹 헤더 스타일 (계층형) */
|
|
.list-header-row {
|
|
color: #334155;
|
|
font-weight: 800;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
border-bottom: 1px solid #f1f5f9;
|
|
}
|
|
.list-header-row td {
|
|
font-size: 13px;
|
|
text-align: left !important;
|
|
padding: 10px 15px !important;
|
|
}
|
|
.list-header-row.lvl-0 td { background: #1e293b !important; color: white !important; font-size: 13.5px; font-weight: 900; }
|
|
.list-header-row.lvl-1 td { background: #3f516a !important; color: white !important; }
|
|
.list-header-row.lvl-2 td { background: #869fb7 !important; color: white !important; }
|
|
.list-header-row.lvl-3 td { background: #4f46e5 !important; color: white !important; }
|
|
.list-header-row.lvl-4 td { background: #e2e8f0 !important; color: #475569 !important; }
|
|
|
|
.list-header-row:hover { filter: brightness(1.1); }
|
|
|
|
.collapse-icon {
|
|
margin-right: 8px;
|
|
transition: transform 0.2s;
|
|
display: inline-block;
|
|
}
|
|
.collapsed .collapse-icon {
|
|
transform: rotate(-90deg);
|
|
}
|
|
.hidden-row {
|
|
display: none !important;
|
|
}
|
|
|
|
.list-table tr:hover td {
|
|
background: #f8fafc;
|
|
}
|
|
.list-table tr.dragging {
|
|
opacity: 0.5;
|
|
background: #eef2ff;
|
|
}
|
|
.list-search-target td {
|
|
background: #eff6ff !important;
|
|
border-top: 2px solid #3b82f6;
|
|
border-bottom: 2px solid #3b82f6;
|
|
}
|
|
|
|
.list-action-btn {
|
|
padding: 4px 8px;
|
|
border-radius: 4px;
|
|
font-size: 10px;
|
|
font-weight: 800;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
}
|
|
.btn-edit { background: #eef2ff; color: #4f46e5; }
|
|
.btn-delete { background: #fef2f2; color: #ef4444; }
|
|
|
|
/* 플로팅 버튼(Speed Dial) 스타일 */
|
|
.fab-container {
|
|
position: fixed;
|
|
bottom: 30px;
|
|
right: 30px;
|
|
display: flex;
|
|
flex-direction: column-reverse;
|
|
align-items: center;
|
|
gap: 15px;
|
|
z-index: 5000; /* 통계 섹션(1010)보다 훨씬 높게 설정 */
|
|
}
|
|
|
|
.fab-main {
|
|
width: 60px;
|
|
height: 60px;
|
|
background: #4f46e5;
|
|
color: white;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 30px;
|
|
box-shadow: 0 10px 25px rgba(79, 70, 229, 0.4);
|
|
cursor: pointer;
|
|
transition: all 0.3s;
|
|
border: none;
|
|
}
|
|
|
|
.fab-menu {
|
|
display: flex;
|
|
flex-direction: column-reverse;
|
|
align-items: center;
|
|
gap: 10px;
|
|
opacity: 0;
|
|
visibility: hidden;
|
|
transform: translateY(20px);
|
|
transition: all 0.3s;
|
|
}
|
|
|
|
.fab-container.active .fab-menu {
|
|
opacity: 1;
|
|
visibility: visible;
|
|
transform: translateY(0);
|
|
}
|
|
.fab-container.active .fab-main {
|
|
transform: rotate(45deg);
|
|
background: #4338ca;
|
|
}
|
|
|
|
.fab-sub {
|
|
width: 50px;
|
|
height: 50px;
|
|
background: white;
|
|
color: #4f46e5;
|
|
border: 2px solid #4f46e5;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 18px;
|
|
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
position: relative;
|
|
}
|
|
|
|
.fab-sub:hover {
|
|
background: #4f46e5;
|
|
color: white;
|
|
transform: scale(1.1);
|
|
}
|
|
|
|
.fab-sub::after {
|
|
content: attr(data-label);
|
|
position: absolute;
|
|
right: 65px;
|
|
background: #1e293b;
|
|
color: white;
|
|
padding: 4px 10px;
|
|
border-radius: 6px;
|
|
font-size: 11px;
|
|
font-weight: 800;
|
|
white-space: nowrap;
|
|
opacity: 0;
|
|
transition: 0.2s;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.fab-sub:hover::after {
|
|
opacity: 1;
|
|
right: 75px;
|
|
}
|
|
|
|
/* 클릭 가능 스타일 */
|
|
.clickable-title {
|
|
cursor: pointer;
|
|
transition: color 0.2s;
|
|
position: relative;
|
|
}
|
|
|
|
.clickable-title:hover {
|
|
color: #818cf8 !important;
|
|
text-decoration: underline;
|
|
}
|
|
|
|
/* 검색 섹션 스타일 (좌상단 플로팅 고정) */
|
|
.search-section {
|
|
position: fixed;
|
|
top: 75px;
|
|
left: 25px;
|
|
background: rgba(255, 255, 255, 0.9);
|
|
border-radius: 12px;
|
|
padding: 10px 18px;
|
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08);
|
|
border: 1px solid #e2e8f0;
|
|
z-index: 1010;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
backdrop-filter: blur(8px);
|
|
transition: all 0.3s;
|
|
}
|
|
|
|
.search-input {
|
|
border: none;
|
|
outline: none;
|
|
background: transparent;
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
color: #1e293b;
|
|
width: 180px;
|
|
}
|
|
|
|
.search-icon {
|
|
color: #64748b;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
/* 통계 섹션 스타일 (우상단 플로팅 고정) */
|
|
.stats-section {
|
|
position: fixed;
|
|
top: 75px;
|
|
right: 25px;
|
|
width: 400px; /* 약간 넓게 조정 */
|
|
background: rgba(255, 255, 255, 0.9);
|
|
border-radius: 12px;
|
|
padding: 15px;
|
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
|
border: 1px solid #e2e8f0;
|
|
z-index: 1010;
|
|
backdrop-filter: blur(8px);
|
|
transition: all 0.3s;
|
|
}
|
|
|
|
.stats-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 11px;
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
border-style: hidden; /* 표 전체 겉 보더는 그림자로 처리하기 위해 */
|
|
box-shadow: 0 0 0 1px #e2e8f0;
|
|
}
|
|
|
|
.stats-table th {
|
|
background: #f8fafc;
|
|
color: #64748b;
|
|
font-weight: 800;
|
|
padding: 8px 4px;
|
|
border: 1px solid #e2e8f0;
|
|
text-align: center;
|
|
}
|
|
|
|
.stats-table td {
|
|
padding: 8px 4px;
|
|
border: 1px solid #e2e8f0;
|
|
text-align: center;
|
|
font-weight: 700;
|
|
color: #1e293b;
|
|
}
|
|
|
|
.stats-table .row-label {
|
|
background: #f8fafc;
|
|
color: #475569;
|
|
font-weight: 800;
|
|
width: 80px;
|
|
}
|
|
|
|
.stats-table .total-cell {
|
|
background: #eff6ff;
|
|
color: #2563eb;
|
|
font-weight: 900;
|
|
}
|
|
|
|
.sum-row {
|
|
background: #f1f5f9;
|
|
}
|
|
.sum-row td {
|
|
font-weight: 900 !important;
|
|
color: #0f172a !important;
|
|
}
|
|
|
|
/* 강조 효과 애니메이션 */
|
|
@keyframes target-pulse {
|
|
0% { box-shadow: 0 0 0 0 rgba(79, 70, 229, 0.7); transform: scale(1); }
|
|
50% { box-shadow: 0 0 0 10px rgba(79, 70, 229, 0); transform: scale(1.05); }
|
|
100% { box-shadow: 0 0 0 0 rgba(79, 70, 229, 0); transform: scale(1); }
|
|
}
|
|
.search-target {
|
|
animation: target-pulse 1.5s ease-in-out 2;
|
|
position: relative;
|
|
z-index: 1000 !important;
|
|
border-color: #4f46e5 !important;
|
|
}
|
|
|
|
/* 관리자 전환 버튼 아이콘 타입 스타일 (중첩 방지를 위해 FAB 좌측으로 이동) */
|
|
.admin-mode-btn {
|
|
position: fixed;
|
|
bottom: 37.5px;
|
|
right: 105px;
|
|
z-index: 5001;
|
|
width: 45px;
|
|
height: 45px;
|
|
background: rgba(255, 255, 255, 0.9);
|
|
backdrop-filter: blur(4px);
|
|
border: 1px solid #e2e8f0;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 18px;
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
|
transition: all 0.3s;
|
|
cursor: pointer;
|
|
}
|
|
/* 툴팁 효과 - 버튼 상단으로 위치 조정 */
|
|
.admin-mode-btn::after {
|
|
content: attr(data-label);
|
|
position: absolute;
|
|
bottom: 55px;
|
|
right: 0;
|
|
background: rgba(0,0,0,0.8);
|
|
color: white;
|
|
padding: 4px 8px;
|
|
border-radius: 4px;
|
|
font-size: 10px;
|
|
white-space: nowrap;
|
|
opacity: 0;
|
|
visibility: hidden;
|
|
transition: all 0.2s;
|
|
pointer-events: none;
|
|
}
|
|
.admin-mode-btn:hover::after {
|
|
opacity: 1;
|
|
visibility: visible;
|
|
}
|
|
.admin-mode-btn.is-admin {
|
|
background: #4f46e5;
|
|
border-color: #4f46e5;
|
|
box-shadow: 0 4px 15px rgba(79, 70, 229, 0.3);
|
|
}
|
|
.admin-mode-btn:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 6px 16px rgba(0,0,0,0.1);
|
|
}
|
|
/* 부서 선택 탭 스타일 */
|
|
.dept-tabs-container {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-top: 15px;
|
|
padding: 5px 0;
|
|
overflow-x: auto;
|
|
scrollbar-width: none; /* Firefox */
|
|
}
|
|
.dept-tabs-container::-webkit-scrollbar { display: none; } /* Chrome */
|
|
|
|
.dept-tab {
|
|
padding: 6px 14px;
|
|
background: white;
|
|
border: 1px solid #e2e8f0;
|
|
border-radius: 20px;
|
|
font-size: 11px;
|
|
font-weight: 800;
|
|
color: #64748b;
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
transition: all 0.2s;
|
|
}
|
|
.dept-tab:hover { border-color: #cbd5e1; background: #f8fafc; }
|
|
.dept-tab.active {
|
|
background: #4f46e5;
|
|
color: white;
|
|
border-color: #4f46e5;
|
|
box-shadow: 0 4px 10px rgba(79, 70, 229, 0.2);
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
<div class="top-wrap">
|
|
<h1 class="text-sm font-black text-slate-800 tracking-tight">MH 조직현황 관리 - 레이아웃 최적화</h1>
|
|
<div class="flex items-center gap-2">
|
|
<input type="file" id="upload-excel" class="hidden" accept=".xlsx, .xls, .csv" />
|
|
<button onclick="document.getElementById('upload-excel').click()" class="btn-primary">조직현황 업로드</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="search-section">
|
|
<div class="flex flex-col w-full">
|
|
<div class="relative flex items-center w-full">
|
|
<span class="search-icon">
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
|
|
</span>
|
|
<input type="text" id="search-input" placeholder="이름 또는 조직 검색" class="search-input" onkeydown="if(event.key==='Enter') handleSearch(this.value)" />
|
|
</div>
|
|
<div id="dept-tabs" class="dept-tabs-container"></div>
|
|
</div>
|
|
</div>
|
|
<div id="stats-area" class="stats-section" style="padding: 10px 15px;">
|
|
<div class="flex justify-between items-center mb-0 cursor-pointer p-0" id="stats-header" onclick="toggleStats()">
|
|
<h2 class="text-xs font-black text-slate-800 flex items-center gap-2">📊 인원 현황 통계 <span id="total-count-badge"
|
|
class="bg-indigo-100 text-indigo-600 text-[10px] px-2 py-0.5 rounded-full">0명</span></h2>
|
|
<span id="stats-toggle-icon" class="text-slate-400 text-xs transition-transform duration-200" style="transform: rotate(-90deg);">▼</span>
|
|
</div>
|
|
<div id="stats-table-container" class="mt-3 overflow-hidden transition-all duration-300" style="display: none;"></div>
|
|
</div>
|
|
|
|
<div id="tree-root" class="org-canvas">
|
|
<div class="text-slate-400 font-bold mt-20 text-xs text-center">파일을 업로드하면 고정 순서 및 레이아웃이 적용됩니다.</div>
|
|
</div>
|
|
|
|
<button id="admin-mode-btn" class="admin-mode-btn" data-label="관리자 모드 전환" onclick="toggleAdminMode(!isAdmin)">🔐</button>
|
|
|
|
<!-- 최근 업데이트 일시 (좌하단 고정) -->
|
|
<div id="last-updated" class="fixed bottom-4 left-5 text-[10px] text-slate-400 font-bold z-[4000] pointer-events-none" style="letter-spacing: 0.02em; opacity: 0.8;"></div>
|
|
|
|
<div class="fab-container" id="fab-container">
|
|
<button class="fab-main text-white" onclick="toggleFab(event)">+</button>
|
|
<div class="fab-menu" id="fab-menu">
|
|
<!-- JS에서 권한에 따라 버튼을 동적으로 생성 -->
|
|
</div>
|
|
</div>
|
|
|
|
<div id="modal">
|
|
<div class="modal-content">
|
|
<div id="modal-header-area">
|
|
<h2 id="modal-title" class="text-xl font-black mb-6 text-slate-800 border-b pb-4">구성원 정보 수정</h2>
|
|
</div>
|
|
<div id="modal-fields" class="grid grid-cols-2 gap-x-8 gap-y-5"></div>
|
|
<div id="modal-footer-area" class="flex gap-4 mt-10">
|
|
<button onclick="closeModal()" class="flex-1 bg-slate-100 py-3.5 rounded-xl font-bold text-sm">취소</button>
|
|
<button id="btn-save" onclick="saveMember()"
|
|
class="flex-1 bg-indigo-600 text-white py-3.5 rounded-xl font-bold text-sm">저장</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let members = [];
|
|
let isAdmin = false; // 기본값: 뷰어 모드
|
|
let selectedDept = '전체'; // 기본 부서 필터
|
|
let editingMembers = []; // 리스트 편집기에서 임시로 사용할 데이터
|
|
let collapsedUnits = new Set();
|
|
const levelOrder = ['부서', '그룹', '디비전', '팀', '셀'];
|
|
const dropdownFields = ['소속회사', '직급', '직책', ...levelOrder];
|
|
|
|
// [정렬 설정] 그룹 순서 고정
|
|
const groupSortOrder = ["엔지니어링 기획그룹", "엔지니어링 개발그룹", "과업수행그룹"];
|
|
// 엔지니어링 기획그룹 및 개발그룹 내부 정렬
|
|
const customSortMap = {
|
|
"엔지니어링 기획그룹": ["일반구조물 Div", "C.C", "Infra Solution Div", "수자원", "상하수도"],
|
|
"엔지니어링 개발그룹": ["천지인", "infra Solution 개발팀", "구조물S/W", "Strana", "그래픽스 개발", "web solution", "gsim"]
|
|
};
|
|
|
|
window.addEventListener('resize', () => { requestAnimationFrame(drawLines); });
|
|
|
|
document.getElementById('upload-excel').onchange = (e) => {
|
|
const file = e.target.files[0];
|
|
const reader = new FileReader();
|
|
reader.onload = (evt) => {
|
|
const data = new Uint8Array(evt.target.result);
|
|
const workbook = XLSX.read(data, { type: 'array' });
|
|
const sheet = workbook.Sheets[workbook.SheetNames[0]];
|
|
const rows = XLSX.utils.sheet_to_json(sheet, { header: 1 });
|
|
const headerIdx = rows.findIndex(r => Array.isArray(r) && r.includes('이름') && r.includes('부서'));
|
|
const headers = rows[headerIdx];
|
|
members = rows.slice(headerIdx + 1).map((row, idx) => {
|
|
let m = { _id: 'm_' + (Date.now() + idx) };
|
|
headers.forEach((h, i) => { if (h) m[h] = String(row[i] || '').trim(); });
|
|
m._path = levelOrder.map(l => ({ level: l, name: m[l] })).filter(item => item.name !== "");
|
|
return m;
|
|
}).filter(m => m['이름']);
|
|
updateTimestamp();
|
|
render();
|
|
};
|
|
reader.readAsArrayBuffer(file);
|
|
};
|
|
|
|
function toggleAdminMode(checked) {
|
|
isAdmin = checked;
|
|
const btn = document.getElementById('admin-mode-btn');
|
|
if (isAdmin) {
|
|
btn.classList.add('is-admin');
|
|
btn.innerText = "🔓";
|
|
btn.setAttribute('data-label', '관리자 모드: ON (클릭 시 로그아웃)');
|
|
} else {
|
|
btn.classList.remove('is-admin');
|
|
btn.innerText = "🔐";
|
|
btn.setAttribute('data-label', '관리자 모드: OFF (클릭 시 로그인)');
|
|
}
|
|
render(); // 전체 UI 갱신
|
|
updateFabMenu(); // FAB 메뉴 갱신
|
|
}
|
|
|
|
function updateFabMenu() {
|
|
const menu = document.getElementById('fab-menu');
|
|
let html = `<button class="fab-sub shadow-xl" data-label="리스트" onclick="openListViewModal(event)">📋</button>`;
|
|
|
|
// 인쇄 버튼은 항상 표시 (관리자/비관리자 공통)
|
|
html += `<button class="fab-sub shadow-xl" data-label="조직도 인쇄(A3)" onclick="printA3()">🖨️</button>`;
|
|
|
|
if (isAdmin) {
|
|
html += `
|
|
<button class="fab-sub shadow-xl" data-label="신규 구성원" onclick="openAddModal(event)">👤</button>
|
|
<button class="fab-sub shadow-xl" data-label="신규 팀/그룹/셀" onclick="openUnitAddModal(event)">🏢</button>
|
|
`;
|
|
}
|
|
menu.innerHTML = html;
|
|
}
|
|
|
|
function updateTimestamp() {
|
|
const now = new Date();
|
|
const pad = (n) => n.toString().padStart(2, '0');
|
|
const dateStr = `${now.getFullYear() % 100}.${pad(now.getMonth() + 1)}.${pad(now.getDate())}`;
|
|
const timeStr = `${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
|
document.getElementById('last-updated').innerText = `Recently updated: ${dateStr} ${timeStr}`;
|
|
}
|
|
|
|
function printA3() {
|
|
// 인쇄 전 레이아웃 최적화 (필요시 배율 조정)
|
|
window.print();
|
|
}
|
|
|
|
function handleSearch(val) {
|
|
const query = val.trim().toLowerCase();
|
|
if (!query) return;
|
|
|
|
// 이전 강조 제거
|
|
document.querySelectorAll('.search-target').forEach(el => el.classList.remove('search-target'));
|
|
|
|
let targetEl = null;
|
|
|
|
// 1. 구성원 검색
|
|
const memberMatch = members.find(m => (m['이름'] || '').toLowerCase().includes(query));
|
|
if (memberMatch) {
|
|
targetEl = document.getElementById('card-' + memberMatch._id);
|
|
}
|
|
|
|
// 2. 조직 검색 (구성원 매칭이 없으면)
|
|
if (!targetEl) {
|
|
for (const level of levelOrder) {
|
|
const orgName = Array.from(new Set(members.map(m => m[level]).filter(v => v)))
|
|
.find(name => name.toLowerCase().includes(query));
|
|
if (orgName) {
|
|
targetEl = document.getElementById('node-' + encodeURIComponent(level + '_' + orgName));
|
|
if (targetEl) break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (targetEl) {
|
|
targetEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
targetEl.classList.add('search-target');
|
|
} else {
|
|
alert('검색 결과를 찾을 수 없습니다.');
|
|
}
|
|
}
|
|
|
|
function render() {
|
|
const container = document.getElementById('tree-root');
|
|
container.innerHTML = '<svg id="svg-canvas" style="position: absolute; top:0; left:0; width:100%; height:100%; pointer-events:none; z-index: 0;"></svg>';
|
|
|
|
if (!members.length) {
|
|
container.innerHTML += '<div class="text-slate-400 font-bold mt-20 text-xs text-center">파일을 업로드하면 고정 순서 및 레이아웃이 적용됩니다.</div>';
|
|
return;
|
|
}
|
|
|
|
// 부서 목록 추출 및 탭 생성
|
|
const allDepts = Array.from(new Set(members.map(m => m['부서']).filter(v => v))).sort();
|
|
updateDeptTabs(['전체', ...allDepts]);
|
|
|
|
let depts = allDepts;
|
|
if (selectedDept !== '전체') {
|
|
depts = [selectedDept];
|
|
}
|
|
|
|
depts.forEach(deptName => {
|
|
const deptData = members.filter(m => m['부서'] === deptName);
|
|
const hierarchy = buildHierarchy(deptData, 0);
|
|
|
|
const deptSection = document.createElement('div');
|
|
deptSection.className = 'dept-section';
|
|
const deptId = 'node-' + encodeURIComponent('부서_' + deptName);
|
|
|
|
const deptNode = hierarchy.length > 0 ? hierarchy[0] : null;
|
|
if (deptNode) calculateTotalCount(deptNode); // 인원수 집계
|
|
|
|
const hasMembers = deptNode && deptNode.members && deptNode.members.length > 0;
|
|
const totalCount = deptNode ? deptNode.totalCount : 0;
|
|
|
|
// 부서 (상단 컨테이너)
|
|
const deptBox = document.createElement('div');
|
|
deptBox.id = deptId;
|
|
deptBox.className = 'dept-box';
|
|
deptBox.setAttribute('data-level', '부서');
|
|
|
|
const deptTitleClass = isAdmin ? 'clickable-title' : '';
|
|
const deptAction = isAdmin ? `onclick="openOrgEditModal('부서', '${deptName}')"` : '';
|
|
deptBox.innerHTML = `<div class="dept-header ${deptTitleClass} ${hasMembers ? 'has-members' : ''}" ${deptAction}>${deptName} (${totalCount})</div>`;
|
|
|
|
// 부서 직속 인원들(부서장 등)
|
|
if (hasMembers) {
|
|
const mGrid = document.createElement('div');
|
|
// 상위 조직(부서/그룹/디비전)은 그리드 대신 플렉스로 꽉 차게 배치
|
|
mGrid.className = 'flex flex-col w-full';
|
|
mGrid.style.padding = '0 15px 15px 15px';
|
|
deptNode.members.forEach(m => mGrid.appendChild(createMemberCard(m, true)));
|
|
deptBox.appendChild(mGrid);
|
|
}
|
|
deptSection.appendChild(deptBox);
|
|
|
|
const groupContainer = document.createElement('div');
|
|
groupContainer.className = 'node-group';
|
|
|
|
// 부서 하위(그룹 레벨)부터 createNodeDOM 재귀 호출
|
|
if (deptNode && deptNode.children) {
|
|
deptNode.children.forEach(child => {
|
|
groupContainer.appendChild(createNodeDOM(child, deptId));
|
|
});
|
|
}
|
|
|
|
deptSection.appendChild(groupContainer);
|
|
container.appendChild(deptSection);
|
|
});
|
|
updateStatsTable(); // 통계 표 업데이트
|
|
setTimeout(drawLines, 50);
|
|
}
|
|
|
|
function calculateTotalCount(node) {
|
|
let count = node.members.length;
|
|
if (node.children) {
|
|
node.children.forEach(child => {
|
|
count += calculateTotalCount(child);
|
|
});
|
|
}
|
|
node.totalCount = count;
|
|
return count;
|
|
}
|
|
|
|
function updateDeptTabs(deptList) {
|
|
const tabsContainer = document.getElementById('dept-tabs');
|
|
if (!tabsContainer) return;
|
|
|
|
tabsContainer.innerHTML = deptList.map(d => `
|
|
<div class="dept-tab ${selectedDept === d ? 'active' : ''}" onclick="selectDept('${d}')">${d}</div>
|
|
`).join('');
|
|
}
|
|
|
|
function selectDept(dept) {
|
|
selectedDept = dept;
|
|
render();
|
|
}
|
|
|
|
function toggleStats() {
|
|
const container = document.getElementById('stats-table-container');
|
|
const icon = document.getElementById('stats-toggle-icon');
|
|
const area = document.getElementById('stats-area');
|
|
|
|
if (container.style.display === 'none') {
|
|
container.style.display = 'block';
|
|
icon.style.transform = 'rotate(0deg)';
|
|
area.style.padding = '15px';
|
|
} else {
|
|
container.style.display = 'none';
|
|
icon.style.transform = 'rotate(-90deg)';
|
|
area.style.padding = '10px 15px';
|
|
}
|
|
}
|
|
|
|
function updateStatsTable() {
|
|
if (!members || members.length === 0) return;
|
|
|
|
const companies = ["한맥", "삼안", "피티씨", "바론"];
|
|
const rankGroups = {
|
|
"경영진": ["사장", "부사장"],
|
|
"수석": ["수석"],
|
|
"책임": ["책임"],
|
|
"선임": ["선임"],
|
|
"연구": ["연구"]
|
|
};
|
|
|
|
const columns = Object.keys(rankGroups);
|
|
const stats = {};
|
|
|
|
companies.forEach(c => {
|
|
stats[c] = {};
|
|
columns.forEach(col => stats[c][col] = 0);
|
|
stats[c]._total = 0;
|
|
});
|
|
|
|
const targetMembers = selectedDept === '전체' ? members : members.filter(m => m['부서'] === selectedDept);
|
|
targetMembers.forEach(m => {
|
|
const co = companies.find(c => (m['소속회사'] || '').includes(c));
|
|
if (!co) return;
|
|
|
|
const rank = m['직급'] || '';
|
|
let matched = false;
|
|
|
|
for (const [groupName, keywords] of Object.entries(rankGroups)) {
|
|
if (keywords.some(k => rank.includes(k))) {
|
|
stats[co][groupName]++;
|
|
matched = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// 매칭되는 그룹이 없을 경우 (기타 처리 등 필요시)
|
|
stats[co]._total++;
|
|
});
|
|
|
|
let html = `<table class="stats-table">
|
|
<thead>
|
|
<tr>
|
|
<th class="row-label">구분</th>
|
|
${columns.map(c => `<th>${c}</th>`).join('')}
|
|
<th>합계</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>`;
|
|
|
|
let colSums = {};
|
|
columns.forEach(c => colSums[c] = 0);
|
|
let grandTotal = 0;
|
|
|
|
companies.forEach(co => {
|
|
html += `<tr>
|
|
<td class="row-label">${co}</td>
|
|
${columns.map(col => {
|
|
colSums[col] += stats[co][col];
|
|
return `<td>${stats[co][col] || '-'}</td>`;
|
|
}).join('')}
|
|
<td class="total-cell">${stats[co]._total}</td>
|
|
</tr>`;
|
|
grandTotal += stats[co]._total;
|
|
});
|
|
|
|
// 합계 행
|
|
html += `<tr class="sum-row">
|
|
<td class="row-label">전체 합계</td>
|
|
${columns.map(col => `<td>${colSums[col]}</td>`).join('')}
|
|
<td class="total-cell">${grandTotal}</td>
|
|
</tr></tbody></table>`;
|
|
|
|
document.getElementById('stats-table-container').innerHTML = html;
|
|
document.getElementById('total-count-badge').innerText = grandTotal + '명';
|
|
}
|
|
|
|
function buildHierarchy(data, depth) {
|
|
if (!data || data.length === 0) return [];
|
|
const orderedGroups = [];
|
|
const groupMap = {};
|
|
|
|
data.forEach(m => {
|
|
const currentStep = m._path[depth];
|
|
if (!currentStep) return;
|
|
const currentName = currentStep.name;
|
|
|
|
if (!groupMap[currentName]) {
|
|
groupMap[currentName] = {
|
|
name: currentName,
|
|
level: currentStep.level,
|
|
members: [],
|
|
subData: []
|
|
};
|
|
orderedGroups.push(groupMap[currentName]);
|
|
}
|
|
|
|
if (m._path.length === depth + 1) groupMap[currentName].members.push(m);
|
|
else groupMap[currentName].subData.push(m);
|
|
});
|
|
|
|
return orderedGroups.map(g => ({
|
|
...g,
|
|
children: buildHierarchy(g.subData, depth + 1)
|
|
}));
|
|
}
|
|
|
|
function createNodeDOM(node, parentId) {
|
|
const nodeItem = document.createElement('div');
|
|
nodeItem.className = 'node-item' + (node.children.length || node.members.length ? ' has-children' : '');
|
|
const myId = 'node-' + encodeURIComponent(node.level + '_' + node.name);
|
|
|
|
const box = document.createElement('div');
|
|
box.className = 'box transition-all duration-200';
|
|
box.id = myId;
|
|
box.setAttribute('data-level', node.level);
|
|
if (parentId) box.setAttribute('data-parent', parentId);
|
|
|
|
// 드롭존 (조직 단위에는 구성원 투하만 지원)
|
|
if (isAdmin) {
|
|
box.setAttribute('ondragover', `handleDragOver(event)`);
|
|
box.setAttribute('ondragleave', `handleDragLeave(event)`);
|
|
box.setAttribute('ondrop', `handleDrop(event, '${node.level}', '${node.name}')`);
|
|
}
|
|
|
|
// 색상 그라데이션용 클래스 할당 (예: box-level-그룹)
|
|
box.classList.add('box-level-' + node.level);
|
|
if (node.level === '팀') box.classList.add('box-team');
|
|
|
|
const displayTitle = `${node.name} (${node.totalCount})`;
|
|
const nodeTitleClass = isAdmin ? 'clickable-title' : '';
|
|
const nodeAction = isAdmin ? `onclick="openOrgEditModal('${node.level}', '${node.name}')"` : '';
|
|
box.innerHTML = `<div class="box-name ${nodeTitleClass}" ${nodeAction}>${displayTitle}</div>`;
|
|
|
|
const mGrid = document.createElement('div');
|
|
const isHighLevel = node.level !== '팀' && node.level !== '셀';
|
|
mGrid.className = isHighLevel ? 'flex flex-col w-full' : 'member-grid';
|
|
|
|
if (node.level === '팀') {
|
|
let teamItems = collectTeamItems(node);
|
|
// [레이아웃 핵심] 팀장 위치 고정 및 다중 열 공백(팀장 우측칸 비움), 마지막 10번째 줄 셀명 하단 배치 방지
|
|
const leaderIdx = teamItems.findIndex(i => i['직책'] === '팀장');
|
|
let finalItems = [];
|
|
|
|
if (leaderIdx !== -1) {
|
|
finalItems.push(teamItems.splice(leaderIdx, 1)[0]);
|
|
} else if (teamItems.length > 0) {
|
|
finalItems.push(teamItems.shift());
|
|
}
|
|
|
|
while (teamItems.length > 0) {
|
|
let nextIndex = finalItems.length;
|
|
|
|
if (nextIndex > 0 && nextIndex % 10 === 0) {
|
|
finalItems.push({ isSpacer: true });
|
|
continue;
|
|
}
|
|
|
|
// 마지막 10번째 줄(인덱스 9, 19, 29...)에 셀명이 들어갈 경우 공백(비움) 처리하고 다음 열로 넘김
|
|
if (nextIndex % 10 === 9 && teamItems[0].isCellHeader) {
|
|
finalItems.push({ isSpacer: true });
|
|
continue;
|
|
}
|
|
|
|
finalItems.push(teamItems.shift());
|
|
}
|
|
|
|
finalItems.forEach(item => {
|
|
if (item.isSpacer) {
|
|
const spacer = document.createElement('div');
|
|
spacer.className = 'spacer-box';
|
|
mGrid.appendChild(spacer);
|
|
} else if (item.isCellHeader) {
|
|
const label = document.createElement('div');
|
|
label.className = 'cell-label clickable-title';
|
|
label.innerText = item.name;
|
|
label.onclick = () => openOrgEditModal('셀', item.name);
|
|
mGrid.appendChild(label);
|
|
} else {
|
|
mGrid.appendChild(createMemberCard(item));
|
|
}
|
|
});
|
|
} else {
|
|
const isFullWidth = node.level !== '팀' && node.level !== '셀';
|
|
node.members.forEach(m => mGrid.appendChild(createMemberCard(m, isFullWidth)));
|
|
}
|
|
|
|
box.appendChild(mGrid);
|
|
nodeItem.appendChild(box);
|
|
|
|
if (node.level !== '팀' && node.children && node.children.length > 0) {
|
|
const childrenWrapper = document.createElement('div');
|
|
childrenWrapper.className = 'node-group';
|
|
node.children.forEach(child => childrenWrapper.appendChild(createNodeDOM(child, myId)));
|
|
nodeItem.appendChild(childrenWrapper);
|
|
}
|
|
return nodeItem;
|
|
}
|
|
|
|
function drawLines() {
|
|
const container = document.getElementById('tree-root');
|
|
const svg = document.getElementById('svg-canvas');
|
|
if (!svg || !container) return;
|
|
|
|
const cRect = container.getBoundingClientRect();
|
|
|
|
let paths = '';
|
|
const boxConnections = container.querySelectorAll('[data-parent]');
|
|
|
|
boxConnections.forEach(box => {
|
|
const parentId = box.getAttribute('data-parent');
|
|
const parentBox = document.getElementById(parentId);
|
|
|
|
if (parentBox) {
|
|
const parentLevel = parentBox.getAttribute('data-level');
|
|
const childLevel = box.getAttribute('data-level');
|
|
|
|
// 부서(최상위)에서 하위 '그룹'으로 연결되는 선만 선택적으로 제거
|
|
if (parentLevel === '부서' && childLevel === '그룹') {
|
|
return;
|
|
}
|
|
|
|
const pRect = parentBox.getBoundingClientRect();
|
|
const childRect = box.getBoundingClientRect();
|
|
|
|
const pX = pRect.left + pRect.width / 2 - cRect.left;
|
|
const pY = pRect.bottom - cRect.top;
|
|
const cX = childRect.left + childRect.width / 2 - cRect.left;
|
|
const cY = childRect.top - cRect.top;
|
|
|
|
// 부모 아래쪽과 요소 위쪽의 중간 지점에서 꺾이도록 설정
|
|
const curveY = cY - 20;
|
|
|
|
// Orthogonal path (계단형 직선)
|
|
paths += `<path d="M ${pX} ${pY} L ${pX} ${curveY} L ${cX} ${curveY} L ${cX} ${cY}" stroke="#cbd5e1" stroke-width="2" fill="none" stroke-linejoin="round" />`;
|
|
}
|
|
});
|
|
svg.innerHTML = paths;
|
|
}
|
|
|
|
function collectTeamItems(teamNode) {
|
|
let list = [...teamNode.members];
|
|
teamNode.children.forEach(cell => {
|
|
list.push({ isCellHeader: true, name: cell.name });
|
|
list = list.concat(getAllSubMembers(cell));
|
|
});
|
|
return list;
|
|
}
|
|
|
|
function getAllSubMembers(node) {
|
|
let mList = [...node.members];
|
|
node.children.forEach(child => { mList = mList.concat(getAllSubMembers(child)); });
|
|
return mList;
|
|
}
|
|
|
|
function createMemberCard(m, isFullWidth = false) {
|
|
const card = document.createElement('div');
|
|
card.id = 'card-' + m._id;
|
|
card.className = `member-card co-${m['소속회사'] || 'default'} transition-all duration-200 mb-1 last:mb-0`;
|
|
if (isFullWidth) card.classList.add('full-width');
|
|
|
|
card.onclick = (e) => { e.stopPropagation(); openModal(m._id); };
|
|
|
|
if (isAdmin) {
|
|
card.setAttribute('draggable', 'true');
|
|
card.setAttribute('ondragstart', `handleDragStart(event, 'member', '${m._id}')`);
|
|
card.setAttribute('ondragend', `handleDragEnd(event)`);
|
|
card.setAttribute('ondragover', `handleDragOverMember(event)`);
|
|
card.setAttribute('ondragleave', `handleDragLeaveMember(event)`);
|
|
card.setAttribute('ondrop', `handleDropMember(event, '${m._id}')`);
|
|
}
|
|
|
|
const isLeave = m['근무상태'] === '휴직';
|
|
const roleDisplay = isLeave ? `<span class="m-role" style="color:#ef4444; border-color:#fee2e2; background:#fff1f2;">휴직</span>` : ((m['직책'] && m['직책'] !== '팀원') ? `<span class="m-role">${m['직책']}</span>` : '');
|
|
const rankDisplay = m['직급'] || '';
|
|
card.innerHTML = `<div class="m-top"><span class="m-name">${m['이름']}</span>${roleDisplay}<span class="m-rank">${rankDisplay}</span></div>`;
|
|
return card;
|
|
}
|
|
|
|
function toggleFab(e) {
|
|
if (e) e.stopPropagation();
|
|
document.getElementById('fab-container').classList.toggle('active');
|
|
}
|
|
|
|
// 외부 클릭 시 FAB 닫기
|
|
window.addEventListener('click', () => {
|
|
document.getElementById('fab-container').classList.remove('active');
|
|
});
|
|
|
|
function openAddModal(e) {
|
|
if (e) e.stopPropagation();
|
|
console.log('openAddModal triggered');
|
|
openModal(null);
|
|
}
|
|
|
|
function openUnitAddModal(e) {
|
|
if (e) e.stopPropagation();
|
|
const units = ['그룹', '디비전', '팀', '셀'];
|
|
const modal = document.getElementById('modal');
|
|
modal.querySelector('.modal-content').classList.remove('wide');
|
|
|
|
document.getElementById('modal-title').innerText = "신규 조직 단위 추가";
|
|
const fieldsArea = document.getElementById('modal-fields');
|
|
fieldsArea.className = "grid grid-cols-2 gap-x-8 gap-y-5"; // 레이아웃 초기화
|
|
fieldsArea.style.maxHeight = "none";
|
|
|
|
fieldsArea.innerHTML = `
|
|
<div class="col-span-2">
|
|
<label class="text-[11px] font-black text-slate-600 block">만들고 싶은 조직 종류</label>
|
|
<select id="new-unit-type" onchange="updateParentList()" class="w-full bg-white p-3 rounded-xl border text-sm font-bold outline-none">
|
|
${units.map(u => `<option value="${u}">${u}</option>`).join('')}
|
|
</select>
|
|
</div>
|
|
<div class="col-span-2">
|
|
<label class="text-[11px] font-black text-slate-600 block">상위 위치 선택</label>
|
|
<select id="new-unit-parent" class="w-full bg-white p-3 rounded-xl border text-sm font-bold outline-none"></select>
|
|
</div>
|
|
<div class="col-span-2">
|
|
<label class="text-[11px] font-black text-slate-600 block">신규 명칭 입력</label>
|
|
<input id="new-unit-name" placeholder="예: 신규개발팀" class="w-full bg-slate-50 p-3 rounded-xl border font-bold text-sm outline-none">
|
|
</div>`;
|
|
|
|
updateParentList();
|
|
document.getElementById('btn-save').setAttribute('onclick', 'saveNewUnit()');
|
|
document.getElementById('modal').style.display = 'flex';
|
|
}
|
|
|
|
function updateParentList() {
|
|
const type = document.getElementById('new-unit-type').value;
|
|
const parentSelect = document.getElementById('new-unit-parent');
|
|
const typeIdx = levelOrder.indexOf(type);
|
|
const parentType = levelOrder[typeIdx - 1]; // 부서, 그룹, 디비전, 팀 중 하나
|
|
|
|
const parents = Array.from(new Set(members.map(m => m[parentType]).filter(v => v))).sort();
|
|
parentSelect.innerHTML = `<option value="">-- 선택 안 함 (기본) --</option>` + parents.map(p => `<option value="${p}">${p}</option>`).join('');
|
|
}
|
|
|
|
function saveNewUnit() {
|
|
const type = document.getElementById('new-unit-type').value;
|
|
const parentName = document.getElementById('new-unit-parent').value;
|
|
const name = document.getElementById('new-unit-name').value.trim();
|
|
|
|
if (!name) { alert('이름을 입력해주세요.'); return; }
|
|
|
|
// 가상 구성원 검색 또는 생성
|
|
const typeIdx = levelOrder.indexOf(type);
|
|
const parentType = levelOrder[typeIdx - 1];
|
|
|
|
// 뼈대 멤버 잡기 (지정한 부모가 있으면 해당 부모 아래로, 없으면 전체 첫번째 기반)
|
|
const template = (parentName && parentType) ? (members.find(m => m[parentType] === parentName) || members[0]) : members[0];
|
|
|
|
let newM = {
|
|
...template,
|
|
_id: 'm_v_' + Date.now(),
|
|
'이름': '공석(신규)',
|
|
'직급': '',
|
|
'직책': '',
|
|
'소속회사': 'default'
|
|
};
|
|
|
|
// 선택한 상단 부모 정보가 없으면, 해당 레벨 이전의 필드들도 비우기 (부서 제외)
|
|
if (!parentName) {
|
|
for (let i = 1; i < typeIdx; i++) {
|
|
newM[levelOrder[i]] = "";
|
|
}
|
|
} else {
|
|
newM[parentType] = parentName;
|
|
}
|
|
|
|
// 선택한 단위 명칭 설정
|
|
newM[type] = name;
|
|
// 하위 단위가 있다면 비우기 (팀을 만들면 셀은 비움)
|
|
for (let i = typeIdx + 1; i < levelOrder.length; i++) {
|
|
newM[levelOrder[i]] = "";
|
|
}
|
|
|
|
m._path = levelOrder.map(l => ({ level: l, name: newM[l] })).filter(item => item.name !== "");
|
|
members.push(newM);
|
|
|
|
updateTimestamp();
|
|
render();
|
|
closeModal();
|
|
}
|
|
|
|
function openOrgEditModal(level, oldName) {
|
|
const modal = document.getElementById('modal');
|
|
modal.querySelector('.modal-content').classList.remove('wide');
|
|
|
|
document.getElementById('modal-title').innerText = `${level} 이름 수정`;
|
|
const fieldsArea = document.getElementById('modal-fields');
|
|
fieldsArea.className = "grid grid-cols-2 gap-x-8 gap-y-5"; // 레이아웃 초기화
|
|
fieldsArea.style.maxHeight = "none";
|
|
fieldsArea.innerHTML = `<div class="col-span-2">
|
|
<label class="text-[11px] font-black text-slate-400 block">새로운 ${level} 명칭</label>
|
|
<input id="new-org-name" value="${oldName}" class="w-full bg-slate-50 p-3 rounded-xl border font-bold text-sm outline-none">
|
|
</div>`;
|
|
document.getElementById('btn-save').setAttribute('onclick', `saveOrgName('${level}', '${oldName}')`);
|
|
|
|
// 삭제 버튼 추가/업데이트
|
|
const footer = document.getElementById('modal-footer-area');
|
|
footer.innerHTML = `
|
|
<button onclick="deleteOrg('${level}', '${oldName}')" class="bg-red-50 text-red-600 py-3.5 px-6 rounded-xl font-bold text-sm border border-red-100 hover:bg-red-100 transition-colors">삭제</button>
|
|
<button onclick="closeModal()" class="flex-1 bg-slate-100 py-3.5 rounded-xl font-bold text-sm">취소</button>
|
|
<button id="btn-save" onclick="saveOrgName('${level}', '${oldName}')" class="flex-1 bg-indigo-600 text-white py-3.5 rounded-xl font-bold text-sm">저장</button>
|
|
`;
|
|
document.getElementById('modal').style.display = 'flex';
|
|
}
|
|
|
|
function deleteOrg(level, name) {
|
|
if (!confirm(`'${name}' ${level}과 소속된 모든 인원 정보가 삭제됩니다. 정말 삭제하시겠습니까?`)) return;
|
|
|
|
members = members.filter(m => m[level] !== name);
|
|
updateTimestamp();
|
|
render();
|
|
closeModal();
|
|
}
|
|
|
|
function saveOrgName(level, oldName) {
|
|
const newName = document.getElementById('new-org-name').value.trim();
|
|
if (!newName) { alert('이름을 입력해주세요.'); return; }
|
|
if (oldName === newName) { closeModal(); return; }
|
|
|
|
// 모든 멤버 순회하며 해당 레벨의 소속명 변경
|
|
members.forEach(m => {
|
|
if (m[level] === oldName) {
|
|
m[level] = newName;
|
|
// 경로 재계산
|
|
m._path = levelOrder.map(l => ({ level: l, name: m[l] })).filter(item => item.name !== "");
|
|
}
|
|
});
|
|
|
|
updateTimestamp();
|
|
render();
|
|
closeModal();
|
|
}
|
|
|
|
function openModal(id) {
|
|
const modal = document.getElementById('modal');
|
|
modal.querySelector('.modal-content').classList.remove('wide');
|
|
const fieldsArea = document.getElementById('modal-fields');
|
|
const footer = document.getElementById('modal-footer-area');
|
|
|
|
const m = id ? (members.find(x => x._id === id) || {}) : {};
|
|
|
|
if (!isAdmin && id) {
|
|
// [조회 모드 - 프로필 카드 형태]
|
|
document.getElementById('modal-title').innerText = "구성원 상세 프로필";
|
|
fieldsArea.className = "flex flex-col items-center gap-6 py-4";
|
|
fieldsArea.style.maxHeight = "none";
|
|
|
|
const photoUrl = m['사진'] || 'https://via.placeholder.com/120?text=Profile';
|
|
|
|
fieldsArea.innerHTML = `
|
|
<div class="relative w-32 h-32 rounded-full overflow-hidden border-4 border-indigo-100 shadow-lg">
|
|
<img src="${photoUrl}" class="w-full h-full object-cover">
|
|
</div>
|
|
<div class="text-center">
|
|
<h2 class="text-2xl font-black text-slate-800">${m['이름']}</h2>
|
|
<p class="text-indigo-600 font-bold">${m['직급']} / ${m['직책'] || '팀원'}</p>
|
|
<p class="text-slate-400 text-xs mt-1 font-medium">${(m._path || []).map(p=>p.name).join(' > ')}</p>
|
|
</div>
|
|
<div class="w-full grid grid-cols-2 gap-3 mt-4">
|
|
<div class="bg-indigo-50 p-4 rounded-2xl border border-indigo-100 col-span-2 flex items-center gap-4">
|
|
<div class="flex-1">
|
|
<label class="text-[10px] text-indigo-400 font-bold block mb-1">📞 연락처</label>
|
|
<span class="text-sm font-black text-indigo-700">${m['전화번호'] || '정보 없음'}</span>
|
|
</div>
|
|
<div class="flex-1">
|
|
<label class="text-[10px] text-indigo-400 font-bold block mb-1">✉️ 이메일</label>
|
|
<span class="text-sm font-black text-indigo-700">${m['이메일'] || '정보 없음'}</span>
|
|
</div>
|
|
</div>
|
|
<div class="bg-slate-50 p-4 rounded-2xl border border-slate-100 col-span-2">
|
|
<label class="text-[10px] text-slate-400 font-bold block mb-1">📍 사무실 위치 (자리)</label>
|
|
<span class="text-sm font-black text-slate-700">${m['자리위치'] || '정보 없음'}</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
footer.innerHTML = `<button onclick="closeModal()" class="w-full bg-slate-800 text-white py-4 rounded-xl font-bold text-sm shadow-lg">닫기</button>`;
|
|
modal.style.display = 'flex';
|
|
return;
|
|
}
|
|
|
|
// [편집 모드 - 관리자 전용 탭 구조]
|
|
document.getElementById('modal-title').innerText = id ? "구성원 정보 수정" : "신규 구성원 추가";
|
|
fieldsArea.className = "flex flex-col w-full";
|
|
fieldsArea.style.maxHeight = "75vh";
|
|
fieldsArea.style.overflowY = "auto";
|
|
|
|
const tabHeader = `
|
|
<div class="flex border-b mb-6 sticky top-0 bg-white z-10">
|
|
<button id="modal-tab-basic" onclick="switchModalTab('basic')" class="flex-1 py-3 font-bold border-b-2 border-indigo-600 text-indigo-600 text-sm transition-all">기본 정보</button>
|
|
<button id="modal-tab-org" onclick="switchModalTab('org')" class="flex-1 py-3 font-bold border-b-2 border-transparent text-slate-400 text-sm transition-all">조직 및 근무</button>
|
|
</div>
|
|
`;
|
|
|
|
let basicFields = `
|
|
<div id="modal-sec-basic" class="grid grid-cols-2 gap-4">
|
|
<input type="hidden" id="m-id" value="${id || ''}">
|
|
<div class="col-span-2"><label class="text-[11px] font-black text-slate-600 block">이름 (필수)</label><input id="m-name" value="${m['이름'] || ''}" class="w-full bg-slate-50 p-3 rounded-xl border font-bold text-sm outline-none"></div>
|
|
<div class="col-span-1"><label class="text-[11px] font-black text-slate-600 block">전화번호</label><input id="m-phone" value="${m['전화번호'] || ''}" placeholder="010-0000-0000" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none"></div>
|
|
<div class="col-span-1"><label class="text-[11px] font-black text-slate-600 block">이메일</label><input id="m-email" value="${m['이메일'] || ''}" placeholder="example@gmail.com" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none"></div>
|
|
<div class="col-span-2"><label class="text-[11px] font-black text-slate-600 block">자리 위치</label><input id="m-seat" value="${m['자리위치'] || ''}" placeholder="예: 4층 A-12" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none"></div>
|
|
<div class="col-span-2"><label class="text-[11px] font-black text-slate-600 block">사진 URL</label><input id="m-photo" value="${m['사진'] || ''}" placeholder="이미지 주소를 입력하세요" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none text-xs"></div>
|
|
</div>
|
|
`;
|
|
|
|
let orgFields = `<div id="modal-sec-org" class="hidden grid grid-cols-2 gap-4">`;
|
|
|
|
dropdownFields.forEach(field => {
|
|
const uniqueValues = Array.from(new Set(members.map(x => x[field]).filter(v => v))).sort();
|
|
const currentValue = m[field] || '';
|
|
orgFields += `<div class="col-span-1"><label class="text-[11px] font-black text-slate-600 block">${field}</label>
|
|
<select id="sel-${field}" onchange="toggleManualInput('${field}')" class="w-full bg-white p-3 rounded-xl border text-sm font-bold text-slate-700 outline-none">
|
|
<option value="__NEW__" class="text-indigo-600 font-bold">+ 직접/신규 입력</option>
|
|
<option value="__NONE__" ${currentValue === '' ? 'selected' : ''}>-- 선택 안 함 --</option>
|
|
${uniqueValues.map(v => `<option value="${v}" ${v === currentValue ? 'selected' : ''}>${v}</option>`).join('')}
|
|
</select>
|
|
<div id="manual-${field}" class="hidden mt-2">
|
|
<input id="input-${field}" placeholder="직접 입력" class="w-full bg-indigo-50 p-3 rounded-xl border-indigo-200 border text-sm font-bold">
|
|
</div></div>`;
|
|
});
|
|
|
|
const isFlexible = m['근무시간'] === '유연근무제';
|
|
orgFields += `
|
|
<div class="col-span-1">
|
|
<label class="text-[11px] font-black text-slate-600 block">근무 상태</label>
|
|
<select id="m-status" class="w-full bg-white p-3 rounded-xl border text-sm font-bold outline-none">
|
|
<option value="근무" ${m['근무상태'] !== '휴직' ? 'selected' : ''}>근무</option>
|
|
<option value="휴직" ${m['근무상태'] === '휴직' ? 'selected' : ''}>휴직</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-span-1">
|
|
<label class="text-[11px] font-black text-slate-600 block">근무 시간</label>
|
|
<select id="m-worktime" onchange="toggleFlexibleTime(this.value)" class="w-full bg-white p-3 rounded-xl border text-sm font-bold outline-none">
|
|
<option value="09~18" ${!isFlexible ? 'selected' : ''}>09~18</option>
|
|
<option value="유연근무제" ${isFlexible ? 'selected' : ''}>유연근무제</option>
|
|
</select>
|
|
<div id="flexible-time-area" class="${isFlexible ? '' : 'hidden'} mt-2 flex items-center gap-2">
|
|
<input type="time" id="m-work-start" value="${m['유연근무_시작'] || '09:00'}" class="bg-indigo-50 p-2 rounded-lg border border-indigo-100 text-xs font-bold w-full">
|
|
<input type="time" id="m-work-end" value="${m['유연근무_종료'] || '18:00'}" class="bg-indigo-50 p-2 rounded-lg border border-indigo-100 text-xs font-bold w-full">
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
|
|
fieldsArea.innerHTML = tabHeader + basicFields + orgFields;
|
|
|
|
const deleteBtn = id ? `<button onclick="deleteMember('${id}')" class="bg-red-50 text-red-600 py-3.5 px-6 rounded-xl font-bold text-sm border border-red-100 hover:bg-red-100 transition-colors">삭제</button>` : '';
|
|
footer.innerHTML = `
|
|
${deleteBtn}
|
|
<button onclick="closeModal()" class="flex-1 bg-slate-100 py-3.5 rounded-xl font-bold text-sm">취소</button>
|
|
<button id="btn-save" onclick="saveMember()" class="flex-1 bg-indigo-600 text-white py-3.5 rounded-xl font-bold text-sm">저장</button>
|
|
`;
|
|
|
|
modal.style.display = 'flex';
|
|
}
|
|
|
|
function switchModalTab(tab) {
|
|
const isBasic = tab === 'basic';
|
|
document.getElementById('modal-sec-basic').classList.toggle('hidden', !isBasic);
|
|
document.getElementById('modal-sec-org').classList.toggle('hidden', isBasic);
|
|
|
|
const btnBasic = document.getElementById('modal-tab-basic');
|
|
const btnOrg = document.getElementById('modal-tab-org');
|
|
|
|
btnBasic.className = isBasic ? 'flex-1 py-3 font-bold border-b-2 border-indigo-600 text-indigo-600 text-sm transition-all' : 'flex-1 py-3 font-bold border-b-2 border-transparent text-slate-400 text-sm transition-all';
|
|
btnOrg.className = !isBasic ? 'flex-1 py-3 font-bold border-b-2 border-indigo-600 text-indigo-600 text-sm transition-all' : 'flex-1 py-3 font-bold border-b-2 border-transparent text-slate-400 text-sm transition-all';
|
|
}
|
|
|
|
function deleteMember(id) {
|
|
if (!confirm('해당 구성원을 삭제하시겠습니까?')) return;
|
|
members = members.filter(m => m._id !== id);
|
|
render();
|
|
closeModal();
|
|
}
|
|
|
|
function toggleManualInput(field) {
|
|
document.getElementById(`manual-${field}`).classList.toggle('hidden', document.getElementById(`sel-${field}`).value !== "__NEW__");
|
|
}
|
|
|
|
function toggleFlexibleTime(val) {
|
|
document.getElementById('flexible-time-area').classList.toggle('hidden', val !== '유연근무제');
|
|
}
|
|
|
|
let isListMode = false;
|
|
|
|
function openListViewModal(e) {
|
|
if (e) e.stopPropagation();
|
|
const modal = document.getElementById('modal');
|
|
const content = modal.querySelector('.modal-content');
|
|
content.classList.add('wide');
|
|
document.getElementById('modal-title').innerText = "인원 명단";
|
|
|
|
const fieldsArea = document.getElementById('modal-fields');
|
|
fieldsArea.className = "flex flex-col w-full overflow-hidden";
|
|
fieldsArea.style.maxHeight = "75vh";
|
|
|
|
// 모드 변경 및 데이터 복제
|
|
isListMode = true;
|
|
editingMembers = JSON.parse(JSON.stringify(members));
|
|
|
|
fieldsArea.innerHTML = `
|
|
<div class="mb-4 flex gap-2 p-1">
|
|
<input type="text" id="list-search-input" placeholder="이름 또는 부서/팀 검색 (Enter 시 이동)"
|
|
class="flex-1 bg-slate-50 border-2 border-slate-100 p-3 rounded-xl text-sm outline-none font-bold focus:border-indigo-400 transition-all"
|
|
onkeydown="if(event.key==='Enter') handleListSearch(this.value)">
|
|
<button onclick="handleListSearch(document.getElementById('list-search-input').value)"
|
|
class="bg-indigo-600 text-white px-5 rounded-xl font-bold text-sm">검색</button>
|
|
</div>
|
|
<div id="list-table-container" class="overflow-y-auto flex-1 border rounded-xl">
|
|
</div>
|
|
`;
|
|
|
|
renderListViewTable();
|
|
|
|
const footer = document.getElementById('modal-footer-area');
|
|
if (isAdmin) {
|
|
footer.innerHTML = `
|
|
<div class="flex gap-2 w-full justify-between items-center">
|
|
<div class="flex gap-2">
|
|
<button onclick="openAddModal(event)" class="bg-indigo-50 text-indigo-600 px-4 py-2 rounded-lg text-xs font-bold border border-indigo-100">+ 구성원 추가</button>
|
|
<button onclick="openUnitAddModal(event)" class="bg-indigo-50 text-indigo-600 px-4 py-2 rounded-lg text-xs font-bold border border-indigo-100">+ 조직 추가</button>
|
|
</div>
|
|
<div class="flex gap-2 items-center">
|
|
<p class="text-[10px] text-slate-400 font-bold mr-4">항목을 드래그하여 순서를 바꿀 수 있습니다.</p>
|
|
<button onclick="closeModal();" class="bg-slate-100 text-slate-600 px-6 py-2 rounded-lg text-xs font-bold">취소</button>
|
|
<button onclick="applyListViewChanges();" class="bg-indigo-600 text-white px-8 py-2 rounded-lg text-xs font-bold shadow-lg shadow-indigo-200">반영하기</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
} else {
|
|
footer.innerHTML = `
|
|
<div class="flex gap-2 w-full justify-end items-center">
|
|
<button onclick="closeModal();" class="bg-indigo-600 text-white px-10 py-2.5 rounded-lg text-xs font-bold shadow-lg shadow-indigo-200">닫기</button>
|
|
</div>
|
|
`;
|
|
}
|
|
modal.style.display = 'flex';
|
|
}
|
|
|
|
function applyListViewChanges() {
|
|
if(!confirm('리스트의 변경 사항을 메인 화면에 반영하시겠습니까?')) return;
|
|
members = JSON.parse(JSON.stringify(editingMembers));
|
|
isListMode = false;
|
|
updateTimestamp();
|
|
render();
|
|
closeModal();
|
|
}
|
|
|
|
function renderListViewTable() {
|
|
const container = document.getElementById('list-table-container');
|
|
if (!container) return;
|
|
|
|
let html = `<table class="list-table">
|
|
<thead>
|
|
<tr>
|
|
${isAdmin ? '<th width="40">순서</th>' : ''}
|
|
<th class="col-name">이름</th>
|
|
<th class="col-rank">직급</th>
|
|
<th class="col-pos">직책</th>
|
|
<th class="col-unit-sm">셀</th>
|
|
<th class="col-unit-sm">팀</th>
|
|
<th class="col-unit-lg">디비전</th>
|
|
<th class="col-unit-lg">그룹</th>
|
|
<th class="col-unit-lg">부서</th>
|
|
<th class="col-corp">소속</th>
|
|
<th class="col-action">${isAdmin ? '관리' : '조회'}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="list-body">`;
|
|
|
|
let lastValues = {};
|
|
levelOrder.forEach(l => lastValues[l] = '');
|
|
|
|
editingMembers.forEach((m, idx) => {
|
|
let isAnyParentCollapsed = false;
|
|
levelOrder.forEach((lvl, depth) => {
|
|
const val = (m[lvl] || '').trim();
|
|
if (!val) return;
|
|
const key = lvl + '_' + val;
|
|
const parentLevels = levelOrder.slice(0, depth);
|
|
if (parentLevels.some(pl => m[pl] && collapsedUnits.has(pl + '_' + m[pl].trim()))) {
|
|
isAnyParentCollapsed = true;
|
|
}
|
|
if (val !== lastValues[lvl]) {
|
|
const isCollapsed = collapsedUnits.has(key);
|
|
const shouldHideHeader = isAnyParentCollapsed;
|
|
const headerDragAttr = isAdmin ? `draggable="true" ondragstart="handleListGroupDragStart(event, '${lvl}', '${val}')" ondragover="event.preventDefault()" ondrop="handleListGroupDrop(event, '${lvl}', '${val}')"` : '';
|
|
html += `<tr ${headerDragAttr}
|
|
class="list-header-row lvl-${depth} ${isCollapsed ? 'collapsed' : ''} ${shouldHideHeader ? 'hidden-row' : ''}">
|
|
<td colspan="${(isAdmin ? 10 : 9) + 1}" onclick="toggleUnitCollapse('${lvl}', '${val}')"
|
|
style="padding-left: 15px !important;">
|
|
<span class="collapse-icon">▼</span> ${val}
|
|
</td>
|
|
</tr>`;
|
|
lastValues[lvl] = val;
|
|
levelOrder.slice(depth + 1).forEach(childLvl => lastValues[childLvl] = '');
|
|
}
|
|
});
|
|
const isHiddenRow = levelOrder.some(lvl => m[lvl] && collapsedUnits.has(lvl + '_' + m[lvl].trim())) || isAnyParentCollapsed;
|
|
const rowDraggingAttr = isAdmin ? `draggable="true" ondragstart="handleListDragStart(event, ${idx})" ondragover="event.preventDefault()" ondrop="handleListDrop(event, ${idx})"` : '';
|
|
html += `
|
|
<tr id="list-row-${m._id}" ${rowDraggingAttr} class="${isHiddenRow ? 'hidden-row' : ''}">
|
|
${isAdmin ? '<td class="text-slate-300 cursor-move">☰</td>' : ''}
|
|
<td class="font-black text-slate-700">${m['이름']}</td>
|
|
<td>${m['직급'] || '-'}</td>
|
|
<td>${m['근무상태'] === '휴직' ? '<span class="text-red-500 font-black">휴직</span>' : (m['직책'] || '-')}</td>
|
|
<td>${m['셀'] || '-'}</td>
|
|
<td>${m['팀'] || '-'}</td>
|
|
<td>${m['디비전'] || '-'}</td>
|
|
<td>${m['그룹'] || '-'}</td>
|
|
<td>${m['부서'] || '-'}</td>
|
|
<td>${m['소속회사']}</td>
|
|
<td>
|
|
${isAdmin ? `
|
|
<div class="flex gap-1 justify-center">
|
|
<span class="list-action-btn btn-edit" onclick="openModal('${m._id}')">수정</span>
|
|
<span class="list-action-btn btn-delete" onclick="deleteMember('${m._id}'); renderListViewTable();">삭제</span>
|
|
</div>
|
|
` : `
|
|
<span class="list-action-btn btn-edit bg-indigo-50 text-indigo-600 border border-indigo-100" onclick="openModal('${m._id}')">조회</span>
|
|
`}
|
|
</td>
|
|
</tr>`;
|
|
});
|
|
html += `</tbody></table>`;
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
function toggleUnitCollapse(level, name) {
|
|
const key = level + '_' + name;
|
|
if (collapsedUnits.has(key)) collapsedUnits.delete(key);
|
|
else collapsedUnits.add(key);
|
|
renderListViewTable();
|
|
}
|
|
|
|
let draggedGroup = null;
|
|
function handleListGroupDragStart(e, level, name) {
|
|
draggedGroup = { level, name };
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
}
|
|
|
|
function handleListGroupDrop(e, targetLevel, targetName) {
|
|
e.preventDefault();
|
|
if (!draggedGroup) return;
|
|
if (draggedGroup.level === targetLevel && draggedGroup.name === targetName) return;
|
|
const movingMembers = editingMembers.filter(m => m[draggedGroup.level] === draggedGroup.name);
|
|
if (movingMembers.length === 0) return;
|
|
editingMembers = editingMembers.filter(m => m[draggedGroup.level] !== draggedGroup.name);
|
|
let targetIdx = editingMembers.findIndex(m => m[targetLevel] === targetName);
|
|
if (targetIdx === -1) targetIdx = editingMembers.length;
|
|
editingMembers.splice(targetIdx, 0, ...movingMembers);
|
|
draggedGroup = null;
|
|
renderListViewTable();
|
|
}
|
|
|
|
function handleListSearch(val) {
|
|
const query = val.trim().toLowerCase();
|
|
if (!query) return;
|
|
document.querySelectorAll('.list-search-target').forEach(el => el.classList.remove('list-search-target'));
|
|
const targetMember = editingMembers.find(m =>
|
|
(m['이름'] || '').toLowerCase().includes(query) ||
|
|
levelOrder.some(l => (m[l] || '').toLowerCase().includes(query))
|
|
);
|
|
if (targetMember) {
|
|
const row = document.getElementById('list-row-' + targetMember._id);
|
|
if (row) {
|
|
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
row.classList.add('list-search-target');
|
|
setTimeout(() => row.classList.remove('list-search-target'), 2000);
|
|
}
|
|
} else { alert('검색 결과가 없습니다.'); }
|
|
}
|
|
|
|
let draggedIdx = null;
|
|
function handleListDragStart(e, idx) {
|
|
draggedIdx = idx;
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
e.target.classList.add('dragging');
|
|
}
|
|
|
|
function handleListDrop(e, targetIdx) {
|
|
e.preventDefault();
|
|
if (draggedIdx === null || draggedIdx === targetIdx) return;
|
|
|
|
// members 대신 editingMembers 사용
|
|
const movedItem = editingMembers.splice(draggedIdx, 1)[0];
|
|
editingMembers.splice(targetIdx, 0, movedItem);
|
|
|
|
draggedIdx = null;
|
|
renderListViewTable(); // 리스트 테이블만 갱신 (메인은 반영하기 버튼 클릭 시)
|
|
}
|
|
|
|
function closeModal() {
|
|
document.getElementById('modal').style.display = 'none';
|
|
// 필드 상태 원복
|
|
const fieldsArea = document.getElementById('modal-fields');
|
|
fieldsArea.className = "grid grid-cols-2 gap-x-8 gap-y-5";
|
|
fieldsArea.style.maxHeight = "none";
|
|
document.querySelector('.modal-content').classList.remove('wide');
|
|
isListMode = false;
|
|
}
|
|
|
|
function saveMember() {
|
|
const id = document.getElementById('m-id').value;
|
|
const name = document.getElementById('m-name').value.trim();
|
|
if (!name) { alert('이름을 입력해주세요.'); return; }
|
|
|
|
// 모드에 따른 대상 리스트 결정
|
|
const targetList = isListMode ? editingMembers : members;
|
|
|
|
let m = id ? targetList.find(x => x._id === id) : { _id: 'm_' + Date.now() };
|
|
m['이름'] = name;
|
|
|
|
dropdownFields.forEach(field => {
|
|
const selVal = document.getElementById(`sel-${field}`).value;
|
|
if (selVal === "__NEW__") {
|
|
m[field] = document.getElementById(`input-${field}`).value.trim();
|
|
} else if (selVal === "__NONE__") {
|
|
m[field] = "";
|
|
} else {
|
|
m[field] = selVal;
|
|
}
|
|
});
|
|
|
|
// [신규 필드 저장]
|
|
m['근무상태'] = document.getElementById('m-status').value;
|
|
m['근무시간'] = document.getElementById('m-worktime').value;
|
|
m['전화번호'] = document.getElementById('m-phone').value.trim();
|
|
m['이메일'] = document.getElementById('m-email').value.trim();
|
|
m['자리위치'] = document.getElementById('m-seat').value.trim();
|
|
m['사진'] = document.getElementById('m-photo').value.trim();
|
|
|
|
if (m['근무시간'] === '유연근무제') {
|
|
m['유연근무_시작'] = document.getElementById('m-work-start').value;
|
|
m['유연근무_종료'] = document.getElementById('m-work-end').value;
|
|
} else {
|
|
m['유연근무_시작'] = '';
|
|
m['유연근무_종료'] = '';
|
|
}
|
|
|
|
// 경로 재계산
|
|
m._path = levelOrder.map(l => ({ level: l, name: m[l] })).filter(i => i.name !== "");
|
|
|
|
if (!id) targetList.push(m);
|
|
|
|
if (isListMode) renderListViewTable();
|
|
else {
|
|
updateTimestamp();
|
|
render();
|
|
}
|
|
closeModal();
|
|
}
|
|
|
|
function deleteMember(id) {
|
|
if (!confirm('해당 구성원을 삭제하시겠습니까?')) return;
|
|
const targetList = isListMode ? editingMembers : members;
|
|
const idx = targetList.findIndex(m => m._id === id);
|
|
if (idx !== -1) targetList.splice(idx, 1);
|
|
|
|
if (isListMode) renderListViewTable();
|
|
else {
|
|
updateTimestamp();
|
|
render();
|
|
closeModal();
|
|
}
|
|
}
|
|
|
|
// --- 드래그 앤 드롭 구현부 (구성원 이동/순서 지정) ---
|
|
function handleDragStart(e, type, idOrLevel, name) {
|
|
e.stopPropagation();
|
|
if (type !== 'member') return; // 조직 단위 드래그 기능 제외
|
|
const data = { type, id: idOrLevel };
|
|
e.dataTransfer.setData('text/plain', JSON.stringify(data));
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
setTimeout(() => e.target.classList.add('opacity-40', 'scale-95'), 0);
|
|
}
|
|
|
|
function handleDragEnd(e) {
|
|
e.stopPropagation();
|
|
e.target.classList.remove('opacity-40', 'scale-95');
|
|
}
|
|
|
|
/* 그룹/팀 박스에 직접 드롭 시 맨 마지막에 추가 */
|
|
function handleDragOver(e) {
|
|
e.preventDefault(); e.stopPropagation();
|
|
e.dataTransfer.dropEffect = 'move';
|
|
e.currentTarget.classList.add('ring-4', 'ring-indigo-400', 'bg-indigo-50');
|
|
}
|
|
|
|
function handleDragLeave(e) {
|
|
e.stopPropagation();
|
|
e.currentTarget.classList.remove('ring-4', 'ring-indigo-400', 'bg-indigo-50');
|
|
}
|
|
|
|
function handleDrop(e, targetLevel, targetName) {
|
|
e.preventDefault(); e.stopPropagation();
|
|
e.currentTarget.classList.remove('ring-4', 'ring-indigo-400', 'bg-indigo-50');
|
|
|
|
try {
|
|
const dataStr = e.dataTransfer.getData('text/plain');
|
|
if (!dataStr) return;
|
|
const data = JSON.parse(dataStr);
|
|
if (data.type !== 'member') return;
|
|
|
|
const targetMember = members.find(m => m[targetLevel] === targetName);
|
|
const targetLvlIdx = levelOrder.indexOf(targetLevel);
|
|
const mIdx = members.findIndex(x => x._id === data.id);
|
|
|
|
if (mIdx === -1) return;
|
|
const m = members[mIdx];
|
|
|
|
for (let i = 0; i <= targetLvlIdx; i++) m[levelOrder[i]] = targetMember ? targetMember[levelOrder[i]] : targetName;
|
|
for (let i = targetLvlIdx + 1; i < levelOrder.length; i++) m[levelOrder[i]] = '';
|
|
m._path = levelOrder.map(l => ({ level: l, name: m[l] })).filter(i => i.name !== "");
|
|
|
|
// 박스에 직접 드롭한 경우 배열 마지막으로 재배치(해당 조직 맨 뒤 위치)
|
|
members.splice(mIdx, 1);
|
|
members.push(m);
|
|
|
|
render();
|
|
} catch (ex) { console.error(ex); }
|
|
}
|
|
|
|
/* 구성원 간의 드래그 앤 드롭 (순서 지정 로직) */
|
|
function handleDragOverMember(e) {
|
|
e.preventDefault(); e.stopPropagation();
|
|
e.dataTransfer.dropEffect = 'move';
|
|
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const relX = e.clientX - rect.left;
|
|
|
|
if (relX < rect.width / 2) {
|
|
e.currentTarget.classList.add('drop-left');
|
|
e.currentTarget.classList.remove('drop-right');
|
|
} else {
|
|
e.currentTarget.classList.add('drop-right');
|
|
e.currentTarget.classList.remove('drop-left');
|
|
}
|
|
}
|
|
|
|
function handleDragLeaveMember(e) {
|
|
e.stopPropagation();
|
|
e.currentTarget.classList.remove('drop-left', 'drop-right');
|
|
}
|
|
|
|
function handleDropMember(e, targetId) {
|
|
e.preventDefault(); e.stopPropagation();
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const relX = e.clientX - rect.left;
|
|
const insertAfter = relX >= rect.width / 2;
|
|
|
|
e.currentTarget.classList.remove('drop-left', 'drop-right');
|
|
|
|
try {
|
|
const dataStr = e.dataTransfer.getData('text/plain');
|
|
if (!dataStr) return;
|
|
const data = JSON.parse(dataStr);
|
|
if (data.type !== 'member' || data.id === targetId) return;
|
|
|
|
let mIdx = members.findIndex(x => x._id === data.id);
|
|
let tIdx = members.findIndex(x => x._id === targetId);
|
|
if (mIdx === -1 || tIdx === -1) return;
|
|
|
|
const m = members[mIdx];
|
|
const t = members[tIdx];
|
|
|
|
// 타겟의 소속 경로를 그대로 복사
|
|
levelOrder.forEach(l => { m[l] = t[l]; });
|
|
m._path = [...t._path];
|
|
|
|
// 재배열
|
|
members.splice(mIdx, 1);
|
|
tIdx = members.findIndex(x => x._id === targetId); // M을 뽑아내면 tIdx가 단축됐을 수 있음
|
|
const newIdx = insertAfter ? tIdx + 1 : tIdx;
|
|
members.splice(newIdx, 0, m);
|
|
|
|
render();
|
|
} catch (ex) { console.error(ex); }
|
|
}
|
|
|
|
// 초기 실행
|
|
updateFabMenu();
|
|
</script>
|
|
</body>
|
|
|
|
</html> |