From f09aa0875ae2925e6d48440926d038b2b516535a Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Mon, 23 Mar 2026 15:47:14 +0900 Subject: [PATCH] =?UTF-8?q?feat(tkuser):=20=EC=9E=85=EC=82=AC=EC=9D=BC=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=ED=91=9C=EC=8B=9C=20+=20=ED=87=B4=EC=82=AC?= =?UTF-8?q?=EC=9E=90=20=EB=AA=A9=EB=A1=9D=20=EB=B6=84=EB=A6=AC=20+=20?= =?UTF-8?q?=ED=87=B4=EC=82=AC=EC=9D=BC=20=EA=B4=80=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 사용자 추가 시 hire_date 전송 (서울 오늘날짜 기본값) - resigned_date 컬럼 마이그레이션 + CRUD 지원 - 비활성화(삭제) 시 resigned_date 자동 설정 (COALESCE) - 활성/비활성 사용자 목록 분리, 퇴사자 접기/펼치기 - 퇴사자 재활성화 기능 (resigned_date 초기화) - 편집 모달에 퇴사일 필드 추가 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../api/controllers/userController.js | 5 +- user-management/api/index.js | 3 +- user-management/api/models/userModel.js | 13 ++-- .../api/models/vacationSettingsModel.js | 23 ++++++- .../migrations/20260323_add_resigned_date.sql | 1 + user-management/web/index.html | 34 ++++++++-- user-management/web/static/js/tkuser-core.js | 1 + user-management/web/static/js/tkuser-users.js | 67 +++++++++++++++---- 8 files changed, 120 insertions(+), 27 deletions(-) create mode 100644 user-management/migrations/20260323_add_resigned_date.sql 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'); } });