feat: 권한 탭 분리 + 부서 인원 표시 + 다수 시스템 개선

- tkuser: 권한 관리를 별도 탭으로 분리, 부서 클릭 시 소속 인원 목록 표시
- system1: 모바일 UI 개선, nginx 권한 보정, 신고 카테고리 타입 마이그레이션
- system2: 신고 상세/보고서 개선, 내 보고서 페이지 추가
- system3: 이슈 뷰/수신함/관리함 개선
- gateway: 포털 라우팅 수정
- user-management API: 부서별 권한 벌크 설정 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-02-23 14:12:57 +09:00
parent bf4000c4ae
commit 3cc29c03a8
37 changed files with 1751 additions and 233 deletions

View File

@@ -7,7 +7,7 @@
<link rel="preload" href="https://cdn.tailwindcss.com" as="script">
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkuser.css?v=20260213">
<link rel="stylesheet" href="/static/css/tkuser.css?v=20260223">
</head>
<body>
<!-- Header -->
@@ -46,6 +46,9 @@
<button class="tab-btn px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap" onclick="switchTab('departments')">
<i class="fas fa-sitemap mr-2"></i>부서
</button>
<button class="tab-btn px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap" onclick="switchTab('permissions')">
<i class="fas fa-shield-alt mr-2"></i>권한
</button>
<button class="tab-btn px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap" onclick="switchTab('issueTypes')">
<i class="fas fa-exclamation-triangle mr-2"></i>이슈 유형
</button>
@@ -86,13 +89,8 @@
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">부서</label>
<select id="newDepartment" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
<select id="newDepartmentId" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
<option value="">선택</option>
<option value="production">생산</option>
<option value="quality">품질</option>
<option value="purchasing">구매</option>
<option value="design">설계</option>
<option value="sales">영업</option>
</select>
</div>
<div>
@@ -118,66 +116,9 @@
</div>
</div>
<!-- 페이지 권한 관리 -->
<div class="mt-6 bg-white rounded-xl shadow-sm p-5">
<div class="flex items-center justify-between mb-5">
<h2 class="text-base font-semibold text-gray-800"><i class="fas fa-shield-alt text-slate-400 mr-2"></i>페이지 접근 권한</h2>
<select id="permissionUserSelect" class="input-field px-3 py-1.5 rounded-lg text-sm min-w-[200px]">
<option value="">사용자 선택</option>
</select>
</div>
<div id="permissionPanel" class="hidden">
<!-- System 1 - 공장관리 -->
<div class="system-section system1 rounded-lg mb-5 bg-white">
<div class="flex items-center justify-between px-4 py-3 bg-blue-50 rounded-t-lg border border-blue-100">
<div class="flex items-center gap-2">
<i class="fas fa-industry text-blue-500"></i>
<span class="font-semibold text-sm text-blue-900">공장관리</span>
<span class="text-xs text-blue-500 bg-blue-100 px-2 py-0.5 rounded-full">System 1</span>
</div>
<div class="flex gap-2">
<button onclick="toggleSystemAll('s1', true)" class="text-xs text-blue-600 hover:underline">전체 허용</button>
<span class="text-gray-300">|</span>
<button onclick="toggleSystemAll('s1', false)" class="text-xs text-blue-600 hover:underline">전체 해제</button>
</div>
</div>
<div id="s1-perms" class="p-4 border border-t-0 border-blue-100 rounded-b-lg space-y-4"></div>
</div>
<!-- System 3 - 부적합관리 -->
<div class="system-section system3 rounded-lg mb-5 bg-white">
<div class="flex items-center justify-between px-4 py-3 bg-purple-50 rounded-t-lg border border-purple-100">
<div class="flex items-center gap-2">
<i class="fas fa-shield-halved text-purple-500"></i>
<span class="font-semibold text-sm text-purple-900">부적합관리</span>
<span class="text-xs text-purple-500 bg-purple-100 px-2 py-0.5 rounded-full">System 3</span>
</div>
<div class="flex gap-2">
<button onclick="toggleSystemAll('s3', true)" class="text-xs text-purple-600 hover:underline">전체 허용</button>
<span class="text-gray-300">|</span>
<button onclick="toggleSystemAll('s3', false)" class="text-xs text-purple-600 hover:underline">전체 해제</button>
</div>
</div>
<div id="s3-perms" class="p-4 border border-t-0 border-purple-100 rounded-b-lg space-y-4"></div>
</div>
<!-- 저장 버튼 -->
<div class="flex items-center gap-3 pt-2">
<button id="savePermissionsBtn" class="px-6 py-2.5 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium">
<i class="fas fa-save mr-2"></i>권한 저장
</button>
<span id="permissionSaveStatus" class="text-sm"></span>
</div>
</div>
<div id="permissionEmpty" class="text-center text-gray-400 py-8 text-sm">
<i class="fas fa-hand-pointer text-2xl mb-2"></i>
<p>사용자를 선택하면 권한을 설정할 수 있습니다</p>
</div>
</div>
</div>
<!-- 비밀번호 변경 (일반 사용자) -->
<div id="passwordChangeSection" class="hidden">
<div class="bg-white rounded-xl shadow-sm p-6 max-w-md mx-auto">
@@ -203,6 +144,128 @@
</div>
</div>
<!-- ============ 권한 탭 ============ -->
<div id="tab-permissions" class="hidden">
<!-- 부서별 기본 권한 설정 -->
<div class="bg-white rounded-xl shadow-sm p-5">
<div class="flex items-center justify-between mb-5">
<h2 class="text-base font-semibold text-gray-800"><i class="fas fa-building text-slate-400 mr-2"></i>부서별 기본 권한</h2>
<select id="deptPermSelect" class="input-field px-3 py-1.5 rounded-lg text-sm min-w-[200px]">
<option value="">부서 선택</option>
</select>
</div>
<div id="deptPermPanel" class="hidden">
<!-- System 1 -->
<div class="system-section system1 rounded-lg mb-5 bg-white">
<div class="flex items-center justify-between px-4 py-3 bg-blue-50 rounded-t-lg border border-blue-100">
<div class="flex items-center gap-2">
<i class="fas fa-industry text-blue-500"></i>
<span class="font-semibold text-sm text-blue-900">공장관리</span>
<span class="text-xs text-blue-500 bg-blue-100 px-2 py-0.5 rounded-full">System 1</span>
</div>
<div class="flex gap-2">
<button onclick="toggleDeptSystemAll('s1', true)" class="text-xs text-blue-600 hover:underline">전체 허용</button>
<span class="text-gray-300">|</span>
<button onclick="toggleDeptSystemAll('s1', false)" class="text-xs text-blue-600 hover:underline">전체 해제</button>
</div>
</div>
<div id="dept-s1-perms" class="p-4 border border-t-0 border-blue-100 rounded-b-lg space-y-4"></div>
</div>
<!-- System 3 -->
<div class="system-section system3 rounded-lg mb-5 bg-white">
<div class="flex items-center justify-between px-4 py-3 bg-purple-50 rounded-t-lg border border-purple-100">
<div class="flex items-center gap-2">
<i class="fas fa-shield-halved text-purple-500"></i>
<span class="font-semibold text-sm text-purple-900">부적합관리</span>
<span class="text-xs text-purple-500 bg-purple-100 px-2 py-0.5 rounded-full">System 3</span>
</div>
<div class="flex gap-2">
<button onclick="toggleDeptSystemAll('s3', true)" class="text-xs text-purple-600 hover:underline">전체 허용</button>
<span class="text-gray-300">|</span>
<button onclick="toggleDeptSystemAll('s3', false)" class="text-xs text-purple-600 hover:underline">전체 해제</button>
</div>
</div>
<div id="dept-s3-perms" class="p-4 border border-t-0 border-purple-100 rounded-b-lg space-y-4"></div>
</div>
<!-- 저장 -->
<div class="flex items-center gap-3 pt-2">
<button id="saveDeptPermBtn" class="px-6 py-2.5 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium">
<i class="fas fa-save mr-2"></i>부서 권한 저장
</button>
<span id="deptPermSaveStatus" class="text-sm"></span>
</div>
</div>
<div id="deptPermEmpty" class="text-center text-gray-400 py-8 text-sm">
<i class="fas fa-building text-2xl mb-2"></i>
<p>부서를 선택하면 기본 권한을 설정할 수 있습니다</p>
</div>
</div>
<!-- 페이지 권한 관리 (개인) -->
<div class="mt-6 bg-white rounded-xl shadow-sm p-5">
<div class="flex items-center justify-between mb-5">
<h2 class="text-base font-semibold text-gray-800"><i class="fas fa-shield-alt text-slate-400 mr-2"></i>개인 페이지 권한</h2>
<select id="permissionUserSelect" class="input-field px-3 py-1.5 rounded-lg text-sm min-w-[200px]">
<option value="">사용자 선택</option>
</select>
</div>
<div id="permissionPanel" class="hidden">
<!-- System 1 - 공장관리 -->
<div class="system-section system1 rounded-lg mb-5 bg-white">
<div class="flex items-center justify-between px-4 py-3 bg-blue-50 rounded-t-lg border border-blue-100">
<div class="flex items-center gap-2">
<i class="fas fa-industry text-blue-500"></i>
<span class="font-semibold text-sm text-blue-900">공장관리</span>
<span class="text-xs text-blue-500 bg-blue-100 px-2 py-0.5 rounded-full">System 1</span>
</div>
<div class="flex gap-2">
<button onclick="toggleSystemAll('s1', true)" class="text-xs text-blue-600 hover:underline">전체 허용</button>
<span class="text-gray-300">|</span>
<button onclick="toggleSystemAll('s1', false)" class="text-xs text-blue-600 hover:underline">전체 해제</button>
</div>
</div>
<div id="s1-perms" class="p-4 border border-t-0 border-blue-100 rounded-b-lg space-y-4"></div>
</div>
<!-- System 3 - 부적합관리 -->
<div class="system-section system3 rounded-lg mb-5 bg-white">
<div class="flex items-center justify-between px-4 py-3 bg-purple-50 rounded-t-lg border border-purple-100">
<div class="flex items-center gap-2">
<i class="fas fa-shield-halved text-purple-500"></i>
<span class="font-semibold text-sm text-purple-900">부적합관리</span>
<span class="text-xs text-purple-500 bg-purple-100 px-2 py-0.5 rounded-full">System 3</span>
</div>
<div class="flex gap-2">
<button onclick="toggleSystemAll('s3', true)" class="text-xs text-purple-600 hover:underline">전체 허용</button>
<span class="text-gray-300">|</span>
<button onclick="toggleSystemAll('s3', false)" class="text-xs text-purple-600 hover:underline">전체 해제</button>
</div>
</div>
<div id="s3-perms" class="p-4 border border-t-0 border-purple-100 rounded-b-lg space-y-4"></div>
</div>
<!-- 저장 버튼 -->
<div class="flex items-center gap-3 pt-2">
<button id="savePermissionsBtn" class="px-6 py-2.5 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium">
<i class="fas fa-save mr-2"></i>권한 저장
</button>
<button id="resetToDefaultBtn" class="px-4 py-2.5 border border-gray-300 text-gray-600 rounded-lg hover:bg-gray-50 text-sm font-medium">
<i class="fas fa-undo mr-2"></i>부서 기본으로 초기화
</button>
<span id="permissionSaveStatus" class="text-sm"></span>
</div>
</div>
<div id="permissionEmpty" class="text-center text-gray-400 py-8 text-sm">
<i class="fas fa-hand-pointer text-2xl mb-2"></i>
<p>사용자를 선택하면 권한을 설정할 수 있습니다</p>
</div>
</div>
</div>
<!-- ============ 준비 중 탭 (placeholder) ============ -->
<div id="tab-projects" class="hidden">
<div class="grid lg:grid-cols-5 gap-6">
@@ -286,6 +349,10 @@
<div id="departmentList" class="space-y-2 max-h-[520px] overflow-y-auto">
<div class="text-gray-400 text-center py-8"><i class="fas fa-spinner fa-spin text-2xl"></i><p class="mt-2 text-sm">로딩 중...</p></div>
</div>
<div id="deptMembersPanel" class="hidden mt-4 border-t pt-4">
<h3 class="text-sm font-semibold text-gray-700 mb-3"><i class="fas fa-users text-slate-400 mr-1.5"></i>소속 인원</h3>
<div id="deptMembersList" class="space-y-2"></div>
</div>
</div>
</div>
</div>
@@ -723,13 +790,8 @@
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">부서</label>
<select id="editDepartment" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
<select id="editDepartmentId" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
<option value="">선택</option>
<option value="production">생산</option>
<option value="quality">품질</option>
<option value="purchasing">구매</option>
<option value="design">설계</option>
<option value="sales">영업</option>
</select>
</div>
<div>
@@ -1208,18 +1270,18 @@
</div>
<!-- JS: Core (config, token, api, toast, helpers, init) -->
<script src="/static/js/tkuser-core.js?v=20260213"></script>
<script src="/static/js/tkuser-core.js?v=20260223"></script>
<!-- JS: Tabs -->
<script src="/static/js/tkuser-tabs.js?v=20260213"></script>
<script src="/static/js/tkuser-tabs.js?v=20260223"></script>
<!-- JS: Individual modules -->
<script src="/static/js/tkuser-users.js?v=20260213"></script>
<script src="/static/js/tkuser-projects.js?v=20260213"></script>
<script src="/static/js/tkuser-departments.js?v=20260213"></script>
<script src="/static/js/tkuser-issue-types.js?v=20260213"></script>
<script src="/static/js/tkuser-workplaces.js?v=20260213"></script>
<script src="/static/js/tkuser-tasks.js?v=20260213"></script>
<script src="/static/js/tkuser-vacations.js?v=20260213"></script>
<script src="/static/js/tkuser-layout-map.js?v=20260213"></script>
<script src="/static/js/tkuser-users.js?v=20260223"></script>
<script src="/static/js/tkuser-projects.js?v=20260223"></script>
<script src="/static/js/tkuser-departments.js?v=20260223"></script>
<script src="/static/js/tkuser-issue-types.js?v=20260223"></script>
<script src="/static/js/tkuser-workplaces.js?v=20260223"></script>
<script src="/static/js/tkuser-tasks.js?v=20260223"></script>
<script src="/static/js/tkuser-vacations.js?v=20260223"></script>
<script src="/static/js/tkuser-layout-map.js?v=20260223"></script>
<!-- Boot -->
<script>init();</script>
</body>

