feat: 작업자-계정 통합 및 연차/출근 관리 시스템 구축
모든 작업자가 개인 계정으로 로그인하여 본인의 연차와 출근 기록을 확인할 수 있는 시스템을 구축했습니다. 주요 기능: - 작업자-계정 1:1 통합 (기존 작업자 자동 계정 생성) - 연차 관리 시스템 (연도별 잔액 관리) - 출근 기록 시스템 (일일 근태 기록) - 나의 대시보드 페이지 (개인 정보 조회) 데이터베이스: - workers 테이블에 salary, base_annual_leave 컬럼 추가 - work_attendance_types, vacation_types 테이블 생성 - daily_attendance_records 테이블 생성 - worker_vacation_balance 테이블 생성 - 기존 작업자 자동 계정 생성 (username: 이름 기반) - Guest 역할 추가 백엔드 API: - 한글→영문 변환 유틸리티 (hangulToRoman.js) - UserRoutes에 개인 정보 조회 API 추가 - GET /api/users/me (내 정보) - GET /api/users/me/attendance-records (출근 기록) - GET /api/users/me/vacation-balance (연차 잔액) - GET /api/users/me/work-reports (작업 보고서) - GET /api/users/me/monthly-stats (월별 통계) 프론트엔드: - 나의 대시보드 페이지 (my-dashboard.html) - 연차 정보 위젯 (총/사용/잔여) - 월별 출근 캘린더 - 근무 시간 통계 - 최근 작업 보고서 목록 - 네비게이션 바에 "나의 대시보드" 메뉴 추가 배포 시 주의사항: - 마이그레이션 실행 필요 - 자동 생성된 계정 초기 비밀번호: 1234 - 작업자들에게 첫 로그인 후 비밀번호 변경 안내 필요 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -208,12 +208,38 @@ async function loadUsers() {
|
||||
list.forEach(item => {
|
||||
item.access_level = accessLabels[item.access_level] || item.access_level;
|
||||
item.worker_id = item.worker_id || '-';
|
||||
const row = createRow(item, [
|
||||
'user_id', 'username', 'name', 'access_level', 'worker_id'
|
||||
], async u => {
|
||||
|
||||
// 행 생성
|
||||
const tr = document.createElement('tr');
|
||||
|
||||
// 데이터 컬럼
|
||||
['user_id', 'username', 'name', 'access_level', 'worker_id'].forEach(key => {
|
||||
const td = document.createElement('td');
|
||||
td.textContent = item[key] || '-';
|
||||
tr.appendChild(td);
|
||||
});
|
||||
|
||||
// 작업 컬럼 (페이지 권한 버튼 + 삭제 버튼)
|
||||
const actionTd = document.createElement('td');
|
||||
|
||||
// 페이지 권한 버튼 (Admin/System이 아닌 경우에만)
|
||||
if (item.access_level !== '관리자' && item.access_level !== '시스템') {
|
||||
const pageAccessBtn = document.createElement('button');
|
||||
pageAccessBtn.textContent = '페이지 권한';
|
||||
pageAccessBtn.className = 'btn btn-info btn-sm';
|
||||
pageAccessBtn.style.marginRight = '5px';
|
||||
pageAccessBtn.onclick = () => openPageAccessModal(item.user_id, item.username, item.name);
|
||||
actionTd.appendChild(pageAccessBtn);
|
||||
}
|
||||
|
||||
// 삭제 버튼
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.textContent = '삭제';
|
||||
delBtn.className = 'btn-delete';
|
||||
delBtn.onclick = async () => {
|
||||
if (!confirm('삭제하시겠습니까?')) return;
|
||||
try {
|
||||
const delRes = await fetch(`${API}/auth/users/${u.user_id}`, {
|
||||
const delRes = await fetch(`${API}/auth/users/${item.user_id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
@@ -226,8 +252,11 @@ async function loadUsers() {
|
||||
} catch (error) {
|
||||
alert('🚨 삭제 중 오류 발생');
|
||||
}
|
||||
});
|
||||
tbody.appendChild(row);
|
||||
};
|
||||
actionTd.appendChild(delBtn);
|
||||
|
||||
tr.appendChild(actionTd);
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
} else {
|
||||
tbody.innerHTML = '<tr><td colspan="6">데이터 형식 오류</td></tr>';
|
||||
@@ -288,6 +317,195 @@ function showToast(message) {
|
||||
setTimeout(() => toast.remove(), 2000);
|
||||
}
|
||||
|
||||
// ========== 페이지 접근 권한 관리 ==========
|
||||
|
||||
let currentEditingUserId = null;
|
||||
let currentUserPageAccess = [];
|
||||
|
||||
/**
|
||||
* 페이지 권한 관리 모달 열기
|
||||
*/
|
||||
async function openPageAccessModal(userId, username, name) {
|
||||
currentEditingUserId = userId;
|
||||
|
||||
const modal = document.getElementById('pageAccessModal');
|
||||
const modalUserInfo = document.getElementById('modalUserInfo');
|
||||
const modalUserRole = document.getElementById('modalUserRole');
|
||||
|
||||
modalUserInfo.textContent = `${name} (${username})`;
|
||||
modalUserRole.textContent = `사용자 ID: ${userId}`;
|
||||
|
||||
try {
|
||||
// 사용자의 페이지 접근 권한 조회
|
||||
const res = await fetch(`${API}/users/${userId}/page-access`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('페이지 접근 권한을 불러오는데 실패했습니다.');
|
||||
}
|
||||
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
currentUserPageAccess = result.data.pageAccess;
|
||||
renderPageAccessList(result.data.pageAccess);
|
||||
modal.style.display = 'block';
|
||||
} else {
|
||||
throw new Error(result.error || '데이터 로드 실패');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('페이지 권한 로드 오류:', error);
|
||||
alert('❌ 페이지 권한을 불러오는데 실패했습니다: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 접근 권한 목록 렌더링
|
||||
*/
|
||||
function renderPageAccessList(pageAccess) {
|
||||
const categories = {
|
||||
dashboard: document.getElementById('dashboardPageList'),
|
||||
management: document.getElementById('managementPageList'),
|
||||
common: document.getElementById('commonPageList')
|
||||
};
|
||||
|
||||
// 카테고리별로 초기화
|
||||
Object.values(categories).forEach(el => {
|
||||
if (el) el.innerHTML = '';
|
||||
});
|
||||
|
||||
// 카테고리별로 그룹화
|
||||
const grouped = pageAccess.reduce((acc, page) => {
|
||||
if (!acc[page.category]) acc[page.category] = [];
|
||||
acc[page.category].push(page);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// 각 카테고리별로 렌더링
|
||||
Object.keys(grouped).forEach(category => {
|
||||
const container = categories[category];
|
||||
if (!container) return;
|
||||
|
||||
grouped[category].forEach(page => {
|
||||
const pageItem = document.createElement('div');
|
||||
pageItem.className = 'page-item';
|
||||
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.id = `page_${page.page_id}`;
|
||||
checkbox.checked = page.can_access === 1 || page.can_access === true;
|
||||
checkbox.dataset.pageId = page.page_id;
|
||||
|
||||
const label = document.createElement('label');
|
||||
label.htmlFor = `page_${page.page_id}`;
|
||||
label.textContent = page.page_name;
|
||||
|
||||
const pathSpan = document.createElement('span');
|
||||
pathSpan.className = 'page-path';
|
||||
pathSpan.textContent = page.page_path;
|
||||
|
||||
pageItem.appendChild(checkbox);
|
||||
pageItem.appendChild(label);
|
||||
pageItem.appendChild(pathSpan);
|
||||
|
||||
container.appendChild(pageItem);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 권한 변경 사항 저장
|
||||
*/
|
||||
async function savePageAccessChanges() {
|
||||
if (!currentEditingUserId) {
|
||||
alert('사용자 정보가 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 모든 체크박스 상태 가져오기
|
||||
const checkboxes = document.querySelectorAll('.page-item input[type="checkbox"]');
|
||||
const pageAccessUpdates = {};
|
||||
|
||||
checkboxes.forEach(checkbox => {
|
||||
const pageId = parseInt(checkbox.dataset.pageId);
|
||||
const canAccess = checkbox.checked;
|
||||
pageAccessUpdates[pageId] = canAccess;
|
||||
});
|
||||
|
||||
try {
|
||||
// 변경된 페이지 권한을 서버로 전송
|
||||
const pageIds = Object.keys(pageAccessUpdates).map(id => parseInt(id));
|
||||
const canAccessValues = pageIds.map(id => pageAccessUpdates[id]);
|
||||
|
||||
// 접근 가능한 페이지
|
||||
const accessiblePages = pageIds.filter((id, index) => canAccessValues[index]);
|
||||
// 접근 불가능한 페이지
|
||||
const inaccessiblePages = pageIds.filter((id, index) => !canAccessValues[index]);
|
||||
|
||||
// 접근 가능 페이지 업데이트
|
||||
if (accessiblePages.length > 0) {
|
||||
await fetch(`${API}/users/${currentEditingUserId}/page-access`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
pageIds: accessiblePages,
|
||||
canAccess: true
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// 접근 불가능 페이지 업데이트
|
||||
if (inaccessiblePages.length > 0) {
|
||||
await fetch(`${API}/users/${currentEditingUserId}/page-access`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
pageIds: inaccessiblePages,
|
||||
canAccess: false
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
showToast('✅ 페이지 접근 권한이 저장되었습니다.');
|
||||
closePageAccessModal();
|
||||
} catch (error) {
|
||||
console.error('페이지 권한 저장 오류:', error);
|
||||
alert('❌ 페이지 권한 저장에 실패했습니다: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 페이지 권한 관리 모달 닫기
|
||||
*/
|
||||
function closePageAccessModal() {
|
||||
const modal = document.getElementById('pageAccessModal');
|
||||
modal.style.display = 'none';
|
||||
currentEditingUserId = null;
|
||||
currentUserPageAccess = [];
|
||||
}
|
||||
|
||||
// 모달 닫기 버튼 이벤트
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const modal = document.getElementById('pageAccessModal');
|
||||
const closeBtn = modal?.querySelector('.close');
|
||||
|
||||
if (closeBtn) {
|
||||
closeBtn.onclick = closePageAccessModal;
|
||||
}
|
||||
|
||||
// 모달 외부 클릭 시 닫기
|
||||
window.onclick = (event) => {
|
||||
if (event.target === modal) {
|
||||
closePageAccessModal();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// 전역 함수로 노출
|
||||
window.openPageAccessModal = openPageAccessModal;
|
||||
window.closePageAccessModal = closePageAccessModal;
|
||||
window.savePageAccessChanges = savePageAccessChanges;
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
loadUsers();
|
||||
loadWorkerOptions();
|
||||
|
||||
189
web-ui/js/my-dashboard.js
Normal file
189
web-ui/js/my-dashboard.js
Normal file
@@ -0,0 +1,189 @@
|
||||
// My Dashboard - 나의 대시보드 JavaScript
|
||||
|
||||
import './api-config.js';
|
||||
|
||||
// 전역 변수
|
||||
let currentYear = new Date().getFullYear();
|
||||
let currentMonth = new Date().getMonth() + 1;
|
||||
|
||||
// 페이지 초기화
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
console.log('📊 나의 대시보드 초기화 시작');
|
||||
|
||||
await loadUserInfo();
|
||||
await loadVacationBalance();
|
||||
await loadMonthlyCalendar();
|
||||
await loadWorkHoursStats();
|
||||
await loadRecentReports();
|
||||
|
||||
console.log('✅ 나의 대시보드 초기화 완료');
|
||||
});
|
||||
|
||||
// 사용자 정보 로드
|
||||
async function loadUserInfo() {
|
||||
try {
|
||||
const response = await apiCall('/users/me', 'GET');
|
||||
const user = response.data || response;
|
||||
|
||||
document.getElementById('userName').textContent = user.name || '사용자';
|
||||
document.getElementById('department').textContent = user.department || '-';
|
||||
document.getElementById('jobType').textContent = user.job_type || '-';
|
||||
document.getElementById('hireDate').textContent = user.hire_date || '-';
|
||||
} catch (error) {
|
||||
console.error('사용자 정보 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 연차 정보 로드
|
||||
async function loadVacationBalance() {
|
||||
try {
|
||||
const response = await apiCall('/users/me/vacation-balance', 'GET');
|
||||
const balance = response.data || response;
|
||||
|
||||
const total = balance.total_annual_leave || 15;
|
||||
const used = balance.used_annual_leave || 0;
|
||||
const remaining = total - used;
|
||||
|
||||
document.getElementById('totalLeave').textContent = total;
|
||||
document.getElementById('usedLeave').textContent = used;
|
||||
document.getElementById('remainingLeave').textContent = remaining;
|
||||
|
||||
// 프로그레스 바 업데이트
|
||||
const percentage = (used / total) * 100;
|
||||
document.getElementById('vacationProgress').style.width = `${percentage}%`;
|
||||
} catch (error) {
|
||||
console.error('연차 정보 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 월별 캘린더 로드
|
||||
async function loadMonthlyCalendar() {
|
||||
try {
|
||||
const response = await apiCall(
|
||||
`/users/me/attendance-records?year=${currentYear}&month=${currentMonth}`,
|
||||
'GET'
|
||||
);
|
||||
const records = response.data || response;
|
||||
|
||||
renderCalendar(currentYear, currentMonth, records);
|
||||
document.getElementById('currentMonth').textContent = `${currentYear}년 ${currentMonth}월`;
|
||||
} catch (error) {
|
||||
console.error('캘린더 로드 실패:', error);
|
||||
renderCalendar(currentYear, currentMonth, []);
|
||||
}
|
||||
}
|
||||
|
||||
// 캘린더 렌더링
|
||||
function renderCalendar(year, month, records) {
|
||||
const calendar = document.getElementById('calendar');
|
||||
const firstDay = new Date(year, month - 1, 1).getDay();
|
||||
const daysInMonth = new Date(year, month, 0).getDate();
|
||||
|
||||
let html = '';
|
||||
|
||||
// 요일 헤더
|
||||
const weekdays = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
weekdays.forEach(day => {
|
||||
html += `<div class="calendar-header">${day}</div>`;
|
||||
});
|
||||
|
||||
// 빈 칸
|
||||
for (let i = 0; i < firstDay; i++) {
|
||||
html += '<div class="calendar-day empty"></div>';
|
||||
}
|
||||
|
||||
// 날짜
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
const record = Array.isArray(records) ? records.find(r => r.record_date === dateStr) : null;
|
||||
|
||||
let statusClass = '';
|
||||
if (record) {
|
||||
const typeCode = record.attendance_type_code || record.type_code || '';
|
||||
statusClass = typeCode.toLowerCase();
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="calendar-day ${statusClass}" title="${dateStr}">
|
||||
<span class="day-number">${day}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
calendar.innerHTML = html;
|
||||
}
|
||||
|
||||
// 근무 시간 통계 로드
|
||||
async function loadWorkHoursStats() {
|
||||
try {
|
||||
const response = await apiCall(
|
||||
`/users/me/monthly-stats?year=${currentYear}&month=${currentMonth}`,
|
||||
'GET'
|
||||
);
|
||||
const stats = response.data || response;
|
||||
|
||||
document.getElementById('monthHours').textContent = stats.month_hours || 0;
|
||||
document.getElementById('workDays').textContent = stats.work_days || 0;
|
||||
} catch (error) {
|
||||
console.error('근무 시간 통계 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 최근 작업 보고서 로드
|
||||
async function loadRecentReports() {
|
||||
try {
|
||||
const endDate = new Date().toISOString().split('T')[0];
|
||||
const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
|
||||
.toISOString().split('T')[0];
|
||||
|
||||
const response = await apiCall(
|
||||
`/users/me/work-reports?startDate=${startDate}&endDate=${endDate}`,
|
||||
'GET'
|
||||
);
|
||||
const reports = response.data || response;
|
||||
|
||||
const list = document.getElementById('recentReportsList');
|
||||
|
||||
if (!Array.isArray(reports) || reports.length === 0) {
|
||||
list.innerHTML = '<p class="empty-message">최근 7일간의 작업 보고서가 없습니다.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = reports.map(r => `
|
||||
<div class="report-item">
|
||||
<span class="date">${r.report_date}</span>
|
||||
<span class="project">${r.project_name || 'N/A'}</span>
|
||||
<span class="hours">${r.work_hours}시간</span>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (error) {
|
||||
console.error('최근 작업 보고서 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 이전 달
|
||||
function previousMonth() {
|
||||
currentMonth--;
|
||||
if (currentMonth < 1) {
|
||||
currentMonth = 12;
|
||||
currentYear--;
|
||||
}
|
||||
loadMonthlyCalendar();
|
||||
loadWorkHoursStats();
|
||||
}
|
||||
|
||||
// 다음 달
|
||||
function nextMonth() {
|
||||
currentMonth++;
|
||||
if (currentMonth > 12) {
|
||||
currentMonth = 1;
|
||||
currentYear++;
|
||||
}
|
||||
loadMonthlyCalendar();
|
||||
loadWorkHoursStats();
|
||||
}
|
||||
|
||||
// 전역 함수 노출
|
||||
window.previousMonth = previousMonth;
|
||||
window.nextMonth = nextMonth;
|
||||
window.loadMonthlyCalendar = loadMonthlyCalendar;
|
||||
Reference in New Issue
Block a user