Files
MH-DashBoard-organization/DashBoard-organization.html
2026-03-24 17:51:42 +09:00

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>