diff --git a/api.hyungi.net/controllers/userController.js b/api.hyungi.net/controllers/userController.js index fc24ba7..8b8e22e 100644 --- a/api.hyungi.net/controllers/userController.js +++ b/api.hyungi.net/controllers/userController.js @@ -35,19 +35,20 @@ const getAllUsers = asyncHandler(async (req, res) => { try { const query = ` SELECT - user_id, - username, - name, - email, - phone, - role, - access_level, - is_active, - created_at, - updated_at, - last_login - FROM users - ORDER BY created_at DESC + u.user_id, + u.username, + u.name, + u.email, + u.role_id, + r.name as role, + u._access_level_old as access_level, + u.is_active, + u.created_at, + u.updated_at, + u.last_login_at as last_login + FROM users u + LEFT JOIN roles r ON u.role_id = r.id + ORDER BY u.created_at DESC `; const [users] = await db.execute(query); diff --git a/api.hyungi.net/db/migrations/20260119000000_simplify_permissions_and_add_page_access.js b/api.hyungi.net/db/migrations/20260119000000_simplify_permissions_and_add_page_access.js index 7d7a1e2..8c77102 100644 --- a/api.hyungi.net/db/migrations/20260119000000_simplify_permissions_and_add_page_access.js +++ b/api.hyungi.net/db/migrations/20260119000000_simplify_permissions_and_add_page_access.js @@ -23,13 +23,13 @@ exports.up = async function(knex) { // 2. 사용자별 페이지 접근 권한 테이블 생성 await knex.schema.createTable('user_page_access', function(table) { - table.integer('user_id').unsigned().notNullable() + table.integer('user_id').notNullable() .references('user_id').inTable('users').onDelete('CASCADE'); table.integer('page_id').unsigned().notNullable() .references('id').inTable('pages').onDelete('CASCADE'); table.boolean('can_access').defaultTo(true); // 접근 가능 여부 table.timestamp('granted_at').defaultTo(knex.fn.now()); - table.integer('granted_by').unsigned() // 권한을 부여한 Admin의 user_id + table.integer('granted_by') // 권한을 부여한 Admin의 user_id .references('user_id').inTable('users').onDelete('SET NULL'); table.primary(['user_id', 'page_id']); }); diff --git a/api.hyungi.net/db/migrations/20260119120002_create_accounts_for_existing_workers.js b/api.hyungi.net/db/migrations/20260119120002_create_accounts_for_existing_workers.js index 0de152c..6a45e11 100644 --- a/api.hyungi.net/db/migrations/20260119120002_create_accounts_for_existing_workers.js +++ b/api.hyungi.net/db/migrations/20260119120002_create_accounts_for_existing_workers.js @@ -7,7 +7,7 @@ * 2. 각 작업자에 대해 users 테이블에 계정 생성 * 3. username은 이름 기반으로 자동 생성 (예: 홍길동 → hong.gildong) * 4. 초기 비밀번호는 '1234'로 통일 (첫 로그인 시 변경 권장) - * 5. 현재 연도 연차 잔액 초기화 + * 5. 현재 연도 연차 잔액 초기화 (workers.annual_leave 사용) */ const bcrypt = require('bcrypt'); @@ -25,7 +25,7 @@ exports.up = async function(knex) { 'workers.worker_name', 'workers.email', 'workers.status', - 'workers.base_annual_leave' + 'workers.annual_leave' ); console.log(`📊 계정이 없는 작업자: ${workersWithoutAccount.length}명`); @@ -41,7 +41,7 @@ exports.up = async function(knex) { // User 역할 ID 조회 const userRole = await knex('roles') - .where('role_name', 'User') + .where('name', 'User') .first(); if (!userRole) { @@ -77,7 +77,7 @@ exports.up = async function(knex) { await knex('worker_vacation_balance').insert({ worker_id: worker.worker_id, year: currentYear, - total_annual_leave: worker.base_annual_leave || 15, + total_annual_leave: worker.annual_leave || 15, used_annual_leave: 0, created_at: knex.fn.now(), updated_at: knex.fn.now() diff --git a/api.hyungi.net/db/migrations/20260119120003_add_guest_role.js b/api.hyungi.net/db/migrations/20260119120003_add_guest_role.js index ff88ca9..29a00fb 100644 --- a/api.hyungi.net/db/migrations/20260119120003_add_guest_role.js +++ b/api.hyungi.net/db/migrations/20260119120003_add_guest_role.js @@ -12,8 +12,8 @@ exports.up = async function(knex) { // 1. Guest 역할 추가 const [guestRoleId] = await knex('roles').insert({ - role_name: 'Guest', - role_description: '게스트 (계정 없이 특정 기능 접근 가능)', + name: 'Guest', + description: '게스트 (계정 없이 특정 기능 접근 가능)', created_at: knex.fn.now(), updated_at: knex.fn.now() }); @@ -44,7 +44,7 @@ exports.down = async function(knex) { // 역할 제거 await knex('roles') - .where('role_name', 'Guest') + .where('name', 'Guest') .delete(); console.log('✅ 게스트 역할 제거 완료'); diff --git a/api.hyungi.net/deploy.sh b/api.hyungi.net/deploy.sh old mode 100644 new mode 100755 diff --git a/api.hyungi.net/middlewares/auth.js b/api.hyungi.net/middlewares/auth.js index 12017f5..e956724 100644 --- a/api.hyungi.net/middlewares/auth.js +++ b/api.hyungi.net/middlewares/auth.js @@ -129,8 +129,10 @@ const requireRole = (...roles) => { } const userRole = req.user.role; + const userRoleLower = userRole ? userRole.toLowerCase() : ''; + const rolesLower = roles.map(r => r.toLowerCase()); - if (!roles.includes(userRole)) { + if (!rolesLower.includes(userRoleLower)) { logger.warn('권한 체크 실패: 역할 불일치', { user_id: req.user.user_id || req.user.id, username: req.user.username, diff --git a/api.hyungi.net/models/attendanceModel.js b/api.hyungi.net/models/attendanceModel.js index b83939f..5b16472 100644 --- a/api.hyungi.net/models/attendanceModel.js +++ b/api.hyungi.net/models/attendanceModel.js @@ -5,7 +5,7 @@ class AttendanceModel { static async getDailyAttendanceRecords(date, workerId = null) { const db = await getDb(); let query = ` - SELECT + SELECT dar.*, w.worker_name, w.job_type, @@ -34,6 +34,39 @@ class AttendanceModel { return rows; } + // 기간별 근태 기록 조회 (월별 조회용) + static async getDailyRecords(startDate, endDate, workerId = null) { + const db = await getDb(); + let query = ` + SELECT + dar.*, + w.worker_name, + w.job_type, + wat.type_name as attendance_type_name, + wat.type_code as attendance_type_code, + vt.type_name as vacation_type_name, + vt.type_code as vacation_type_code, + vt.deduct_days as vacation_days + FROM daily_attendance_records dar + LEFT JOIN workers w ON dar.worker_id = w.worker_id + LEFT JOIN work_attendance_types wat ON dar.attendance_type_id = wat.id + LEFT JOIN vacation_types vt ON dar.vacation_type_id = vt.id + WHERE dar.record_date BETWEEN ? AND ? + `; + + const params = [startDate, endDate]; + + if (workerId) { + query += ' AND dar.worker_id = ?'; + params.push(workerId); + } + + query += ' ORDER BY dar.record_date ASC'; + + const [rows] = await db.execute(query, params); + return rows; + } + // 작업 보고서와 근태 기록 동기화 (시간 합산 및 상태 업데이트) static async syncWithWorkReports(workerId, date) { const db = await getDb(); diff --git a/api.hyungi.net/models/userModel.js b/api.hyungi.net/models/userModel.js index c952cff..bd4c54f 100644 --- a/api.hyungi.net/models/userModel.js +++ b/api.hyungi.net/models/userModel.js @@ -5,7 +5,14 @@ const findByUsername = async (username) => { try { const db = await getDb(); const [rows] = await db.query( - 'SELECT user_id, username, password, name, email, role, access_level, worker_id, is_active, last_login_at, password_changed_at, failed_login_attempts, locked_until, created_at, updated_at FROM users WHERE username = ?', [username] + `SELECT u.user_id, u.username, u.password, u.name, u.email, + u.role_id, r.name as role_name, + u._access_level_old as access_level, u.worker_id, u.is_active, + u.last_login_at, u.password_changed_at, u.failed_login_attempts, + u.locked_until, u.created_at, u.updated_at + FROM users u + LEFT JOIN roles r ON u.role_id = r.id + WHERE u.username = ?`, [username] ); return rows[0]; } catch (err) { diff --git a/api.hyungi.net/routes/userRoutes.js b/api.hyungi.net/routes/userRoutes.js index a66356a..580ee3a 100644 --- a/api.hyungi.net/routes/userRoutes.js +++ b/api.hyungi.net/routes/userRoutes.js @@ -22,7 +22,8 @@ router.use(verifyToken); * 관리자 권한 확인 미들웨어 */ const adminOnly = (req, res, next) => { - if (req.user && (req.user.role === 'admin' || req.user.role === 'system')) { + const userRole = req.user?.role?.toLowerCase(); + if (req.user && (userRole === 'admin' || userRole === 'system' || userRole === 'system admin')) { next(); } else { logger.warn('관리자 권한 없는 접근 시도', { @@ -87,8 +88,9 @@ router.get('/me/work-reports', async (req, res) => { router.get('/me/monthly-stats', async (req, res) => { try { const { year, month } = req.query; - const db = require('../config/database'); - const stats = await db.query( + const { getDb } = require('../dbPool'); + const db = await getDb(); + const [stats] = await db.execute( `SELECT SUM(total_work_hours) as month_hours, COUNT(DISTINCT record_date) as work_days diff --git a/api.hyungi.net/services/auth.service.js b/api.hyungi.net/services/auth.service.js index 1970149..a7481b4 100644 --- a/api.hyungi.net/services/auth.service.js +++ b/api.hyungi.net/services/auth.service.js @@ -57,7 +57,7 @@ const loginService = async (username, password, ipAddress, userAgent) => { const token = jwt.sign( - { user_id: user.user_id, username: user.username, role: user.role, access_level: user.access_level, worker_id: user.worker_id, name: user.name || user.username }, + { user_id: user.user_id, username: user.username, role: user.role_name, role_id: user.role_id, access_level: user.access_level, worker_id: user.worker_id, name: user.name || user.username }, process.env.JWT_SECRET || 'your-secret-key', { expiresIn: process.env.JWT_EXPIRES_IN || '24h' } ); @@ -80,7 +80,7 @@ const loginService = async (username, password, ipAddress, userAgent) => { user_id: user.user_id, username: user.username, name: user.name || user.username, - role: user.role, + role: user.role_name, access_level: user.access_level, worker_id: user.worker_id } diff --git a/web-ui/components/navbar.html b/web-ui/components/navbar.html index efbe4bd..8b01e7f 100644 --- a/web-ui/components/navbar.html +++ b/web-ui/components/navbar.html @@ -1,470 +1,287 @@ - - + \ No newline at end of file diff --git a/web-ui/css/my-attendance.css b/web-ui/css/my-attendance.css new file mode 100644 index 0000000..a984fb8 --- /dev/null +++ b/web-ui/css/my-attendance.css @@ -0,0 +1,583 @@ +/** + * 나의 출근 현황 페이지 스타일 + */ + +/* 페이지 헤더 */ +.page-header { + margin-bottom: 24px; +} + +.page-title { + font-size: 28px; + font-weight: 700; + color: #1a1a1a; + margin: 0 0 8px 0; + display: flex; + align-items: center; + gap: 12px; +} + +.title-icon { + font-size: 32px; +} + +.page-description { + font-size: 14px; + color: #666; + margin: 0; +} + +/* 통계 카드 섹션 */ +.stats-section { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} + +.stat-card { + background: white; + border-radius: 12px; + padding: 20px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + display: flex; + align-items: center; + gap: 16px; + transition: transform 0.2s, box-shadow 0.2s; +} + +.stat-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); +} + +.stat-icon { + font-size: 36px; + width: 56px; + height: 56px; + display: flex; + align-items: center; + justify-content: center; + background: #f8f9fa; + border-radius: 12px; +} + +.stat-info { + flex: 1; +} + +.stat-value { + font-size: 24px; + font-weight: 700; + color: #1a1a1a; + margin-bottom: 4px; +} + +.stat-label { + font-size: 13px; + color: #666; + font-weight: 500; +} + +/* 탭 컨테이너 */ +.tab-container { + display: flex; + gap: 8px; + margin-bottom: 20px; + border-bottom: 2px solid #e9ecef; +} + +.tab-btn { + background: none; + border: none; + padding: 12px 24px; + font-size: 14px; + font-weight: 600; + color: #6c757d; + cursor: pointer; + border-bottom: 3px solid transparent; + transition: all 0.2s; + display: flex; + align-items: center; + gap: 8px; +} + +.tab-btn:hover { + color: #495057; + background: #f8f9fa; +} + +.tab-btn.active { + color: #007bff; + border-bottom-color: #007bff; +} + +.tab-icon { + font-size: 16px; +} + +/* 탭 컨텐츠 */ +.tab-content { + display: none; +} + +.tab-content.active { + display: block; +} + +/* 테이블 스타일 */ +#attendanceTable { + background: white; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); +} + +#attendanceTable thead { + background: #f8f9fa; +} + +#attendanceTable th { + padding: 16px 12px; + font-weight: 600; + color: #495057; + text-align: center; + border-bottom: 2px solid #dee2e6; +} + +#attendanceTable td { + padding: 14px 12px; + text-align: center; + border-bottom: 1px solid #f1f3f5; +} + +#attendanceTable tbody tr { + transition: background-color 0.2s; + cursor: pointer; +} + +#attendanceTable tbody tr:hover { + background-color: #f8f9fa; +} + +.loading-cell, +.empty-cell, +.error-cell { + text-align: center; + padding: 40px 20px; + color: #6c757d; + font-size: 14px; +} + +.error-cell { + color: #dc3545; +} + +.notes-cell { + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: left; + color: #6c757d; + font-size: 13px; +} + +/* 상태 배지 */ +.status-badge { + display: inline-block; + padding: 4px 12px; + border-radius: 12px; + font-size: 12px; + font-weight: 600; + text-transform: capitalize; +} + +.status-badge.normal { + background: #d4edda; + color: #155724; +} + +.status-badge.late { + background: #fff3cd; + color: #856404; +} + +.status-badge.early { + background: #ffe5b5; + color: #a56200; +} + +.status-badge.absent { + background: #f8d7da; + color: #721c24; +} + +.status-badge.vacation { + background: #cce5ff; + color: #004085; +} + +/* 달력 스타일 */ +#calendarContainer { + background: white; + border-radius: 12px; + padding: 24px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); +} + +.calendar-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; +} + +.calendar-header h3 { + font-size: 20px; + font-weight: 700; + color: #1a1a1a; + margin: 0; +} + +.calendar-nav-btn { + background: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 8px; + width: 40px; + height: 40px; + font-size: 18px; + cursor: pointer; + transition: all 0.2s; +} + +.calendar-nav-btn:hover { + background: #e9ecef; + border-color: #adb5bd; +} + +.calendar-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 8px; +} + +.calendar-day-header { + text-align: center; + font-weight: 600; + font-size: 13px; + color: #495057; + padding: 12px 8px; + background: #f8f9fa; + border-radius: 4px; +} + +.calendar-day { + aspect-ratio: 1; + border: 1px solid #e9ecef; + border-radius: 8px; + padding: 8px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + transition: all 0.2s; + background: white; +} + +.calendar-day.empty { + background: #f8f9fa; + border-color: #f1f3f5; +} + +.calendar-day.has-record { + cursor: pointer; + font-weight: 600; +} + +.calendar-day.has-record:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); +} + +/* 달력 날짜 상태별 색상 */ +.calendar-day.normal { + background: #d4edda; + border-color: #c3e6cb; + color: #155724; +} + +.calendar-day.late { + background: #fff3cd; + border-color: #ffeaa7; + color: #856404; +} + +.calendar-day.early { + background: #ffe5b5; + border-color: #ffd98a; + color: #a56200; +} + +.calendar-day.absent { + background: #f8d7da; + border-color: #f5c6cb; + color: #721c24; +} + +.calendar-day.vacation { + background: #cce5ff; + border-color: #b8daff; + color: #004085; +} + +.calendar-day-number { + font-size: 14px; + margin-bottom: 4px; +} + +.calendar-day-status { + font-size: 16px; +} + +/* 달력 범례 */ +.calendar-legend { + display: flex; + flex-wrap: wrap; + gap: 16px; + justify-content: center; + margin-top: 24px; + padding-top: 20px; + border-top: 1px solid #e9ecef; +} + +.legend-item { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: #495057; +} + +.legend-dot { + width: 16px; + height: 16px; + border-radius: 50%; + border: 2px solid #dee2e6; +} + +.legend-dot.normal { + background: #d4edda; + border-color: #c3e6cb; +} + +.legend-dot.late { + background: #fff3cd; + border-color: #ffeaa7; +} + +.legend-dot.early { + background: #ffe5b5; + border-color: #ffd98a; +} + +.legend-dot.absent { + background: #f8d7da; + border-color: #f5c6cb; +} + +.legend-dot.vacation { + background: #cce5ff; + border-color: #b8daff; +} + +/* 모달 스타일 */ +.modal { + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; +} + +.modal-content { + background-color: white; + border-radius: 12px; + width: 90%; + max-width: 500px; + max-height: 80vh; + overflow-y: auto; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); +} + +.modal-header { + padding: 20px 24px; + border-bottom: 1px solid #e9ecef; + display: flex; + justify-content: space-between; + align-items: center; +} + +.modal-header h2 { + margin: 0; + font-size: 20px; + font-weight: 700; + color: #1a1a1a; +} + +.modal-close-btn { + background: none; + border: none; + font-size: 28px; + color: #6c757d; + cursor: pointer; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: all 0.2s; +} + +.modal-close-btn:hover { + background: #f8f9fa; + color: #343a40; +} + +.modal-body { + padding: 24px; +} + +.modal-footer { + padding: 16px 24px; + border-top: 1px solid #e9ecef; + display: flex; + justify-content: flex-end; + gap: 12px; +} + +/* 상세 정보 그리드 */ +.detail-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 20px; +} + +.detail-item { + display: flex; + flex-direction: column; + gap: 8px; +} + +.detail-item.full-width { + grid-column: 1 / -1; +} + +.detail-item label { + font-size: 13px; + font-weight: 600; + color: #6c757d; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.detail-item div { + font-size: 15px; + color: #1a1a1a; +} + +/* 버튼 스타일 */ +.btn-primary { + background-color: #007bff; + color: white; + border: none; + padding: 8px 16px; + border-radius: 6px; + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s; +} + +.btn-primary:hover { + background-color: #0056b3; +} + +.btn-secondary { + background-color: #6c757d; + color: white; + border: none; + padding: 8px 16px; + border-radius: 6px; + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s; +} + +.btn-secondary:hover { + background-color: #5a6268; +} + +/* 반응형 디자인 */ +@media (max-width: 768px) { + .stats-section { + grid-template-columns: 1fr; + } + + .stat-card { + padding: 16px; + } + + .stat-icon { + width: 48px; + height: 48px; + font-size: 28px; + } + + .stat-value { + font-size: 20px; + } + + .tab-btn { + padding: 10px 16px; + font-size: 13px; + } + + .calendar-grid { + gap: 4px; + } + + .calendar-day { + padding: 4px; + } + + .calendar-day-number { + font-size: 12px; + } + + .calendar-day-status { + font-size: 14px; + } + + .detail-grid { + grid-template-columns: 1fr; + } + + #attendanceTable { + font-size: 12px; + } + + #attendanceTable th, + #attendanceTable td { + padding: 10px 8px; + } + + .notes-cell { + max-width: 120px; + } +} diff --git a/web-ui/js/auth-check.js b/web-ui/js/auth-check.js index 0f243be..c3224b0 100644 --- a/web-ui/js/auth-check.js +++ b/web-ui/js/auth-check.js @@ -28,14 +28,15 @@ function clearAuthData() { const currentUser = getUser(); // 사용자 정보가 유효한지 확인 (토큰은 있지만 유저 정보가 깨졌을 경우) - if (!currentUser || !currentUser.username || !currentUser.role) { + if (!currentUser || !currentUser.username) { console.error('🚨 사용자 정보가 유효하지 않습니다. 강제 로그아웃 처리합니다.'); clearAuthData(); window.location.href = '/index.html'; return; } - console.log(`✅ ${currentUser.username}(${currentUser.role})님 인증 성공.`); + const userRole = currentUser.role || currentUser.access_level || '사용자'; + console.log(`✅ ${currentUser.username}(${userRole})님 인증 성공.`); // 역할 기반 메뉴 제어 로직은 각 컴포넌트 로더(load-navbar.js 등)로 이전함. // 전역 변수 할당(window.currentUser) 제거. diff --git a/web-ui/js/load-navbar.js b/web-ui/js/load-navbar.js index 51b0d4f..d62bd47 100644 --- a/web-ui/js/load-navbar.js +++ b/web-ui/js/load-navbar.js @@ -57,39 +57,22 @@ function populateUserInfo(doc, user) { const roleName = ROLE_NAMES[user.role] || ROLE_NAMES.default; const elements = { - 'user-name': displayName, - 'user-role': roleName, - 'dropdown-user-fullname': displayName, - 'dropdown-user-id': `@${user.username}`, + 'userName': displayName, + 'userRole': roleName, + 'userInitial': displayName.charAt(0), }; for (const id in elements) { const el = doc.getElementById(id); if (el) el.textContent = elements[id]; } - - const systemBtn = doc.getElementById('systemBtn'); - if (systemBtn && user.role === 'system') { - systemBtn.style.display = 'flex'; - } } /** * 네비게이션 바와 관련된 모든 이벤트를 설정합니다. */ function setupNavbarEvents() { - const userInfoDropdown = document.getElementById('user-info-dropdown'); - const profileDropdownMenu = document.getElementById('profile-dropdown-menu'); - - if (userInfoDropdown && profileDropdownMenu) { - userInfoDropdown.addEventListener('click', (e) => { - e.stopPropagation(); - profileDropdownMenu.classList.toggle('show'); - userInfoDropdown.classList.toggle('active'); - }); - } - - const logoutButton = document.getElementById('dropdown-logout'); + const logoutButton = document.getElementById('logoutBtn'); if (logoutButton) { logoutButton.addEventListener('click', () => { if (confirm('로그아웃 하시겠습니까?')) { @@ -98,34 +81,13 @@ function setupNavbarEvents() { } }); } - - const systemButton = document.getElementById('systemBtn'); - if (systemButton) { - systemButton.addEventListener('click', () => { - window.location.href = config.paths.systemDashboard; - }); - } - - const dashboardButton = document.querySelector('.dashboard-btn'); - if (dashboardButton) { - dashboardButton.addEventListener('click', () => { - window.location.href = config.paths.groupLeaderDashboard; - }); - } - - document.addEventListener('click', (e) => { - if (profileDropdownMenu && !userInfoDropdown.contains(e.target) && !profileDropdownMenu.contains(e.target)) { - profileDropdownMenu.classList.remove('show'); - userInfoDropdown.classList.remove('active'); - } - }); } /** * 현재 시간을 업데이트하는 함수 */ function updateTime() { - const timeElement = document.getElementById('current-time'); + const timeElement = document.getElementById('timeValue'); if (timeElement) { const now = new Date(); timeElement.textContent = now.toLocaleTimeString('ko-KR', { hour12: false }); diff --git a/web-ui/js/my-attendance.js b/web-ui/js/my-attendance.js new file mode 100644 index 0000000..efe5825 --- /dev/null +++ b/web-ui/js/my-attendance.js @@ -0,0 +1,390 @@ +/** + * 나의 출근 현황 페이지 + * 본인의 출근 기록과 근태 현황을 조회하고 표시합니다 + */ + +// 전역 상태 +let currentYear = new Date().getFullYear(); +let currentMonth = new Date().getMonth() + 1; +let attendanceData = []; +let vacationBalance = null; +let monthlyStats = null; + +// 페이지 로드 시 초기화 +document.addEventListener('DOMContentLoaded', () => { + initializePage(); +}); + +/** + * 페이지 초기화 + */ +function initializePage() { + initializeYearMonthSelects(); + setupEventListeners(); + loadAttendanceData(); +} + +/** + * 년도/월 선택 옵션 초기화 + */ +function initializeYearMonthSelects() { + const yearSelect = document.getElementById('yearSelect'); + const monthSelect = document.getElementById('monthSelect'); + + // 년도 옵션 (현재 년도 기준 ±2년) + const currentYearValue = new Date().getFullYear(); + for (let year = currentYearValue - 2; year <= currentYearValue + 2; year++) { + const option = document.createElement('option'); + option.value = year; + option.textContent = `${year}년`; + if (year === currentYear) option.selected = true; + yearSelect.appendChild(option); + } + + // 월 옵션 + for (let month = 1; month <= 12; month++) { + const option = document.createElement('option'); + option.value = month; + option.textContent = `${month}월`; + if (month === currentMonth) option.selected = true; + monthSelect.appendChild(option); + } +} + +/** + * 이벤트 리스너 설정 + */ +function setupEventListeners() { + // 조회 버튼 + document.getElementById('loadAttendance').addEventListener('click', () => { + currentYear = parseInt(document.getElementById('yearSelect').value); + currentMonth = parseInt(document.getElementById('monthSelect').value); + loadAttendanceData(); + }); + + // 탭 전환 + document.querySelectorAll('.tab-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + const tabName = e.currentTarget.dataset.tab; + switchTab(tabName); + }); + }); + + // 달력 네비게이션 + document.getElementById('prevMonth').addEventListener('click', () => { + changeMonth(-1); + }); + + document.getElementById('nextMonth').addEventListener('click', () => { + changeMonth(1); + }); +} + +/** + * 출근 데이터 로드 + */ +async function loadAttendanceData() { + try { + showLoading(); + + // 병렬로 데이터 로드 + const [attendanceRes, vacationRes, statsRes] = await Promise.all([ + window.apiGet(`/users/me/attendance-records?year=${currentYear}&month=${currentMonth}`), + window.apiGet(`/users/me/vacation-balance?year=${currentYear}`), + window.apiGet(`/users/me/monthly-stats?year=${currentYear}&month=${currentMonth}`) + ]); + + attendanceData = attendanceRes.data || attendanceRes || []; + vacationBalance = vacationRes.data || vacationRes; + monthlyStats = statsRes.data || statsRes; + + // UI 업데이트 + updateStats(); + renderTable(); + renderCalendar(); + + } catch (error) { + console.error('출근 데이터 로드 실패:', error); + showError('출근 데이터를 불러오는데 실패했습니다.'); + } +} + +/** + * 통계 업데이트 + */ +function updateStats() { + // 총 근무시간 (API는 month_hours 반환) + const totalHours = monthlyStats?.month_hours || monthlyStats?.total_work_hours || 0; + document.getElementById('totalHours').textContent = `${totalHours}시간`; + + // 근무일수 + const totalDays = monthlyStats?.work_days || 0; + document.getElementById('totalDays').textContent = `${totalDays}일`; + + // 잔여 연차 + const remaining = vacationBalance?.remaining_annual_leave || + (vacationBalance?.total_annual_leave || 0) - (vacationBalance?.used_annual_leave || 0); + document.getElementById('remainingLeave').textContent = `${remaining}일`; +} + +/** + * 테이블 렌더링 + */ +function renderTable() { + const tbody = document.getElementById('attendanceTableBody'); + tbody.innerHTML = ''; + + if (!attendanceData || attendanceData.length === 0) { + tbody.innerHTML = '출근 기록이 없습니다.'; + return; + } + + attendanceData.forEach(record => { + const tr = document.createElement('tr'); + tr.className = `attendance-row ${getStatusClass(record.attendance_type_code || record.type_code)}`; + tr.onclick = () => showDetailModal(record); + + const date = new Date(record.record_date); + const dayOfWeek = ['일', '월', '화', '수', '목', '금', '토'][date.getDay()]; + + tr.innerHTML = ` + ${formatDate(record.record_date)} + ${dayOfWeek} + ${record.check_in_time || '-'} + ${record.check_out_time || '-'} + ${record.total_work_hours ? `${record.total_work_hours}h` : '-'} + ${getStatusText(record)} + ${record.notes || '-'} + `; + + tbody.appendChild(tr); + }); +} + +/** + * 달력 렌더링 + */ +function renderCalendar() { + const calendarTitle = document.getElementById('calendarTitle'); + const calendarGrid = document.getElementById('calendarGrid'); + + calendarTitle.textContent = `${currentYear}년 ${currentMonth}월`; + + // 달력 그리드 초기화 + calendarGrid.innerHTML = ''; + + // 요일 헤더 + const weekdays = ['일', '월', '화', '수', '목', '금', '토']; + weekdays.forEach(day => { + const dayHeader = document.createElement('div'); + dayHeader.className = 'calendar-day-header'; + dayHeader.textContent = day; + calendarGrid.appendChild(dayHeader); + }); + + // 해당 월의 첫날과 마지막 날 + const firstDay = new Date(currentYear, currentMonth - 1, 1); + const lastDay = new Date(currentYear, currentMonth, 0); + const daysInMonth = lastDay.getDate(); + const startDayOfWeek = firstDay.getDay(); + + // 출근 데이터를 날짜별로 매핑 + const attendanceMap = {}; + if (attendanceData) { + attendanceData.forEach(record => { + const date = new Date(record.record_date); + const day = date.getDate(); + attendanceMap[day] = record; + }); + } + + // 빈 칸 (이전 달) + for (let i = 0; i < startDayOfWeek; i++) { + const emptyCell = document.createElement('div'); + emptyCell.className = 'calendar-day empty'; + calendarGrid.appendChild(emptyCell); + } + + // 날짜 칸 + for (let day = 1; day <= daysInMonth; day++) { + const dayCell = document.createElement('div'); + dayCell.className = 'calendar-day'; + + const record = attendanceMap[day]; + if (record) { + dayCell.classList.add('has-record', getStatusClass(record.attendance_type_code || record.type_code)); + dayCell.onclick = () => showDetailModal(record); + } + + dayCell.innerHTML = ` +
${day}
+ ${record ? `
${getStatusIcon(record)}
` : ''} + `; + + calendarGrid.appendChild(dayCell); + } +} + +/** + * 탭 전환 + */ +function switchTab(tabName) { + // 탭 버튼 활성화 토글 + document.querySelectorAll('.tab-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.tab === tabName); + }); + + // 탭 컨텐츠 토글 + document.querySelectorAll('.tab-content').forEach(content => { + content.classList.remove('active'); + }); + + if (tabName === 'list') { + document.getElementById('listView').classList.add('active'); + } else if (tabName === 'calendar') { + document.getElementById('calendarView').classList.add('active'); + } +} + +/** + * 월 변경 + */ +function changeMonth(offset) { + currentMonth += offset; + + if (currentMonth < 1) { + currentMonth = 12; + currentYear--; + } else if (currentMonth > 12) { + currentMonth = 1; + currentYear++; + } + + // Select 박스 업데이트 + document.getElementById('yearSelect').value = currentYear; + document.getElementById('monthSelect').value = currentMonth; + + loadAttendanceData(); +} + +/** + * 상세 모달 표시 + */ +function showDetailModal(record) { + const modal = document.getElementById('detailModal'); + const modalBody = document.getElementById('modalBody'); + const modalTitle = document.getElementById('modalTitle'); + + const date = new Date(record.record_date); + modalTitle.textContent = `${formatDate(record.record_date)} 출근 상세`; + + modalBody.innerHTML = ` +
+
+ +
${formatDate(record.record_date)}
+
+
+ +
${getStatusText(record)}
+
+
+ +
${record.check_in_time || '기록 없음'}
+
+
+ +
${record.check_out_time || '기록 없음'}
+
+
+ +
${record.total_work_hours ? `${record.total_work_hours} 시간` : '계산 불가'}
+
+ ${record.vacation_type_name ? ` +
+ +
${record.vacation_type_name}
+
+ ` : ''} + ${record.notes ? ` +
+ +
${record.notes}
+
+ ` : ''} +
+ `; + + modal.style.display = 'block'; +} + +/** + * 모달 닫기 + */ +function closeDetailModal() { + document.getElementById('detailModal').style.display = 'none'; +} + +// 모달 외부 클릭 시 닫기 +window.onclick = function(event) { + const modal = document.getElementById('detailModal'); + if (event.target === modal) { + closeDetailModal(); + } +}; + +/** + * 유틸리티 함수들 + */ + +function formatDate(dateString) { + const date = new Date(dateString); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${month}/${day}`; +} + +function getStatusClass(typeCode) { + const typeMap = { + 'NORMAL': 'normal', + 'LATE': 'late', + 'EARLY_LEAVE': 'early', + 'ABSENT': 'absent', + 'VACATION': 'vacation' + }; + return typeMap[typeCode] || 'normal'; +} + +function getStatusText(record) { + if (record.vacation_type_name) { + return record.vacation_type_name; + } + return record.attendance_type_name || record.type_name || '정상'; +} + +function getStatusIcon(record) { + const typeCode = record.attendance_type_code || record.type_code; + const iconMap = { + 'NORMAL': '✓', + 'LATE': '⚠', + 'EARLY_LEAVE': '⏰', + 'ABSENT': '✗', + 'VACATION': '🌴' + }; + return iconMap[typeCode] || '✓'; +} + +function showLoading() { + const tbody = document.getElementById('attendanceTableBody'); + tbody.innerHTML = '데이터를 불러오는 중...'; +} + +function showError(message) { + const tbody = document.getElementById('attendanceTableBody'); + tbody.innerHTML = `${message}`; + + // 통계 초기화 + document.getElementById('totalHours').textContent = '-'; + document.getElementById('totalDays').textContent = '-'; + document.getElementById('remainingLeave').textContent = '-'; +} diff --git a/web-ui/pages/common/daily-work-report.html b/web-ui/pages/common/daily-work-report.html index a751f8e..f8fed74 100644 --- a/web-ui/pages/common/daily-work-report.html +++ b/web-ui/pages/common/daily-work-report.html @@ -13,13 +13,7 @@
- - -
-

✍️ 일일 작업보고서 작성

-

단계별로 오늘의 작업 내용을 간편하게 기록하고 관리하세요.

-
- +
diff --git a/web-ui/pages/common/my-attendance.html b/web-ui/pages/common/my-attendance.html new file mode 100644 index 0000000..cecce25 --- /dev/null +++ b/web-ui/pages/common/my-attendance.html @@ -0,0 +1,151 @@ + + + + + + 나의 출근 현황 | (주)테크니컬코리아 + + + + + + + +
+ + +
+ + +
+ + + + +
+ + + + + + + +
+ + +
+
+
⏱️
+
+
-
+
총 근무시간
+
+
+ +
+
📅
+
+
-
+
근무일수
+
+
+ +
+
🌴
+
+
-
+
잔여 연차
+
+
+
+ + +
+ + +
+ + +
+
+ + + + + + + + + + + + + + + + + +
날짜요일출근시간퇴근시간근무시간상태비고
데이터를 불러오는 중...
+
+
+ + +
+
+
+ +

2026년 1월

+ +
+
+ +
+
+ 정상 + 지각 + 조퇴 + 결근 + 휴가 +
+
+
+ +
+
+
+ + + + + + + + + + + diff --git a/web-ui/pages/common/worker-individual-report.html b/web-ui/pages/common/worker-individual-report.html index 9716d28..40eee9e 100644 --- a/web-ui/pages/common/worker-individual-report.html +++ b/web-ui/pages/common/worker-individual-report.html @@ -16,13 +16,7 @@
- - -
-

👤 개별 작업 보고서

-

작업자의 일일 작업 내용을 입력하고 수정합니다.

-
- +
diff --git a/web-ui/pages/dashboard/user.html b/web-ui/pages/dashboard/user.html index 0c14efd..4bad1ca 100644 --- a/web-ui/pages/dashboard/user.html +++ b/web-ui/pages/dashboard/user.html @@ -43,7 +43,7 @@ 📊 일일 이슈 보고 - + 📋 출근부 확인 diff --git a/web-ui/pages/management/code-management.html b/web-ui/pages/management/code-management.html index 809a696..6e839b6 100644 --- a/web-ui/pages/management/code-management.html +++ b/web-ui/pages/management/code-management.html @@ -11,16 +11,10 @@ -
- - - - -
-

🏷️ 코드 관리

-

작업 상태, 오류 유형, 작업 유형 등 시스템에서 사용하는 코드를 관리합니다

-
+ + +
@@ -37,13 +31,12 @@

작업 상태, 오류 유형, 작업 유형 등 시스템에서 사용하는 코드를 관리합니다

- +
- -
+
diff --git a/web-ui/pages/management/project-management.html b/web-ui/pages/management/project-management.html index ec8b874..e2aef5f 100644 --- a/web-ui/pages/management/project-management.html +++ b/web-ui/pages/management/project-management.html @@ -10,16 +10,10 @@ -
- - - - -
-

📁 프로젝트 관리

-

프로젝트 등록, 수정, 삭제 및 기본 정보를 관리합니다

-
+ + +
@@ -36,17 +30,16 @@

프로젝트 등록, 수정, 삭제 및 기본 정보를 관리합니다

- +
- - -
+ +
diff --git a/web-ui/pages/management/work-management.html b/web-ui/pages/management/work-management.html index 2e711db..3ee6a18 100644 --- a/web-ui/pages/management/work-management.html +++ b/web-ui/pages/management/work-management.html @@ -10,16 +10,10 @@ -
- - - - -
-

🔧 작업 관리

-

프로젝트, 작업자, 작업 유형 등 기본 데이터를 관리합니다

-
+ + +
diff --git a/web-ui/pages/management/worker-management.html b/web-ui/pages/management/worker-management.html index af7b72a..7acd58c 100644 --- a/web-ui/pages/management/worker-management.html +++ b/web-ui/pages/management/worker-management.html @@ -11,16 +11,10 @@ -
- - - - -
-

👥 작업자 관리

-

작업자 등록, 수정, 삭제 및 기본 정보를 관리합니다

-
+ + +
@@ -37,17 +31,16 @@

작업자 등록, 수정, 삭제 및 기본 정보를 관리합니다

- +
- - -
+ +
diff --git a/개발 log/2026-01-20.md b/개발 log/2026-01-20.md new file mode 100644 index 0000000..9d3e6aa --- /dev/null +++ b/개발 log/2026-01-20.md @@ -0,0 +1,180 @@ +# 개발 로그 - 2026-01-20 + +## 타임라인 + +### 오전 - 로그인 API 500 에러 수정 +- **작업**: 수정 +- **대상**: `api.hyungi.net/services/auth.service.js` +- **문제**: + - 로그인 페이지에서 500 Internal Server Error 발생 + - 브라우저 콘솔 에러: "Failed to load resource: the server responded with a status of 500" +- **원인**: + - `userModel.findByUsername()`은 `role_name` 필드를 반환하는데 + - `auth.service.js:84`에서 `user.role`로 접근하여 undefined 발생 + - 응답 객체에 undefined 값이 포함되어 JSON 직렬화 실패 +- **해결방법**: + - `role: user.role` → `role: user.role_name`으로 수정 +- **변경 내용**: + ```javascript + // 이전 + user: { + user_id: user.user_id, + username: user.username, + name: user.name || user.username, + role: user.role, // undefined + access_level: user.access_level, + worker_id: user.worker_id + } + + // 이후 + user: { + user_id: user.user_id, + username: user.username, + name: user.name || user.username, + role: user.role_name, // 올바른 필드 사용 + access_level: user.access_level, + worker_id: user.worker_id + } + ``` +- **파일**: `api.hyungi.net/services/auth.service.js:83` + +--- + +### 오전 - 네비게이션 헤더 전면 개편 +- **작업**: 대규모 리팩토링 +- **대상**: navbar 컴포넌트 및 관련 페이지 전체 +- **배경**: + - 구식 navbar 디자인 사용 중 + - group-leader.html의 최신 dashboard-header 스타일이 표준 + - 모든 페이지를 최신 디자인으로 통일 필요 + +#### 1단계: 구버전 헤더 제거 +- **작업**: 수정 +- **대상**: 6개 페이지 +- **문제**: + - `work-report-header` 클래스의 구버전 헤더가 navbar와 중복 표시 + - 페이지마다 불필요한 제목과 설명 중복 +- **해결방법**: + - 모든 페이지에서 `
` 블록 제거 + - navbar 컴포넌트만 유지 +- **제거된 헤더 예시**: + ```html + +
+

🔧 작업 관리

+

프로젝트, 작업자, 작업 유형 등 기본 데이터를 관리합니다

+
+ ``` +- **수정된 파일** (6개): + - `web-ui/pages/management/work-management.html` + - `web-ui/pages/management/project-management.html` + - `web-ui/pages/management/code-management.html` + - `web-ui/pages/management/worker-management.html` + - `web-ui/pages/common/worker-individual-report.html` + - `web-ui/pages/common/daily-work-report.html` + +#### 2단계: navbar 컴포넌트 최신화 +- **작업**: 전면 재작성 +- **대상**: `web-ui/components/navbar.html` +- **변경 내용**: + 1. **HTML 구조 변경**: + - 기존: `