diff --git a/user-management/api/controllers/userController.js b/user-management/api/controllers/userController.js index 2b00b40..792400e 100644 --- a/user-management/api/controllers/userController.js +++ b/user-management/api/controllers/userController.js @@ -24,7 +24,7 @@ async function getUsers(req, res, next) { */ async function createUser(req, res, next) { try { - const { username, password, name, full_name, department, department_id, role } = req.body; + const { username, password, name, full_name, department, department_id, role, hire_date } = req.body; if (!username || !password) { return res.status(400).json({ success: false, error: '사용자명과 비밀번호는 필수입니다' }); @@ -41,7 +41,8 @@ async function createUser(req, res, next) { name: name || full_name, department, department_id: department_id || null, - role + role, + hire_date: hire_date || null }); res.status(201).json({ success: true, data: user }); } catch (err) { diff --git a/user-management/api/index.js b/user-management/api/index.js index a56c161..eb1d747 100644 --- a/user-management/api/index.js +++ b/user-management/api/index.js @@ -90,8 +90,9 @@ app.use((err, req, res, next) => { // Startup: 마이그레이션 완료 후 서버 시작 async function start() { try { - const { runMigration } = require('./models/vacationSettingsModel'); + const { runMigration, runGenericMigration } = require('./models/vacationSettingsModel'); await runMigration(); + await runGenericMigration('20260323_add_resigned_date.sql'); } catch (err) { if (!['ER_DUP_FIELDNAME', 'ER_TABLE_EXISTS_ERROR', 'ER_DUP_KEYNAME'].includes(err.code)) { console.error('Fatal migration error:', err.message); diff --git a/user-management/api/models/userModel.js b/user-management/api/models/userModel.js index 5c438bc..eba6dd2 100644 --- a/user-management/api/models/userModel.js +++ b/user-management/api/models/userModel.js @@ -79,18 +79,18 @@ async function findById(userId) { async function findAll() { const db = getPool(); const [rows] = await db.query( - 'SELECT user_id, username, name, department, department_id, role, system1_access, system2_access, system3_access, is_active, last_login, created_at, hire_date FROM sso_users WHERE partner_company_id IS NULL ORDER BY user_id' + 'SELECT user_id, username, name, department, department_id, role, system1_access, system2_access, system3_access, is_active, last_login, created_at, hire_date, resigned_date FROM sso_users WHERE partner_company_id IS NULL ORDER BY user_id' ); return rows; } -async function create({ username, password, name, department, department_id, role }) { +async function create({ username, password, name, department, department_id, role, hire_date }) { const db = getPool(); const password_hash = await hashPassword(password); const [result] = await db.query( - `INSERT INTO sso_users (username, password_hash, name, department, department_id, role) - VALUES (?, ?, ?, ?, ?, ?)`, - [username, password_hash, name || null, department || null, department_id || null, role || 'user'] + `INSERT INTO sso_users (username, password_hash, name, department, department_id, role, hire_date) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [username, password_hash, name || null, department || null, department_id || null, role || 'user', hire_date || null] ); return findById(result.insertId); } @@ -109,6 +109,7 @@ async function update(userId, data) { if (data.system3_access !== undefined) { fields.push('system3_access = ?'); values.push(data.system3_access); } if (data.is_active !== undefined) { fields.push('is_active = ?'); values.push(data.is_active); } if (data.hire_date !== undefined) { fields.push('hire_date = ?'); values.push(data.hire_date || null); } + if (data.resigned_date !== undefined) { fields.push('resigned_date = ?'); values.push(data.resigned_date || null); } if (data.password) { fields.push('password_hash = ?'); values.push(await hashPassword(data.password)); @@ -126,7 +127,7 @@ async function update(userId, data) { async function deleteUser(userId) { const db = getPool(); - await db.query('UPDATE sso_users SET is_active = FALSE WHERE user_id = ?', [userId]); + await db.query('UPDATE sso_users SET is_active = FALSE, resigned_date = COALESCE(resigned_date, CURDATE()) WHERE user_id = ?', [userId]); } module.exports = { diff --git a/user-management/api/models/vacationSettingsModel.js b/user-management/api/models/vacationSettingsModel.js index e1ccd78..1b96e6a 100644 --- a/user-management/api/models/vacationSettingsModel.js +++ b/user-management/api/models/vacationSettingsModel.js @@ -59,4 +59,25 @@ async function runMigration() { console.log('[tkuser] Sprint 001 migration completed'); } -module.exports = { getAll, getByKey, updateSettings, loadAsObject, runMigration }; +async function runGenericMigration(filename) { + const db = getPool(); + const fs = require('fs'); + const path = require('path'); + const sqlFile = path.join(__dirname, '..', '..', 'migrations', filename); + const sql = fs.readFileSync(sqlFile, 'utf8'); + const statements = sql.split(';').map(s => s.trim()).filter(s => s.length > 0); + for (const stmt of statements) { + try { + await db.query(stmt); + } catch (err) { + if (['ER_DUP_FIELDNAME', 'ER_TABLE_EXISTS_ERROR', 'ER_DUP_KEYNAME'].includes(err.code)) { + // Already migrated — safe to ignore + } else { + throw err; + } + } + } + console.log(`[tkuser] Migration ${filename} completed`); +} + +module.exports = { getAll, getByKey, updateSettings, loadAsObject, runMigration, runGenericMigration }; diff --git a/user-management/migrations/20260323_add_resigned_date.sql b/user-management/migrations/20260323_add_resigned_date.sql new file mode 100644 index 0000000..f6c78e3 --- /dev/null +++ b/user-management/migrations/20260323_add_resigned_date.sql @@ -0,0 +1 @@ +ALTER TABLE sso_users ADD COLUMN IF NOT EXISTS resigned_date DATE NULL COMMENT '퇴사일'; diff --git a/user-management/web/index.html b/user-management/web/index.html index 5a485ca..4b37fa1 100644 --- a/user-management/web/index.html +++ b/user-management/web/index.html @@ -137,6 +137,10 @@ +
+ + +
@@ -152,6 +156,20 @@ + + + @@ -1043,9 +1061,15 @@ -
- - +
+
+ + +
+
+ + +
@@ -2323,11 +2347,11 @@
- + - + diff --git a/user-management/web/static/js/tkuser-core.js b/user-management/web/static/js/tkuser-core.js index a9a7272..a1b1389 100644 --- a/user-management/web/static/js/tkuser-core.js +++ b/user-management/web/static/js/tkuser-core.js @@ -70,6 +70,7 @@ function deptLabel(d, deptId) { return DEPT_FALLBACK[d] || d || ''; } function formatDate(d) { if (!d) return ''; return d.substring(0, 10); } +function getSeoulToday() { return new Date().toLocaleDateString('en-CA', { timeZone: 'Asia/Seoul' }); } function escHtml(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } /* ===== Logout ===== */ diff --git a/user-management/web/static/js/tkuser-users.js b/user-management/web/static/js/tkuser-users.js index 7c12622..857da72 100644 --- a/user-management/web/static/js/tkuser-users.js +++ b/user-management/web/static/js/tkuser-users.js @@ -113,6 +113,8 @@ async function loadUsers() { const r = await api('/users'); users = r.data || r; displayUsers(); updatePermissionUserSelect(); populateUserDeptSelects(); + const hireDateInput = document.getElementById('newHireDate'); + if (hireDateInput && !hireDateInput.value) hireDateInput.value = getSeoulToday(); } catch (err) { document.getElementById('userList').innerHTML = `

${err.message}

`; } @@ -127,35 +129,75 @@ function populateUserDeptSelects() { sel.value = val; }); } -function displayUsers() { - const c = document.getElementById('userList'); - if (!users.length) { c.innerHTML = '

등록된 사용자가 없습니다.

'; return; } - c.innerHTML = users.map(u => ` -
+function renderUserRow(u, isResigned) { + return `
-
${u.name||u.username}
+
${u.name||u.username}
${u.username} ${u.department||u.department_id?`${deptLabel(u.department, u.department_id)}`:''} ${u.role==='admin'?'관리자':'사용자'} ${u.hire_date ? `입사 ${formatDate(u.hire_date)}` : '입사일 미등록'} - ${u.is_active===0||u.is_active===false?'비활성':''} + ${isResigned && u.resigned_date ? `퇴사 ${formatDate(u.resigned_date)}` : ''}
+ ${isResigned ? `` : ` - ${u.username!=='hyungi'?``:''} + ${u.username!=='hyungi'?``:''}`}
-
`).join(''); +
`; +} + +function displayUsers() { + const activeUsers = users.filter(u => u.is_active !== 0 && u.is_active !== false); + const resignedUsers = users.filter(u => u.is_active === 0 || u.is_active === false); + + const c = document.getElementById('userList'); + if (!activeUsers.length) { c.innerHTML = '

등록된 사용자가 없습니다.

'; } + else { c.innerHTML = activeUsers.map(u => renderUserRow(u, false)).join(''); } + + const resignedSection = document.getElementById('resignedSection'); + const resignedList = document.getElementById('resignedUserList'); + const resignedCount = document.getElementById('resignedCount'); + if (resignedUsers.length > 0) { + resignedSection.classList.remove('hidden'); + resignedCount.textContent = `(${resignedUsers.length}명)`; + resignedList.innerHTML = resignedUsers.map(u => renderUserRow(u, true)).join(''); + } else { + resignedSection.classList.add('hidden'); + } +} + +function toggleResignedList() { + const list = document.getElementById('resignedUserList'); + const btn = document.getElementById('resignedToggleBtn'); + if (list.classList.contains('hidden')) { + list.classList.remove('hidden'); + btn.innerHTML = '접기'; + } else { + list.classList.add('hidden'); + btn.innerHTML = '펼치기'; + } +} + +async function reactivateUser(id, name) { + if (!confirm(`${name}을(를) 재활성화하시겠습니까?`)) return; + try { + await api(`/users/${id}`, { method: 'PUT', body: JSON.stringify({ is_active: true, resigned_date: null }) }); + showToast('재활성화 완료'); + await loadUsers(); + } catch(e) { showToast(e.message, 'error'); } } document.getElementById('addUserForm').addEventListener('submit', async e => { e.preventDefault(); const deptIdVal = document.getElementById('newDepartmentId').value; + const hireDateVal = document.getElementById('newHireDate').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_id: deptIdVal ? parseInt(deptIdVal) : null, role: document.getElementById('newRole').value }) }); - showToast('사용자가 추가되었습니다.'); document.getElementById('addUserForm').reset(); await loadUsers(); + 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, hire_date: hireDateVal || null }) }); + showToast('사용자가 추가되었습니다.'); document.getElementById('addUserForm').reset(); document.getElementById('newHireDate').value = getSeoulToday(); await loadUsers(); } catch(e) { showToast(e.message,'error'); } }); @@ -167,6 +209,7 @@ function editUser(id) { document.getElementById('editDepartmentId').value=u.department_id||''; document.getElementById('editRole').value=u.role; document.getElementById('editHireDate').value = formatDate(u.hire_date); + document.getElementById('editResignedDate').value = formatDate(u.resigned_date); document.getElementById('editUserModal').classList.remove('hidden'); } function closeEditModal() { document.getElementById('editUserModal').classList.add('hidden'); } @@ -175,7 +218,7 @@ 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_id: deptIdVal ? parseInt(deptIdVal) : null, role: document.getElementById('editRole').value, hire_date: document.getElementById('editHireDate').value || null }) }); + 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, hire_date: document.getElementById('editHireDate').value || null, resigned_date: document.getElementById('editResignedDate').value || null }) }); showToast('수정되었습니다.'); closeEditModal(); await loadUsers(); } catch(e) { showToast(e.message,'error'); } });