feat(tkuser): 입사일 자동표시 + 퇴사자 목록 분리 + 퇴사일 관리

- 사용자 추가 시 hire_date 전송 (서울 오늘날짜 기본값)
- resigned_date 컬럼 마이그레이션 + CRUD 지원
- 비활성화(삭제) 시 resigned_date 자동 설정 (COALESCE)
- 활성/비활성 사용자 목록 분리, 퇴사자 접기/펼치기
- 퇴사자 재활성화 기능 (resigned_date 초기화)
- 편집 모달에 퇴사일 필드 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-03-23 15:47:14 +09:00
parent 1f3eb14128
commit f09aa0875a
8 changed files with 120 additions and 27 deletions

View File

@@ -24,7 +24,7 @@ async function getUsers(req, res, next) {
*/ */
async function createUser(req, res, next) { async function createUser(req, res, next) {
try { 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) { if (!username || !password) {
return res.status(400).json({ success: false, error: '사용자명과 비밀번호는 필수입니다' }); return res.status(400).json({ success: false, error: '사용자명과 비밀번호는 필수입니다' });
@@ -41,7 +41,8 @@ async function createUser(req, res, next) {
name: name || full_name, name: name || full_name,
department, department,
department_id: department_id || null, department_id: department_id || null,
role role,
hire_date: hire_date || null
}); });
res.status(201).json({ success: true, data: user }); res.status(201).json({ success: true, data: user });
} catch (err) { } catch (err) {

View File

@@ -90,8 +90,9 @@ app.use((err, req, res, next) => {
// Startup: 마이그레이션 완료 후 서버 시작 // Startup: 마이그레이션 완료 후 서버 시작
async function start() { async function start() {
try { try {
const { runMigration } = require('./models/vacationSettingsModel'); const { runMigration, runGenericMigration } = require('./models/vacationSettingsModel');
await runMigration(); await runMigration();
await runGenericMigration('20260323_add_resigned_date.sql');
} catch (err) { } catch (err) {
if (!['ER_DUP_FIELDNAME', 'ER_TABLE_EXISTS_ERROR', 'ER_DUP_KEYNAME'].includes(err.code)) { if (!['ER_DUP_FIELDNAME', 'ER_TABLE_EXISTS_ERROR', 'ER_DUP_KEYNAME'].includes(err.code)) {
console.error('Fatal migration error:', err.message); console.error('Fatal migration error:', err.message);

View File

@@ -79,18 +79,18 @@ async function findById(userId) {
async function findAll() { async function findAll() {
const db = getPool(); const db = getPool();
const [rows] = await db.query( 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; 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 db = getPool();
const password_hash = await hashPassword(password); const password_hash = await hashPassword(password);
const [result] = await db.query( const [result] = await db.query(
`INSERT INTO sso_users (username, password_hash, name, department, department_id, role) `INSERT INTO sso_users (username, password_hash, name, department, department_id, role, hire_date)
VALUES (?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?)`,
[username, password_hash, name || null, department || null, department_id || null, role || 'user'] [username, password_hash, name || null, department || null, department_id || null, role || 'user', hire_date || null]
); );
return findById(result.insertId); 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.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.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.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) { if (data.password) {
fields.push('password_hash = ?'); fields.push('password_hash = ?');
values.push(await hashPassword(data.password)); values.push(await hashPassword(data.password));
@@ -126,7 +127,7 @@ async function update(userId, data) {
async function deleteUser(userId) { async function deleteUser(userId) {
const db = getPool(); 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 = { module.exports = {

View File

@@ -59,4 +59,25 @@ async function runMigration() {
console.log('[tkuser] Sprint 001 migration completed'); 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 };

View File

@@ -0,0 +1 @@
ALTER TABLE sso_users ADD COLUMN IF NOT EXISTS resigned_date DATE NULL COMMENT '퇴사일';

View File

@@ -137,6 +137,10 @@
</select> </select>
</div> </div>
</div> </div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">입사일</label>
<input type="date" id="newHireDate" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
</div>
<button type="submit" class="w-full px-4 py-2 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium"> <button type="submit" class="w-full px-4 py-2 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium">
<i class="fas fa-plus mr-1"></i>추가 <i class="fas fa-plus mr-1"></i>추가
</button> </button>
@@ -152,6 +156,20 @@
</div> </div>
</div> </div>
<!-- 퇴사자 목록 -->
<div id="resignedSection" class="mt-6 bg-white rounded-xl shadow-sm p-5 hidden">
<div class="flex items-center justify-between mb-4">
<h2 class="text-base font-semibold text-gray-800">
<i class="fas fa-user-slash text-gray-400 mr-2"></i>퇴사자 목록
<span id="resignedCount" class="text-xs font-normal text-gray-400 ml-1"></span>
</h2>
<button onclick="toggleResignedList()" id="resignedToggleBtn" class="text-xs text-gray-500 hover:text-gray-700">
<i class="fas fa-chevron-down mr-1"></i>펼치기
</button>
</div>
<div id="resignedUserList" class="space-y-2 max-h-[300px] overflow-y-auto hidden"></div>
</div>
</div> </div>
@@ -1043,9 +1061,15 @@
</select> </select>
</div> </div>
</div> </div>
<div> <div class="grid grid-cols-2 gap-3">
<label class="block text-xs font-medium text-gray-600 mb-1">입사일</label> <div>
<input type="date" id="editHireDate" class="input-field w-full px-3 py-1.5 rounded-lg text-sm"> <label class="block text-xs font-medium text-gray-600 mb-1">입사일</label>
<input type="date" id="editHireDate" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">퇴사일</label>
<input type="date" id="editResignedDate" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
</div>
</div> </div>
<div class="flex gap-3 pt-3"> <div class="flex gap-3 pt-3">
<button type="button" onclick="closeEditModal()" class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-sm">취소</button> <button type="button" onclick="closeEditModal()" class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-sm">취소</button>
@@ -2323,11 +2347,11 @@
</div> </div>
<!-- JS: Core (config, token, api, toast, helpers, init) --> <!-- JS: Core (config, token, api, toast, helpers, init) -->
<script src="/static/js/tkuser-core.js?v=2026032301"></script> <script src="/static/js/tkuser-core.js?v=2026032303"></script>
<!-- JS: Tabs --> <!-- JS: Tabs -->
<script src="/static/js/tkuser-tabs.js?v=2026032301"></script> <script src="/static/js/tkuser-tabs.js?v=2026032301"></script>
<!-- JS: Individual modules --> <!-- JS: Individual modules -->
<script src="/static/js/tkuser-users.js?v=2026032302"></script> <script src="/static/js/tkuser-users.js?v=2026032303"></script>
<script src="/static/js/tkuser-projects.js?v=2026031401"></script> <script src="/static/js/tkuser-projects.js?v=2026031401"></script>
<script src="/static/js/tkuser-departments.js?v=2026032302"></script> <script src="/static/js/tkuser-departments.js?v=2026032302"></script>
<script src="/static/js/tkuser-issue-types.js?v=2026031401"></script> <script src="/static/js/tkuser-issue-types.js?v=2026031401"></script>

View File

@@ -70,6 +70,7 @@ function deptLabel(d, deptId) {
return DEPT_FALLBACK[d] || d || ''; return DEPT_FALLBACK[d] || d || '';
} }
function formatDate(d) { if (!d) return ''; return d.substring(0, 10); } 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; } function escHtml(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
/* ===== Logout ===== */ /* ===== Logout ===== */

View File

@@ -113,6 +113,8 @@ async function loadUsers() {
const r = await api('/users'); users = r.data || r; const r = await api('/users'); users = r.data || r;
displayUsers(); updatePermissionUserSelect(); displayUsers(); updatePermissionUserSelect();
populateUserDeptSelects(); populateUserDeptSelects();
const hireDateInput = document.getElementById('newHireDate');
if (hireDateInput && !hireDateInput.value) hireDateInput.value = getSeoulToday();
} catch (err) { } 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>`; 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>`;
} }
@@ -127,35 +129,75 @@ function populateUserDeptSelects() {
sel.value = val; sel.value = val;
}); });
} }
function displayUsers() { function renderUserRow(u, isResigned) {
const c = document.getElementById('userList'); return `<div class="flex items-center justify-between p-2.5 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
if (!users.length) { c.innerHTML = '<p class="text-gray-400 text-center py-4 text-sm">등록된 사용자가 없습니다.</p>'; return; }
c.innerHTML = users.map(u => `
<div class="flex items-center justify-between p-2.5 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<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-sm font-medium ${isResigned ? 'text-gray-400' : '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"> <div class="text-xs text-gray-500 flex items-center gap-1.5 mt-0.5 flex-wrap">
<span>${u.username}</span> <span>${u.username}</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>`:''} ${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> <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.hire_date ? `<span class="px-1.5 py-0.5 rounded bg-blue-50 text-blue-600">입사 ${formatDate(u.hire_date)}</span>` : '<span class="px-1.5 py-0.5 rounded bg-orange-50 text-orange-500">입사일 미등록</span>'} ${u.hire_date ? `<span class="px-1.5 py-0.5 rounded bg-blue-50 text-blue-600">입사 ${formatDate(u.hire_date)}</span>` : '<span class="px-1.5 py-0.5 rounded bg-orange-50 text-orange-500">입사일 미등록</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>':''} ${isResigned && u.resigned_date ? `<span class="px-1.5 py-0.5 rounded bg-gray-200 text-gray-500">퇴사 ${formatDate(u.resigned_date)}</span>` : ''}
</div> </div>
</div> </div>
<div class="flex gap-1 ml-2 flex-shrink-0"> <div class="flex gap-1 ml-2 flex-shrink-0">
<button onclick="editUser(${u.user_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> <button onclick="editUser(${u.user_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>
${isResigned ? `<button onclick="reactivateUser(${u.user_id},'${escHtml(u.name||u.username)}')" class="p-1.5 text-emerald-500 hover:text-emerald-700 hover:bg-emerald-100 rounded" title="재활성화"><i class="fas fa-user-check text-xs"></i></button>` : `
<button onclick="resetPassword(${u.user_id},'${u.username}')" class="p-1.5 text-amber-500 hover:text-amber-700 hover:bg-amber-100 rounded" title="비밀번호 초기화"><i class="fas fa-key text-xs"></i></button> <button onclick="resetPassword(${u.user_id},'${u.username}')" class="p-1.5 text-amber-500 hover:text-amber-700 hover:bg-amber-100 rounded" title="비밀번호 초기화"><i class="fas fa-key text-xs"></i></button>
${u.username!=='hyungi'?`<button onclick="deleteUser(${u.user_id},'${u.username}')" class="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-100 rounded" title="삭제"><i class="fas fa-trash text-xs"></i></button>`:''} ${u.username!=='hyungi'?`<button onclick="deleteUser(${u.user_id},'${u.username}')" class="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-100 rounded" title="삭제"><i class="fas fa-trash text-xs"></i></button>`:''}`}
</div> </div>
</div>`).join(''); </div>`;
}
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 = '<p class="text-gray-400 text-center py-4 text-sm">등록된 사용자가 없습니다.</p>'; }
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 = '<i class="fas fa-chevron-up mr-1"></i>접기';
} else {
list.classList.add('hidden');
btn.innerHTML = '<i class="fas fa-chevron-down mr-1"></i>펼치기';
}
}
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 => { document.getElementById('addUserForm').addEventListener('submit', async e => {
e.preventDefault(); e.preventDefault();
const deptIdVal = document.getElementById('newDepartmentId').value; const deptIdVal = document.getElementById('newDepartmentId').value;
const hireDateVal = document.getElementById('newHireDate').value;
try { 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 }) }); 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(); await loadUsers(); showToast('사용자가 추가되었습니다.'); document.getElementById('addUserForm').reset(); document.getElementById('newHireDate').value = getSeoulToday(); await loadUsers();
} catch(e) { showToast(e.message,'error'); } } catch(e) { showToast(e.message,'error'); }
}); });
@@ -167,6 +209,7 @@ function editUser(id) {
document.getElementById('editDepartmentId').value=u.department_id||''; document.getElementById('editDepartmentId').value=u.department_id||'';
document.getElementById('editRole').value=u.role; document.getElementById('editRole').value=u.role;
document.getElementById('editHireDate').value = formatDate(u.hire_date); document.getElementById('editHireDate').value = formatDate(u.hire_date);
document.getElementById('editResignedDate').value = formatDate(u.resigned_date);
document.getElementById('editUserModal').classList.remove('hidden'); document.getElementById('editUserModal').classList.remove('hidden');
} }
function closeEditModal() { document.getElementById('editUserModal').classList.add('hidden'); } function closeEditModal() { document.getElementById('editUserModal').classList.add('hidden'); }
@@ -175,7 +218,7 @@ document.getElementById('editUserForm').addEventListener('submit', async e => {
e.preventDefault(); e.preventDefault();
const deptIdVal = document.getElementById('editDepartmentId').value; const deptIdVal = document.getElementById('editDepartmentId').value;
try { 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(); showToast('수정되었습니다.'); closeEditModal(); await loadUsers();
} catch(e) { showToast(e.message,'error'); } } catch(e) { showToast(e.message,'error'); }
}); });