diff --git a/DEV_LOG.md b/DEV_LOG.md index 0887e7c..816b76f 100644 --- a/DEV_LOG.md +++ b/DEV_LOG.md @@ -41,6 +41,18 @@ - **원인**: `workerModel.update` 쿼리에 DB에 존재하지 않는 `join_date` 컬럼을 업데이트하려는 시도가 있어 SQL 에러 발생. - **해결**: `workerModel.js`에서 잘못된 컬럼(`join_date`) 참조 제거. (올바른 컬럼 `hire_date`는 유지) + +3. **일일 근태 추적 시스템 구현 (Daily Attendance Tracking)** + - **Backend**: + - `AttendanceModel.initializeDailyRecords` 추가: 모든 활성 작업자에 대해 'incomplete' 상태의 근태 기록 자동 생성 (Lazy Initialization). + - `AttendanceModel.syncWithWorkReports` 추가: 작업 보고서 작성/수정/삭제 시 근태 상태(미제출/부분/완료/초과) 자동 동기화. + - `dailyWorkReportModel.js`에 동기화 로직 통합 (트랜잭션 후 처리). + - `attendanceService`에서 상태 조회 시 초기화 로직 수행. + - **Frontend**: + - `group-leader-dashboard.js` 리팩토링: 모의 데이터 대신 실제 API(`/attendance/daily-status`) 연동. + - `modern-dashboard.css`: 근태 현황 카드(`worker-card`) 및 그리드 스타일 추가. + - `group-leader.html`: 스크립트 로드 추가 및 DOM 구조 확인. + --- ## 🛡보안 및 검토 리포트 (History) diff --git a/api.hyungi.net/models/attendanceModel.js b/api.hyungi.net/models/attendanceModel.js index 0f7a8c4..b83939f 100644 --- a/api.hyungi.net/models/attendanceModel.js +++ b/api.hyungi.net/models/attendanceModel.js @@ -20,24 +20,151 @@ class AttendanceModel { LEFT JOIN vacation_types vt ON dar.vacation_type_id = vt.id WHERE dar.record_date = ? `; - + const params = [date]; - + if (workerId) { query += ' AND dar.worker_id = ?'; params.push(workerId); } - + query += ' ORDER BY w.worker_name'; - + const [rows] = await db.execute(query, params); return rows; } + // 작업 보고서와 근태 기록 동기화 (시간 합산 및 상태 업데이트) + static async syncWithWorkReports(workerId, date) { + const db = await getDb(); + + // 1. 해당 날짜의 총 작업 시간 계산 + const [reportStats] = await db.execute(` + SELECT + COALESCE(SUM(work_hours), 0) as total_hours, + COUNT(*) as report_count + FROM daily_work_reports + WHERE worker_id = ? AND report_date = ? + `, [workerId, date]); + + const totalHours = parseFloat(reportStats[0].total_hours || 0); + const reportCount = reportStats[0].report_count; + + // 2. 근태 유형 및 상태 결정 + // 기본 규칙: 0시간 -> incomplete, <8시간 -> partial, 8시간 -> complete, >8시간 -> overtime + // (휴가는 별도 로직이지만 여기서 덮어쓰지 않도록 주의해야 함. 하지만 작업보고서가 추가되면 실 근무로 간주) + + let status = 'incomplete'; + let typeCode = 'REGULAR'; // 기본값 + + if (totalHours === 0) { + status = 'incomplete'; + } else if (totalHours < 8) { + status = 'partial'; + typeCode = 'PARTIAL'; + } else if (totalHours === 8) { + status = 'complete'; + typeCode = 'REGULAR'; + } else { + status = 'overtime'; + typeCode = 'OVERTIME'; + } + + // 근태 유형 ID 조회 + const [types] = await db.execute('SELECT id FROM work_attendance_types WHERE type_code = ?', [typeCode]); + const typeId = types[0]?.id; + + // 3. 기록 업데이트 (휴가 정보는 유지) + // 기존 기록 조회 + const [existing] = await db.execute( + 'SELECT id, vacation_type_id FROM daily_attendance_records WHERE worker_id = ? AND record_date = ?', + [workerId, date] + ); + + if (existing.length > 0) { + // 휴가가 설정되어 있고 시간이 0이면 휴가 상태 유지, 시간이 있으면 근무+휴가 복합 상태일 수 있음 + // 여기서는 단순화하여 근무 시간이 있으면 근무 상태로 업데이트 (단, vacation_type_id는 유지) + + const recordId = existing[0].id; + // 만약 기존 상태가 'vacation'이고 근무시간이 0이면 업데이트 건너뛸 수도 있지만, + // 작업보고서가 삭제되어 0이 된 경우도 있으므로 업데이트는 수행해야 함. + + await db.execute(` + UPDATE daily_attendance_records + SET + total_work_hours = ?, + attendance_type_id = ?, + status = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `, [totalHours, typeId, status, recordId]); + + return { synced: true, totalHours, status }; + } else { + // 기록이 없으면 생성 (일반적으로는 initializeDailyRecords로 생성되어 있어야 함) + // 생성자가 명확하지 않으므로 시스템(1) 또는 알 수 없음 처리 + await db.execute(` + INSERT INTO daily_attendance_records + (record_date, worker_id, total_work_hours, attendance_type_id, status, created_by) + VALUES (?, ?, ?, ?, ?, 1) + `, [date, workerId, totalHours, typeId, status]); + + return { synced: true, totalHours, status, created: true }; + } + } + + // 일일 근태 기록 초기화 (모든 활성 작업자에 대한 기본 레코드 생성) + static async initializeDailyRecords(date, createdBy) { + const db = await getDb(); + + // 1. 활성 작업자 조회 + const [workers] = await db.execute( + 'SELECT worker_id FROM workers WHERE status = "active"' // is_active check not needed as status covers it based on previous fix? Wait, previous fix used status='active'. + ); + + if (workers.length === 0) return { inserted: 0 }; + + // 2. 일일 근태 레코드 일괄 생성 (이미 존재하면 무시) + // VALUES (...), (...), ... + const values = workers.map(w => [date, w.worker_id, 'incomplete', createdBy]); + + // Bulk INSERT IGNORE + // Note: mysql2 execute doesn't support nested arrays for bulk insert easily with placeholder ? + // We should build the query or use query method for pool? + // Using simple loop for safety and compatibility or building string. + + let insertedCount = 0; + + // 트랜잭션 사용 권장 + const conn = await db.getConnection(); + try { + await conn.beginTransaction(); + + for (const w of workers) { + const [result] = await conn.execute(` + INSERT IGNORE INTO daily_attendance_records + (record_date, worker_id, status, created_by) + VALUES (?, ?, 'incomplete', ?) + `, [date, w.worker_id, createdBy]); + + insertedCount += result.affectedRows; + } + + await conn.commit(); + } catch (err) { + await conn.rollback(); + throw err; + } finally { + conn.release(); + } + + return { inserted: insertedCount, total_active_workers: workers.length }; + } + // 근태 기록 생성 또는 업데이트 static async upsertAttendanceRecord(recordData) { const db = await getDb(); - + const { record_date, worker_id, @@ -72,7 +199,7 @@ class AttendanceModel { is_overtime_approved, existing[0].id ]); - + return { id: existing[0].id, affected: result.affectedRows }; } else { // 생성 @@ -90,7 +217,7 @@ class AttendanceModel { is_overtime_approved, created_by ]); - + return { id: result.insertId, affected: result.affectedRows }; } } @@ -98,7 +225,7 @@ class AttendanceModel { // 작업자별 근태 현황 조회 (대시보드용) static async getWorkerAttendanceStatus(date) { const db = await getDb(); - + // 모든 작업자와 해당 날짜의 근태 기록을 조회 const [rows] = await db.execute(` SELECT @@ -127,51 +254,51 @@ class AttendanceModel { WHERE w.is_active = TRUE ORDER BY w.worker_name `, [date, date, date]); - + return rows; } // 휴가 처리 static async processVacation(workerId, date, vacationType, createdBy) { const db = await getDb(); - + // 휴가 유형 정보 조회 const [vacationTypes] = await db.execute( 'SELECT id, type_code, type_name, hours_deduction, description, is_active, created_at, updated_at FROM vacation_types WHERE type_code = ?', [vacationType] ); - + if (vacationTypes.length === 0) { throw new Error('유효하지 않은 휴가 유형입니다.'); } - + const vacationTypeInfo = vacationTypes[0]; - + // 현재 작업 시간 조회 const [workHours] = await db.execute(` SELECT COALESCE(SUM(work_hours), 0) as total_hours FROM daily_work_reports WHERE worker_id = ? AND report_date = ? `, [workerId, date]); - + const currentHours = parseFloat(workHours[0].total_hours); const vacationHours = parseFloat(vacationTypeInfo.hours_deduction); const totalHours = currentHours + vacationHours; - + // 근로 유형 결정 let attendanceTypeCode = 'VACATION'; let status = 'vacation'; - + if (totalHours >= 8) { attendanceTypeCode = totalHours > 8 ? 'OVERTIME' : 'REGULAR'; status = totalHours > 8 ? 'overtime' : 'complete'; } - + const [attendanceTypes] = await db.execute( 'SELECT id FROM work_attendance_types WHERE type_code = ?', [attendanceTypeCode] ); - + // 휴가 작업 기록 생성 (프로젝트 ID 13 = "연차/휴무", work_type_id 1 = 기본) await db.execute(` INSERT INTO daily_work_reports ( @@ -185,7 +312,7 @@ class AttendanceModel { `${vacationTypeInfo.type_name} 처리`, createdBy ]); - + // 근태 기록 업데이트 const attendanceData = { record_date: date, @@ -196,14 +323,14 @@ class AttendanceModel { is_overtime_approved: false, created_by: createdBy }; - + return await this.upsertAttendanceRecord(attendanceData); } // 초과근무 승인 static async approveOvertime(workerId, date, approvedBy) { const db = await getDb(); - + const [result] = await db.execute(` UPDATE daily_attendance_records SET @@ -214,7 +341,7 @@ class AttendanceModel { updated_at = CURRENT_TIMESTAMP WHERE worker_id = ? AND record_date = ? `, [approvedBy, approvedBy, workerId, date]); - + return result.affectedRows > 0; } @@ -240,19 +367,19 @@ class AttendanceModel { static async getWorkerVacationBalance(workerId, year = null) { const db = await getDb(); const currentYear = year || new Date().getFullYear(); - + const [rows] = await db.execute(` SELECT id, worker_id, year, total_annual_leave, used_annual_leave, notes, created_at, updated_at FROM worker_vacation_balance WHERE worker_id = ? AND year = ? `, [workerId, currentYear]); - + if (rows.length === 0) { // 기본 연차 생성 (15일) await db.execute(` INSERT INTO worker_vacation_balance (worker_id, year, total_annual_leave) VALUES (?, ?, 15.0) `, [workerId, currentYear]); - + return { worker_id: workerId, year: currentYear, @@ -261,14 +388,14 @@ class AttendanceModel { remaining_annual_leave: 15.0 }; } - + return rows[0]; } // 월별 근태 통계 static async getMonthlyAttendanceStats(year, month, workerId = null) { const db = await getDb(); - + let query = ` SELECT w.worker_id, @@ -285,16 +412,16 @@ class AttendanceModel { AND YEAR(dar.record_date) = ? AND MONTH(dar.record_date) = ? WHERE w.is_active = TRUE `; - + const params = [year, month]; - + if (workerId) { query += ' AND w.worker_id = ?'; params.push(workerId); } - + query += ' GROUP BY w.worker_id, w.worker_name ORDER BY w.worker_name'; - + const [rows] = await db.execute(query, params); return rows; } diff --git a/api.hyungi.net/models/dailyWorkReportModel.js b/api.hyungi.net/models/dailyWorkReportModel.js index f9e799e..39346e4 100644 --- a/api.hyungi.net/models/dailyWorkReportModel.js +++ b/api.hyungi.net/models/dailyWorkReportModel.js @@ -50,15 +50,15 @@ const createDailyReport = async (reportData, callback) => { console.log(`📝 ${created_by_name}이 ${report_date} ${worker_id}번 작업자에게 데이터 추가 중...`); - // ✅ 수정된 쿼리 (테이블 alias 추가): -const [existingReports] = await conn.query( - `SELECT dwr.created_by, u.name as created_by_name, COUNT(*) as count, SUM(dwr.work_hours) as total_hours + // ✅ 수정된 쿼리 (테이블 alias 추가): + const [existingReports] = await conn.query( + `SELECT dwr.created_by, u.name as created_by_name, COUNT(*) as count, SUM(dwr.work_hours) as total_hours FROM daily_work_reports dwr LEFT JOIN users u ON dwr.created_by = u.user_id WHERE dwr.report_date = ? AND dwr.worker_id = ? GROUP BY dwr.created_by`, - [report_date, worker_id] -); + [report_date, worker_id] + ); console.log('기존 데이터 (삭제하지 않음):', existingReports); @@ -67,26 +67,26 @@ const [existingReports] = await conn.query( const insertedIds = []; for (const entry of work_entries) { const { project_id, work_type_id, work_status_id, error_type_id, work_hours } = entry; - + const [insertResult] = await conn.query( `INSERT INTO daily_work_reports (report_date, worker_id, project_id, work_type_id, work_status_id, error_type_id, work_hours, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW())`, [report_date, worker_id, project_id, work_type_id, work_status_id || 1, error_type_id || null, work_hours, created_by] ); - + insertedIds.push(insertResult.insertId); } - // ✅ 수정된 쿼리: -const [finalReports] = await conn.query( - `SELECT dwr.created_by, u.name as created_by_name, COUNT(*) as count, SUM(dwr.work_hours) as total_hours + // ✅ 수정된 쿼리: + const [finalReports] = await conn.query( + `SELECT dwr.created_by, u.name as created_by_name, COUNT(*) as count, SUM(dwr.work_hours) as total_hours FROM daily_work_reports dwr LEFT JOIN users u ON dwr.created_by = u.user_id WHERE dwr.report_date = ? AND dwr.worker_id = ? GROUP BY dwr.created_by`, - [report_date, worker_id] -); + [report_date, worker_id] + ); const grandTotal = finalReports.reduce((sum, report) => sum + parseFloat(report.total_hours || 0), 0); const myTotal = finalReports.find(r => r.created_by === created_by)?.total_hours || 0; @@ -104,7 +104,7 @@ const [finalReports] = await conn.query( (action, report_id, new_values, changed_by, change_reason, created_at) VALUES (?, ?, ?, ?, ?, NOW())`, [ - 'ADD_ACCUMULATE', + 'ADD_ACCUMULATE', insertedIds[0] || null, JSON.stringify({ report_date, @@ -124,9 +124,18 @@ const [finalReports] = await conn.query( } await conn.commit(); - - callback(null, { - success: true, + + // 5. 근태 기록 동기화 (추가) + try { + const AttendanceModel = require('./attendanceModel'); + await AttendanceModel.syncWithWorkReports(worker_id, report_date); + } catch (syncErr) { + console.error('근태 기록 동기화 실패:', syncErr); + // 메인 트랜잭션은 성공했으므로 동기화 실패로 롤백하지 않음 (비동기 처리 또는 무시) + } + + callback(null, { + success: true, inserted_count: insertedIds.length, deleted_count: 0, // 항상 0 (삭제 안함) action: 'accumulated', @@ -138,7 +147,7 @@ const [finalReports] = await conn.query( contributors: finalReports } }); - + } catch (err) { await conn.rollback(); console.error('작업보고서 누적 추가 오류:', err); @@ -154,7 +163,7 @@ const [finalReports] = await conn.query( const getMyAccumulatedHours = async (date, worker_id, created_by, callback) => { try { const db = await getDb(); - + const sql = ` SELECT SUM(work_hours) as my_total_hours, @@ -167,7 +176,7 @@ const getMyAccumulatedHours = async (date, worker_id, created_by, callback) => { LEFT JOIN projects p ON dwr.project_id = p.project_id WHERE dwr.report_date = ? AND dwr.worker_id = ? AND dwr.created_by = ? `; - + const [rows] = await db.query(sql, [date, worker_id, created_by]); callback(null, rows[0] || { my_total_hours: 0, my_entry_count: 0, my_entries: null }); } catch (err) { @@ -182,12 +191,12 @@ const getMyAccumulatedHours = async (date, worker_id, created_by, callback) => { const getAccumulatedReportsByDate = async (date, worker_id, callback) => { try { const db = await getDb(); - + const sql = getSelectQuery() + ` WHERE dwr.report_date = ? AND dwr.worker_id = ? ORDER BY dwr.created_by, dwr.created_at ASC `; - + const [rows] = await db.query(sql, [date, worker_id]); callback(null, rows); } catch (err) { @@ -202,7 +211,7 @@ const getAccumulatedReportsByDate = async (date, worker_id, callback) => { const getContributorsByDate = async (date, worker_id, callback) => { try { const db = await getDb(); - + const sql = ` SELECT dwr.created_by, @@ -222,7 +231,7 @@ const getContributorsByDate = async (date, worker_id, callback) => { GROUP BY dwr.created_by ORDER BY total_hours DESC, first_entry ASC `; - + const [rows] = await db.query(sql, [date, worker_id]); callback(null, rows); } catch (err) { @@ -237,10 +246,10 @@ const getContributorsByDate = async (date, worker_id, callback) => { const removeSpecificEntry = async (entry_id, deleted_by, callback) => { const db = await getDb(); const conn = await db.getConnection(); - + try { await conn.beginTransaction(); - + // 삭제 전 정보 확인 const [entryInfo] = await conn.query( `SELECT dwr.*, w.worker_name, p.project_name, u.name as created_by_name @@ -267,12 +276,12 @@ const removeSpecificEntry = async (entry_id, deleted_by, callback) => { // 개별 항목 삭제 const [result] = await conn.query('DELETE FROM daily_work_reports WHERE id = ?', [entry_id]); - + // 감사 로그 (삭제된 테이블이므로 콘솔 로그로 대체) console.log(`[삭제 로그] 작업자: ${entry.worker_name}, 프로젝트: ${entry.project_name}, 작업시간: ${entry.work_hours}시간, 삭제자: ${deleted_by}`); await conn.commit(); - callback(null, { + callback(null, { success: true, deleted_entry: { worker_name: entry.worker_name, @@ -280,7 +289,7 @@ const removeSpecificEntry = async (entry_id, deleted_by, callback) => { work_hours: entry.work_hours } }); - + } catch (err) { await conn.rollback(); console.error('개별 항목 삭제 오류:', err); @@ -431,10 +440,10 @@ const getByRange = async (start_date, end_date, callback) => { */ const searchWithDetails = async (params, callback) => { const { start_date, end_date, worker_id, project_id, work_status_id, created_by, page, limit } = params; - + try { const db = await getDb(); - + // 조건 구성 let whereConditions = ['dwr.report_date BETWEEN ? AND ?']; let queryParams = [start_date, end_date]; @@ -460,7 +469,7 @@ const searchWithDetails = async (params, callback) => { } const whereClause = whereConditions.join(' AND '); - + // 총 개수 조회 const countQuery = ` SELECT COUNT(*) as total @@ -477,7 +486,7 @@ const searchWithDetails = async (params, callback) => { ORDER BY dwr.report_date DESC, w.worker_name ASC, dwr.created_at DESC LIMIT ? OFFSET ? `; - + const dataParams = [...queryParams, limit, offset]; const [rows] = await db.query(dataQuery, dataParams); @@ -494,7 +503,7 @@ const searchWithDetails = async (params, callback) => { const getSummaryByDate = async (date, callback) => { try { const db = await getDb(); - + const sql = ` SELECT dwr.worker_id, @@ -523,7 +532,7 @@ const getSummaryByDate = async (date, callback) => { const getSummaryByWorker = async (worker_id, callback) => { try { const db = await getDb(); - + const sql = ` SELECT dwr.report_date, @@ -554,7 +563,7 @@ const getMonthlySummary = async (year, month, callback) => { const db = await getDb(); const start = `${year.padStart(4, '0')}-${month.padStart(2, '0')}-01`; const end = `${year.padStart(4, '0')}-${month.padStart(2, '0')}-31`; - + const sql = ` SELECT dwr.report_date, @@ -588,47 +597,61 @@ const getMonthlySummary = async (year, month, callback) => { const updateById = async (id, updateData, callback) => { try { const db = await getDb(); - + const setFields = []; const values = []; - + if (updateData.work_hours !== undefined) { setFields.push('work_hours = ?'); values.push(updateData.work_hours); } - + if (updateData.work_status_id !== undefined) { setFields.push('work_status_id = ?'); values.push(updateData.work_status_id); } - + if (updateData.error_type_id !== undefined) { setFields.push('error_type_id = ?'); values.push(updateData.error_type_id); } - + if (updateData.project_id !== undefined) { setFields.push('project_id = ?'); values.push(updateData.project_id); } - + if (updateData.work_type_id !== undefined) { setFields.push('work_type_id = ?'); values.push(updateData.work_type_id); } - + setFields.push('updated_at = NOW()'); - + if (updateData.updated_by) { setFields.push('updated_by = ?'); values.push(updateData.updated_by); } - + values.push(id); - + const sql = `UPDATE daily_work_reports SET ${setFields.join(', ')} WHERE id = ?`; const [result] = await db.query(sql, values); - + + + + // [Sync] 근태 기록 동기화 + try { + const [targetReport] = await db.query('SELECT worker_id, report_date FROM daily_work_reports WHERE id = ?', [id]); + if (targetReport.length > 0) { + const { worker_id, report_date } = targetReport[0]; + const AttendanceModel = require('./attendanceModel'); + await AttendanceModel.syncWithWorkReports(worker_id, report_date); + } + } catch (syncErr) { + console.error('근태 기록 동기화 실패 (Update):', syncErr); + } + callback(null, result.affectedRows); } catch (err) { console.error('작업보고서 수정 오류:', err); @@ -642,16 +665,16 @@ const updateById = async (id, updateData, callback) => { const removeById = async (id, deletedBy, callback) => { const db = await getDb(); const conn = await db.getConnection(); - + try { await conn.beginTransaction(); - + // 삭제 전 정보 저장 (감사 로그용) const [reportInfo] = await conn.query('SELECT * FROM daily_work_reports WHERE id = ?', [id]); - + // 작업보고서 삭제 const [result] = await conn.query('DELETE FROM daily_work_reports WHERE id = ?', [id]); - + // 감사 로그 추가 if (reportInfo.length > 0 && deletedBy) { try { @@ -665,8 +688,21 @@ const removeById = async (id, deletedBy, callback) => { console.warn('감사 로그 추가 실패:', auditErr.message); } } - + await conn.commit(); + + + // [Sync] 근태 기록 동기화 + if (reportInfo.length > 0) { + try { + const { worker_id, report_date } = reportInfo[0]; + const AttendanceModel = require('./attendanceModel'); + await AttendanceModel.syncWithWorkReports(worker_id, report_date); + } catch (syncErr) { + console.error('근태 기록 동기화 실패 (Delete):', syncErr); + } + } + callback(null, result.affectedRows); } catch (err) { await conn.rollback(); @@ -683,22 +719,22 @@ const removeById = async (id, deletedBy, callback) => { const removeByDateAndWorker = async (date, worker_id, deletedBy, callback) => { const db = await getDb(); const conn = await db.getConnection(); - + try { await conn.beginTransaction(); - + // 삭제 전 정보 저장 (감사 로그용) const [reportInfos] = await conn.query( - 'SELECT id, report_date, worker_id, project_id, work_type_id, work_status_id, error_type_id, work_hours, created_at, updated_at, created_by, updated_by FROM daily_work_reports WHERE report_date = ? AND worker_id = ?', + 'SELECT id, report_date, worker_id, project_id, work_type_id, work_status_id, error_type_id, work_hours, created_at, updated_at, created_by, updated_by FROM daily_work_reports WHERE report_date = ? AND worker_id = ?', [date, worker_id] ); - + // 작업보고서 삭제 const [result] = await conn.query( 'DELETE FROM daily_work_reports WHERE report_date = ? AND worker_id = ?', [date, worker_id] ); - + // 감사 로그 추가 if (reportInfos.length > 0 && deletedBy) { try { @@ -712,8 +748,18 @@ const removeByDateAndWorker = async (date, worker_id, deletedBy, callback) => { console.warn('감사 로그 추가 실패:', auditErr.message); } } - + await conn.commit(); + + + // [Sync] 근태 기록 동기화 + try { + const AttendanceModel = require('./attendanceModel'); + await AttendanceModel.syncWithWorkReports(worker_id, date); + } catch (syncErr) { + console.error('근태 기록 동기화 실패 (Batch Delete):', syncErr); + } + callback(null, result.affectedRows); } catch (err) { await conn.rollback(); @@ -730,7 +776,7 @@ const removeByDateAndWorker = async (date, worker_id, deletedBy, callback) => { const getStatistics = async (start_date, end_date) => { try { const db = await getDb(); - + const overallSql = ` SELECT COUNT(*) as total_reports, @@ -741,7 +787,7 @@ const getStatistics = async (start_date, end_date) => { WHERE report_date BETWEEN ? AND ? `; const [overallRows] = await db.query(overallSql, [start_date, end_date]); - + const dailyStatsSql = ` SELECT report_date, @@ -753,7 +799,7 @@ const getStatistics = async (start_date, end_date) => { ORDER BY report_date DESC `; const [dailyStats] = await db.query(dailyStatsSql, [start_date, end_date]); - + return { overall: overallRows[0], daily_breakdown: dailyStats @@ -798,7 +844,17 @@ const createReportEntries = async ({ report_date, worker_id, entries }) => { } await conn.commit(); - + + + + // [Sync] 근태 기록 동기화 + try { + const AttendanceModel = require('./attendanceModel'); + await AttendanceModel.syncWithWorkReports(worker_id, report_date); + } catch (syncErr) { + console.error('근태 기록 동기화 실패 (V2 Create):', syncErr); + } + console.log(`[Model] ${insertedIds.length}개 작업 항목 생성 완료.`); return { inserted_ids: insertedIds, @@ -863,7 +919,7 @@ const getReportsWithOptions = async (options) => { whereConditions.push('dwr.report_date BETWEEN ? AND ?'); queryParams.push(options.start_date, options.end_date); } - + if (options.worker_id) { whereConditions.push('dwr.worker_id = ?'); queryParams.push(options.worker_id); @@ -898,7 +954,7 @@ const getReportsWithOptions = async (options) => { */ const updateReportById = async (reportId, updateData) => { const db = await getDb(); - + // 허용된 필드 목록 (보안 및 안정성) - 실제 테이블 컬럼명 사용 const allowedFields = ['project_id', 'work_type_id', 'work_hours', 'work_status_id', 'error_type_id']; const setClauses = []; @@ -913,8 +969,8 @@ const updateReportById = async (reportId, updateData) => { // updated_by_user_id는 항상 업데이트 if (updateData.updated_by_user_id) { - setClauses.push('updated_by_user_id = ?'); - queryParams.push(updateData.updated_by_user_id); + setClauses.push('updated_by_user_id = ?'); + queryParams.push(updateData.updated_by_user_id); } if (setClauses.length === 0) { @@ -923,10 +979,28 @@ const updateReportById = async (reportId, updateData) => { queryParams.push(reportId); + // [Sync] 업데이트 전 정보 조회 (동기화를 위해) + let targetInfo = null; + try { + const [rows] = await db.query('SELECT worker_id, report_date FROM daily_work_reports WHERE id = ?', [reportId]); + if (rows.length > 0) targetInfo = rows[0]; + } catch (e) { console.warn('Sync fetch failed', e); } + const sql = `UPDATE daily_work_reports SET ${setClauses.join(', ')} WHERE id = ?`; try { const [result] = await db.query(sql, queryParams); + + // [Sync] 근태 기록 동기화 + if (targetInfo) { + try { + const AttendanceModel = require('./attendanceModel'); + await AttendanceModel.syncWithWorkReports(targetInfo.worker_id, targetInfo.report_date); + } catch (syncErr) { + console.error('근태 기록 동기화 실패 (V2 Update):', syncErr); + } + } + return result.affectedRows; } catch (err) { console.error(`[Model] 작업 보고서 수정 오류 (id: ${reportId}):`, err); @@ -943,22 +1017,34 @@ const updateReportById = async (reportId, updateData) => { const removeReportById = async (reportId, deletedByUserId) => { const db = await getDb(); const conn = await db.getConnection(); - + try { await conn.beginTransaction(); - + // 감사 로그를 위해 삭제 전 정보 조회 const [reportInfo] = await conn.query('SELECT id, report_date, worker_id, project_id, work_type_id, work_status_id, error_type_id, work_hours, created_at, updated_at, created_by, updated_by FROM daily_work_reports WHERE id = ?', [reportId]); - + // 실제 삭제 작업 const [result] = await conn.query('DELETE FROM daily_work_reports WHERE id = ?', [reportId]); - + // 감사 로그 (삭제된 테이블이므로 콘솔 로그로 대체) if (reportInfo.length > 0 && deletedByUserId) { console.log(`[삭제 로그] 보고서 ID: ${reportId}, 삭제자: ${deletedByUserId}, 사유: Manual deletion by user`); } - + await conn.commit(); + + // [Sync] 근태 기록 동기화 + if (reportInfo.length > 0) { + try { + const { worker_id, report_date } = reportInfo[0]; + const AttendanceModel = require('./attendanceModel'); + await AttendanceModel.syncWithWorkReports(worker_id, report_date); + } catch (syncErr) { + console.error('근태 기록 동기화 실패 (V2 Delete):', syncErr); + } + } + return result.affectedRows; } catch (err) { @@ -1141,7 +1227,7 @@ module.exports = { getAllWorkTypes, getAllWorkStatusTypes, getAllErrorTypes, - + // 마스터 데이터 CRUD createWorkType, updateWorkType, diff --git a/api.hyungi.net/services/attendanceService.js b/api.hyungi.net/services/attendanceService.js index fe91a2f..2795692 100644 --- a/api.hyungi.net/services/attendanceService.js +++ b/api.hyungi.net/services/attendanceService.js @@ -25,6 +25,12 @@ const getDailyAttendanceStatusService = async (date) => { logger.info('일일 근태 현황 조회 요청', { date }); try { + // 조회 전 초기화 수행 (Lazy Initialization) + // 생성자는 시스템(1) 또는 요청자가 될 수 있으나, 여기서는 안전하게 1(System/Admin) 사용 + // 혹은 req.user가 없으므로 서비스 레벨에서는 1로 가정하거나 파라미터로 받아야 함. + // 서비스 인터페이스 변경 최소화를 위해 하드코딩 또는 안전장치. + await AttendanceModel.initializeDailyRecords(date, 1); + const attendanceStatus = await AttendanceModel.getWorkerAttendanceStatus(date); logger.info('일일 근태 현황 조회 성공', { date, count: attendanceStatus.length }); return attendanceStatus; diff --git a/web-ui/css/modern-dashboard.css b/web-ui/css/modern-dashboard.css index 7d40fa3..be06295 100644 --- a/web-ui/css/modern-dashboard.css +++ b/web-ui/css/modern-dashboard.css @@ -1896,3 +1896,99 @@ font-size: 0.75rem; } } + +/* ========== 근태 현황 그리드 (Added dynamically) ========== */ +.status-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 1rem; + padding: 1rem; +} + +.worker-card { + background: white; + border-radius: 0.75rem; + padding: 1rem; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); + border: 1px solid #e2e8f0; + display: flex; + flex-direction: column; + gap: 0.75rem; + position: relative; + overflow: hidden; + transition: all 0.2s ease; +} + +.worker-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); +} + +.worker-card.status-alert { + background-color: #fef2f2; +} + +.worker-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0; +} + +.worker-name { + font-weight: 600; + color: #1f2937; + font-size: 1rem; +} + +.worker-job { + font-size: 0.75rem; + color: #6b7280; + background: #f3f4f6; + padding: 2px 6px; + border-radius: 4px; +} + +.worker-body { + display: flex; + justify-content: space-between; + align-items: center; +} + +.status-badge { + font-size: 0.8rem; + padding: 4px 8px; + border-radius: 6px; + font-weight: 500; + display: flex; + align-items: center; + gap: 4px; +} + +.work-hours { + font-weight: 600; + font-size: 0.9rem; + color: #374151; +} + +.worker-footer { + margin-top: auto; + padding-top: 0.5rem; + border-top: 1px dashed #e5e7eb; +} + +.alert-text { + color: #ef4444; + font-size: 0.75rem; + font-weight: 600; + display: flex; + align-items: center; + gap: 0.25rem; +} + +.empty-state { + text-align: center; + padding: 3rem; + color: #6b7280; + width: 100%; +} diff --git a/web-ui/js/group-leader-dashboard.js b/web-ui/js/group-leader-dashboard.js index 1fa0292..0a78a85 100644 --- a/web-ui/js/group-leader-dashboard.js +++ b/web-ui/js/group-leader-dashboard.js @@ -1,103 +1,174 @@ // /js/group-leader-dashboard.js -// 그룹장 전용 대시보드 기능 +// 그룹장 전용 대시보드 - 실시간 근태 및 작업 현황 (Real Data Version) -console.log('📊 그룹장 대시보드 스크립트 로딩'); +console.log('📊 그룹장 대시보드 스크립트 로딩 (Live Data)'); + +// 상태별 스타일/텍스트 매핑 +const STATUS_MAP = { + 'incomplete': { text: '미제출', class: 'status-incomplete', icon: '❌', color: '#ff5252' }, + 'partial': { text: '작성중', class: 'status-warning', icon: '📝', color: '#ff9800' }, + 'complete': { text: '제출완료', class: 'status-success', icon: '✅', color: '#4caf50' }, + 'overtime': { text: '초과근무', class: 'status-info', icon: '🌙', color: '#673ab7' }, + 'vacation': { text: '휴가', class: 'status-vacation', icon: '🏖️', color: '#2196f3' } +}; + +// 현재 선택된 날짜 +let currentSelectedDate = new Date().toISOString().split('T')[0]; + +/** + * 📅 날짜 초기화 및 이벤트 리스너 등록 + */ +function initDateSelector() { + const dateInput = document.getElementById('selectedDate'); + const refreshBtn = document.getElementById('refreshBtn'); + + if (dateInput) { + dateInput.value = currentSelectedDate; + dateInput.addEventListener('change', (e) => { + currentSelectedDate = e.target.value; + loadDailyWorkStatus(); + }); + } + + if (refreshBtn) { + refreshBtn.addEventListener('click', () => { + loadDailyWorkStatus(); + showToast('데이터를 새로고침했습니다.', 'success'); + }); + } +} + +/** + * 🔄 일일 근태 현황 로드 (API 호출) + */ +async function loadDailyWorkStatus() { + const container = document.getElementById('workStatusContainer'); + if (!container) return; + + // 로딩 표시 + container.innerHTML = ` +
작업 현황을 불러오는 중...
+⚠️ 데이터를 불러오는데 실패했습니다.
+