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:
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE sso_users ADD COLUMN IF NOT EXISTS resigned_date DATE NULL COMMENT '퇴사일';
|
||||
@@ -137,6 +137,10 @@
|
||||
</select>
|
||||
</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">
|
||||
<i class="fas fa-plus mr-1"></i>추가
|
||||
</button>
|
||||
@@ -152,6 +156,20 @@
|
||||
</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>
|
||||
|
||||
|
||||
@@ -1043,9 +1061,15 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<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 class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<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 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>
|
||||
@@ -2323,11 +2347,11 @@
|
||||
</div>
|
||||
|
||||
<!-- 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 -->
|
||||
<script src="/static/js/tkuser-tabs.js?v=2026032301"></script>
|
||||
<!-- 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-departments.js?v=2026032302"></script>
|
||||
<script src="/static/js/tkuser-issue-types.js?v=2026031401"></script>
|
||||
|
||||
@@ -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 ===== */
|
||||
|
||||
@@ -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 = `<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;
|
||||
});
|
||||
}
|
||||
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; }
|
||||
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">
|
||||
function renderUserRow(u, isResigned) {
|
||||
return `<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="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">
|
||||
<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>`:''}
|
||||
<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.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 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>
|
||||
${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>
|
||||
${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>`).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 => {
|
||||
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'); }
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user