View File

@@ -33,8 +33,8 @@ server {
try_files $uri $uri/ /index.html;
}
# 업로드 파일 프록시
location /uploads/ {
# 업로드 파일 프록시 (^~ 로 regex location보다 우선 매칭)
location ^~ /uploads/ {
proxy_pass http://tkuser-api:3000/uploads/;
proxy_http_version 1.1;
proxy_set_header Host $host;

View File

@@ -33,8 +33,21 @@ function showToast(msg, type = 'success') {
}
/* ===== Helpers ===== */
const DEPT = { production:'생산', quality:'품질', purchasing:'구매', design:'설계', sales:'영업' };
function deptLabel(d) { return DEPT[d] || d || ''; }
const DEPT_FALLBACK = { production:'생산', quality:'품질', purchasing:'구매', design:'설계', sales:'영업' };
let departmentsCache = [];
async function loadDepartmentsCache() {
try {
const r = await api('/departments');
departmentsCache = (r.data || r).filter(d => d.is_active !== 0 && d.is_active !== false);
} catch(e) { console.warn('부서 캐시 로드 실패:', e); }
}
function deptLabel(d, deptId) {
if (deptId && departmentsCache.length) {
const dept = departmentsCache.find(x => x.department_id === deptId);
if (dept) return dept.department_name;
}
return DEPT_FALLBACK[d] || d || '';
}
function formatDate(d) { if (!d) return ''; return d.substring(0, 10); }
function escHtml(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
@@ -64,6 +77,7 @@ async function init() {
if (currentUser.role === 'admin') {
document.getElementById('tabNav').classList.remove('hidden');
document.getElementById('adminSection').classList.remove('hidden');
await loadDepartmentsCache();
await loadUsers();
} else {
document.getElementById('passwordChangeSection').classList.remove('hidden');

View File

@@ -24,11 +24,13 @@ function populateParentDeptSelects() {
});
}
let selectedDeptForMembers = null;
function displayDepartments() {
const c = document.getElementById('departmentList');
if (!departments.length) { c.innerHTML = '<p class="text-gray-400 text-center py-4 text-sm">등록된 부서가 없습니다.</p>'; return; }
c.innerHTML = departments.map(d => `
<div class="flex items-center justify-between p-2.5 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
<div class="flex items-center justify-between p-2.5 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer ${selectedDeptForMembers === d.department_id ? 'bg-blue-50 ring-1 ring-blue-200' : 'bg-gray-50'}" onclick="showDeptMembers(${d.department_id})">
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-gray-800 truncate"><i class="fas fa-sitemap mr-1.5 text-gray-400 text-xs"></i>${d.department_name}</div>
<div class="text-xs text-gray-500 flex items-center gap-1.5 mt-0.5 flex-wrap">
@@ -37,13 +39,58 @@ function displayDepartments() {
${d.is_active === 0 || d.is_active === false ? '<span class="px-1.5 py-0.5 rounded bg-gray-100 text-gray-400">비활성</span>' : '<span class="px-1.5 py-0.5 rounded bg-emerald-50 text-emerald-600">활성</span>'}
</div>
</div>
<div class="flex gap-1 ml-2 flex-shrink-0">
<div class="flex gap-1 ml-2 flex-shrink-0" onclick="event.stopPropagation()">
<button onclick="editDepartment(${d.department_id})" class="p-1.5 text-slate-500 hover:text-slate-700 hover:bg-slate-200 rounded" title="편집"><i class="fas fa-pen-to-square text-xs"></i></button>
${d.is_active !== 0 && d.is_active !== false ? `<button onclick="deactivateDepartment(${d.department_id},'${(d.department_name||'').replace(/'/g,"\\'")}')" class="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-100 rounded" title="비활성화"><i class="fas fa-ban text-xs"></i></button>` : ''}
</div>
</div>`).join('');
}
async function showDeptMembers(deptId) {
selectedDeptForMembers = deptId;
displayDepartments();
const panel = document.getElementById('deptMembersPanel');
const list = document.getElementById('deptMembersList');
panel.classList.remove('hidden');
list.innerHTML = '<div class="text-gray-400 text-center py-4"><i class="fas fa-spinner fa-spin"></i></div>';
let deptUsers = users;
if (!deptUsers || !deptUsers.length) {
try {
const r = await api('/users');
deptUsers = r.data || r;
} catch (e) {
list.innerHTML = `<p class="text-red-500 text-sm">${e.message}</p>`;
return;
}
}
const members = deptUsers.filter(u => u.department_id === deptId);
const dept = departments.find(d => d.department_id === deptId);
const title = panel.querySelector('h3');
if (title) title.innerHTML = `<i class="fas fa-users text-slate-400 mr-1.5"></i>소속 인원 — ${dept ? dept.department_name : ''}`;
if (!members.length) {
list.innerHTML = '<p class="text-gray-400 text-center py-4 text-sm">소속 인원이 없습니다</p>';
return;
}
list.innerHTML = members.map(u => `
<div class="flex items-center justify-between p-2 bg-gray-50 rounded-lg">
<div class="flex items-center gap-2">
<div class="w-7 h-7 bg-slate-200 rounded-full flex items-center justify-center text-xs font-semibold text-slate-600">${(u.name || u.username).charAt(0)}</div>
<div>
<div class="text-sm font-medium text-gray-800">${u.name || u.username}</div>
<div class="text-xs text-gray-500">${u.username}</div>
</div>
</div>
<div class="flex items-center gap-1.5">
<span class="px-1.5 py-0.5 rounded text-xs ${u.role === 'admin' ? 'bg-red-50 text-red-600' : 'bg-slate-50 text-slate-500'}">${u.role === 'admin' ? '관리자' : '사용자'}</span>
${u.is_active === 0 || u.is_active === false ? '<span class="px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-400">비활성</span>' : '<span class="px-1.5 py-0.5 rounded text-xs bg-emerald-50 text-emerald-600">활성</span>'}
</div>
</div>`).join('');
}
document.getElementById('addDepartmentForm').addEventListener('submit', async e => {
e.preventDefault();
try {

View File

@@ -21,4 +21,5 @@ function switchTab(name) {
if (name === 'workplaces' && !workplacesLoaded) loadWorkplaces();
if (name === 'tasks' && !tasksLoaded) loadTasksTab();
if (name === 'vacations' && !vacationsLoaded) loadVacationsTab();
if (name === 'permissions' && !permissionsTabLoaded) loadPermissionsTab();
}

View File

@@ -56,18 +56,38 @@ const SYSTEM3_PAGES = {
]
};
/* ===== Permissions Tab State ===== */
let permissionsTabLoaded = false;
function loadPermissionsTab() {
populateDeptPermSelect();
updatePermissionUserSelect();
permissionsTabLoaded = true;
}
/* ===== Users State ===== */
let users = [], selectedUserId = null, currentPermissions = {};
let users = [], selectedUserId = null, currentPermissions = {}, currentPermSources = {};
/* ===== Users CRUD ===== */
async function loadUsers() {
try {
const r = await api('/users'); users = r.data || r;
displayUsers(); updatePermissionUserSelect();
populateUserDeptSelects();
} catch (err) {
document.getElementById('userList').innerHTML = `<div class="text-red-500 text-center py-6"><i class="fas fa-exclamation-triangle text-xl"></i><p class="text-sm mt-2">${err.message}</p></div>`;
}
}
function populateUserDeptSelects() {
['newDepartmentId','editDepartmentId'].forEach(id => {
const sel = document.getElementById(id); if (!sel) return;
const val = sel.value;
sel.innerHTML = '<option value="">선택</option>';
departmentsCache.forEach(d => { const o = document.createElement('option'); o.value = d.department_id; o.textContent = d.department_name; sel.appendChild(o); });
sel.value = val;
});
}
function displayUsers() {
const c = document.getElementById('userList');
if (!users.length) { c.innerHTML = '<p class="text-gray-400 text-center py-4 text-sm">등록된 사용자가 없습니다.</p>'; return; }
@@ -77,7 +97,7 @@ function displayUsers() {
<div class="text-sm font-medium text-gray-800 truncate"><i class="fas fa-user mr-1.5 text-gray-400 text-xs"></i>${u.name||u.username}</div>
<div class="text-xs text-gray-500 flex items-center gap-1.5 mt-0.5 flex-wrap">
<span>${u.username}</span>
${u.department?`<span class="px-1.5 py-0.5 rounded bg-green-50 text-green-600">${deptLabel(u.department)}</span>`:''}
${u.department||u.department_id?`<span class="px-1.5 py-0.5 rounded bg-green-50 text-green-600">${deptLabel(u.department, u.department_id)}</span>`:''}
<span class="px-1.5 py-0.5 rounded ${u.role==='admin'?'bg-red-50 text-red-600':'bg-slate-50 text-slate-500'}">${u.role==='admin'?'관리자':'사용자'}</span>
${u.is_active===0||u.is_active===false?'<span class="px-1.5 py-0.5 rounded bg-gray-100 text-gray-400">비활성</span>':''}
</div>
@@ -92,8 +112,9 @@ function displayUsers() {
document.getElementById('addUserForm').addEventListener('submit', async e => {
e.preventDefault();
const deptIdVal = document.getElementById('newDepartmentId').value;
try {
await api('/users', { method:'POST', body: JSON.stringify({ username: document.getElementById('newUsername').value.trim(), name: document.getElementById('newFullName').value.trim(), password: document.getElementById('newPassword').value, department: document.getElementById('newDepartment').value||null, role: document.getElementById('newRole').value }) });
await api('/users', { method:'POST', body: JSON.stringify({ username: document.getElementById('newUsername').value.trim(), name: document.getElementById('newFullName').value.trim(), password: document.getElementById('newPassword').value, department_id: deptIdVal ? parseInt(deptIdVal) : null, role: document.getElementById('newRole').value }) });
showToast('사용자가 추가되었습니다.'); document.getElementById('addUserForm').reset(); await loadUsers();
} catch(e) { showToast(e.message,'error'); }
});
@@ -101,15 +122,19 @@ document.getElementById('addUserForm').addEventListener('submit', async e => {
function editUser(id) {
const u = users.find(x=>x.user_id===id); if(!u) return;
document.getElementById('editUserId').value=u.user_id; document.getElementById('editUsername').value=u.username;
document.getElementById('editFullName').value=u.name||''; document.getElementById('editDepartment').value=u.department||''; document.getElementById('editRole').value=u.role;
document.getElementById('editFullName').value=u.name||'';
populateUserDeptSelects();
document.getElementById('editDepartmentId').value=u.department_id||'';
document.getElementById('editRole').value=u.role;
document.getElementById('editUserModal').classList.remove('hidden');
}
function closeEditModal() { document.getElementById('editUserModal').classList.add('hidden'); }
document.getElementById('editUserForm').addEventListener('submit', async e => {
e.preventDefault();
const deptIdVal = document.getElementById('editDepartmentId').value;
try {
await api(`/users/${document.getElementById('editUserId').value}`, { method:'PUT', body: JSON.stringify({ name: document.getElementById('editFullName').value.trim()||null, department: document.getElementById('editDepartment').value||null, role: document.getElementById('editRole').value }) });
await api(`/users/${document.getElementById('editUserId').value}`, { method:'PUT', body: JSON.stringify({ name: document.getElementById('editFullName').value.trim()||null, department_id: deptIdVal ? parseInt(deptIdVal) : null, role: document.getElementById('editRole').value }) });
showToast('수정되었습니다.'); closeEditModal(); await loadUsers();
} catch(e) { showToast(e.message,'error'); }
});
@@ -154,13 +179,18 @@ document.getElementById('permissionUserSelect').addEventListener('change', async
});
async function loadUserPermissions(userId) {
// 기본값 세팅
currentPermissions = {};
currentPermSources = {};
const allDefs = { ...SYSTEM1_PAGES, ...SYSTEM3_PAGES };
Object.values(allDefs).flat().forEach(p => { currentPermissions[p.key] = p.def; });
Object.values(allDefs).flat().forEach(p => { currentPermissions[p.key] = p.def; currentPermSources[p.key] = 'default'; });
try {
const perms = await api(`/users/${userId}/page-permissions`);
(Array.isArray(perms)?perms:[]).forEach(p => { currentPermissions[p.page_name] = !!p.can_access; });
const result = await api(`/permissions/users/${userId}/effective-permissions`);
if (result.permissions) {
for (const [pageName, info] of Object.entries(result.permissions)) {
currentPermissions[pageName] = info.can_access;
currentPermSources[pageName] = info.source;
}
}
} catch(e) { console.warn('권한 로드 실패:', e); }
}
@@ -169,6 +199,12 @@ function renderPermissionGrid() {
renderSystemPerms('s3-perms', SYSTEM3_PAGES, 'purple');
}
function sourceLabel(src) {
if (src === 'explicit') return '<span class="ml-auto text-[10px] px-1.5 py-0.5 rounded bg-blue-50 text-blue-500">개인</span>';
if (src === 'department') return '<span class="ml-auto text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-500">부서</span>';
return '';
}
function renderSystemPerms(containerId, pageDef, color) {
const container = document.getElementById(containerId);
let html = '';
@@ -192,12 +228,14 @@ function renderSystemPerms(containerId, pageDef, color) {
<div id="${groupId}" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 mt-1">
${pages.map(p => {
const checked = currentPermissions[p.key] || false;
const src = currentPermSources[p.key] || 'default';
return `
<label class="perm-item flex items-center gap-2.5 p-2.5 border rounded-lg cursor-pointer ${checked?'checked':'border-gray-200'}" data-group="${groupId}">
<input type="checkbox" id="perm_${p.key}" ${checked?'checked':''} class="h-4 w-4 text-${color}-500 rounded border-gray-300 focus:ring-${color}-400"
onchange="onPermChange(this)">
<i class="fas ${p.icon} text-sm ${checked?`text-${color}-500`:'text-gray-400'}" data-color="${color}"></i>
<span class="text-sm text-gray-700">${p.title}</span>
${sourceLabel(src)}
</label>`;
}).join('')}
</div>
@@ -268,6 +306,157 @@ document.getElementById('savePermissionsBtn').addEventListener('click', async ()
} finally { btn.disabled = false; btn.innerHTML = '<i class="fas fa-save mr-2"></i>권한 저장'; }
});
/* ===== Department Permissions ===== */
let selectedDeptId = null, deptPermissions = {};
function populateDeptPermSelect() {
const sel = document.getElementById('deptPermSelect'); if (!sel) return;
sel.innerHTML = '<option value="">부서 선택</option>';
departmentsCache.forEach(d => { const o = document.createElement('option'); o.value = d.department_id; o.textContent = d.department_name; sel.appendChild(o); });
}
document.addEventListener('DOMContentLoaded', () => {
const sel = document.getElementById('deptPermSelect');
if (sel) sel.addEventListener('change', async e => {
selectedDeptId = e.target.value;
if (selectedDeptId) {
await loadDeptPermissions(selectedDeptId);
renderDeptPermissionGrid();
document.getElementById('deptPermPanel').classList.remove('hidden');
document.getElementById('deptPermEmpty').classList.add('hidden');
} else {
document.getElementById('deptPermPanel').classList.add('hidden');
document.getElementById('deptPermEmpty').classList.remove('hidden');
}
});
const saveBtn = document.getElementById('saveDeptPermBtn');
if (saveBtn) saveBtn.addEventListener('click', saveDeptPermissions);
const resetBtn = document.getElementById('resetToDefaultBtn');
if (resetBtn) resetBtn.addEventListener('click', resetUserPermToDefault);
});
async function loadDeptPermissions(deptId) {
deptPermissions = {};
const allDefs = { ...SYSTEM1_PAGES, ...SYSTEM3_PAGES };
Object.values(allDefs).flat().forEach(p => { deptPermissions[p.key] = p.def; });
try {
const result = await api(`/permissions/departments/${deptId}/permissions`);
(result.data || []).forEach(p => { deptPermissions[p.page_name] = !!p.can_access; });
} catch(e) { console.warn('부서 권한 로드 실패:', e); }
}
function renderDeptPermissionGrid() {
renderDeptSystemPerms('dept-s1-perms', SYSTEM1_PAGES, 'blue');
renderDeptSystemPerms('dept-s3-perms', SYSTEM3_PAGES, 'purple');
}
function renderDeptSystemPerms(containerId, pageDef, color) {
const container = document.getElementById(containerId);
if (!container) return;
let html = '';
Object.entries(pageDef).forEach(([groupName, pages]) => {
const groupId = containerId + '-' + groupName.replace(/\s/g,'');
const allChecked = pages.every(p => deptPermissions[p.key]);
html += `
<div>
<div class="group-header flex items-center justify-between py-2 px-1 rounded" onclick="toggleGroup('${groupId}')">
<div class="flex items-center gap-2">
<i class="fas fa-chevron-down text-xs text-gray-400 transition-transform" id="arrow-${groupId}"></i>
<span class="text-xs font-semibold text-gray-600 uppercase tracking-wide">${groupName}</span>
<span class="text-xs text-gray-400">${pages.length}</span>
</div>
<label class="flex items-center gap-1.5 text-xs text-gray-500 cursor-pointer" onclick="event.stopPropagation()">
<input type="checkbox" ${allChecked?'checked':''} onchange="toggleDeptGroupAll('${groupId}', this.checked)"
class="h-3.5 w-3.5 text-${color}-500 rounded border-gray-300">
<span>전체</span>
</label>
</div>
<div id="${groupId}" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 mt-1">
${pages.map(p => {
const checked = deptPermissions[p.key] || false;
return `
<label class="perm-item flex items-center gap-2.5 p-2.5 border rounded-lg cursor-pointer ${checked?'checked':'border-gray-200'}" data-group="${groupId}">
<input type="checkbox" id="dperm_${p.key}" ${checked?'checked':''} class="h-4 w-4 text-${color}-500 rounded border-gray-300 focus:ring-${color}-400"
onchange="onDeptPermChange(this)">
<i class="fas ${p.icon} text-sm ${checked?`text-${color}-500`:'text-gray-400'}" data-color="${color}"></i>
<span class="text-sm text-gray-700">${p.title}</span>
</label>`;
}).join('')}
</div>
</div>`;
});
container.innerHTML = html;
}
function onDeptPermChange(cb) {
const item = cb.closest('.perm-item');
const icon = item.querySelector('i[data-color]');
const color = icon.dataset.color;
item.classList.toggle('checked', cb.checked);
icon.classList.toggle(`text-${color}-500`, cb.checked);
icon.classList.toggle('text-gray-400', !cb.checked);
const group = item.dataset.group;
const groupCbs = document.querySelectorAll(`[data-group="${group}"] input[type="checkbox"]`);
const allChecked = [...groupCbs].every(c => c.checked);
const groupHeader = document.getElementById(group)?.previousElementSibling;
if (groupHeader) { const gc = groupHeader.querySelector('input[type="checkbox"]'); if(gc) gc.checked = allChecked; }
}
function toggleDeptGroupAll(groupId, checked) {
document.querySelectorAll(`#${groupId} input[type="checkbox"]`).forEach(cb => {
cb.checked = checked;
onDeptPermChange(cb);
});
}
function toggleDeptSystemAll(prefix, checked) {
const containerId = prefix === 's1' ? 'dept-s1-perms' : 'dept-s3-perms';
document.querySelectorAll(`#${containerId} input[type="checkbox"]`).forEach(cb => {
cb.checked = checked;
onDeptPermChange(cb);
});
document.querySelectorAll(`#${containerId} .group-header input[type="checkbox"]`).forEach(cb => cb.checked = checked);
}
async function saveDeptPermissions() {
if (!selectedDeptId) return;
const btn = document.getElementById('saveDeptPermBtn');
const st = document.getElementById('deptPermSaveStatus');
btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>저장 중...';
try {
const allPages = [...Object.values(SYSTEM1_PAGES).flat(), ...Object.values(SYSTEM3_PAGES).flat()];
const permissions = allPages.map(p => {
const cb = document.getElementById('dperm_' + p.key);
return { page_name: p.key, can_access: cb ? cb.checked : false };
});
await api(`/permissions/departments/${selectedDeptId}/bulk-set`, { method:'POST', body: JSON.stringify({ permissions }) });
st.textContent = '저장 완료'; st.className = 'text-sm text-emerald-600';
showToast('부서 권한이 저장되었습니다.');
setTimeout(() => { st.textContent = ''; }, 3000);
} catch(e) {
st.textContent = e.message; st.className = 'text-sm text-red-500';
showToast('저장 실패: ' + e.message, 'error');
} finally { btn.disabled = false; btn.innerHTML = '<i class="fas fa-save mr-2"></i>부서 권한 저장'; }
}
async function resetUserPermToDefault() {
if (!selectedUserId) return;
if (!confirm('이 사용자의 개인 권한을 모두 삭제하고 부서 기본 권한으로 되돌리시겠습니까?')) return;
try {
const perms = await api(`/users/${selectedUserId}/page-permissions`);
const permArr = Array.isArray(perms) ? perms : [];
for (const p of permArr) {
await api(`/permissions/${p.id}`, { method: 'DELETE' });
}
showToast('부서 기본 권한으로 초기화되었습니다.');
await loadUserPermissions(selectedUserId);
renderPermissionGrid();
} catch(e) { showToast('초기화 실패: ' + e.message, 'error'); }
}
/* ===== Workers CRUD ===== */
let workers = [], workersLoaded = false, departmentsForSelect = [];