diff --git a/api.hyungi.net/controllers/attendanceController.js b/api.hyungi.net/controllers/attendanceController.js new file mode 100644 index 0000000..2dece5b --- /dev/null +++ b/api.hyungi.net/controllers/attendanceController.js @@ -0,0 +1,306 @@ +const AttendanceModel = require('../models/attendanceModel'); + +class AttendanceController { + // 일일 근태 현황 조회 (대시보드용) + static async getDailyAttendanceStatus(req, res) { + try { + const { date } = req.query; + + if (!date) { + return res.status(400).json({ + success: false, + message: '날짜가 필요합니다.' + }); + } + + const attendanceStatus = await AttendanceModel.getWorkerAttendanceStatus(date); + + res.json({ + success: true, + data: attendanceStatus, + message: '근태 현황을 성공적으로 조회했습니다.' + }); + } catch (error) { + console.error('근태 현황 조회 오류:', error); + res.status(500).json({ + success: false, + message: '근태 현황 조회 중 오류가 발생했습니다.', + error: error.message + }); + } + } + + // 일일 근태 기록 조회 + static async getDailyAttendanceRecords(req, res) { + try { + const { date, worker_id } = req.query; + + if (!date) { + return res.status(400).json({ + success: false, + message: '날짜가 필요합니다.' + }); + } + + const records = await AttendanceModel.getDailyAttendanceRecords(date, worker_id); + + res.json({ + success: true, + data: records, + message: '근태 기록을 성공적으로 조회했습니다.' + }); + } catch (error) { + console.error('근태 기록 조회 오류:', error); + res.status(500).json({ + success: false, + message: '근태 기록 조회 중 오류가 발생했습니다.', + error: error.message + }); + } + } + + // 근태 기록 생성/업데이트 + static async upsertAttendanceRecord(req, res) { + try { + const { + record_date, + worker_id, + total_work_hours, + attendance_type_id, + vacation_type_id, + is_vacation_processed, + overtime_approved, + status, + notes + } = req.body; + + // 필수 필드 검증 + if (!record_date || !worker_id) { + return res.status(400).json({ + success: false, + message: '날짜와 작업자 ID가 필요합니다.' + }); + } + + const recordData = { + record_date, + worker_id, + total_work_hours: total_work_hours || 0, + attendance_type_id, + vacation_type_id, + is_vacation_processed: is_vacation_processed || false, + overtime_approved: overtime_approved || false, + status: status || 'incomplete', + notes, + created_by: req.user.user_id + }; + + const result = await AttendanceModel.upsertAttendanceRecord(recordData); + + res.json({ + success: true, + data: result, + message: '근태 기록이 성공적으로 저장되었습니다.' + }); + } catch (error) { + console.error('근태 기록 저장 오류:', error); + res.status(500).json({ + success: false, + message: '근태 기록 저장 중 오류가 발생했습니다.', + error: error.message + }); + } + } + + // 휴가 처리 + static async processVacation(req, res) { + try { + const { worker_id, date, vacation_type } = req.body; + + // 필수 필드 검증 + if (!worker_id || !date || !vacation_type) { + return res.status(400).json({ + success: false, + message: '작업자 ID, 날짜, 휴가 유형이 필요합니다.' + }); + } + + // 휴가 유형 검증 + const validVacationTypes = ['ANNUAL_FULL', 'ANNUAL_HALF', 'ANNUAL_QUARTER']; + if (!validVacationTypes.includes(vacation_type)) { + return res.status(400).json({ + success: false, + message: '유효하지 않은 휴가 유형입니다.' + }); + } + + const result = await AttendanceModel.processVacation( + worker_id, + date, + vacation_type, + req.user.user_id + ); + + res.json({ + success: true, + data: result, + message: '휴가 처리가 성공적으로 완료되었습니다.' + }); + } catch (error) { + console.error('휴가 처리 오류:', error); + res.status(500).json({ + success: false, + message: '휴가 처리 중 오류가 발생했습니다.', + error: error.message + }); + } + } + + // 초과근무 승인 + static async approveOvertime(req, res) { + try { + const { worker_id, date } = req.body; + + // 필수 필드 검증 + if (!worker_id || !date) { + return res.status(400).json({ + success: false, + message: '작업자 ID와 날짜가 필요합니다.' + }); + } + + const result = await AttendanceModel.approveOvertime( + worker_id, + date, + req.user.user_id + ); + + if (result) { + res.json({ + success: true, + message: '초과근무가 성공적으로 승인되었습니다.' + }); + } else { + res.status(404).json({ + success: false, + message: '해당 날짜의 근태 기록을 찾을 수 없습니다.' + }); + } + } catch (error) { + console.error('초과근무 승인 오류:', error); + res.status(500).json({ + success: false, + message: '초과근무 승인 중 오류가 발생했습니다.', + error: error.message + }); + } + } + + // 근로 유형 목록 조회 + static async getAttendanceTypes(req, res) { + try { + const attendanceTypes = await AttendanceModel.getAttendanceTypes(); + + res.json({ + success: true, + data: attendanceTypes, + message: '근로 유형 목록을 성공적으로 조회했습니다.' + }); + } catch (error) { + console.error('근로 유형 조회 오류:', error); + res.status(500).json({ + success: false, + message: '근로 유형 조회 중 오류가 발생했습니다.', + error: error.message + }); + } + } + + // 휴가 유형 목록 조회 + static async getVacationTypes(req, res) { + try { + const vacationTypes = await AttendanceModel.getVacationTypes(); + + res.json({ + success: true, + data: vacationTypes, + message: '휴가 유형 목록을 성공적으로 조회했습니다.' + }); + } catch (error) { + console.error('휴가 유형 조회 오류:', error); + res.status(500).json({ + success: false, + message: '휴가 유형 조회 중 오류가 발생했습니다.', + error: error.message + }); + } + } + + // 작업자 휴가 잔여 조회 + static async getWorkerVacationBalance(req, res) { + try { + const { worker_id } = req.params; + const { year } = req.query; + + if (!worker_id) { + return res.status(400).json({ + success: false, + message: '작업자 ID가 필요합니다.' + }); + } + + const balance = await AttendanceModel.getWorkerVacationBalance( + parseInt(worker_id), + year ? parseInt(year) : null + ); + + res.json({ + success: true, + data: balance, + message: '휴가 잔여 정보를 성공적으로 조회했습니다.' + }); + } catch (error) { + console.error('휴가 잔여 조회 오류:', error); + res.status(500).json({ + success: false, + message: '휴가 잔여 조회 중 오류가 발생했습니다.', + error: error.message + }); + } + } + + // 월별 근태 통계 + static async getMonthlyAttendanceStats(req, res) { + try { + const { year, month, worker_id } = req.query; + + if (!year || !month) { + return res.status(400).json({ + success: false, + message: '연도와 월이 필요합니다.' + }); + } + + const stats = await AttendanceModel.getMonthlyAttendanceStats( + parseInt(year), + parseInt(month), + worker_id ? parseInt(worker_id) : null + ); + + res.json({ + success: true, + data: stats, + message: '월별 근태 통계를 성공적으로 조회했습니다.' + }); + } catch (error) { + console.error('월별 근태 통계 조회 오류:', error); + res.status(500).json({ + success: false, + message: '월별 근태 통계 조회 중 오류가 발생했습니다.', + error: error.message + }); + } + } +} + +module.exports = AttendanceController; diff --git a/api.hyungi.net/controllers/monthlyStatusController.js b/api.hyungi.net/controllers/monthlyStatusController.js new file mode 100644 index 0000000..21ff297 --- /dev/null +++ b/api.hyungi.net/controllers/monthlyStatusController.js @@ -0,0 +1,201 @@ +// controllers/monthlyStatusController.js +// 월별 작업자 상태 집계 컨트롤러 + +const MonthlyStatusModel = require('../models/monthlyStatusModel'); + +class MonthlyStatusController { + // 월별 캘린더 데이터 조회 + static async getMonthlyCalendarData(req, res) { + try { + const { year, month } = req.query; + + if (!year || !month) { + return res.status(400).json({ + success: false, + message: '연도(year)와 월(month)이 필요합니다.' + }); + } + + const yearNum = parseInt(year); + const monthNum = parseInt(month); + + if (yearNum < 2020 || yearNum > 2030 || monthNum < 1 || monthNum > 12) { + return res.status(400).json({ + success: false, + message: '유효하지 않은 연도 또는 월입니다.' + }); + } + + console.log(`📅 월별 캘린더 데이터 조회: ${year}년 ${month}월`); + + const summaryData = await MonthlyStatusModel.getMonthlySummary(yearNum, monthNum); + + // 날짜별 객체로 변환 (날짜 키를 YYYY-MM-DD 형식으로 변환) + const calendarData = {}; + summaryData.forEach(day => { + const dateKey = day.date.toISOString().split('T')[0]; // YYYY-MM-DD 형식으로 변환 + calendarData[dateKey] = { + totalWorkers: day.total_workers, + workingWorkers: day.working_workers, + hasIssues: day.has_issues, + hasErrors: day.has_errors, + hasOvertimeWarning: day.has_overtime_warning, + incompleteWorkers: day.incomplete_workers, + partialWorkers: day.partial_workers, + errorWorkers: day.error_workers, + overtimeWarningWorkers: day.overtime_warning_workers, + totalHours: parseFloat(day.total_work_hours || 0), + totalTasks: day.total_work_count, + errorCount: day.total_error_count, + lastUpdated: day.last_updated + }; + }); + + res.json({ + success: true, + data: calendarData, + message: `${year}년 ${month}월 캘린더 데이터를 성공적으로 조회했습니다.` + }); + + } catch (error) { + console.error('월별 캘린더 데이터 조회 오류:', error); + res.status(500).json({ + success: false, + message: '월별 캘린더 데이터 조회 중 오류가 발생했습니다.', + error: error.message + }); + } + } + + // 특정 날짜의 작업자별 상세 상태 조회 + static async getDailyWorkerDetails(req, res) { + try { + const { date } = req.query; + + if (!date) { + return res.status(400).json({ + success: false, + message: '날짜(date)가 필요합니다.' + }); + } + + console.log(`👥 일별 작업자 상세 조회: ${date}`); + + const workerDetails = await MonthlyStatusModel.getDailyWorkerStatus(date); + + // 프론트엔드에서 사용하기 쉽도록 데이터 변환 + const formattedData = workerDetails.map(worker => ({ + workerId: worker.worker_id, + workerName: worker.worker_name, + jobType: worker.job_type, + totalHours: parseFloat(worker.total_work_hours || 0), + actualWorkHours: parseFloat(worker.actual_work_hours || 0), + vacationHours: parseFloat(worker.vacation_hours || 0), + totalWorkCount: worker.total_work_count, + regularWorkCount: worker.regular_work_count, + errorWorkCount: worker.error_work_count, + status: worker.work_status, + hasVacation: worker.has_vacation, + hasError: worker.has_error, + hasIssues: worker.has_issues, + lastUpdated: worker.last_updated + })); + + // 요약 정보 계산 + const summary = { + totalWorkers: formattedData.length, + totalHours: formattedData.reduce((sum, w) => sum + w.totalHours, 0), + totalTasks: formattedData.reduce((sum, w) => sum + w.totalWorkCount, 0), + errorCount: formattedData.reduce((sum, w) => sum + w.errorWorkCount, 0) + }; + + res.json({ + success: true, + data: { + workers: formattedData, + summary + }, + message: `${date} 작업자 상세 정보를 성공적으로 조회했습니다.` + }); + + } catch (error) { + console.error('일별 작업자 상세 조회 오류:', error); + res.status(500).json({ + success: false, + message: '일별 작업자 상세 조회 중 오류가 발생했습니다.', + error: error.message + }); + } + } + + // 월별 집계 재계산 (관리자용) + static async recalculateMonth(req, res) { + try { + const { year, month } = req.body; + + if (!year || !month) { + return res.status(400).json({ + success: false, + message: '연도(year)와 월(month)이 필요합니다.' + }); + } + + // 관리자 권한 확인 + if (req.user.role !== 'admin' && req.user.role !== 'system') { + return res.status(403).json({ + success: false, + message: '관리자 권한이 필요합니다.' + }); + } + + console.log(`🔄 월별 집계 재계산 시작: ${year}년 ${month}월`); + + const result = await MonthlyStatusModel.recalculateMonth(parseInt(year), parseInt(month)); + + res.json({ + success: true, + data: result, + message: `${year}년 ${month}월 집계 재계산이 완료되었습니다.` + }); + + } catch (error) { + console.error('월별 집계 재계산 오류:', error); + res.status(500).json({ + success: false, + message: '월별 집계 재계산 중 오류가 발생했습니다.', + error: error.message + }); + } + } + + // 집계 테이블 상태 확인 (관리자용) + static async getStatusInfo(req, res) { + try { + // 관리자 권한 확인 + if (req.user.role !== 'admin' && req.user.role !== 'system') { + return res.status(403).json({ + success: false, + message: '관리자 권한이 필요합니다.' + }); + } + + const statusInfo = await MonthlyStatusModel.getStatusInfo(); + + res.json({ + success: true, + data: statusInfo, + message: '집계 테이블 상태 정보를 성공적으로 조회했습니다.' + }); + + } catch (error) { + console.error('집계 테이블 상태 확인 오류:', error); + res.status(500).json({ + success: false, + message: '집계 테이블 상태 확인 중 오류가 발생했습니다.', + error: error.message + }); + } + } +} + +module.exports = MonthlyStatusController; diff --git a/api.hyungi.net/create-attendance-tables.js b/api.hyungi.net/create-attendance-tables.js new file mode 100644 index 0000000..f9901b7 --- /dev/null +++ b/api.hyungi.net/create-attendance-tables.js @@ -0,0 +1,193 @@ +// 근태 관리 테이블 생성 스크립트 +const mysql = require('mysql2/promise'); + +async function createAttendanceTables() { + let connection; + + try { + // 로컬 MySQL 연결 (기본 설정) + connection = await mysql.createConnection({ + host: 'localhost', + user: 'root', + password: '', // 비밀번호가 있다면 여기에 입력 + database: 'hyungi' + }); + + console.log('✅ MySQL 연결 성공'); + + // 1. 근로 유형 테이블 생성 + console.log('📋 근로 유형 테이블 생성 중...'); + await connection.execute(` + CREATE TABLE IF NOT EXISTS work_attendance_types ( + id INT PRIMARY KEY AUTO_INCREMENT, + type_code VARCHAR(20) NOT NULL UNIQUE COMMENT '근로 유형 코드', + type_name VARCHAR(50) NOT NULL COMMENT '근로 유형명', + description TEXT COMMENT '설명', + is_active BOOLEAN DEFAULT TRUE COMMENT '활성 상태', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) COMMENT='근로 유형 관리 테이블' + `); + + // 2. 휴가 유형 테이블 생성 + console.log('🏖️ 휴가 유형 테이블 생성 중...'); + await connection.execute(` + CREATE TABLE IF NOT EXISTS vacation_types ( + id INT PRIMARY KEY AUTO_INCREMENT, + type_code VARCHAR(20) NOT NULL UNIQUE COMMENT '휴가 유형 코드', + type_name VARCHAR(50) NOT NULL COMMENT '휴가 유형명', + hours_deduction DECIMAL(4,2) NOT NULL COMMENT '차감 시간', + description TEXT COMMENT '설명', + is_active BOOLEAN DEFAULT TRUE COMMENT '활성 상태', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) COMMENT='휴가 유형 관리 테이블' + `); + + // 3. 일일 근태 기록 테이블 생성 + console.log('📊 일일 근태 기록 테이블 생성 중...'); + await connection.execute(` + CREATE TABLE IF NOT EXISTS daily_attendance_records ( + id INT PRIMARY KEY AUTO_INCREMENT, + record_date DATE NOT NULL COMMENT '기록 날짜', + worker_id INT NOT NULL COMMENT '작업자 ID', + total_work_hours DECIMAL(4,2) DEFAULT 0 COMMENT '총 작업 시간', + attendance_type_id INT COMMENT '근로 유형 ID', + vacation_type_id INT NULL COMMENT '휴가 유형 ID', + is_vacation_processed BOOLEAN DEFAULT FALSE COMMENT '휴가 처리 여부', + overtime_approved BOOLEAN DEFAULT FALSE COMMENT '초과근무 승인 여부', + overtime_approved_by INT NULL COMMENT '초과근무 승인자 ID', + overtime_approved_at TIMESTAMP NULL COMMENT '초과근무 승인 시간', + status ENUM('incomplete', 'partial', 'complete', 'overtime', 'vacation', 'error') DEFAULT 'incomplete' COMMENT '상태', + notes TEXT COMMENT '비고', + created_by INT NOT NULL DEFAULT 1 COMMENT '생성자 ID', + updated_by INT NULL COMMENT '수정자 ID', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + UNIQUE KEY unique_worker_date (worker_id, record_date), + INDEX idx_record_date (record_date), + INDEX idx_worker_date (worker_id, record_date), + INDEX idx_status (status) + ) COMMENT='일일 근태 기록 테이블' + `); + + // 4. 작업자 휴가 잔여 관리 테이블 생성 + console.log('📅 휴가 잔여 관리 테이블 생성 중...'); + await connection.execute(` + CREATE TABLE IF NOT EXISTS worker_vacation_balance ( + id INT PRIMARY KEY AUTO_INCREMENT, + worker_id INT NOT NULL COMMENT '작업자 ID', + year YEAR NOT NULL COMMENT '연도', + total_annual_leave DECIMAL(4,2) DEFAULT 15.0 COMMENT '연간 총 연차 (일)', + used_annual_leave DECIMAL(4,2) DEFAULT 0 COMMENT '사용한 연차 (일)', + remaining_annual_leave DECIMAL(4,2) GENERATED ALWAYS AS (total_annual_leave - used_annual_leave) STORED COMMENT '잔여 연차 (일)', + notes TEXT COMMENT '비고', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + UNIQUE KEY unique_worker_year (worker_id, year), + INDEX idx_worker_year (worker_id, year) + ) COMMENT='작업자별 휴가 잔여 관리 테이블' + `); + + // 5. 기본 데이터 삽입 + console.log('📝 기본 데이터 삽입 중...'); + + // 근로 유형 기본 데이터 + await connection.execute(` + INSERT IGNORE INTO work_attendance_types (type_code, type_name, description) VALUES + ('REGULAR', '정시근로', '8시간 정규 근무'), + ('OVERTIME', '연장근로', '8시간 초과 근무'), + ('PARTIAL', '부분근로', '8시간 미만 근무'), + ('VACATION', '휴가근로', '휴가와 함께하는 부분 근무') + `); + + // 휴가 유형 기본 데이터 + await connection.execute(` + INSERT IGNORE INTO vacation_types (type_code, type_name, hours_deduction, description) VALUES + ('ANNUAL_FULL', '연차', 8.0, '하루 전체 연차'), + ('ANNUAL_HALF', '반차', 4.0, '반일 연차'), + ('ANNUAL_QUARTER', '반반차', 2.0, '1/4일 연차'), + ('SICK_FULL', '병가', 8.0, '하루 전체 병가'), + ('SICK_HALF', '반일병가', 4.0, '반일 병가'), + ('PERSONAL_FULL', '개인사유', 8.0, '개인사유로 인한 휴가'), + ('PERSONAL_HALF', '반일개인사유', 4.0, '반일 개인사유 휴가') + `); + + // 6. 휴가 전용 작업 유형 추가 + console.log('🏖️ 휴가 전용 작업 유형 추가 중...'); + await connection.execute(` + INSERT IGNORE INTO work_types (name, description, is_active) VALUES + ('휴가', '연차, 반차, 병가 등 휴가 처리용', TRUE) + `); + + // 7. daily_work_reports 테이블에 근태 기록 연결 컬럼 추가 (이미 있으면 무시) + try { + await connection.execute(` + ALTER TABLE daily_work_reports + ADD COLUMN attendance_record_id INT NULL COMMENT '근태 기록 ID' AFTER updated_by + `); + console.log('✅ daily_work_reports 테이블에 attendance_record_id 컬럼 추가됨'); + } catch (error) { + if (error.code !== 'ER_DUP_FIELDNAME') { + console.log('⚠️ attendance_record_id 컬럼 추가 실패:', error.message); + } else { + console.log('✅ attendance_record_id 컬럼이 이미 존재함'); + } + } + + // 8. 인덱스 추가 + try { + await connection.execute(`CREATE INDEX idx_attendance_record ON daily_work_reports(attendance_record_id)`); + console.log('✅ attendance_record_id 인덱스 추가됨'); + } catch (error) { + console.log('⚠️ 인덱스 추가 실패 (이미 존재할 수 있음):', error.message); + } + + console.log('🎉 근태 관리 DB 설정 완료!'); + console.log(''); + console.log('📋 생성된 테이블:'); + console.log(' - work_attendance_types (근로 유형)'); + console.log(' - vacation_types (휴가 유형)'); + console.log(' - daily_attendance_records (일일 근태 기록)'); + console.log(' - worker_vacation_balance (휴가 잔여 관리)'); + console.log(''); + console.log('✅ 기본 데이터도 모두 삽입되었습니다.'); + + } catch (error) { + console.error('❌ DB 설정 중 오류 발생:', error); + + // 다른 연결 정보로 시도 + if (error.code === 'ECONNREFUSED' || error.code === 'ER_ACCESS_DENIED_ERROR') { + console.log(''); + console.log('💡 다른 DB 연결 정보를 시도해보세요:'); + console.log(' - host: localhost 또는 127.0.0.1'); + console.log(' - port: 3306 (기본값)'); + console.log(' - user: root 또는 다른 사용자'); + console.log(' - password: 설정된 비밀번호'); + console.log(' - database: hyungi'); + } + + throw error; + } finally { + if (connection) { + await connection.end(); + } + } +} + +// 직접 실행 +if (require.main === module) { + createAttendanceTables() + .then(() => { + console.log('✅ 설정 완료'); + process.exit(0); + }) + .catch((error) => { + console.error('❌ 설정 실패:', error); + process.exit(1); + }); +} + +module.exports = { createAttendanceTables }; diff --git a/api.hyungi.net/index.js b/api.hyungi.net/index.js index 186c0a2..c7c099f 100644 --- a/api.hyungi.net/index.js +++ b/api.hyungi.net/index.js @@ -117,6 +117,11 @@ app.get('/api/ping', (req, res) => { }); }); +// ✅ 개발용 DB 설정 엔드포인트 (임시 비활성화) +// app.post('/api/setup-attendance-db', async (req, res) => { +// // DB 설정 로직 임시 비활성화 +// }); + // ✅ 서버 상태 엔드포인트 app.get('/api/status', (req, res) => { console.log('📊 Status 요청 받음!'); @@ -277,6 +282,9 @@ app.use('/api/auth/check-password-strength', loginLimiter); // 나머지 인증 라우트 app.use('/api/auth', authRoutes); +// 🔧 DB 설정 라우트 (개발용 - 인증 없이 접근 가능) +app.use('/api/setup', require('./routes/setupRoutes')); + // 🔒 일반 API 속도 제한 적용 app.use('/api/', apiLimiter); @@ -291,7 +299,14 @@ app.use('/api/*', (req, res, next) => { '/api/auth/check-password-strength', '/api/health', '/api/ping', // 개발용 핑 - '/api/status' // 서버 상태 + '/api/status', // 서버 상태 + '/api/setup/setup-attendance-db', + '/api/setup/setup-monthly-status', // DB 설정 (개발용) + '/api/setup/add-overtime-warning', // 12시간 초과 상태 추가 (개발용) + '/api/setup/migrate-existing-data', // 기존 데이터 마이그레이션 (개발용) + '/api/setup/check-data-status', // DB 상태 확인 (개발용) + '/api/monthly-status/calendar', // 월별 집계 테스트용 + '/api/monthly-status/daily-details' // 일별 상세 테스트용 ]; // 정확한 경로 매칭 확인 @@ -327,6 +342,8 @@ app.use('/api/daily-work-reports', dailyWorkReportRoutes); app.use('/api/work-analysis', workAnalysisRoutes); app.use('/api/analysis', analysisRoutes); // 새로운 분석 라우트 등록 app.use('/api/daily-work-reports-analysis', require('./routes/workReportAnalysisRoutes')); // 데일리 워크 레포트 분석 라우트 +app.use('/api/attendance', require('./routes/attendanceRoutes')); // 근태 관리 라우트 +app.use('/api/monthly-status', require('./routes/monthlyStatusRoutes')); // 월별 상태 집계 라우트 // 📊 리포트 및 분석 app.use('/api/workreports', workReportRoutes); diff --git a/api.hyungi.net/migrations/004_add_work_attendance_tracking.sql b/api.hyungi.net/migrations/004_add_work_attendance_tracking.sql new file mode 100644 index 0000000..d27de88 --- /dev/null +++ b/api.hyungi.net/migrations/004_add_work_attendance_tracking.sql @@ -0,0 +1,359 @@ +-- 근로 및 휴가 관리를 위한 테이블 확장 +-- 작성일: 2025-11-03 + +-- 1. 근로 유형 테이블 생성 +CREATE TABLE IF NOT EXISTS work_attendance_types ( + id INT PRIMARY KEY AUTO_INCREMENT, + type_code VARCHAR(20) NOT NULL UNIQUE COMMENT '근로 유형 코드', + type_name VARCHAR(50) NOT NULL COMMENT '근로 유형명', + description TEXT COMMENT '설명', + is_active BOOLEAN DEFAULT TRUE COMMENT '활성 상태', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) COMMENT='근로 유형 관리 테이블'; + +-- 2. 휴가 유형 테이블 생성 +CREATE TABLE IF NOT EXISTS vacation_types ( + id INT PRIMARY KEY AUTO_INCREMENT, + type_code VARCHAR(20) NOT NULL UNIQUE COMMENT '휴가 유형 코드', + type_name VARCHAR(50) NOT NULL COMMENT '휴가 유형명', + hours_deduction DECIMAL(4,2) NOT NULL COMMENT '차감 시간', + description TEXT COMMENT '설명', + is_active BOOLEAN DEFAULT TRUE COMMENT '활성 상태', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) COMMENT='휴가 유형 관리 테이블'; + +-- 3. 일일 근태 기록 테이블 생성 +CREATE TABLE IF NOT EXISTS daily_attendance_records ( + id INT PRIMARY KEY AUTO_INCREMENT, + record_date DATE NOT NULL COMMENT '기록 날짜', + worker_id INT NOT NULL COMMENT '작업자 ID', + total_work_hours DECIMAL(4,2) DEFAULT 0 COMMENT '총 작업 시간', + attendance_type_id INT COMMENT '근로 유형 ID', + vacation_type_id INT NULL COMMENT '휴가 유형 ID', + is_vacation_processed BOOLEAN DEFAULT FALSE COMMENT '휴가 처리 여부', + overtime_approved BOOLEAN DEFAULT FALSE COMMENT '초과근무 승인 여부', + overtime_approved_by INT NULL COMMENT '초과근무 승인자 ID', + overtime_approved_at TIMESTAMP NULL COMMENT '초과근무 승인 시간', + status ENUM('incomplete', 'partial', 'complete', 'overtime', 'vacation', 'error') DEFAULT 'incomplete' COMMENT '상태', + notes TEXT COMMENT '비고', + created_by INT NOT NULL COMMENT '생성자 ID', + updated_by INT NULL COMMENT '수정자 ID', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + -- 외래키 제약조건 + FOREIGN KEY (worker_id) REFERENCES workers(worker_id) ON DELETE CASCADE, + FOREIGN KEY (attendance_type_id) REFERENCES work_attendance_types(id), + FOREIGN KEY (vacation_type_id) REFERENCES vacation_types(id), + FOREIGN KEY (overtime_approved_by) REFERENCES users(user_id), + FOREIGN KEY (created_by) REFERENCES users(user_id), + FOREIGN KEY (updated_by) REFERENCES users(user_id), + + -- 유니크 제약조건 (작업자별 날짜별 하나의 기록) + UNIQUE KEY unique_worker_date (worker_id, record_date), + + -- 인덱스 + INDEX idx_record_date (record_date), + INDEX idx_worker_date (worker_id, record_date), + INDEX idx_status (status) +) COMMENT='일일 근태 기록 테이블'; + +-- 4. 휴가 잔여 관리 테이블 생성 +CREATE TABLE IF NOT EXISTS worker_vacation_balance ( + id INT PRIMARY KEY AUTO_INCREMENT, + worker_id INT NOT NULL COMMENT '작업자 ID', + year YEAR NOT NULL COMMENT '연도', + total_annual_leave DECIMAL(4,2) DEFAULT 15.0 COMMENT '연간 총 연차 (일)', + used_annual_leave DECIMAL(4,2) DEFAULT 0 COMMENT '사용한 연차 (일)', + remaining_annual_leave DECIMAL(4,2) GENERATED ALWAYS AS (total_annual_leave - used_annual_leave) STORED COMMENT '잔여 연차 (일)', + notes TEXT COMMENT '비고', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + -- 외래키 제약조건 + FOREIGN KEY (worker_id) REFERENCES workers(worker_id) ON DELETE CASCADE, + + -- 유니크 제약조건 (작업자별 연도별 하나의 기록) + UNIQUE KEY unique_worker_year (worker_id, year), + + -- 인덱스 + INDEX idx_worker_year (worker_id, year) +) COMMENT='작업자별 휴가 잔여 관리 테이블'; + +-- 5. 기본 데이터 삽입 + +-- 근로 유형 기본 데이터 +INSERT INTO work_attendance_types (type_code, type_name, description) VALUES +('REGULAR', '정시근로', '8시간 정규 근무'), +('OVERTIME', '연장근로', '8시간 초과 근무'), +('PARTIAL', '부분근로', '8시간 미만 근무'), +('VACATION', '휴가근로', '휴가와 함께하는 부분 근무') +ON DUPLICATE KEY UPDATE + type_name = VALUES(type_name), + description = VALUES(description); + +-- 휴가 유형 기본 데이터 +INSERT INTO vacation_types (type_code, type_name, hours_deduction, description) VALUES +('ANNUAL_FULL', '연차', 8.0, '하루 전체 연차'), +('ANNUAL_HALF', '반차', 4.0, '반일 연차'), +('ANNUAL_QUARTER', '반반차', 2.0, '1/4일 연차'), +('SICK_FULL', '병가', 8.0, '하루 전체 병가'), +('SICK_HALF', '반일병가', 4.0, '반일 병가'), +('PERSONAL_FULL', '개인사유', 8.0, '개인사유로 인한 휴가'), +('PERSONAL_HALF', '반일개인사유', 4.0, '반일 개인사유 휴가') +ON DUPLICATE KEY UPDATE + type_name = VALUES(type_name), + hours_deduction = VALUES(hours_deduction), + description = VALUES(description); + +-- 6. daily_work_reports 테이블에 근태 기록 연결 컬럼 추가 +ALTER TABLE daily_work_reports +ADD COLUMN attendance_record_id INT NULL COMMENT '근태 기록 ID' AFTER updated_by, +ADD INDEX idx_attendance_record (attendance_record_id); + +-- 외래키 제약조건 추가 (나중에 데이터 정리 후) +-- ALTER TABLE daily_work_reports +-- ADD FOREIGN KEY (attendance_record_id) REFERENCES daily_attendance_records(id); + +-- 7. 휴가 전용 작업 유형 추가 (work_types 테이블에) +INSERT INTO work_types (name, description, is_active) VALUES +('휴가', '연차, 반차, 병가 등 휴가 처리용', TRUE) +ON DUPLICATE KEY UPDATE + description = VALUES(description), + is_active = VALUES(is_active); + +-- 8. 뷰 생성 - 일일 근태 현황 조회용 +CREATE OR REPLACE VIEW v_daily_attendance_summary AS +SELECT + dar.id, + dar.record_date, + dar.worker_id, + w.worker_name, + w.job_type, + dar.total_work_hours, + wat.type_name as attendance_type, + vt.type_name as vacation_type, + dar.is_vacation_processed, + dar.overtime_approved, + dar.status, + CASE + WHEN dar.status = 'incomplete' THEN '미입력' + WHEN dar.status = 'partial' THEN '부분입력' + WHEN dar.status = 'complete' THEN '정시근로' + WHEN dar.status = 'overtime' THEN '연장근로' + WHEN dar.status = 'vacation' THEN '휴가' + WHEN dar.status = 'error' THEN '오류' + ELSE '알수없음' + END as status_text, + dar.notes, + dar.created_at, + dar.updated_at +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 +ORDER BY dar.record_date DESC, w.worker_name; + +-- 9. 트리거 생성 - daily_work_reports 변경 시 근태 기록 자동 업데이트 +DELIMITER // + +CREATE OR REPLACE TRIGGER tr_update_attendance_on_work_report +AFTER INSERT ON daily_work_reports +FOR EACH ROW +BEGIN + DECLARE total_hours DECIMAL(4,2); + DECLARE attendance_type INT; + DECLARE vacation_type INT; + DECLARE record_status VARCHAR(20); + DECLARE existing_record_id INT; + + -- 해당 작업자의 해당 날짜 총 작업시간 계산 + SELECT COALESCE(SUM(work_hours), 0) INTO total_hours + FROM daily_work_reports + WHERE worker_id = NEW.worker_id + AND report_date = NEW.report_date; + + -- 휴가 처리 여부 확인 (work_type_id가 휴가용인지) + SELECT id INTO vacation_type + FROM vacation_types + WHERE (total_hours = 0 AND type_code = 'ANNUAL_FULL') + OR (total_hours = 4 AND type_code = 'ANNUAL_HALF') + OR (total_hours = 6 AND type_code = 'ANNUAL_QUARTER') + LIMIT 1; + + -- 근로 유형 결정 + IF total_hours = 0 THEN + SELECT id INTO attendance_type FROM work_attendance_types WHERE type_code = 'PARTIAL'; + SET record_status = 'incomplete'; + ELSEIF total_hours < 8 THEN + SELECT id INTO attendance_type FROM work_attendance_types WHERE type_code = 'PARTIAL'; + SET record_status = 'partial'; + ELSEIF total_hours = 8 THEN + SELECT id INTO attendance_type FROM work_attendance_types WHERE type_code = 'REGULAR'; + SET record_status = 'complete'; + ELSE + SELECT id INTO attendance_type FROM work_attendance_types WHERE type_code = 'OVERTIME'; + SET record_status = 'overtime'; + END IF; + + -- 휴가 처리된 경우 상태 조정 + IF vacation_type IS NOT NULL THEN + SET record_status = 'vacation'; + END IF; + + -- 기존 근태 기록 확인 + SELECT id INTO existing_record_id + FROM daily_attendance_records + WHERE worker_id = NEW.worker_id AND record_date = NEW.report_date; + + -- 근태 기록 업데이트 또는 생성 + IF existing_record_id IS NOT NULL THEN + UPDATE daily_attendance_records + SET + total_work_hours = total_hours, + attendance_type_id = attendance_type, + vacation_type_id = vacation_type, + is_vacation_processed = (vacation_type IS NOT NULL), + status = record_status, + updated_by = NEW.created_by, + updated_at = CURRENT_TIMESTAMP + WHERE id = existing_record_id; + ELSE + INSERT INTO daily_attendance_records ( + record_date, worker_id, total_work_hours, attendance_type_id, + vacation_type_id, is_vacation_processed, status, created_by + ) VALUES ( + NEW.report_date, NEW.worker_id, total_hours, attendance_type, + vacation_type, (vacation_type IS NOT NULL), record_status, NEW.created_by + ); + END IF; +END// + +DELIMITER ; + +-- 10. 기존 데이터 마이그레이션을 위한 프로시저 +DELIMITER // + +CREATE OR REPLACE PROCEDURE sp_migrate_existing_work_reports() +BEGIN + DECLARE done INT DEFAULT FALSE; + DECLARE v_worker_id INT; + DECLARE v_report_date DATE; + DECLARE cur CURSOR FOR + SELECT DISTINCT worker_id, report_date + FROM daily_work_reports + ORDER BY report_date DESC, worker_id; + DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE; + + OPEN cur; + + read_loop: LOOP + FETCH cur INTO v_worker_id, v_report_date; + IF done THEN + LEAVE read_loop; + END IF; + + -- 각 작업자별 날짜별로 근태 기록 생성/업데이트 + CALL sp_update_attendance_record(v_worker_id, v_report_date); + END LOOP; + + CLOSE cur; +END// + +CREATE OR REPLACE PROCEDURE sp_update_attendance_record( + IN p_worker_id INT, + IN p_report_date DATE +) +BEGIN + DECLARE total_hours DECIMAL(4,2); + DECLARE attendance_type INT; + DECLARE vacation_type INT; + DECLARE record_status VARCHAR(20); + DECLARE existing_record_id INT; + DECLARE has_vacation_work INT DEFAULT 0; + + -- 해당 작업자의 해당 날짜 총 작업시간 계산 + SELECT COALESCE(SUM(work_hours), 0) INTO total_hours + FROM daily_work_reports + WHERE worker_id = p_worker_id + AND report_date = p_report_date; + + -- 휴가 관련 작업이 있는지 확인 (work_type_id = 999 또는 휴가 관련) + SELECT COUNT(*) INTO has_vacation_work + FROM daily_work_reports dwr + JOIN work_types wt ON dwr.work_type_id = wt.id + WHERE dwr.worker_id = p_worker_id + AND dwr.report_date = p_report_date + AND (wt.name LIKE '%휴가%' OR wt.name LIKE '%연차%' OR wt.name LIKE '%반차%'); + + -- 휴가 유형 결정 + IF has_vacation_work > 0 THEN + IF total_hours = 0 THEN + SELECT id INTO vacation_type FROM vacation_types WHERE type_code = 'ANNUAL_FULL' LIMIT 1; + ELSEIF total_hours = 4 THEN + SELECT id INTO vacation_type FROM vacation_types WHERE type_code = 'ANNUAL_HALF' LIMIT 1; + ELSEIF total_hours = 6 THEN + SELECT id INTO vacation_type FROM vacation_types WHERE type_code = 'ANNUAL_QUARTER' LIMIT 1; + END IF; + END IF; + + -- 근로 유형 및 상태 결정 + IF vacation_type IS NOT NULL THEN + SELECT id INTO attendance_type FROM work_attendance_types WHERE type_code = 'VACATION'; + SET record_status = 'vacation'; + ELSEIF total_hours = 0 THEN + SELECT id INTO attendance_type FROM work_attendance_types WHERE type_code = 'PARTIAL'; + SET record_status = 'incomplete'; + ELSEIF total_hours < 8 THEN + SELECT id INTO attendance_type FROM work_attendance_types WHERE type_code = 'PARTIAL'; + SET record_status = 'partial'; + ELSEIF total_hours = 8 THEN + SELECT id INTO attendance_type FROM work_attendance_types WHERE type_code = 'REGULAR'; + SET record_status = 'complete'; + ELSE + SELECT id INTO attendance_type FROM work_attendance_types WHERE type_code = 'OVERTIME'; + SET record_status = 'overtime'; + END IF; + + -- 기존 근태 기록 확인 + SELECT id INTO existing_record_id + FROM daily_attendance_records + WHERE worker_id = p_worker_id AND record_date = p_report_date; + + -- 근태 기록 업데이트 또는 생성 + IF existing_record_id IS NOT NULL THEN + UPDATE daily_attendance_records + SET + total_work_hours = total_hours, + attendance_type_id = attendance_type, + vacation_type_id = vacation_type, + is_vacation_processed = (vacation_type IS NOT NULL), + status = record_status, + updated_by = 1, + updated_at = CURRENT_TIMESTAMP + WHERE id = existing_record_id; + ELSE + INSERT INTO daily_attendance_records ( + record_date, worker_id, total_work_hours, attendance_type_id, + vacation_type_id, is_vacation_processed, status, created_by + ) VALUES ( + p_report_date, p_worker_id, total_hours, attendance_type, + vacation_type, (vacation_type IS NOT NULL), record_status, 1 + ); + END IF; +END// + +DELIMITER ; + +-- 11. 권한 및 인덱스 최적화 +-- 추가 인덱스 생성 +CREATE INDEX idx_daily_work_reports_worker_date ON daily_work_reports(worker_id, report_date); +CREATE INDEX idx_daily_work_reports_work_type ON daily_work_reports(work_type_id); + +-- 마이그레이션 실행 (주석 해제하여 실행) +-- CALL sp_migrate_existing_work_reports(); + +-- 완료 메시지 +SELECT 'DB 확장 완료: 근로 및 휴가 관리 시스템이 성공적으로 구축되었습니다.' as message; diff --git a/api.hyungi.net/migrations/005_add_attendance_management.sql b/api.hyungi.net/migrations/005_add_attendance_management.sql new file mode 100644 index 0000000..00905f2 --- /dev/null +++ b/api.hyungi.net/migrations/005_add_attendance_management.sql @@ -0,0 +1,143 @@ +-- 근태 관리 시스템 테이블 추가 +-- 작성일: 2025-11-03 +-- 설명: 근로 유형, 휴가 유형, 일일 근태 기록, 휴가 잔여 관리 테이블 + +-- 1. 근로 유형 테이블 생성 +CREATE TABLE IF NOT EXISTS `work_attendance_types` ( + `id` INT PRIMARY KEY AUTO_INCREMENT, + `type_code` VARCHAR(20) NOT NULL UNIQUE COMMENT '근로 유형 코드', + `type_name` VARCHAR(50) NOT NULL COMMENT '근로 유형명', + `description` TEXT COMMENT '설명', + `is_active` BOOLEAN DEFAULT TRUE COMMENT '활성 상태', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='근로 유형 관리 테이블'; + +-- 2. 휴가 유형 테이블 생성 +CREATE TABLE IF NOT EXISTS `vacation_types` ( + `id` INT PRIMARY KEY AUTO_INCREMENT, + `type_code` VARCHAR(20) NOT NULL UNIQUE COMMENT '휴가 유형 코드', + `type_name` VARCHAR(50) NOT NULL COMMENT '휴가 유형명', + `hours_deduction` DECIMAL(4,2) NOT NULL COMMENT '차감 시간', + `description` TEXT COMMENT '설명', + `is_active` BOOLEAN DEFAULT TRUE COMMENT '활성 상태', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='휴가 유형 관리 테이블'; + +-- 3. 일일 근태 기록 테이블 생성 +CREATE TABLE IF NOT EXISTS `daily_attendance_records` ( + `id` INT PRIMARY KEY AUTO_INCREMENT, + `record_date` DATE NOT NULL COMMENT '기록 날짜', + `worker_id` INT NOT NULL COMMENT '작업자 ID', + `total_work_hours` DECIMAL(4,2) DEFAULT 0 COMMENT '총 작업 시간', + `attendance_type_id` INT COMMENT '근로 유형 ID', + `vacation_type_id` INT NULL COMMENT '휴가 유형 ID', + `is_vacation_processed` BOOLEAN DEFAULT FALSE COMMENT '휴가 처리 여부', + `overtime_approved` BOOLEAN DEFAULT FALSE COMMENT '초과근무 승인 여부', + `overtime_approved_by` INT NULL COMMENT '초과근무 승인자 ID', + `overtime_approved_at` TIMESTAMP NULL COMMENT '초과근무 승인 시간', + `status` ENUM('incomplete', 'partial', 'complete', 'overtime', 'vacation', 'error') DEFAULT 'incomplete' COMMENT '상태', + `notes` TEXT COMMENT '비고', + `created_by` INT NOT NULL DEFAULT 1 COMMENT '생성자 ID', + `updated_by` INT NULL COMMENT '수정자 ID', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + -- 인덱스 + UNIQUE KEY `unique_worker_date` (`worker_id`, `record_date`), + INDEX `idx_record_date` (`record_date`), + INDEX `idx_worker_date` (`worker_id`, `record_date`), + INDEX `idx_status` (`status`), + + -- 외래키 (기존 테이블과의 관계) + FOREIGN KEY (`worker_id`) REFERENCES `workers`(`worker_id`) ON DELETE CASCADE, + FOREIGN KEY (`attendance_type_id`) REFERENCES `work_attendance_types`(`id`), + FOREIGN KEY (`vacation_type_id`) REFERENCES `vacation_types`(`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='일일 근태 기록 테이블'; + +-- 4. 작업자 휴가 잔여 관리 테이블 생성 +CREATE TABLE IF NOT EXISTS `worker_vacation_balance` ( + `id` INT PRIMARY KEY AUTO_INCREMENT, + `worker_id` INT NOT NULL COMMENT '작업자 ID', + `year` YEAR NOT NULL COMMENT '연도', + `total_annual_leave` DECIMAL(4,2) DEFAULT 15.0 COMMENT '연간 총 연차 (일)', + `used_annual_leave` DECIMAL(4,2) DEFAULT 0 COMMENT '사용한 연차 (일)', + `remaining_annual_leave` DECIMAL(4,2) GENERATED ALWAYS AS (`total_annual_leave` - `used_annual_leave`) STORED COMMENT '잔여 연차 (일)', + `notes` TEXT COMMENT '비고', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + -- 인덱스 + UNIQUE KEY `unique_worker_year` (`worker_id`, `year`), + INDEX `idx_worker_year` (`worker_id`, `year`), + + -- 외래키 + FOREIGN KEY (`worker_id`) REFERENCES `workers`(`worker_id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='작업자별 휴가 잔여 관리 테이블'; + +-- 5. daily_work_reports 테이블에 근태 기록 연결 컬럼 추가 +ALTER TABLE `daily_work_reports` +ADD COLUMN IF NOT EXISTS `attendance_record_id` INT NULL COMMENT '근태 기록 ID' AFTER `updated_by`; + +-- 인덱스 추가 +CREATE INDEX IF NOT EXISTS `idx_attendance_record` ON `daily_work_reports`(`attendance_record_id`); +CREATE INDEX IF NOT EXISTS `idx_daily_work_reports_worker_date` ON `daily_work_reports`(`worker_id`, `report_date`); + +-- 6. 기본 데이터 삽입 + +-- 근로 유형 기본 데이터 +INSERT IGNORE INTO `work_attendance_types` (`type_code`, `type_name`, `description`) VALUES +('REGULAR', '정시근로', '8시간 정규 근무'), +('OVERTIME', '연장근로', '8시간 초과 근무'), +('PARTIAL', '부분근로', '8시간 미만 근무'), +('VACATION', '휴가근로', '휴가와 함께하는 부분 근무'); + +-- 휴가 유형 기본 데이터 +INSERT IGNORE INTO `vacation_types` (`type_code`, `type_name`, `hours_deduction`, `description`) VALUES +('ANNUAL_FULL', '연차', 8.0, '하루 전체 연차'), +('ANNUAL_HALF', '반차', 4.0, '반일 연차'), +('ANNUAL_QUARTER', '반반차', 2.0, '1/4일 연차'), +('SICK_FULL', '병가', 8.0, '하루 전체 병가'), +('SICK_HALF', '반일병가', 4.0, '반일 병가'), +('PERSONAL_FULL', '개인사유', 8.0, '개인사유로 인한 휴가'), +('PERSONAL_HALF', '반일개인사유', 4.0, '반일 개인사유 휴가'); + +-- 7. 휴가 전용 작업 유형 추가 (이미 있으면 무시) +INSERT IGNORE INTO `work_types` (`name`, `description`, `is_active`) VALUES +('휴가', '연차, 반차, 병가 등 휴가 처리용', TRUE); + +-- 8. 뷰 생성 - 일일 근태 현황 조회용 +CREATE OR REPLACE VIEW `v_daily_attendance_summary` AS +SELECT + dar.id, + dar.record_date, + dar.worker_id, + w.worker_name, + w.job_type, + dar.total_work_hours, + wat.type_name as attendance_type, + vt.type_name as vacation_type, + dar.is_vacation_processed, + dar.overtime_approved, + dar.status, + CASE + WHEN dar.status = 'incomplete' THEN '미입력' + WHEN dar.status = 'partial' THEN '부분입력' + WHEN dar.status = 'complete' THEN '정시근로' + WHEN dar.status = 'overtime' THEN '연장근로' + WHEN dar.status = 'vacation' THEN '휴가' + WHEN dar.status = 'error' THEN '오류' + ELSE '알수없음' + END as status_text, + dar.notes, + dar.created_at, + dar.updated_at +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 +ORDER BY dar.record_date DESC, w.worker_name; + +-- 완료 메시지 +SELECT '✅ 근태 관리 시스템 테이블이 성공적으로 생성되었습니다.' as message; diff --git a/api.hyungi.net/migrations/006_add_description_column.sql b/api.hyungi.net/migrations/006_add_description_column.sql new file mode 100644 index 0000000..9626382 --- /dev/null +++ b/api.hyungi.net/migrations/006_add_description_column.sql @@ -0,0 +1,16 @@ +-- 006_add_description_column.sql +-- daily_work_reports 테이블에 description 컬럼 추가 + +-- description 컬럼 추가 (이미 존재하는 경우 무시) +SET @sql = (SELECT IF( + (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE table_name = 'daily_work_reports' + AND table_schema = 'hyungi' + AND column_name = 'description') = 0, + 'ALTER TABLE daily_work_reports ADD COLUMN description TEXT COMMENT ''작업 설명'' AFTER work_hours', + 'SELECT ''description column already exists'' as message' +)); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/api.hyungi.net/migrations/007_create_monthly_worker_status.sql b/api.hyungi.net/migrations/007_create_monthly_worker_status.sql new file mode 100644 index 0000000..c7a2aae --- /dev/null +++ b/api.hyungi.net/migrations/007_create_monthly_worker_status.sql @@ -0,0 +1,249 @@ +-- 007_create_monthly_worker_status.sql +-- 월별 작업자 상태 집계 테이블 생성 + +-- 월별 작업자 상태 집계 테이블 +CREATE TABLE IF NOT EXISTS monthly_worker_status ( + id INT PRIMARY KEY AUTO_INCREMENT, + year INT NOT NULL COMMENT '연도', + month INT NOT NULL COMMENT '월 (1-12)', + worker_id INT NOT NULL COMMENT '작업자 ID', + date DATE NOT NULL COMMENT '날짜', + + -- 작업 시간 정보 + total_work_hours DECIMAL(5,2) DEFAULT 0.00 COMMENT '총 작업시간', + actual_work_hours DECIMAL(5,2) DEFAULT 0.00 COMMENT '실제 작업시간 (휴가 제외)', + vacation_hours DECIMAL(5,2) DEFAULT 0.00 COMMENT '휴가 시간', + + -- 작업 건수 + total_work_count INT DEFAULT 0 COMMENT '총 작업 건수', + regular_work_count INT DEFAULT 0 COMMENT '정규 작업 건수', + error_work_count INT DEFAULT 0 COMMENT '오류 작업 건수', + + -- 상태 정보 + work_status ENUM( + 'incomplete', -- 미입력 (0시간) + 'partial', -- 부분입력 (8시간 미만) + 'complete', -- 정시근로 (8시간) + 'overtime', -- 연장근로 (8시간 초과) + 'vacation-full', -- 연차 (8시간) + 'vacation-half', -- 반차 (4시간) + 'vacation-quarter',-- 반반차 (2시간) + 'vacation-half-half', -- 조퇴 (6시간) + 'error', -- 오류 발생 + 'overtime-warning' -- 초과근무 확인필요 (12시간 초과) + ) NOT NULL DEFAULT 'incomplete' COMMENT '작업 상태', + + has_vacation BOOLEAN DEFAULT FALSE COMMENT '휴가 여부', + has_error BOOLEAN DEFAULT FALSE COMMENT '오류 여부', + has_issues BOOLEAN DEFAULT FALSE COMMENT '문제 여부 (미입력/부분입력)', + + -- 메타 정보 + last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '마지막 업데이트 시간', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + -- 인덱스 + UNIQUE KEY unique_worker_date (worker_id, date), + KEY idx_year_month (year, month), + KEY idx_worker_year_month (worker_id, year, month), + KEY idx_status (work_status), + KEY idx_has_issues (has_issues), + KEY idx_has_error (has_error), + + -- 외래키 + FOREIGN KEY (worker_id) REFERENCES workers(worker_id) ON DELETE CASCADE +) COMMENT='월별 작업자 상태 집계 테이블'; + +-- 월별 집계 요약 테이블 (캘린더 최적화용) +CREATE TABLE IF NOT EXISTS monthly_summary ( + id INT PRIMARY KEY AUTO_INCREMENT, + year INT NOT NULL COMMENT '연도', + month INT NOT NULL COMMENT '월 (1-12)', + date DATE NOT NULL COMMENT '날짜', + + -- 작업자 수 + total_workers INT DEFAULT 0 COMMENT '총 작업자 수', + working_workers INT DEFAULT 0 COMMENT '작업한 작업자 수', + + -- 상태별 작업자 수 + incomplete_workers INT DEFAULT 0 COMMENT '미입력 작업자 수', + partial_workers INT DEFAULT 0 COMMENT '부분입력 작업자 수', + complete_workers INT DEFAULT 0 COMMENT '완료 작업자 수', + overtime_workers INT DEFAULT 0 COMMENT '연장근로 작업자 수', + vacation_workers INT DEFAULT 0 COMMENT '휴가 작업자 수', + error_workers INT DEFAULT 0 COMMENT '오류 작업자 수', + + -- 집계 정보 + total_work_hours DECIMAL(8,2) DEFAULT 0.00 COMMENT '총 작업시간', + total_work_count INT DEFAULT 0 COMMENT '총 작업 건수', + total_error_count INT DEFAULT 0 COMMENT '총 오류 건수', + + -- 상태 플래그 (캘린더 표시용) + has_issues BOOLEAN DEFAULT FALSE COMMENT '문제 있음 (미입력/부분입력)', + has_errors BOOLEAN DEFAULT FALSE COMMENT '오류 있음', + + -- 메타 정보 + last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + -- 인덱스 + UNIQUE KEY unique_date (date), + KEY idx_year_month (year, month), + KEY idx_has_issues (has_issues), + KEY idx_has_errors (has_errors) +) COMMENT='월별 일자별 요약 테이블 (캘린더 최적화용)'; + +-- 집계 데이터 업데이트 함수 +DELIMITER $$ + +CREATE OR REPLACE PROCEDURE UpdateMonthlyWorkerStatus( + IN p_date DATE, + IN p_worker_id INT +) +BEGIN + DECLARE v_year INT; + DECLARE v_month INT; + DECLARE v_total_hours DECIMAL(5,2); + DECLARE v_actual_hours DECIMAL(5,2); + DECLARE v_vacation_hours DECIMAL(5,2); + DECLARE v_total_count INT; + DECLARE v_regular_count INT; + DECLARE v_error_count INT; + DECLARE v_has_vacation BOOLEAN; + DECLARE v_has_error BOOLEAN; + DECLARE v_has_issues BOOLEAN; + DECLARE v_status VARCHAR(20); + + -- 연도, 월 추출 + SET v_year = YEAR(p_date); + SET v_month = MONTH(p_date); + + -- 해당 날짜의 작업자 데이터 집계 + SELECT + COALESCE(SUM(work_hours), 0), + COALESCE(SUM(CASE WHEN project_id != 13 THEN work_hours ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN project_id = 13 THEN work_hours ELSE 0 END), 0), + COUNT(*), + COUNT(CASE WHEN project_id != 13 AND work_status_id != 2 THEN 1 END), + COUNT(CASE WHEN work_status_id = 2 THEN 1 END), + MAX(CASE WHEN project_id = 13 THEN 1 ELSE 0 END), + MAX(CASE WHEN work_status_id = 2 THEN 1 ELSE 0 END) + INTO + v_total_hours, v_actual_hours, v_vacation_hours, + v_total_count, v_regular_count, v_error_count, + v_has_vacation, v_has_error + FROM daily_work_reports + WHERE report_date = p_date AND worker_id = p_worker_id; + + -- 상태 결정 로직 + IF v_has_error THEN + SET v_status = 'error'; + SET v_has_issues = FALSE; + ELSEIF v_total_hours > 12 THEN + SET v_status = 'overtime-warning'; + SET v_has_issues = TRUE; + ELSEIF v_has_vacation AND v_vacation_hours > 0 THEN + -- 휴가 상태 결정 + CASE v_vacation_hours + WHEN 8 THEN SET v_status = 'vacation-full'; + WHEN 6 THEN SET v_status = 'vacation-half-half'; + WHEN 4 THEN SET v_status = 'vacation-half'; + WHEN 2 THEN SET v_status = 'vacation-quarter'; + ELSE SET v_status = 'vacation-full'; + END CASE; + SET v_has_issues = FALSE; + ELSEIF v_total_hours > 8 THEN + SET v_status = 'overtime'; + SET v_has_issues = FALSE; + ELSEIF v_total_hours = 8 THEN + SET v_status = 'complete'; + SET v_has_issues = FALSE; + ELSEIF v_total_hours > 0 THEN + SET v_status = 'partial'; + SET v_has_issues = TRUE; + ELSE + SET v_status = 'incomplete'; + SET v_has_issues = TRUE; + END IF; + + -- 데이터 업서트 + INSERT INTO monthly_worker_status ( + year, month, worker_id, date, + total_work_hours, actual_work_hours, vacation_hours, + total_work_count, regular_work_count, error_work_count, + work_status, has_vacation, has_error, has_issues + ) VALUES ( + v_year, v_month, p_worker_id, p_date, + v_total_hours, v_actual_hours, v_vacation_hours, + v_total_count, v_regular_count, v_error_count, + v_status, v_has_vacation, v_has_error, v_has_issues + ) ON DUPLICATE KEY UPDATE + total_work_hours = v_total_hours, + actual_work_hours = v_actual_hours, + vacation_hours = v_vacation_hours, + total_work_count = v_total_count, + regular_work_count = v_regular_count, + error_work_count = v_error_count, + work_status = v_status, + has_vacation = v_has_vacation, + has_error = v_has_error, + has_issues = v_has_issues, + last_updated = CURRENT_TIMESTAMP; + + -- 일별 요약도 업데이트 + CALL UpdateDailySummary(p_date); +END$$ + +-- 일별 요약 업데이트 함수 +CREATE OR REPLACE PROCEDURE UpdateDailySummary( + IN p_date DATE +) +BEGIN + DECLARE v_year INT; + DECLARE v_month INT; + + SET v_year = YEAR(p_date); + SET v_month = MONTH(p_date); + + INSERT INTO monthly_summary ( + year, month, date, + total_workers, working_workers, + incomplete_workers, partial_workers, complete_workers, + overtime_workers, vacation_workers, error_workers, + total_work_hours, total_work_count, total_error_count, + has_issues, has_errors + ) + SELECT + v_year, v_month, p_date, + COUNT(*) as total_workers, + COUNT(CASE WHEN work_status != 'incomplete' THEN 1 END) as working_workers, + COUNT(CASE WHEN work_status = 'incomplete' THEN 1 END) as incomplete_workers, + COUNT(CASE WHEN work_status = 'partial' THEN 1 END) as partial_workers, + COUNT(CASE WHEN work_status IN ('complete') THEN 1 END) as complete_workers, + COUNT(CASE WHEN work_status = 'overtime' THEN 1 END) as overtime_workers, + COUNT(CASE WHEN work_status LIKE 'vacation%' THEN 1 END) as vacation_workers, + COUNT(CASE WHEN work_status = 'error' THEN 1 END) as error_workers, + SUM(total_work_hours) as total_work_hours, + SUM(total_work_count) as total_work_count, + SUM(error_work_count) as total_error_count, + MAX(has_issues) as has_issues, + MAX(has_error) as has_errors + FROM monthly_worker_status + WHERE date = p_date + ON DUPLICATE KEY UPDATE + total_workers = VALUES(total_workers), + working_workers = VALUES(working_workers), + incomplete_workers = VALUES(incomplete_workers), + partial_workers = VALUES(partial_workers), + complete_workers = VALUES(complete_workers), + overtime_workers = VALUES(overtime_workers), + vacation_workers = VALUES(vacation_workers), + error_workers = VALUES(error_workers), + total_work_hours = VALUES(total_work_hours), + total_work_count = VALUES(total_work_count), + total_error_count = VALUES(total_error_count), + has_issues = VALUES(has_issues), + has_errors = VALUES(has_errors), + last_updated = CURRENT_TIMESTAMP; +END$$ + +DELIMITER ; diff --git a/api.hyungi.net/migrations/008_create_update_triggers.sql b/api.hyungi.net/migrations/008_create_update_triggers.sql new file mode 100644 index 0000000..7fca042 --- /dev/null +++ b/api.hyungi.net/migrations/008_create_update_triggers.sql @@ -0,0 +1,36 @@ +-- 008_create_update_triggers.sql +-- 작업보고서 변경 시 월별 집계 자동 업데이트 트리거 + +DELIMITER $$ + +-- 작업보고서 INSERT 트리거 +CREATE OR REPLACE TRIGGER tr_daily_work_reports_insert +AFTER INSERT ON daily_work_reports +FOR EACH ROW +BEGIN + CALL UpdateMonthlyWorkerStatus(NEW.report_date, NEW.worker_id); +END$$ + +-- 작업보고서 UPDATE 트리거 +CREATE OR REPLACE TRIGGER tr_daily_work_reports_update +AFTER UPDATE ON daily_work_reports +FOR EACH ROW +BEGIN + -- 기존 날짜 업데이트 + CALL UpdateMonthlyWorkerStatus(OLD.report_date, OLD.worker_id); + + -- 새 날짜가 다르면 새 날짜도 업데이트 + IF OLD.report_date != NEW.report_date OR OLD.worker_id != NEW.worker_id THEN + CALL UpdateMonthlyWorkerStatus(NEW.report_date, NEW.worker_id); + END IF; +END$$ + +-- 작업보고서 DELETE 트리거 +CREATE OR REPLACE TRIGGER tr_daily_work_reports_delete +AFTER DELETE ON daily_work_reports +FOR EACH ROW +BEGIN + CALL UpdateMonthlyWorkerStatus(OLD.report_date, OLD.worker_id); +END$$ + +DELIMITER ; diff --git a/api.hyungi.net/migrations/009_add_overtime_warning_columns.sql b/api.hyungi.net/migrations/009_add_overtime_warning_columns.sql new file mode 100644 index 0000000..7437c7d --- /dev/null +++ b/api.hyungi.net/migrations/009_add_overtime_warning_columns.sql @@ -0,0 +1,67 @@ +-- 009_add_overtime_warning_columns.sql +-- monthly_summary 테이블에 12시간 초과(확인필요) 상태 컬럼 추가 + +-- monthly_summary 테이블에 컬럼 추가 +ALTER TABLE monthly_summary +ADD COLUMN overtime_warning_workers INT DEFAULT 0 COMMENT '확인필요(12시간초과) 작업자 수' AFTER error_workers, +ADD COLUMN has_overtime_warning BOOLEAN DEFAULT FALSE COMMENT '확인필요 상태 있음' AFTER has_errors; + +-- UpdateDailySummary 프로시저 업데이트 +DROP PROCEDURE IF EXISTS UpdateDailySummary; +DELIMITER // +CREATE PROCEDURE UpdateDailySummary( + IN p_date DATE +) +BEGIN + DECLARE v_year INT; + DECLARE v_month INT; + + SET v_year = YEAR(p_date); + SET v_month = MONTH(p_date); + + INSERT INTO monthly_summary ( + year, month, date, + total_workers, working_workers, + incomplete_workers, partial_workers, complete_workers, + overtime_workers, vacation_workers, error_workers, overtime_warning_workers, + total_work_hours, total_work_count, total_error_count, + has_issues, has_errors, has_overtime_warning + ) + SELECT + v_year, v_month, p_date, + COUNT(*) as total_workers, + COUNT(CASE WHEN work_status != 'incomplete' THEN 1 END) as working_workers, + COUNT(CASE WHEN work_status = 'incomplete' THEN 1 END) as incomplete_workers, + COUNT(CASE WHEN work_status = 'partial' THEN 1 END) as partial_workers, + COUNT(CASE WHEN work_status IN ('complete', 'overtime', 'vacation-full', 'vacation-half', 'vacation-quarter', 'vacation-half-half') THEN 1 END) as complete_workers, + COUNT(CASE WHEN work_status = 'overtime' THEN 1 END) as overtime_workers, + COUNT(CASE WHEN work_status LIKE 'vacation%' THEN 1 END) as vacation_workers, + COUNT(CASE WHEN work_status = 'error' THEN 1 END) as error_workers, + COUNT(CASE WHEN work_status = 'overtime-warning' THEN 1 END) as overtime_warning_workers, + SUM(total_work_hours) as total_work_hours, + SUM(total_work_count) as total_work_count, + SUM(error_work_count) as total_error_count, + MAX(has_issues) as has_issues, + MAX(has_error) as has_errors, + MAX(CASE WHEN work_status = 'overtime-warning' THEN 1 ELSE 0 END) as has_overtime_warning + FROM monthly_worker_status + WHERE date = p_date + ON DUPLICATE KEY UPDATE + total_workers = VALUES(total_workers), + working_workers = VALUES(working_workers), + incomplete_workers = VALUES(incomplete_workers), + partial_workers = VALUES(partial_workers), + complete_workers = VALUES(complete_workers), + overtime_workers = VALUES(overtime_workers), + vacation_workers = VALUES(vacation_workers), + error_workers = VALUES(error_workers), + overtime_warning_workers = VALUES(overtime_warning_workers), + total_work_hours = VALUES(total_work_hours), + total_work_count = VALUES(total_work_count), + total_error_count = VALUES(total_error_count), + has_issues = VALUES(has_issues), + has_errors = VALUES(has_errors), + has_overtime_warning = VALUES(has_overtime_warning), + last_updated = CURRENT_TIMESTAMP; +END // +DELIMITER ; diff --git a/api.hyungi.net/models/attendanceModel.js b/api.hyungi.net/models/attendanceModel.js new file mode 100644 index 0000000..e34b2b5 --- /dev/null +++ b/api.hyungi.net/models/attendanceModel.js @@ -0,0 +1,303 @@ +const { getDb } = require('../dbPool'); + +class AttendanceModel { + // 일일 근태 기록 조회 + static async getDailyAttendanceRecords(date, 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.hours_deduction as vacation_hours + 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 = ? + `; + + 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 upsertAttendanceRecord(recordData) { + const db = await getDb(); + + const { + record_date, + worker_id, + total_work_hours, + work_attendance_type_id, + vacation_type_id, + is_overtime_approved, + created_by + } = recordData; + + // 기존 기록 확인 + const [existing] = await db.execute( + 'SELECT id FROM daily_attendance_records WHERE worker_id = ? AND record_date = ?', + [worker_id, record_date] + ); + + if (existing.length > 0) { + // 업데이트 + const [result] = await db.execute(` + UPDATE daily_attendance_records + SET + total_work_hours = ?, + work_attendance_type_id = ?, + vacation_type_id = ?, + is_overtime_approved = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `, [ + total_work_hours, + work_attendance_type_id, + vacation_type_id, + is_overtime_approved, + existing[0].id + ]); + + return { id: existing[0].id, affected: result.affectedRows }; + } else { + // 생성 + const [result] = await db.execute(` + INSERT INTO daily_attendance_records ( + record_date, worker_id, total_work_hours, work_attendance_type_id, + vacation_type_id, is_overtime_approved, created_by + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `, [ + record_date, + worker_id, + total_work_hours, + work_attendance_type_id, + vacation_type_id, + is_overtime_approved, + created_by + ]); + + return { id: result.insertId, affected: result.affectedRows }; + } + } + + // 작업자별 근태 현황 조회 (대시보드용) + static async getWorkerAttendanceStatus(date) { + const db = await getDb(); + + // 모든 작업자와 해당 날짜의 근태 기록을 조회 + const [rows] = await db.execute(` + SELECT + w.worker_id, + w.worker_name, + w.job_type, + COALESCE(dar.total_work_hours, 0) as total_work_hours, + COALESCE(dar.status, 'incomplete') as status, + dar.is_vacation_processed, + dar.overtime_approved, + 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, + dar.notes, + -- 작업 건수 계산 + (SELECT COUNT(*) FROM daily_work_reports dwr + WHERE dwr.worker_id = w.worker_id AND dwr.report_date = ?) as work_count, + -- 오류 건수 계산 + (SELECT COUNT(*) FROM daily_work_reports dwr + WHERE dwr.worker_id = w.worker_id AND dwr.report_date = ? AND dwr.work_status_id = 2) as error_count + FROM workers w + LEFT JOIN daily_attendance_records dar ON w.worker_id = dar.worker_id AND dar.record_date = ? + 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 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 * 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 ( + report_date, worker_id, project_id, work_type_id, work_status_id, + work_hours, description, created_by + ) VALUES (?, ?, 13, 1, 1, ?, ?, ?) + `, [ + date, + workerId, + vacationHours, + `${vacationTypeInfo.type_name} 처리`, + createdBy + ]); + + // 근태 기록 업데이트 + const attendanceData = { + record_date: date, + worker_id: workerId, + total_work_hours: totalHours, + work_attendance_type_id: attendanceTypes[0]?.id, + vacation_type_id: vacationTypeInfo.id, + 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 + overtime_approved = TRUE, + overtime_approved_by = ?, + overtime_approved_at = CURRENT_TIMESTAMP, + updated_by = ?, + updated_at = CURRENT_TIMESTAMP + WHERE worker_id = ? AND record_date = ? + `, [approvedBy, approvedBy, workerId, date]); + + return result.affectedRows > 0; + } + + // 근로 유형 목록 조회 + static async getAttendanceTypes() { + const db = await getDb(); + const [rows] = await db.execute( + 'SELECT * FROM work_attendance_types WHERE is_active = TRUE ORDER BY id' + ); + return rows; + } + + // 휴가 유형 목록 조회 + static async getVacationTypes() { + const db = await getDb(); + const [rows] = await db.execute( + 'SELECT * FROM vacation_types WHERE is_active = TRUE ORDER BY hours_deduction DESC' + ); + return rows; + } + + // 작업자 휴가 잔여 조회 + static async getWorkerVacationBalance(workerId, year = null) { + const db = await getDb(); + const currentYear = year || new Date().getFullYear(); + + const [rows] = await db.execute(` + SELECT * 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, + total_annual_leave: 15.0, + used_annual_leave: 0, + 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, + w.worker_name, + COUNT(CASE WHEN dar.status = 'complete' THEN 1 END) as regular_days, + COUNT(CASE WHEN dar.status = 'overtime' THEN 1 END) as overtime_days, + COUNT(CASE WHEN dar.status = 'vacation' THEN 1 END) as vacation_days, + COUNT(CASE WHEN dar.status = 'partial' THEN 1 END) as partial_days, + COUNT(CASE WHEN dar.status = 'incomplete' THEN 1 END) as incomplete_days, + SUM(dar.total_work_hours) as total_work_hours, + AVG(dar.total_work_hours) as avg_work_hours + FROM workers w + LEFT JOIN daily_attendance_records dar ON w.worker_id = dar.worker_id + 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; + } +} + +module.exports = AttendanceModel; diff --git a/api.hyungi.net/models/dailyWorkReportModel.js b/api.hyungi.net/models/dailyWorkReportModel.js index 4bb339a..6dbd25a 100644 --- a/api.hyungi.net/models/dailyWorkReportModel.js +++ b/api.hyungi.net/models/dailyWorkReportModel.js @@ -855,17 +855,22 @@ const getReportsWithOptions = async (options) => { let whereConditions = []; let queryParams = []; + // 날짜 조건 처리 (단일 날짜 또는 날짜 범위) if (options.date) { whereConditions.push('dwr.report_date = ?'); queryParams.push(options.date); + } else if (options.start_date && options.end_date) { + 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); } - if (options.created_by) { + if (options.created_by_user_id) { whereConditions.push('dwr.created_by = ?'); - queryParams.push(options.created_by); + queryParams.push(options.created_by_user_id); } // 필요에 따라 다른 조건 추가 가능 (project_id 등) diff --git a/api.hyungi.net/models/monthlyStatusModel.js b/api.hyungi.net/models/monthlyStatusModel.js new file mode 100644 index 0000000..5099a0d --- /dev/null +++ b/api.hyungi.net/models/monthlyStatusModel.js @@ -0,0 +1,156 @@ +// models/monthlyStatusModel.js +// 월별 작업자 상태 집계 모델 + +const { getDb } = require('../dbPool'); + +class MonthlyStatusModel { + // 월별 일자별 요약 조회 (캘린더용) + static async getMonthlySummary(year, month) { + const db = await getDb(); + + try { + const [rows] = await db.execute(` + SELECT + date, + total_workers, + working_workers, + incomplete_workers, + partial_workers, + complete_workers, + overtime_workers, + vacation_workers, + error_workers, + overtime_warning_workers, + total_work_hours, + total_work_count, + total_error_count, + has_issues, + has_errors, + has_overtime_warning, + last_updated + FROM monthly_summary + WHERE year = ? AND month = ? + ORDER BY date ASC + `, [year, month]); + + return rows; + } catch (error) { + console.error('월별 요약 조회 오류:', error); + throw error; + } + } + + // 특정 날짜의 작업자별 상태 조회 (모달용) + static async getDailyWorkerStatus(date) { + const db = await getDb(); + + try { + const [rows] = await db.execute(` + SELECT + mws.*, + w.worker_name, + w.job_type + FROM monthly_worker_status mws + JOIN workers w ON mws.worker_id = w.worker_id + WHERE mws.date = ? + ORDER BY w.worker_name ASC + `, [date]); + + return rows; + } catch (error) { + console.error('일별 작업자 상태 조회 오류:', error); + throw error; + } + } + + // 월별 집계 데이터 강제 재계산 (관리용) + static async recalculateMonth(year, month) { + const db = await getDb(); + + try { + // 해당 월의 모든 날짜와 작업자 조합을 찾아서 재계산 + const [workDates] = await db.execute(` + SELECT DISTINCT report_date, worker_id + FROM daily_work_reports + WHERE YEAR(report_date) = ? AND MONTH(report_date) = ? + `, [year, month]); + + let updatedCount = 0; + + for (const { report_date, worker_id } of workDates) { + await db.execute('CALL UpdateMonthlyWorkerStatus(?, ?)', [report_date, worker_id]); + updatedCount++; + } + + console.log(`✅ ${year}년 ${month}월 집계 재계산 완료: ${updatedCount}건`); + return { success: true, updatedCount }; + + } catch (error) { + console.error('월별 집계 재계산 오류:', error); + throw error; + } + } + + // 특정 날짜 집계 강제 업데이트 + static async updateDateSummary(date, workerId = null) { + const db = await getDb(); + + try { + if (workerId) { + // 특정 작업자만 업데이트 + await db.execute('CALL UpdateMonthlyWorkerStatus(?, ?)', [date, workerId]); + } else { + // 해당 날짜의 모든 작업자 업데이트 + const [workers] = await db.execute(` + SELECT DISTINCT worker_id + FROM daily_work_reports + WHERE report_date = ? + `, [date]); + + for (const { worker_id } of workers) { + await db.execute('CALL UpdateMonthlyWorkerStatus(?, ?)', [date, worker_id]); + } + } + + return { success: true }; + } catch (error) { + console.error('날짜별 집계 업데이트 오류:', error); + throw error; + } + } + + // 집계 테이블 상태 확인 + static async getStatusInfo() { + const db = await getDb(); + + try { + const [summaryCount] = await db.execute(` + SELECT + COUNT(*) as total_days, + MIN(date) as earliest_date, + MAX(date) as latest_date, + MAX(last_updated) as last_update + FROM monthly_summary + `); + + const [workerStatusCount] = await db.execute(` + SELECT + COUNT(*) as total_records, + COUNT(DISTINCT worker_id) as unique_workers, + COUNT(DISTINCT date) as unique_dates, + MAX(last_updated) as last_update + FROM monthly_worker_status + `); + + return { + summary: summaryCount[0], + workerStatus: workerStatusCount[0] + }; + } catch (error) { + console.error('집계 테이블 상태 확인 오류:', error); + throw error; + } + } +} + +module.exports = MonthlyStatusModel; diff --git a/api.hyungi.net/routes/attendanceRoutes.js b/api.hyungi.net/routes/attendanceRoutes.js new file mode 100644 index 0000000..dd29b57 --- /dev/null +++ b/api.hyungi.net/routes/attendanceRoutes.js @@ -0,0 +1,37 @@ +const express = require('express'); +const router = express.Router(); +const AttendanceController = require('../controllers/attendanceController'); +const { verifyToken } = require('../middlewares/authMiddleware'); + +// 모든 라우트에 인증 미들웨어 적용 +router.use(verifyToken); + +// 일일 근태 현황 조회 (대시보드용) +router.get('/daily-status', AttendanceController.getDailyAttendanceStatus); + +// 일일 근태 기록 조회 +router.get('/daily-records', AttendanceController.getDailyAttendanceRecords); + +// 근태 기록 생성/업데이트 +router.post('/records', AttendanceController.upsertAttendanceRecord); +router.put('/records', AttendanceController.upsertAttendanceRecord); + +// 휴가 처리 +router.post('/vacation', AttendanceController.processVacation); + +// 초과근무 승인 +router.post('/overtime/approve', AttendanceController.approveOvertime); + +// 근로 유형 목록 조회 +router.get('/attendance-types', AttendanceController.getAttendanceTypes); + +// 휴가 유형 목록 조회 +router.get('/vacation-types', AttendanceController.getVacationTypes); + +// 작업자 휴가 잔여 조회 +router.get('/vacation-balance/:worker_id', AttendanceController.getWorkerVacationBalance); + +// 월별 근태 통계 +router.get('/monthly-stats', AttendanceController.getMonthlyAttendanceStats); + +module.exports = router; diff --git a/api.hyungi.net/routes/monthlyStatusRoutes.js b/api.hyungi.net/routes/monthlyStatusRoutes.js new file mode 100644 index 0000000..56af931 --- /dev/null +++ b/api.hyungi.net/routes/monthlyStatusRoutes.js @@ -0,0 +1,24 @@ +// routes/monthlyStatusRoutes.js +// 월별 작업자 상태 집계 라우트 + +const express = require('express'); +const router = express.Router(); +const MonthlyStatusController = require('../controllers/monthlyStatusController'); +const { verifyToken } = require('../middlewares/authMiddleware'); + +// 모든 라우트에 인증 미들웨어 적용 (임시로 주석 처리 - 테스트용) +// router.use(verifyToken); + +// 월별 캘린더 데이터 조회 (캘린더 페이지용) +router.get('/calendar', MonthlyStatusController.getMonthlyCalendarData); + +// 특정 날짜의 작업자별 상세 상태 조회 (모달용) +router.get('/daily-details', MonthlyStatusController.getDailyWorkerDetails); + +// 월별 집계 재계산 (관리자용) +router.post('/recalculate', MonthlyStatusController.recalculateMonth); + +// 집계 테이블 상태 확인 (관리자용) +router.get('/status', MonthlyStatusController.getStatusInfo); + +module.exports = router; diff --git a/api.hyungi.net/routes/setupRoutes.js b/api.hyungi.net/routes/setupRoutes.js new file mode 100644 index 0000000..b93200f --- /dev/null +++ b/api.hyungi.net/routes/setupRoutes.js @@ -0,0 +1,717 @@ +const express = require('express'); +const router = express.Router(); +const { getDb } = require('../dbPool'); + +// DB 설정 엔드포인트 (개발용 - 인증 없이 접근 가능) +// 월별 집계 테이블 설정 +router.post('/setup-monthly-status', async (req, res) => { + try { + const db = await getDb(); + + console.log('📊 월별 집계 테이블 생성 중...'); + + // 1. 월별 작업자 상태 집계 테이블 + await db.execute(` + CREATE TABLE IF NOT EXISTS monthly_worker_status ( + id INT PRIMARY KEY AUTO_INCREMENT, + year INT NOT NULL COMMENT '연도', + month INT NOT NULL COMMENT '월 (1-12)', + worker_id INT NOT NULL COMMENT '작업자 ID', + date DATE NOT NULL COMMENT '날짜', + + total_work_hours DECIMAL(5,2) DEFAULT 0.00 COMMENT '총 작업시간', + actual_work_hours DECIMAL(5,2) DEFAULT 0.00 COMMENT '실제 작업시간 (휴가 제외)', + vacation_hours DECIMAL(5,2) DEFAULT 0.00 COMMENT '휴가 시간', + + total_work_count INT DEFAULT 0 COMMENT '총 작업 건수', + regular_work_count INT DEFAULT 0 COMMENT '정규 작업 건수', + error_work_count INT DEFAULT 0 COMMENT '오류 작업 건수', + + work_status ENUM( + 'incomplete', 'partial', 'complete', 'overtime', + 'vacation-full', 'vacation-half', 'vacation-quarter', 'vacation-half-half', + 'error', 'overtime-warning' + ) NOT NULL DEFAULT 'incomplete' COMMENT '작업 상태', + + has_vacation BOOLEAN DEFAULT FALSE COMMENT '휴가 여부', + has_error BOOLEAN DEFAULT FALSE COMMENT '오류 여부', + has_issues BOOLEAN DEFAULT FALSE COMMENT '문제 여부 (미입력/부분입력)', + + last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + UNIQUE KEY unique_worker_date (worker_id, date), + KEY idx_year_month (year, month), + KEY idx_worker_year_month (worker_id, year, month), + KEY idx_status (work_status), + KEY idx_has_issues (has_issues), + KEY idx_has_error (has_error), + + FOREIGN KEY (worker_id) REFERENCES workers(worker_id) ON DELETE CASCADE + ) COMMENT='월별 작업자 상태 집계 테이블' + `); + + // 2. 월별 집계 요약 테이블 + await db.execute(` + CREATE TABLE IF NOT EXISTS monthly_summary ( + id INT PRIMARY KEY AUTO_INCREMENT, + year INT NOT NULL COMMENT '연도', + month INT NOT NULL COMMENT '월 (1-12)', + date DATE NOT NULL COMMENT '날짜', + + total_workers INT DEFAULT 0 COMMENT '총 작업자 수', + working_workers INT DEFAULT 0 COMMENT '작업한 작업자 수', + + incomplete_workers INT DEFAULT 0 COMMENT '미입력 작업자 수', + partial_workers INT DEFAULT 0 COMMENT '부분입력 작업자 수', + complete_workers INT DEFAULT 0 COMMENT '완료 작업자 수', + overtime_workers INT DEFAULT 0 COMMENT '연장근로 작업자 수', + vacation_workers INT DEFAULT 0 COMMENT '휴가 작업자 수', + error_workers INT DEFAULT 0 COMMENT '오류 작업자 수', + + total_work_hours DECIMAL(8,2) DEFAULT 0.00 COMMENT '총 작업시간', + total_work_count INT DEFAULT 0 COMMENT '총 작업 건수', + total_error_count INT DEFAULT 0 COMMENT '총 오류 건수', + + has_issues BOOLEAN DEFAULT FALSE COMMENT '문제 있음 (미입력/부분입력)', + has_errors BOOLEAN DEFAULT FALSE COMMENT '오류 있음', + + last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + UNIQUE KEY unique_date (date), + KEY idx_year_month (year, month), + KEY idx_has_issues (has_issues), + KEY idx_has_errors (has_errors) + ) COMMENT='월별 일자별 요약 테이블 (캘린더 최적화용)' + `); + + console.log('📊 집계 프로시저 생성 중...'); + + // 3. 집계 업데이트 프로시저 + await db.execute(`DROP PROCEDURE IF EXISTS UpdateMonthlyWorkerStatus`); + await db.execute(` + CREATE PROCEDURE UpdateMonthlyWorkerStatus( + IN p_date DATE, + IN p_worker_id INT + ) + BEGIN + DECLARE v_year INT; + DECLARE v_month INT; + DECLARE v_total_hours DECIMAL(5,2); + DECLARE v_actual_hours DECIMAL(5,2); + DECLARE v_vacation_hours DECIMAL(5,2); + DECLARE v_total_count INT; + DECLARE v_regular_count INT; + DECLARE v_error_count INT; + DECLARE v_has_vacation BOOLEAN; + DECLARE v_has_error BOOLEAN; + DECLARE v_has_issues BOOLEAN; + DECLARE v_status VARCHAR(20); + + SET v_year = YEAR(p_date); + SET v_month = MONTH(p_date); + + SELECT + COALESCE(SUM(work_hours), 0), + COALESCE(SUM(CASE WHEN project_id != 13 THEN work_hours ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN project_id = 13 THEN work_hours ELSE 0 END), 0), + COUNT(*), + COUNT(CASE WHEN project_id != 13 AND work_status_id != 2 THEN 1 END), + COUNT(CASE WHEN work_status_id = 2 THEN 1 END), + MAX(CASE WHEN project_id = 13 THEN 1 ELSE 0 END), + MAX(CASE WHEN work_status_id = 2 THEN 1 ELSE 0 END) + INTO + v_total_hours, v_actual_hours, v_vacation_hours, + v_total_count, v_regular_count, v_error_count, + v_has_vacation, v_has_error + FROM daily_work_reports + WHERE report_date = p_date AND worker_id = p_worker_id; + + IF v_has_error THEN + SET v_status = 'error'; + SET v_has_issues = FALSE; + ELSEIF v_total_hours > 12 THEN + SET v_status = 'overtime-warning'; + SET v_has_issues = TRUE; + ELSEIF v_has_vacation AND v_vacation_hours > 0 THEN + CASE v_vacation_hours + WHEN 8 THEN SET v_status = 'vacation-full'; + WHEN 6 THEN SET v_status = 'vacation-half-half'; + WHEN 4 THEN SET v_status = 'vacation-half'; + WHEN 2 THEN SET v_status = 'vacation-quarter'; + ELSE SET v_status = 'vacation-full'; + END CASE; + SET v_has_issues = FALSE; + ELSEIF v_total_hours > 8 THEN + SET v_status = 'overtime'; + SET v_has_issues = FALSE; + ELSEIF v_total_hours = 8 THEN + SET v_status = 'complete'; + SET v_has_issues = FALSE; + ELSEIF v_total_hours > 0 THEN + SET v_status = 'partial'; + SET v_has_issues = TRUE; + ELSE + SET v_status = 'incomplete'; + SET v_has_issues = TRUE; + END IF; + + INSERT INTO monthly_worker_status ( + year, month, worker_id, date, + total_work_hours, actual_work_hours, vacation_hours, + total_work_count, regular_work_count, error_work_count, + work_status, has_vacation, has_error, has_issues + ) VALUES ( + v_year, v_month, p_worker_id, p_date, + v_total_hours, v_actual_hours, v_vacation_hours, + v_total_count, v_regular_count, v_error_count, + v_status, v_has_vacation, v_has_error, v_has_issues + ) ON DUPLICATE KEY UPDATE + total_work_hours = v_total_hours, + actual_work_hours = v_actual_hours, + vacation_hours = v_vacation_hours, + total_work_count = v_total_count, + regular_work_count = v_regular_count, + error_work_count = v_error_count, + work_status = v_status, + has_vacation = v_has_vacation, + has_error = v_has_error, + has_issues = v_has_issues, + last_updated = CURRENT_TIMESTAMP; + + CALL UpdateDailySummary(p_date); + END + `); + + await db.execute(`DROP PROCEDURE IF EXISTS UpdateDailySummary`); + await db.execute(` + CREATE PROCEDURE UpdateDailySummary( + IN p_date DATE + ) + BEGIN + DECLARE v_year INT; + DECLARE v_month INT; + + SET v_year = YEAR(p_date); + SET v_month = MONTH(p_date); + + INSERT INTO monthly_summary ( + year, month, date, + total_workers, working_workers, + incomplete_workers, partial_workers, complete_workers, + overtime_workers, vacation_workers, error_workers, + total_work_hours, total_work_count, total_error_count, + has_issues, has_errors + ) + SELECT + v_year, v_month, p_date, + COUNT(*) as total_workers, + COUNT(CASE WHEN work_status != 'incomplete' THEN 1 END) as working_workers, + COUNT(CASE WHEN work_status = 'incomplete' THEN 1 END) as incomplete_workers, + COUNT(CASE WHEN work_status = 'partial' THEN 1 END) as partial_workers, + COUNT(CASE WHEN work_status IN ('complete') THEN 1 END) as complete_workers, + COUNT(CASE WHEN work_status = 'overtime' THEN 1 END) as overtime_workers, + COUNT(CASE WHEN work_status LIKE 'vacation%' THEN 1 END) as vacation_workers, + COUNT(CASE WHEN work_status = 'error' THEN 1 END) as error_workers, + SUM(total_work_hours) as total_work_hours, + SUM(total_work_count) as total_work_count, + SUM(error_work_count) as total_error_count, + MAX(has_issues) as has_issues, + MAX(has_error) as has_errors + FROM monthly_worker_status + WHERE date = p_date + ON DUPLICATE KEY UPDATE + total_workers = VALUES(total_workers), + working_workers = VALUES(working_workers), + incomplete_workers = VALUES(incomplete_workers), + partial_workers = VALUES(partial_workers), + complete_workers = VALUES(complete_workers), + overtime_workers = VALUES(overtime_workers), + vacation_workers = VALUES(vacation_workers), + error_workers = VALUES(error_workers), + total_work_hours = VALUES(total_work_hours), + total_work_count = VALUES(total_work_count), + total_error_count = VALUES(total_error_count), + has_issues = VALUES(has_issues), + has_errors = VALUES(has_errors), + last_updated = CURRENT_TIMESTAMP; + END + `); + + console.log('📊 트리거 생성 중...'); + + // 4. 트리거 생성 + await db.execute(`DROP TRIGGER IF EXISTS tr_daily_work_reports_insert`); + await db.execute(` + CREATE TRIGGER tr_daily_work_reports_insert + AFTER INSERT ON daily_work_reports + FOR EACH ROW + BEGIN + CALL UpdateMonthlyWorkerStatus(NEW.report_date, NEW.worker_id); + END + `); + + await db.execute(`DROP TRIGGER IF EXISTS tr_daily_work_reports_update`); + await db.execute(` + CREATE TRIGGER tr_daily_work_reports_update + AFTER UPDATE ON daily_work_reports + FOR EACH ROW + BEGIN + CALL UpdateMonthlyWorkerStatus(OLD.report_date, OLD.worker_id); + + IF OLD.report_date != NEW.report_date OR OLD.worker_id != NEW.worker_id THEN + CALL UpdateMonthlyWorkerStatus(NEW.report_date, NEW.worker_id); + END IF; + END + `); + + await db.execute(`DROP TRIGGER IF EXISTS tr_daily_work_reports_delete`); + await db.execute(` + CREATE TRIGGER tr_daily_work_reports_delete + AFTER DELETE ON daily_work_reports + FOR EACH ROW + BEGIN + CALL UpdateMonthlyWorkerStatus(OLD.report_date, OLD.worker_id); + END + `); + + console.log('📊 기존 데이터로 집계 테이블 초기화 중...'); + + // 5. 기존 작업 데이터로 집계 테이블 초기화 + const [existingDates] = await db.execute(` + SELECT DISTINCT report_date, worker_id + FROM daily_work_reports + WHERE report_date >= '2025-01-01' + ORDER BY report_date DESC, worker_id ASC + `); + + let processedCount = 0; + const batchSize = 50; + + for (let i = 0; i < existingDates.length; i += batchSize) { + const batch = existingDates.slice(i, i + batchSize); + + for (const { report_date, worker_id } of batch) { + try { + await db.execute('CALL UpdateMonthlyWorkerStatus(?, ?)', [report_date, worker_id]); + processedCount++; + } catch (error) { + console.warn(`집계 처리 실패: ${report_date}, worker ${worker_id}:`, error.message); + } + } + + if (i % 100 === 0) { + console.log(`📊 집계 초기화 진행률: ${processedCount}/${existingDates.length}`); + } + } + + res.json({ + success: true, + message: '월별 집계 시스템이 성공적으로 설정되었습니다.', + data: { + tables_created: [ + 'monthly_worker_status', + 'monthly_summary' + ], + procedures_created: [ + 'UpdateMonthlyWorkerStatus', + 'UpdateDailySummary' + ], + triggers_created: [ + 'tr_daily_work_reports_insert', + 'tr_daily_work_reports_update', + 'tr_daily_work_reports_delete' + ], + initialized_records: processedCount, + total_dates: existingDates.length + } + }); + + } catch (error) { + console.error('❌ 월별 집계 시스템 설정 오류:', error); + res.status(500).json({ + success: false, + message: '월별 집계 시스템 설정 중 오류가 발생했습니다.', + error: error.message + }); + } +}); + +router.post('/setup-attendance-db', async (req, res) => { + try { + console.log('🚀 근태 관리 DB 설정 API 호출됨'); + + const db = await getDb(); + + // 1. 근로 유형 테이블 생성 + console.log('📋 근로 유형 테이블 생성 중...'); + await db.execute(` + CREATE TABLE IF NOT EXISTS work_attendance_types ( + id INT PRIMARY KEY AUTO_INCREMENT, + type_code VARCHAR(20) NOT NULL UNIQUE COMMENT '근로 유형 코드', + type_name VARCHAR(50) NOT NULL COMMENT '근로 유형명', + description TEXT COMMENT '설명', + is_active BOOLEAN DEFAULT TRUE COMMENT '활성 상태', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) COMMENT='근로 유형 관리 테이블' + `); + + // 2. 휴가 유형 테이블 생성 + console.log('🏖️ 휴가 유형 테이블 생성 중...'); + await db.execute(` + CREATE TABLE IF NOT EXISTS vacation_types ( + id INT PRIMARY KEY AUTO_INCREMENT, + type_code VARCHAR(20) NOT NULL UNIQUE COMMENT '휴가 유형 코드', + type_name VARCHAR(50) NOT NULL COMMENT '휴가 유형명', + hours_deduction DECIMAL(4,2) NOT NULL COMMENT '차감 시간', + description TEXT COMMENT '설명', + is_active BOOLEAN DEFAULT TRUE COMMENT '활성 상태', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) COMMENT='휴가 유형 관리 테이블' + `); + + // 3. 일일 근태 기록 테이블 생성 + console.log('📊 일일 근태 기록 테이블 생성 중...'); + await db.execute(` + CREATE TABLE IF NOT EXISTS daily_attendance_records ( + id INT PRIMARY KEY AUTO_INCREMENT, + record_date DATE NOT NULL COMMENT '기록 날짜', + worker_id INT NOT NULL COMMENT '작업자 ID', + work_attendance_type_id INT COMMENT '근로 유형 ID (정시, 연장, 부분, 휴가)', + total_work_hours DECIMAL(4,2) DEFAULT 0.00 COMMENT '총 작업 시간', + vacation_type_id INT COMMENT '휴가 유형 ID (연차, 반차 등)', + is_overtime_approved BOOLEAN DEFAULT FALSE COMMENT '연장근로 승인 여부', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY unique_worker_date (worker_id, record_date), + FOREIGN KEY (worker_id) REFERENCES workers(worker_id) ON DELETE CASCADE, + FOREIGN KEY (work_attendance_type_id) REFERENCES work_attendance_types(id) ON DELETE SET NULL, + FOREIGN KEY (vacation_type_id) REFERENCES vacation_types(id) ON DELETE SET NULL + ) COMMENT='일일 근태 기록 테이블' + `); + + // 4. 작업자별 휴가 잔여 관리 테이블 생성 + console.log('👥 작업자별 휴가 잔여 관리 테이블 생성 중...'); + await db.execute(` + CREATE TABLE IF NOT EXISTS worker_vacation_balance ( + id INT PRIMARY KEY AUTO_INCREMENT, + worker_id INT NOT NULL UNIQUE COMMENT '작업자 ID', + annual_leave_total DECIMAL(5,2) DEFAULT 15.00 COMMENT '총 연차 일수', + annual_leave_used DECIMAL(5,2) DEFAULT 0.00 COMMENT '사용 연차 일수', + sick_leave_total DECIMAL(5,2) DEFAULT 10.00 COMMENT '총 병가 일수', + sick_leave_used DECIMAL(5,2) DEFAULT 0.00 COMMENT '사용 병가 일수', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (worker_id) REFERENCES workers(worker_id) ON DELETE CASCADE + ) COMMENT='작업자별 휴가 잔여 관리 테이블' + `); + + // 5. 기본 데이터 삽입 + console.log('📝 기본 데이터 삽입 중...'); + + // 근로 유형 기본 데이터 + await db.execute(` + INSERT IGNORE INTO work_attendance_types (type_code, type_name, description) VALUES + ('REGULAR', '정시근로', '8시간 정규 근무'), + ('OVERTIME', '연장근로', '8시간 초과 근무'), + ('PARTIAL', '부분근로', '8시간 미만 근무'), + ('VACATION', '휴가근로', '휴가와 함께하는 부분 근무') + `); + + // 휴가 유형 기본 데이터 + await db.execute(` + INSERT IGNORE INTO vacation_types (type_code, type_name, hours_deduction, description) VALUES + ('ANNUAL_FULL', '연차', 8.0, '하루 전체 연차'), + ('ANNUAL_HALF', '반차', 4.0, '반일 연차'), + ('ANNUAL_QUARTER', '반반차', 2.0, '1/4일 연차'), + ('SICK_FULL', '병가', 8.0, '하루 전체 병가'), + ('SICK_HALF', '반일병가', 4.0, '반일 병가') + `); + + res.json({ + success: true, + message: '근태 관리 DB 설정이 완료되었습니다.', + data: { + tables_created: [ + 'work_attendance_types', + 'vacation_types', + 'daily_attendance_records', + 'worker_vacation_balance' + ], + basic_data_inserted: true + } + }); + + } catch (error) { + console.error('❌ DB 설정 API 오류:', error); + res.status(500).json({ + success: false, + message: 'DB 설정 중 오류가 발생했습니다.', + error: error.message + }); + } +}); + +// 12시간 초과 상태 컬럼 추가 +router.post('/add-overtime-warning', async (req, res) => { + try { + const db = await getDb(); + + console.log('⚠️ 12시간 초과 상태 컬럼 추가 중...'); + + // 1. monthly_summary 테이블에 컬럼 추가 + try { + await db.execute(` + ALTER TABLE monthly_summary + ADD COLUMN overtime_warning_workers INT DEFAULT 0 COMMENT '확인필요(12시간초과) 작업자 수' AFTER error_workers + `); + console.log('✅ overtime_warning_workers 컬럼 추가 완료'); + } catch (error) { + if (error.code === 'ER_DUP_FIELDNAME') { + console.log('ℹ️ overtime_warning_workers 컬럼이 이미 존재합니다.'); + } else { + throw error; + } + } + + try { + await db.execute(` + ALTER TABLE monthly_summary + ADD COLUMN has_overtime_warning BOOLEAN DEFAULT FALSE COMMENT '확인필요 상태 있음' AFTER has_errors + `); + console.log('✅ has_overtime_warning 컬럼 추가 완료'); + } catch (error) { + if (error.code === 'ER_DUP_FIELDNAME') { + console.log('ℹ️ has_overtime_warning 컬럼이 이미 존재합니다.'); + } else { + throw error; + } + } + + // 2. UpdateDailySummary 프로시저 업데이트 + await db.execute(`DROP PROCEDURE IF EXISTS UpdateDailySummary`); + + await db.execute(` + CREATE PROCEDURE UpdateDailySummary( + IN p_date DATE + ) + BEGIN + DECLARE v_year INT; + DECLARE v_month INT; + + SET v_year = YEAR(p_date); + SET v_month = MONTH(p_date); + + INSERT INTO monthly_summary ( + year, month, date, + total_workers, working_workers, + incomplete_workers, partial_workers, complete_workers, + overtime_workers, vacation_workers, error_workers, overtime_warning_workers, + total_work_hours, total_work_count, total_error_count, + has_issues, has_errors, has_overtime_warning + ) + SELECT + v_year, v_month, p_date, + COUNT(*) as total_workers, + COUNT(CASE WHEN work_status != 'incomplete' THEN 1 END) as working_workers, + COUNT(CASE WHEN work_status = 'incomplete' THEN 1 END) as incomplete_workers, + COUNT(CASE WHEN work_status = 'partial' THEN 1 END) as partial_workers, + COUNT(CASE WHEN work_status IN ('complete', 'overtime', 'vacation-full', 'vacation-half', 'vacation-quarter', 'vacation-half-half') THEN 1 END) as complete_workers, + COUNT(CASE WHEN work_status = 'overtime' THEN 1 END) as overtime_workers, + COUNT(CASE WHEN work_status LIKE 'vacation%' THEN 1 END) as vacation_workers, + COUNT(CASE WHEN work_status = 'error' THEN 1 END) as error_workers, + COUNT(CASE WHEN work_status = 'overtime-warning' THEN 1 END) as overtime_warning_workers, + SUM(total_work_hours) as total_work_hours, + SUM(total_work_count) as total_work_count, + SUM(error_work_count) as total_error_count, + MAX(has_issues) as has_issues, + MAX(has_error) as has_errors, + MAX(CASE WHEN work_status = 'overtime-warning' THEN 1 ELSE 0 END) as has_overtime_warning + FROM monthly_worker_status + WHERE date = p_date + ON DUPLICATE KEY UPDATE + total_workers = VALUES(total_workers), + working_workers = VALUES(working_workers), + incomplete_workers = VALUES(incomplete_workers), + partial_workers = VALUES(partial_workers), + complete_workers = VALUES(complete_workers), + overtime_workers = VALUES(overtime_workers), + vacation_workers = VALUES(vacation_workers), + error_workers = VALUES(error_workers), + overtime_warning_workers = VALUES(overtime_warning_workers), + total_work_hours = VALUES(total_work_hours), + total_work_count = VALUES(total_work_count), + total_error_count = VALUES(total_error_count), + has_issues = VALUES(has_issues), + has_errors = VALUES(has_errors), + has_overtime_warning = VALUES(has_overtime_warning), + last_updated = CURRENT_TIMESTAMP; + END + `); + console.log('✅ UpdateDailySummary 프로시저 업데이트 완료'); + + res.json({ + success: true, + message: '12시간 초과 상태 컬럼 추가 완료', + columns_added: ['overtime_warning_workers', 'has_overtime_warning'], + procedure_updated: 'UpdateDailySummary' + }); + + } catch (error) { + console.error('❌ 12시간 초과 상태 설정 오류:', error); + res.status(500).json({ + success: false, + message: '12시간 초과 상태 설정 실패', + error: error.message + }); + } +}); + +// 기존 데이터를 월별 집계 테이블로 마이그레이션 +router.post('/migrate-existing-data', async (req, res) => { + try { + const db = await getDb(); + + console.log('🔄 기존 데이터 마이그레이션 시작...'); + + // 1. 기존 데이터 범위 확인 + const [dateRange] = await db.execute(` + SELECT + MIN(report_date) as min_date, + MAX(report_date) as max_date, + COUNT(*) as total_reports + FROM daily_work_reports + `); + + if (dateRange.length === 0 || !dateRange[0].min_date) { + return res.json({ + success: true, + message: '마이그레이션할 데이터가 없습니다.', + migrated_count: 0 + }); + } + + const { min_date, max_date, total_reports } = dateRange[0]; + console.log(`📊 데이터 범위: ${min_date} ~ ${max_date} (총 ${total_reports}건)`); + + // 2. 기존 monthly_worker_status, monthly_summary 데이터 삭제 + await db.execute('DELETE FROM monthly_summary'); + await db.execute('DELETE FROM monthly_worker_status'); + console.log('🗑️ 기존 집계 데이터 삭제 완료'); + + // 3. 날짜별로 작업자별 상태 재계산 + const [allDates] = await db.execute(` + SELECT DISTINCT report_date, worker_id + FROM daily_work_reports + WHERE report_date BETWEEN ? AND ? + ORDER BY report_date, worker_id + `, [min_date, max_date]); + + console.log(`🔄 ${allDates.length}개 날짜-작업자 조합 처리 중...`); + + let processedCount = 0; + for (const { report_date, worker_id } of allDates) { + try { + // UpdateMonthlyWorkerStatus 프로시저 호출 + await db.execute('CALL UpdateMonthlyWorkerStatus(?, ?)', [report_date, worker_id]); + processedCount++; + + if (processedCount % 50 === 0) { + console.log(`📈 진행률: ${processedCount}/${allDates.length} (${Math.round(processedCount/allDates.length*100)}%)`); + } + } catch (error) { + console.error(`❌ ${report_date} ${worker_id} 처리 오류:`, error.message); + } + } + + // 4. 결과 확인 + const [workerStatusCount] = await db.execute('SELECT COUNT(*) as count FROM monthly_worker_status'); + const [summaryCount] = await db.execute('SELECT COUNT(*) as count FROM monthly_summary'); + + console.log(`✅ 마이그레이션 완료:`); + console.log(` - monthly_worker_status: ${workerStatusCount[0].count}건`); + console.log(` - monthly_summary: ${summaryCount[0].count}건`); + + res.json({ + success: true, + message: '기존 데이터 마이그레이션 완료', + original_reports: total_reports, + processed_combinations: processedCount, + worker_status_records: workerStatusCount[0].count, + summary_records: summaryCount[0].count, + date_range: { + from: min_date, + to: max_date + } + }); + + } catch (error) { + console.error('❌ 데이터 마이그레이션 오류:', error); + res.status(500).json({ + success: false, + message: '데이터 마이그레이션 실패', + error: error.message + }); + } +}); + +// DB 상태 확인 +router.get('/check-data-status', async (req, res) => { + try { + const db = await getDb(); + + const [dailyReports] = await db.execute('SELECT COUNT(*) as count FROM daily_work_reports'); + const [workerStatus] = await db.execute('SELECT COUNT(*) as count FROM monthly_worker_status'); + const [monthlySummary] = await db.execute('SELECT COUNT(*) as count FROM monthly_summary'); + + // 최근 데이터 확인 + const [recentData] = await db.execute(` + SELECT + DATE(report_date) as date, + COUNT(*) as reports + FROM daily_work_reports + WHERE report_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) + GROUP BY DATE(report_date) + ORDER BY report_date DESC + LIMIT 5 + `); + + const [recentSummary] = await db.execute(` + SELECT + date, + total_workers, + has_issues, + has_errors, + has_overtime_warning + FROM monthly_summary + WHERE date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) + ORDER BY date DESC + LIMIT 5 + `); + + res.json({ + success: true, + data: { + daily_work_reports: dailyReports[0].count, + monthly_worker_status: workerStatus[0].count, + monthly_summary: monthlySummary[0].count, + recent_daily_reports: recentData, + recent_summary: recentSummary, + migration_needed: workerStatus[0].count === 0 && dailyReports[0].count > 0 + } + }); + + } catch (error) { + console.error('❌ DB 상태 확인 오류:', error); + res.status(500).json({ + success: false, + message: 'DB 상태 확인 실패', + error: error.message + }); + } +}); + +module.exports = router; diff --git a/api.hyungi.net/services/dailyWorkReportService.js b/api.hyungi.net/services/dailyWorkReportService.js index 6a42b78..cb53ff1 100644 --- a/api.hyungi.net/services/dailyWorkReportService.js +++ b/api.hyungi.net/services/dailyWorkReportService.js @@ -86,11 +86,12 @@ const createDailyWorkReportService = async (reportData) => { * @returns {Promise} 조회된 작업 보고서 배열 */ const getDailyWorkReportsService = async (queryParams, userInfo) => { - const { date, worker_id, created_by: requested_created_by, view_all } = queryParams; + const { date, start_date, end_date, worker_id, created_by: requested_created_by, view_all } = queryParams; const { user_id: current_user_id, role } = userInfo; - if (!date) { - throw new Error('조회를 위해 날짜(date)는 필수입니다.'); + // 날짜 또는 날짜 범위 중 하나는 필수 + if (!date && (!start_date || !end_date)) { + throw new Error('조회를 위해 날짜(date) 또는 날짜 범위(start_date, end_date)가 필요합니다.'); } // 관리자 여부 확인 @@ -98,7 +99,14 @@ const getDailyWorkReportsService = async (queryParams, userInfo) => { const canViewAll = isAdmin || view_all === 'true'; // 모델에 전달할 조회 옵션 객체 생성 - const options = { date }; + const options = {}; + + if (date) { + options.date = date; + } else { + options.start_date = start_date; + options.end_date = end_date; + } if (worker_id) { options.worker_id = parseInt(worker_id); diff --git a/api.hyungi.net/setup-attendance-db.js b/api.hyungi.net/setup-attendance-db.js new file mode 100644 index 0000000..284124a --- /dev/null +++ b/api.hyungi.net/setup-attendance-db.js @@ -0,0 +1,177 @@ +const { getDb } = require('./dbPool'); +const fs = require('fs'); +const path = require('path'); + +async function setupAttendanceDB() { + try { + console.log('🚀 근태 관리 DB 설정 시작...'); + + const db = await getDb(); + + // 1. 근로 유형 테이블 생성 + console.log('📋 근로 유형 테이블 생성 중...'); + await db.execute(` + CREATE TABLE IF NOT EXISTS work_attendance_types ( + id INT PRIMARY KEY AUTO_INCREMENT, + type_code VARCHAR(20) NOT NULL UNIQUE COMMENT '근로 유형 코드', + type_name VARCHAR(50) NOT NULL COMMENT '근로 유형명', + description TEXT COMMENT '설명', + is_active BOOLEAN DEFAULT TRUE COMMENT '활성 상태', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) COMMENT='근로 유형 관리 테이블' + `); + + // 2. 휴가 유형 테이블 생성 + console.log('🏖️ 휴가 유형 테이블 생성 중...'); + await db.execute(` + CREATE TABLE IF NOT EXISTS vacation_types ( + id INT PRIMARY KEY AUTO_INCREMENT, + type_code VARCHAR(20) NOT NULL UNIQUE COMMENT '휴가 유형 코드', + type_name VARCHAR(50) NOT NULL COMMENT '휴가 유형명', + hours_deduction DECIMAL(4,2) NOT NULL COMMENT '차감 시간', + description TEXT COMMENT '설명', + is_active BOOLEAN DEFAULT TRUE COMMENT '활성 상태', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) COMMENT='휴가 유형 관리 테이블' + `); + + // 3. 일일 근태 기록 테이블 생성 + console.log('📊 일일 근태 기록 테이블 생성 중...'); + await db.execute(` + CREATE TABLE IF NOT EXISTS daily_attendance_records ( + id INT PRIMARY KEY AUTO_INCREMENT, + record_date DATE NOT NULL COMMENT '기록 날짜', + worker_id INT NOT NULL COMMENT '작업자 ID', + total_work_hours DECIMAL(4,2) DEFAULT 0 COMMENT '총 작업 시간', + attendance_type_id INT COMMENT '근로 유형 ID', + vacation_type_id INT NULL COMMENT '휴가 유형 ID', + is_vacation_processed BOOLEAN DEFAULT FALSE COMMENT '휴가 처리 여부', + overtime_approved BOOLEAN DEFAULT FALSE COMMENT '초과근무 승인 여부', + overtime_approved_by INT NULL COMMENT '초과근무 승인자 ID', + overtime_approved_at TIMESTAMP NULL COMMENT '초과근무 승인 시간', + status ENUM('incomplete', 'partial', 'complete', 'overtime', 'vacation', 'error') DEFAULT 'incomplete' COMMENT '상태', + notes TEXT COMMENT '비고', + created_by INT NOT NULL COMMENT '생성자 ID', + updated_by INT NULL COMMENT '수정자 ID', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + UNIQUE KEY unique_worker_date (worker_id, record_date), + INDEX idx_record_date (record_date), + INDEX idx_worker_date (worker_id, record_date), + INDEX idx_status (status) + ) COMMENT='일일 근태 기록 테이블' + `); + + // 4. 작업자 휴가 잔여 관리 테이블 생성 + console.log('📅 휴가 잔여 관리 테이블 생성 중...'); + await db.execute(` + CREATE TABLE IF NOT EXISTS worker_vacation_balance ( + id INT PRIMARY KEY AUTO_INCREMENT, + worker_id INT NOT NULL COMMENT '작업자 ID', + year YEAR NOT NULL COMMENT '연도', + total_annual_leave DECIMAL(4,2) DEFAULT 15.0 COMMENT '연간 총 연차 (일)', + used_annual_leave DECIMAL(4,2) DEFAULT 0 COMMENT '사용한 연차 (일)', + remaining_annual_leave DECIMAL(4,2) GENERATED ALWAYS AS (total_annual_leave - used_annual_leave) STORED COMMENT '잔여 연차 (일)', + notes TEXT COMMENT '비고', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + UNIQUE KEY unique_worker_year (worker_id, year), + INDEX idx_worker_year (worker_id, year) + ) COMMENT='작업자별 휴가 잔여 관리 테이블' + `); + + // 5. 기본 데이터 삽입 + console.log('📝 기본 데이터 삽입 중...'); + + // 근로 유형 기본 데이터 + await db.execute(` + INSERT IGNORE INTO work_attendance_types (type_code, type_name, description) VALUES + ('REGULAR', '정시근로', '8시간 정규 근무'), + ('OVERTIME', '연장근로', '8시간 초과 근무'), + ('PARTIAL', '부분근로', '8시간 미만 근무'), + ('VACATION', '휴가근로', '휴가와 함께하는 부분 근무') + `); + + // 휴가 유형 기본 데이터 + await db.execute(` + INSERT IGNORE INTO vacation_types (type_code, type_name, hours_deduction, description) VALUES + ('ANNUAL_FULL', '연차', 8.0, '하루 전체 연차'), + ('ANNUAL_HALF', '반차', 4.0, '반일 연차'), + ('ANNUAL_QUARTER', '반반차', 2.0, '1/4일 연차'), + ('SICK_FULL', '병가', 8.0, '하루 전체 병가'), + ('SICK_HALF', '반일병가', 4.0, '반일 병가'), + ('PERSONAL_FULL', '개인사유', 8.0, '개인사유로 인한 휴가'), + ('PERSONAL_HALF', '반일개인사유', 4.0, '반일 개인사유 휴가') + `); + + // 6. 휴가 전용 작업 유형 추가 + console.log('🏖️ 휴가 전용 작업 유형 추가 중...'); + await db.execute(` + INSERT IGNORE INTO work_types (name, description, is_active) VALUES + ('휴가', '연차, 반차, 병가 등 휴가 처리용', TRUE) + `); + + // 7. daily_work_reports 테이블에 근태 기록 연결 컬럼 추가 (이미 있으면 무시) + try { + await db.execute(` + ALTER TABLE daily_work_reports + ADD COLUMN attendance_record_id INT NULL COMMENT '근태 기록 ID' AFTER updated_by + `); + console.log('✅ daily_work_reports 테이블에 attendance_record_id 컬럼 추가됨'); + } catch (error) { + if (error.code !== 'ER_DUP_FIELDNAME') { + console.log('⚠️ attendance_record_id 컬럼 추가 실패 (이미 존재할 수 있음):', error.message); + } else { + console.log('✅ attendance_record_id 컬럼이 이미 존재함'); + } + } + + // 8. 인덱스 추가 + try { + await db.execute(`CREATE INDEX idx_attendance_record ON daily_work_reports(attendance_record_id)`); + console.log('✅ attendance_record_id 인덱스 추가됨'); + } catch (error) { + console.log('⚠️ 인덱스 추가 실패 (이미 존재할 수 있음):', error.message); + } + + try { + await db.execute(`CREATE INDEX idx_daily_work_reports_worker_date ON daily_work_reports(worker_id, report_date)`); + console.log('✅ worker_date 인덱스 추가됨'); + } catch (error) { + console.log('⚠️ 인덱스 추가 실패 (이미 존재할 수 있음):', error.message); + } + + console.log('🎉 근태 관리 DB 설정 완료!'); + console.log(''); + console.log('📋 생성된 테이블:'); + console.log(' - work_attendance_types (근로 유형)'); + console.log(' - vacation_types (휴가 유형)'); + console.log(' - daily_attendance_records (일일 근태 기록)'); + console.log(' - worker_vacation_balance (휴가 잔여 관리)'); + console.log(''); + console.log('✅ 기본 데이터도 모두 삽입되었습니다.'); + + } catch (error) { + console.error('❌ DB 설정 중 오류 발생:', error); + throw error; + } +} + +// 직접 실행 +if (require.main === module) { + setupAttendanceDB() + .then(() => { + console.log('✅ 설정 완료'); + process.exit(0); + }) + .catch((error) => { + console.error('❌ 설정 실패:', error); + process.exit(1); + }); +} + +module.exports = { setupAttendanceDB }; diff --git a/web-ui/css/common.css b/web-ui/css/common.css new file mode 100644 index 0000000..2bfcd3f --- /dev/null +++ b/web-ui/css/common.css @@ -0,0 +1,166 @@ +/* Common CSS - 공통 스타일 */ + +/* Reset and Base Styles */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + line-height: 1.6; + color: #333; + background-color: #f8fafc; +} + +/* Typography */ +h1, h2, h3, h4, h5, h6 { + font-weight: 600; + line-height: 1.25; + margin-bottom: 0.5rem; +} + +h1 { font-size: 2rem; } +h2 { font-size: 1.5rem; } +h3 { font-size: 1.25rem; } +h4 { font-size: 1.125rem; } +h5 { font-size: 1rem; } +h6 { font-size: 0.875rem; } + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.5rem 1rem; + border: none; + border-radius: 0.375rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + text-decoration: none; +} + +.btn-primary { + background-color: #3b82f6; + color: white; +} + +.btn-primary:hover { + background-color: #2563eb; +} + +.btn-secondary { + background-color: #6b7280; + color: white; +} + +.btn-secondary:hover { + background-color: #4b5563; +} + +/* Utilities */ +.text-center { text-align: center; } +.text-left { text-align: left; } +.text-right { text-align: right; } + +.mb-1 { margin-bottom: 0.25rem; } +.mb-2 { margin-bottom: 0.5rem; } +.mb-3 { margin-bottom: 0.75rem; } +.mb-4 { margin-bottom: 1rem; } +.mb-5 { margin-bottom: 1.25rem; } +.mb-6 { margin-bottom: 1.5rem; } + +.mt-1 { margin-top: 0.25rem; } +.mt-2 { margin-top: 0.5rem; } +.mt-3 { margin-top: 0.75rem; } +.mt-4 { margin-top: 1rem; } +.mt-5 { margin-top: 1.25rem; } +.mt-6 { margin-top: 1.5rem; } + +.p-1 { padding: 0.25rem; } +.p-2 { padding: 0.5rem; } +.p-3 { padding: 0.75rem; } +.p-4 { padding: 1rem; } +.p-5 { padding: 1.25rem; } +.p-6 { padding: 1.5rem; } + +/* Container */ +.container { + max-width: 1200px; + margin: 0 auto; + padding: 0 1rem; +} + +/* Cards */ +.card { + background: white; + border-radius: 0.5rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + border: 1px solid #e5e7eb; +} + +.card-header { + padding: 1rem; + border-bottom: 1px solid #e5e7eb; +} + +.card-body { + padding: 1rem; +} + +/* Forms */ +.form-group { + margin-bottom: 1rem; +} + +.form-label { + display: block; + font-weight: 500; + margin-bottom: 0.25rem; + color: #374151; +} + +.form-control { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid #d1d5db; + border-radius: 0.375rem; + font-size: 0.875rem; + transition: border-color 0.2s ease; +} + +.form-control:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +/* Loading */ +.loading { + display: inline-block; + width: 20px; + height: 20px; + border: 3px solid #f3f3f3; + border-top: 3px solid #3b82f6; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Responsive */ +@media (max-width: 768px) { + .container { + padding: 0 0.5rem; + } + + h1 { font-size: 1.5rem; } + h2 { font-size: 1.25rem; } + h3 { font-size: 1.125rem; } +} diff --git a/web-ui/css/daily-work-report.css b/web-ui/css/daily-work-report.css index c085e6b..543f4cc 100644 --- a/web-ui/css/daily-work-report.css +++ b/web-ui/css/daily-work-report.css @@ -817,4 +817,346 @@ .guide-item strong { font-size: var(--text-sm); } +} + +/* ========== 개별 작업 보고서 전용 스타일 ========== */ + +/* 작업자 정보 카드 */ +.worker-info-card { + background: linear-gradient(135deg, var(--primary-50) 0%, var(--secondary-50) 100%); + border: 2px solid var(--primary-200); + border-radius: var(--radius-2xl); + padding: var(--space-8); + margin-bottom: var(--space-8); + display: flex; + align-items: center; + gap: var(--space-6); + box-shadow: var(--shadow-lg); + position: relative; + overflow: hidden; +} + +.worker-info-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: linear-gradient(90deg, var(--primary-500), var(--secondary-500)); +} + +.worker-avatar-large { + width: 80px; + height: 80px; + border-radius: var(--radius-full); + background: linear-gradient(135deg, var(--primary-500), var(--secondary-500)); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-inverse); + font-weight: var(--font-bold); + font-size: var(--text-3xl); + box-shadow: var(--shadow-lg); + flex-shrink: 0; +} + +.worker-info-details { + flex: 1; +} + +.worker-info-details h2 { + font-size: var(--text-2xl); + font-weight: var(--font-bold); + color: var(--text-primary); + margin: 0 0 var(--space-2) 0; +} + +.worker-info-details p { + font-size: var(--text-base); + color: var(--text-secondary); + margin: 0 0 var(--space-1) 0; +} + +.worker-status-summary { + display: flex; + gap: var(--space-6); +} + +.status-item { + text-align: center; + padding: var(--space-4); + background: var(--bg-primary); + border-radius: var(--radius-xl); + border: 1px solid var(--border-light); + min-width: 100px; +} + +.status-label { + display: block; + font-size: var(--text-sm); + color: var(--text-secondary); + margin-bottom: var(--space-1); +} + +.status-value { + display: block; + font-size: var(--text-xl); + font-weight: var(--font-bold); + color: var(--text-primary); +} + +.status-value.warning { + color: var(--error-600); +} + +/* 섹션 헤더 */ +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--space-6); + padding-bottom: var(--space-4); + border-bottom: 2px solid var(--border-light); +} + +.section-header h3 { + font-size: var(--text-xl); + font-weight: var(--font-bold); + color: var(--text-primary); + margin: 0; +} + +/* 기존 작업 목록 */ +.existing-work-section { + margin-bottom: var(--space-8); +} + +.existing-work-item { + background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-tertiary) 100%); + border: 2px solid var(--border-light); + border-radius: var(--radius-xl); + padding: var(--space-6); + margin-bottom: var(--space-4); + transition: var(--transition-normal); + box-shadow: var(--shadow-md); + position: relative; + overflow: hidden; +} + +.existing-work-item::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: var(--success-500); + opacity: 0; + transition: opacity 0.3s ease; +} + +.existing-work-item:hover { + border-color: var(--primary-300); + box-shadow: var(--shadow-lg); + transform: translateY(-2px); +} + +.existing-work-item:hover::before { + opacity: 1; +} + +.work-item-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: var(--space-4); +} + +.work-item-info h4 { + font-size: var(--text-lg); + font-weight: var(--font-semibold); + color: var(--text-primary); + margin: 0 0 var(--space-1) 0; +} + +.work-item-info p { + font-size: var(--text-sm); + color: var(--text-secondary); + margin: 0; +} + +.work-item-status { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: var(--space-2); +} + +.status-badge { + padding: var(--space-1) var(--space-3); + border-radius: var(--radius-full); + font-size: var(--text-xs); + font-weight: var(--font-medium); +} + +.status-badge.normal { + background: var(--success-100); + color: var(--success-700); + border: 1px solid var(--success-300); +} + +.status-badge.error { + background: var(--error-100); + color: var(--error-700); + border: 1px solid var(--error-300); +} + +.work-hours { + font-size: var(--text-lg); + font-weight: var(--font-bold); + color: var(--primary-600); +} + +.work-item-error { + background: var(--error-50); + border: 1px solid var(--error-200); + border-radius: var(--radius-lg); + padding: var(--space-3); + margin-bottom: var(--space-4); +} + +.error-label { + font-size: var(--text-sm); + font-weight: var(--font-medium); + color: var(--error-600); +} + +.error-type { + font-size: var(--text-sm); + color: var(--error-700); + margin-left: var(--space-2); +} + +.work-item-actions { + display: flex; + gap: var(--space-2); + justify-content: flex-end; +} + +/* 새 작업 추가 섹션 */ +.new-work-section { + margin-bottom: var(--space-8); +} + +/* 휴가 처리 섹션 */ +.vacation-section { + margin-bottom: var(--space-8); +} + +.vacation-buttons { + display: flex; + gap: var(--space-4); + flex-wrap: wrap; +} + +.vacation-process-btn { + flex: 1; + min-width: 150px; + padding: var(--space-4) var(--space-6); + font-size: var(--text-base); + font-weight: var(--font-medium); + background: linear-gradient(135deg, var(--warning-500), var(--warning-600)); + border: none; + color: var(--text-inverse); + border-radius: var(--radius-xl); + transition: var(--transition-normal); + box-shadow: var(--shadow-md); +} + +.vacation-process-btn:hover { + background: linear-gradient(135deg, var(--warning-600), var(--warning-700)); + transform: translateY(-2px); + box-shadow: var(--shadow-lg); +} + +/* 빈 상태 */ +.empty-state { + text-align: center; + padding: var(--space-12); + background: var(--bg-secondary); + border-radius: var(--radius-xl); + border: 2px dashed var(--border-light); +} + +.empty-icon { + font-size: var(--text-6xl); + margin-bottom: var(--space-4); +} + +.empty-state h3 { + font-size: var(--text-xl); + font-weight: var(--font-semibold); + color: var(--text-primary); + margin: 0 0 var(--space-2) 0; +} + +.empty-state p { + font-size: var(--text-base); + color: var(--text-secondary); + margin: 0; +} + +/* 개별 보고서 반응형 디자인 */ +@media (max-width: 768px) { + .worker-info-card { + flex-direction: column; + text-align: center; + gap: var(--space-4); + } + + .worker-status-summary { + justify-content: center; + gap: var(--space-4); + } + + .section-header { + flex-direction: column; + align-items: stretch; + gap: var(--space-3); + } + + .work-item-header { + flex-direction: column; + gap: var(--space-3); + } + + .work-item-status { + align-items: flex-start; + flex-direction: row; + justify-content: space-between; + } + + .vacation-buttons { + flex-direction: column; + } + + .vacation-process-btn { + min-width: auto; + } +} + +@media (max-width: 480px) { + .worker-status-summary { + flex-direction: column; + gap: var(--space-3); + } + + .status-item { + min-width: auto; + } + + .work-item-actions { + flex-direction: column; + } } \ No newline at end of file diff --git a/web-ui/css/modern-dashboard.css b/web-ui/css/modern-dashboard.css index 1447fe3..0131b94 100644 --- a/web-ui/css/modern-dashboard.css +++ b/web-ui/css/modern-dashboard.css @@ -8,12 +8,19 @@ background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%); } +/* ========== 메인 레이아웃 ========== */ +.dashboard-main { + flex: 1; + padding: 2rem 0; + min-height: calc(100vh - 80px); +} + /* ========== 헤더 ========== */ .dashboard-header { background: linear-gradient(135deg, #1e40af 0%, #1d4ed8 50%, #2563eb 100%); - color: var(--text-inverse); - padding: var(--space-4) var(--space-6); - box-shadow: var(--shadow-lg); + color: white; + padding: 1rem 1.5rem; + box-shadow: 0 10px 15px rgba(0, 0, 0, 0.1); position: sticky; top: 0; z-index: 100; @@ -30,50 +37,50 @@ .header-left .brand { display: flex; align-items: center; - gap: var(--space-3); + gap: 0.75rem; } .brand-logo { width: 48px; height: 48px; - border-radius: var(--radius-lg); - box-shadow: var(--shadow-md); + border-radius: 0.75rem; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); } .brand-title { - font-size: var(--text-2xl); - font-weight: var(--font-bold); + font-size: 1.5rem; + font-weight: 700; margin: 0; line-height: 1.2; } .brand-subtitle { - font-size: var(--text-sm); + font-size: 0.875rem; opacity: 0.9; margin: 0; - font-weight: var(--font-normal); + font-weight: 400; } .header-center .current-time { background: rgba(255, 255, 255, 0.1); backdrop-filter: blur(10px); border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: var(--radius-full); - padding: var(--space-3) var(--space-4); + border-radius: 9999px; + padding: 0.75rem 1rem; text-align: center; } .time-label { display: block; - font-size: var(--text-xs); + font-size: 0.75rem; opacity: 0.8; margin-bottom: var(--space-1); } .time-value { display: block; - font-size: var(--text-lg); - font-weight: var(--font-bold); + font-size: 1.125rem; + font-weight: 700; font-family: 'Courier New', monospace; } @@ -81,12 +88,12 @@ position: relative; display: flex; align-items: center; - gap: var(--space-3); + gap: 0.75rem; background: rgba(255, 255, 255, 0.1); backdrop-filter: blur(10px); border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: var(--radius-full); - padding: var(--space-2) var(--space-4); + border-radius: 9999px; + padding: 0.5rem 1rem; cursor: pointer; transition: var(--transition-normal); } @@ -98,12 +105,12 @@ .user-avatar { width: 40px; height: 40px; - border-radius: var(--radius-full); + border-radius: 9999px; background: var(--primary-300); display: flex; align-items: center; justify-content: center; - font-weight: var(--font-bold); + font-weight: 700; color: var(--primary-800); } @@ -113,13 +120,13 @@ } .user-name { - font-size: var(--text-sm); - font-weight: var(--font-semibold); + font-size: 0.875rem; + font-weight: 600; line-height: 1.2; } .user-role { - font-size: var(--text-xs); + font-size: 0.75rem; opacity: 0.8; } @@ -127,9 +134,9 @@ position: absolute; top: 100%; right: 0; - margin-top: var(--space-2); + margin-top: 0.5rem; background: var(--bg-primary); - border-radius: var(--radius-lg); + border-radius: 0.75rem; box-shadow: var(--shadow-xl); border: 1px solid var(--border-light); min-width: 200px; @@ -150,15 +157,15 @@ .menu-item { display: flex; align-items: center; - gap: var(--space-3); - padding: var(--space-3) var(--space-4); + gap: 0.75rem; + padding: 0.75rem 1rem; color: var(--text-primary); text-decoration: none; border: none; background: none; width: 100%; text-align: left; - font-size: var(--text-sm); + font-size: 0.875rem; cursor: pointer; transition: var(--transition-fast); } @@ -187,7 +194,7 @@ /* ========== 메인 콘텐츠 ========== */ .dashboard-main { flex: 1; - padding: var(--space-8) var(--space-4); + padding: var(--space-8) 1rem; max-width: 1600px; margin: 0 auto; width: 100%; @@ -217,7 +224,7 @@ .summary-card .card-body { display: flex; align-items: flex-start; - gap: var(--space-4); + gap: 1rem; } .summary-icon { @@ -227,7 +234,7 @@ display: flex; align-items: center; justify-content: center; - font-size: var(--text-2xl); + font-size: 1.5rem; flex-shrink: 0; } @@ -256,38 +263,38 @@ } .summary-title { - font-size: var(--text-sm); - font-weight: var(--font-medium); + font-size: 0.875rem; + font-weight: 500; color: var(--text-secondary); - margin: 0 0 var(--space-2) 0; + margin: 0 0 0.5rem 0; } .summary-value { display: flex; align-items: baseline; gap: var(--space-1); - margin-bottom: var(--space-2); + margin-bottom: 0.5rem; } .value-number { font-size: var(--text-3xl); - font-weight: var(--font-bold); + font-weight: 700; color: var(--text-primary); line-height: 1; } .value-unit { - font-size: var(--text-sm); + font-size: 0.875rem; color: var(--text-secondary); - font-weight: var(--font-medium); + font-weight: 500; } .summary-change { display: flex; align-items: center; gap: var(--space-1); - font-size: var(--text-xs); - font-weight: var(--font-medium); + font-size: 0.75rem; + font-weight: 500; margin: 0; } @@ -304,7 +311,7 @@ } .change-icon { - font-size: var(--text-sm); + font-size: 0.875rem; } /* ========== 콘텐츠 섹션 ========== */ @@ -321,26 +328,26 @@ } .card-title { - font-size: var(--text-lg); - font-weight: var(--font-semibold); + font-size: 1.125rem; + font-weight: 600; color: var(--text-primary); margin: 0; display: flex; align-items: center; - gap: var(--space-2); + gap: 0.5rem; } .date-selector { display: flex; align-items: center; - gap: var(--space-3); + gap: 0.75rem; } .date-input { - padding: var(--space-2) var(--space-3); + padding: 0.5rem var(--space-3); border: 1px solid var(--border-medium); - border-radius: var(--radius-md); - font-size: var(--text-sm); + border-radius: 0.5rem; + font-size: 0.875rem; background: var(--bg-primary); color: var(--text-primary); } @@ -366,7 +373,7 @@ .work-status-container-enhanced { background: linear-gradient(135deg, var(--bg-tertiary) 0%, var(--bg-primary) 100%); - border-radius: var(--radius-lg); + border-radius: 0.75rem; border: 1px solid var(--border-light); position: relative; overflow: hidden; @@ -386,7 +393,7 @@ display: flex; flex-direction: column; align-items: center; - gap: var(--space-4); + gap: 1rem; color: var(--text-secondary); } @@ -398,7 +405,7 @@ .quick-actions-grid-full { display: grid; grid-template-columns: repeat(4, 1fr); - gap: var(--space-6); + gap: 1.5rem; max-width: none; width: 100%; } @@ -406,14 +413,14 @@ .quick-actions-grid { display: grid; grid-template-columns: repeat(2, 1fr); - gap: var(--space-4); + gap: 1rem; } .quick-action-card { display: flex; flex-direction: column; align-items: center; - gap: var(--space-4); + gap: 1rem; padding: var(--space-8); background: linear-gradient(145deg, #ffffff 0%, #f8fafc 50%, #f1f5f9 100%); border-radius: var(--radius-2xl); @@ -479,7 +486,7 @@ width: 80px; height: 80px; background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 50%, #93c5fd 100%); - border-radius: var(--radius-full); + border-radius: 9999px; display: flex; align-items: center; justify-content: center; @@ -526,7 +533,7 @@ flex: 1; display: flex; flex-direction: column; - gap: var(--space-2); + gap: 0.5rem; } .action-content h3 { @@ -556,12 +563,12 @@ .action-arrow { position: absolute; - top: var(--space-4); - right: var(--space-4); + top: 1rem; + right: 1rem; width: 32px; height: 32px; background: linear-gradient(135deg, #f3f4f6, #e5e7eb); - border-radius: var(--radius-full); + border-radius: 9999px; display: flex; align-items: center; justify-content: center; @@ -602,13 +609,13 @@ .quick-actions-grid-full, .quick-actions-grid { grid-template-columns: 1fr; - gap: var(--space-3); + gap: 0.75rem; } .quick-action-card { min-height: 140px; - padding: var(--space-6); - gap: var(--space-3); + padding: 1.5rem; + gap: 0.75rem; } .action-icon-large { @@ -647,7 +654,7 @@ .view-controls { display: flex; - gap: var(--space-2); + gap: 0.5rem; } .workers-container { @@ -658,7 +665,7 @@ .dashboard-footer { background: var(--bg-primary); border-top: 1px solid var(--border-light); - padding: var(--space-6); + padding: 1.5rem; margin-top: auto; } @@ -671,18 +678,18 @@ } .footer-text { - font-size: var(--text-sm); + font-size: 0.875rem; color: var(--text-secondary); margin: 0; } .footer-links { display: flex; - gap: var(--space-6); + gap: 1.5rem; } .footer-link { - font-size: var(--text-sm); + font-size: 0.875rem; color: var(--text-secondary); text-decoration: none; transition: var(--transition-fast); @@ -695,24 +702,24 @@ /* ========== 토스트 알림 ========== */ .toast-container { position: fixed; - top: var(--space-6); - right: var(--space-6); + top: 1.5rem; + right: 1.5rem; z-index: 1000; display: flex; flex-direction: column; - gap: var(--space-3); + gap: 0.75rem; } .toast { background: var(--bg-primary); - border-radius: var(--radius-lg); + border-radius: 0.75rem; box-shadow: var(--shadow-xl); border: 1px solid var(--border-light); - padding: var(--space-4); + padding: 1rem; min-width: 300px; display: flex; align-items: center; - gap: var(--space-3); + gap: 0.75rem; animation: slideInRight var(--transition-normal) ease-out; } @@ -747,13 +754,13 @@ .work-status-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); - gap: var(--space-4); + gap: 1rem; } .project-status-card { background: var(--bg-tertiary); - border-radius: var(--radius-lg); - padding: var(--space-4); + border-radius: 0.75rem; + padding: 1rem; border: 1px solid var(--border-light); } @@ -765,20 +772,20 @@ } .project-name { - font-size: var(--text-base); - font-weight: var(--font-semibold); + font-size: 1rem; + font-weight: 600; margin: 0; color: var(--text-primary); } .work-count { - font-size: var(--text-xs); + font-size: 0.75rem; } .project-stats { display: flex; justify-content: space-between; - gap: var(--space-3); + gap: 0.75rem; } .stat-item { @@ -787,15 +794,15 @@ .stat-label { display: block; - font-size: var(--text-xs); + font-size: 0.75rem; color: var(--text-secondary); margin-bottom: var(--space-1); } .stat-value { display: block; - font-size: var(--text-sm); - font-weight: var(--font-semibold); + font-size: 0.875rem; + font-weight: 600; color: var(--text-primary); } @@ -805,7 +812,7 @@ /* ========== 작업자 카드 스타일 ========== */ .workers-grid { - gap: var(--space-4); + gap: 1rem; } .worker-card { @@ -814,33 +821,33 @@ .worker-card:hover { transform: translateY(-4px); - box-shadow: var(--shadow-lg); + box-shadow: 0 10px 15px rgba(0, 0, 0, 0.1); } .worker-header { display: flex; align-items: center; - gap: var(--space-3); - margin-bottom: var(--space-4); + gap: 0.75rem; + margin-bottom: 1rem; } .worker-avatar { width: 48px; height: 48px; - border-radius: var(--radius-full); + border-radius: 9999px; background: var(--primary-100); display: flex; align-items: center; justify-content: center; - font-weight: var(--font-bold); + font-weight: 700; color: var(--primary-700); - font-size: var(--text-lg); + font-size: 1.125rem; } .worker-avatar.small { width: 32px; height: 32px; - font-size: var(--text-sm); + font-size: 0.875rem; } .worker-info { @@ -848,14 +855,14 @@ } .worker-name { - font-size: var(--text-sm); - font-weight: var(--font-semibold); + font-size: 0.875rem; + font-weight: 600; margin: 0 0 var(--space-1) 0; color: var(--text-primary); } .worker-job { - font-size: var(--text-xs); + font-size: 0.75rem; color: var(--text-secondary); margin: 0; } @@ -868,7 +875,7 @@ .worker-stats { display: flex; justify-content: space-between; - gap: var(--space-2); + gap: 0.5rem; } .stat { @@ -889,25 +896,25 @@ width: 100%; border-collapse: collapse; background: var(--bg-primary); - border-radius: var(--radius-lg); + border-radius: 0.75rem; overflow: hidden; box-shadow: var(--shadow-sm); } .table th { background: var(--gray-50); - padding: var(--space-4); + padding: 1rem; text-align: left; - font-size: var(--text-sm); - font-weight: var(--font-semibold); + font-size: 0.875rem; + font-weight: 600; color: var(--text-secondary); border-bottom: 1px solid var(--border-light); } .table td { - padding: var(--space-4); + padding: 1rem; border-bottom: 1px solid var(--border-light); - font-size: var(--text-sm); + font-size: 0.875rem; color: var(--text-primary); } @@ -922,7 +929,7 @@ .worker-cell { display: flex; align-items: center; - gap: var(--space-3); + gap: 0.75rem; } /* ========== 빈 상태 및 오류 상태 ========== */ @@ -940,42 +947,42 @@ .empty-icon, .error-icon { font-size: var(--text-5xl); - margin-bottom: var(--space-4); + margin-bottom: 1rem; opacity: 0.5; } .empty-state h3, .error-state h3 { - font-size: var(--text-lg); - font-weight: var(--font-semibold); - margin: 0 0 var(--space-2) 0; + font-size: 1.125rem; + font-weight: 600; + margin: 0 0 0.5rem 0; color: var(--text-primary); } .empty-state p, .error-state p { - font-size: var(--text-sm); - margin: 0 0 var(--space-4) 0; + font-size: 0.875rem; + margin: 0 0 1rem 0; max-width: 400px; line-height: 1.5; } /* ========== 토스트 스타일 보완 ========== */ .toast-icon { - font-size: var(--text-lg); + font-size: 1.125rem; flex-shrink: 0; } .toast-message { flex: 1; - font-size: var(--text-sm); + font-size: 0.875rem; color: var(--text-primary); } .toast-close { background: none; border: none; - font-size: var(--text-lg); + font-size: 1.125rem; color: var(--text-secondary); cursor: pointer; padding: 0; @@ -1006,12 +1013,12 @@ @media (max-width: 768px) { .dashboard-main { - padding: var(--space-4); + padding: 1rem; } .header-content { flex-direction: column; - gap: var(--space-4); + gap: 1rem; } .header-center, @@ -1031,22 +1038,22 @@ .footer-content { flex-direction: column; - gap: var(--space-4); + gap: 1rem; text-align: center; } } @media (max-width: 640px) { .dashboard-header { - padding: var(--space-3) var(--space-4); + padding: 0.75rem 1rem; } .brand-title { - font-size: var(--text-lg); + font-size: 1.125rem; } .brand-subtitle { - font-size: var(--text-xs); + font-size: 0.75rem; } .user-info { @@ -1054,11 +1061,775 @@ } .toast-container { - left: var(--space-4); - right: var(--space-4); + left: 1rem; + right: 1rem; } .toast { min-width: auto; } } + +/* ========== 작업자 상태 관리 ========== */ +.worker-status-list { + width: 100%; +} + +.worker-status-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 2px solid var(--border-light); +} + +/* 개선된 헤더 스타일 */ +.header-title { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.header-title h3 { + font-size: var(--text-xl); + font-weight: 700; + color: var(--text-primary); + margin: 0; +} + +.header-date { + background: linear-gradient(135deg, #6366f1, #8b5cf6); + color: white; + padding: var(--space-1) var(--space-3); + border-radius: 9999px; + font-size: 0.875rem; + font-weight: 500; +} + +.status-legend { + display: flex; + gap: 1rem; + flex-wrap: wrap; +} + +/* 개선된 범례 스타일 */ +.legend-item { + font-size: 0.75rem; + padding: var(--space-1) 0.5rem; + border-radius: 0.5rem; + font-weight: 500; + border: 1px solid transparent; + position: relative; +} + +.legend-item::before { + content: ''; + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: var(--space-1); +} + +.legend-complete { background: #ecfdf5; color: #059669; } +.legend-complete::before { background: #059669; } + +.legend-overtime { background: #ecfeff; color: #0891b2; } +.legend-overtime::before { background: #0891b2; } + +.legend-vacation { background: #fffbeb; color: #f59e0b; } +.legend-vacation::before { background: #f59e0b; } + +.legend-partial { background: #fff7ed; color: #f97316; } +.legend-partial::before { background: #f97316; } + +.legend-incomplete { background: #fef2f2; color: #ef4444; } +.legend-incomplete::before { background: #ef4444; } + +.legend-error { background: #fef2f2; color: #dc2626; } +.legend-error::before { background: #dc2626; } + +/* 한 줄 레이아웃으로 변경 */ +.worker-status-rows { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +/* 한 줄 작업자 카드 - 개선된 가독성 */ +.worker-status-row { + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 0.75rem; + padding: var(--space-5) 1.5rem; + transition: all 0.2s ease; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1); + position: relative; + overflow: hidden; + display: flex; + align-items: center; + gap: 1.5rem; + min-height: 85px; +} + +.worker-status-row::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: var(--primary-500); + opacity: 0; + transition: opacity 0.3s ease; +} + +.worker-status-row:hover { + border-color: #d1d5db; + box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 0.15); + transform: translateY(-1px); +} + +.worker-status-row:hover::before { + opacity: 1; +} + +/* 상태별 색상 - 개선된 근로 상태 기준 */ +.worker-status-row.complete::before { background: #059669; } /* 정시근로 - 초록색 */ +.worker-status-row.overtime::before { background: #0891b2; } /* 연장근로 - 청록색 */ +.worker-status-row.vacation-full::before { background: #f59e0b; } /* 연차 - 주황색 */ +.worker-status-row.vacation-half::before { background: #fbbf24; } /* 반차 - 밝은 주황색 */ +.worker-status-row.vacation-half-half::before { background: #fcd34d; } /* 조퇴 - 노란색 */ +.worker-status-row.vacation-quarter::before { background: #fde047; } /* 반반차 - 밝은 노란색 */ +.worker-status-row.partial::before { background: #f97316; } /* 부분입력 - 주황색 */ +.worker-status-row.incomplete::before { background: #ef4444; } /* 미입력 - 빨간색 */ +.worker-status-row.error::before { background: #dc2626; } /* 오류 - 진한 빨간색 */ +.worker-status-row.overtime-warning::before { background: #dc2626; } /* 확인필요 - 진한 빨간색 */ + +/* 한 줄 레이아웃 요소들 */ +.worker-basic-info { + display: flex; + align-items: center; + gap: 0.75rem; + flex: 0 0 200px; +} + +.worker-stats-inline { + display: flex; + gap: 1rem; + flex: 1; + justify-content: center; +} + +.worker-stats-inline .stat-item { + text-align: center; + padding: 0.5rem var(--space-3); + background: #f9fafb; + border-radius: 0.5rem; + border: 1px solid #f3f4f6; + min-width: 80px; +} + +.worker-stats-inline .stat-label { + display: block; + font-size: 0.75rem; + color: #6b7280; + margin-bottom: var(--space-1); + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.worker-stats-inline .stat-value { + display: block; + font-size: 1rem; + font-weight: 700; + color: #1f2937; +} + +.worker-stats-inline .stat-value.warning { + color: #dc2626; +} + +.worker-stats-inline .stat-item.error .stat-value { + color: #ef4444; +} + +.worker-actions-inline { + display: flex; + gap: 0.5rem; + flex: 0 0 auto; +} + +/* 상태 배지 스타일 */ +.status-badge { + padding: var(--space-1) var(--space-3); + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + border: 1px solid transparent; + min-width: 70px; + text-align: center; +} + +.status-complete { background: #ecfdf5; color: #059669; border-color: #6ee7b7; } +.status-overtime { background: #ecfeff; color: #0891b2; border-color: #67e8f9; } +.status-vacation-full { background: #fffbeb; color: #f59e0b; border-color: #fde68a; } +.status-vacation-half { background: #fffbeb; color: #f59e0b; border-color: #fde68a; } +.status-vacation-half-half { background: #fffbeb; color: #f59e0b; border-color: #fde68a; } +.status-vacation-quarter { background: #fffbeb; color: #f59e0b; border-color: #fde68a; } +.status-partial { background: #fff7ed; color: #f97316; border-color: #fed7aa; } +.status-incomplete { background: #fef2f2; color: #ef4444; border-color: #fca5a5; } +.status-error { background: #fef2f2; color: #dc2626; border-color: #f87171; } +.status-overtime-warning { background: #fef2f2; color: #dc2626; border-color: #f87171; } + +.worker-avatar { + width: 56px; + height: 56px; + border-radius: 9999px; + background: linear-gradient(135deg, var(--primary-500), var(--secondary-500)); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-inverse); + font-weight: 700; + font-size: var(--text-xl); + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +.worker-details { + flex: 1; +} + +.worker-name { + font-size: 1.125rem; + font-weight: 700; + color: #1f2937; + margin: 0 0 var(--space-1) 0; + line-height: 1.2; +} + +.worker-job { + font-size: 0.875rem; + color: #6b7280; + margin: 0; + font-weight: 500; +} + +.worker-status-indicator { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-1); +} + +.status-icon { + font-size: 1.5rem; +} + +.status-text { + font-size: 0.75rem; + font-weight: 500; + color: var(--text-secondary); + text-align: center; +} + +.worker-stats-row { + display: flex; + gap: 1rem; + margin-bottom: 1rem; + padding: var(--space-3); + background: var(--bg-secondary); + border-radius: 0.75rem; +} + +.worker-stats-row .stat-item { + flex: 1; + text-align: center; +} + +.worker-stats-row .stat-label { + display: block; + font-size: 0.75rem; + color: var(--text-secondary); + margin-bottom: var(--space-1); +} + +.worker-stats-row .stat-value { + display: block; + font-size: 1.125rem; + font-weight: 700; + color: var(--text-primary); +} + +.worker-stats-row .stat-value.warning { + color: var(--error-600); +} + +.worker-stats-row .stat-item.error .stat-value { + color: var(--error-600); +} + +.worker-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.worker-actions .btn { + flex: 1; + min-width: 120px; + font-size: 0.875rem; + padding: 0.5rem var(--space-3); +} + +.worker-edit-btn { + background: linear-gradient(135deg, var(--primary-500), var(--primary-600)); + border: none; + color: var(--text-inverse); +} + +.worker-edit-btn:hover { + background: linear-gradient(135deg, var(--primary-600), var(--primary-700)); + transform: translateY(-1px); +} + +.vacation-btn { + background: linear-gradient(135deg, var(--warning-500), var(--warning-600)); + border: none; + color: var(--text-inverse); +} + +.vacation-btn:hover { + background: linear-gradient(135deg, var(--warning-600), var(--warning-700)); + transform: translateY(-1px); +} + +.confirm-overtime-btn { + background: linear-gradient(135deg, var(--error-500), var(--error-600)); + border: none; + color: var(--text-inverse); +} + +.confirm-overtime-btn:hover { + background: linear-gradient(135deg, var(--error-600), var(--error-700)); + transform: translateY(-1px); +} + +/* ========== 모달 시스템 ========== */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + display: none; + align-items: center; + justify-content: center; + z-index: 1000; + backdrop-filter: blur(4px); +} + +.modal-container { + background: var(--bg-primary); + border-radius: var(--radius-2xl); + box-shadow: var(--shadow-2xl); + width: 90%; + max-width: 800px; + max-height: 90vh; + overflow: hidden; + display: flex; + flex-direction: column; + animation: modalSlideIn 0.3s ease-out; +} + +@keyframes modalSlideIn { + from { + opacity: 0; + transform: translateY(-50px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.modal-header { + background: linear-gradient(135deg, var(--primary-500), var(--secondary-500)); + color: var(--text-inverse); + padding: 1.5rem; + display: flex; + justify-content: space-between; + align-items: center; +} + +.modal-header h2 { + margin: 0; + font-size: var(--text-xl); + font-weight: 700; +} + +.modal-close-btn { + background: none; + border: none; + color: var(--text-inverse); + font-size: var(--text-xl); + cursor: pointer; + padding: 0.5rem; + border-radius: 0.5rem; + transition: var(--transition-normal); +} + +.modal-close-btn:hover { + background: rgba(255, 255, 255, 0.2); +} + +.modal-body { + padding: 1.5rem; + overflow-y: auto; + flex: 1; +} + +.modal-footer { + padding: 1rem 1.5rem; + border-top: 1px solid var(--border-light); + display: flex; + justify-content: flex-end; + gap: 0.75rem; +} + +/* 모달 작업자 정보 */ +.modal-worker-info { + background: linear-gradient(135deg, var(--primary-50), var(--secondary-50)); + border-radius: var(--radius-xl); + padding: 1.5rem; + margin-bottom: 1.5rem; + display: flex; + align-items: center; + gap: 1rem; +} + +.modal-worker-avatar { + width: 64px; + height: 64px; + border-radius: 9999px; + background: linear-gradient(135deg, var(--primary-500), var(--secondary-500)); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-inverse); + font-weight: 700; + font-size: 1.5rem; + box-shadow: 0 10px 15px rgba(0, 0, 0, 0.1); + flex-shrink: 0; +} + +.modal-worker-details { + flex: 1; +} + +.modal-worker-details h3 { + margin: 0 0 var(--space-1) 0; + font-size: var(--text-xl); + font-weight: 700; + color: var(--text-primary); +} + +.modal-worker-details p { + margin: 0; + color: var(--text-secondary); +} + +.modal-worker-summary { + display: flex; + gap: 1rem; +} + +.modal-stat { + text-align: center; + padding: var(--space-3); + background: var(--bg-primary); + border-radius: 0.75rem; + border: 1px solid var(--border-light); + min-width: 80px; +} + +.modal-stat-label { + display: block; + font-size: 0.75rem; + color: var(--text-secondary); + margin-bottom: var(--space-1); +} + +.modal-stat-value { + display: block; + font-size: 1.125rem; + font-weight: 700; + color: var(--text-primary); +} + +.modal-stat-value.warning { + color: var(--error-600); +} + +/* 모달 섹션 */ +.modal-section { + margin-bottom: 1.5rem; +} + +.modal-section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + padding-bottom: var(--space-3); + border-bottom: 2px solid var(--border-light); +} + +.modal-section-header h4 { + margin: 0; + font-size: 1.125rem; + font-weight: 700; + color: var(--text-primary); +} + +/* 기존 작업 목록 */ +.modal-existing-work { + max-height: 200px; + overflow-y: auto; +} + +.modal-work-item { + background: var(--bg-secondary); + border-radius: 0.75rem; + padding: 1rem; + margin-bottom: var(--space-3); + display: flex; + justify-content: space-between; + align-items: center; + border: 1px solid var(--border-light); + transition: var(--transition-normal); +} + +.modal-work-item:hover { + border-color: var(--primary-300); + box-shadow: var(--shadow-sm); +} + +.modal-work-info h5 { + margin: 0 0 var(--space-1) 0; + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); +} + +.modal-work-info p { + margin: 0; + font-size: 0.875rem; + color: var(--text-secondary); +} + +.modal-error-badge { + display: inline-block; + background: var(--error-100); + color: var(--error-700); + padding: var(--space-1) 0.5rem; + border-radius: 0.5rem; + font-size: 0.75rem; + margin-top: var(--space-1); +} + +.modal-work-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.modal-work-hours { + font-size: 1.125rem; + font-weight: 700; + color: var(--primary-600); +} + +/* 새 작업 폼 */ +.modal-work-form { + background: var(--bg-secondary); + border-radius: var(--radius-xl); + padding: 1.5rem; +} + +.modal-form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + margin-bottom: 1rem; +} + +.modal-form-group { + margin-bottom: 1rem; +} + +.modal-form-group label { + display: block; + font-size: 0.875rem; + font-weight: 500; + color: var(--text-primary); + margin-bottom: 0.5rem; +} + +.modal-select, +.modal-input { + width: 100%; + padding: var(--space-3); + border: 2px solid var(--border-light); + border-radius: 0.75rem; + font-size: 1rem; + background: var(--bg-primary); + color: var(--text-primary); + transition: var(--transition-normal); +} + +.modal-select:focus, +.modal-input:focus { + outline: none; + border-color: var(--primary-500); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.modal-quick-time { + display: flex; + gap: 0.5rem; + margin-top: 0.5rem; + flex-wrap: wrap; +} + +.modal-time-btn { + padding: 0.5rem var(--space-3); + border: 1px solid var(--primary-300); + background: var(--primary-100); + color: var(--primary-700); + border-radius: 0.5rem; + font-size: 0.875rem; + cursor: pointer; + transition: var(--transition-normal); +} + +.modal-time-btn:hover { + background: var(--primary-200); + border-color: var(--primary-400); +} + +.modal-save-btn { + width: 100%; + padding: 1rem; + font-size: 1rem; + font-weight: 500; + margin-top: 1rem; +} + +/* 휴가 처리 버튼 */ +.modal-vacation-buttons { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; +} + +.modal-vacation-btn { + flex: 1; + min-width: 150px; + padding: 0.75rem 1rem; + font-size: 0.875rem; + font-weight: 500; +} + +/* 빈 상태 */ +.modal-empty-state { + text-align: center; + padding: var(--space-8); + color: var(--text-secondary); +} + +.modal-empty-icon { + font-size: var(--text-4xl); + margin-bottom: 0.5rem; +} + +/* 작업자 상태 반응형 */ +@media (max-width: 1200px) { + .worker-status-rows { + /* 한 줄 레이아웃은 그대로 유지 */ + } +} + +@media (max-width: 768px) { + .worker-status-header { + flex-direction: column; + align-items: stretch; + gap: 0.75rem; + } + + .status-legend { + justify-content: center; + } + + .worker-status-row { + flex-direction: column; + align-items: stretch; + gap: 0.75rem; + padding: 1rem; + } + + .worker-basic-info { + flex: none; + justify-content: center; + } + + .worker-stats-inline { + justify-content: space-around; + } + + .worker-actions-inline { + justify-content: center; + flex-wrap: wrap; + } + + /* 모달 반응형 */ + .modal-container { + width: 95%; + max-height: 95vh; + } + + .modal-form-row { + grid-template-columns: 1fr; + } + + .modal-worker-info { + flex-direction: column; + text-align: center; + gap: 0.75rem; + } + + .modal-worker-summary { + justify-content: center; + } + + .modal-vacation-buttons { + flex-direction: column; + } +} + +@media (max-width: 480px) { + .worker-stats-row { + flex-direction: column; + gap: 0.5rem; + } + + .worker-actions .btn { + min-width: 100px; + font-size: 0.75rem; + } +} diff --git a/web-ui/css/work-report-calendar.css b/web-ui/css/work-report-calendar.css new file mode 100644 index 0000000..806ea1f --- /dev/null +++ b/web-ui/css/work-report-calendar.css @@ -0,0 +1,1565 @@ +/* 작업 현황 캘린더 스타일 */ + +/* 월 네비게이션 */ +.month-navigation { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-6); + padding: var(--space-4); + background: var(--white); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); +} + +.month-nav-btn { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); + background: var(--primary-50); + border: 1px solid var(--primary-200); + border-radius: var(--radius-md); + color: var(--primary-700); + font-weight: 500; + cursor: pointer; + transition: var(--transition-normal); +} + +.month-nav-btn:hover { + background: var(--primary-100); + border-color: var(--primary-300); + transform: translateY(-1px); +} + +.month-info { + display: flex; + align-items: center; + gap: var(--space-4); +} + +.month-info h2 { + margin: 0; + font-size: var(--text-2xl); + font-weight: 700; + color: var(--gray-900); +} + +.today-btn { + padding: var(--space-2) var(--space-4); + background: var(--success-500); + border: none; + border-radius: var(--radius-md); + color: var(--white); + font-weight: 500; + cursor: pointer; + transition: var(--transition-normal); +} + +.today-btn:hover { + background: var(--success-600); + transform: translateY(-1px); +} + +/* 캘린더 범례 */ +.calendar-legend { + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-6); + margin-bottom: var(--space-6); + padding: var(--space-4); + background: var(--gray-50); + border-radius: var(--radius-lg); +} + +.legend-item { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: var(--text-sm); + font-weight: 500; + color: var(--gray-700); +} + +.legend-dot { + width: 12px; + height: 12px; + border-radius: 50%; + flex-shrink: 0; +} + +.legend-dot.normal { background: var(--gray-300); } +.legend-dot.issues { background: var(--warning-500); } +.legend-dot.errors { background: var(--error-500); } + +/* 캘린더 컨테이너 */ +.calendar-container { + background: var(--white); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + overflow: hidden; +} + +.calendar-grid { + display: flex; + flex-direction: column; +} + +.calendar-header { + display: grid; + grid-template-columns: repeat(7, 1fr); + background: var(--gray-100); + border-bottom: 1px solid var(--gray-200); +} + +.day-header { + padding: var(--space-4); + text-align: center; + font-weight: 600; + font-size: var(--text-sm); + color: var(--gray-700); + border-right: 1px solid var(--gray-200); +} + +.day-header:last-child { + border-right: none; +} + +.day-header:first-child, +.day-header:last-child { + color: var(--red-600); /* 일요일, 토요일 */ +} + +.calendar-days { + display: grid; + grid-template-columns: repeat(7, 1fr); +} + +/* 캘린더 날짜 셀 */ +.calendar-day { + position: relative; + min-height: 140px; + padding: var(--space-3); + border-right: 1px solid var(--gray-200); + border-bottom: 1px solid var(--gray-200); + cursor: pointer; + transition: all 0.2s ease; + background: var(--white); + display: flex; + flex-direction: column; +} + +.calendar-day:last-child { + border-right: none; +} + +.calendar-day:hover { + background: var(--gray-50); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0,0,0,0.1); +} + +.calendar-day.other-month { + background: var(--gray-50); + color: var(--gray-400); +} + +.calendar-day.other-month:hover { + background: var(--gray-100); +} + +.calendar-day.today { + background: linear-gradient(135deg, var(--primary-50), var(--primary-100)); + border: 2px solid var(--primary-500); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.calendar-day.has-issues { + background: linear-gradient(135deg, var(--orange-100), var(--red-100)); + border: 2px solid var(--orange-400); + box-shadow: 0 0 0 3px rgba(251, 146, 60, 0.2); + animation: pulse-warning 2s infinite; +} + +.calendar-day.has-errors { + background: linear-gradient(135deg, var(--red-100), var(--red-200)); + border: 2px solid var(--red-500); + box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.3); + animation: pulse-error 2s infinite; +} + +@keyframes pulse-warning { + 0%, 100% { + box-shadow: 0 0 0 3px rgba(251, 146, 60, 0.2); + } + 50% { + box-shadow: 0 0 0 6px rgba(251, 146, 60, 0.1); + } +} + +@keyframes pulse-error { + 0%, 100% { + box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.3); + } + 50% { + box-shadow: 0 0 0 6px rgba(239, 68, 68, 0.1); + } +} + +.calendar-day.weekend { + background: var(--blue-50); +} + +.calendar-day.weekend.has-issues { + background: linear-gradient(135deg, var(--blue-50), var(--warning-100)); +} + +.calendar-day.weekend.has-errors { + background: linear-gradient(135deg, var(--blue-50), var(--error-100)); +} + +/* 날짜 번호 */ +.day-number { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + margin-bottom: var(--space-2); + font-weight: 700; + font-size: var(--text-base); + border-radius: 50%; + transition: all 0.2s ease; +} + +.calendar-day.today .day-number { + background: var(--primary-500); + color: var(--white); + box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3); + transform: scale(1.1); +} + +.calendar-day.sunday .day-number, +.calendar-day.saturday .day-number { + color: var(--red-600); + font-weight: 800; +} + +.calendar-day:hover .day-number { + background: var(--gray-200); + transform: scale(1.05); +} + +.calendar-day.today:hover .day-number { + background: var(--primary-600); + transform: scale(1.15); +} + +/* 문제 표시 아이콘 */ +.problem-indicator { + position: absolute; + bottom: var(--space-2); + left: 50%; + transform: translateX(-50%); + width: 20px; + height: 20px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: bold; + color: var(--white); + box-shadow: 0 2px 4px rgba(0,0,0,0.2); +} + +.problem-indicator.issues { + background: var(--warning-500); +} + +.problem-indicator.errors { + background: var(--error-500); + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} + +/* 작업 입력 모달 스타일 - 대시보드와 동일한 디자인 */ +#workEntryModal .modal-container { + width: 90%; + max-width: 600px; + max-height: 90vh; + background: white; + border-radius: 1rem; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + overflow: hidden; + border: 1px solid #e5e7eb; +} + +#workEntryModal .modal-header { + background: linear-gradient(135deg, #1e40af 0%, #1d4ed8 50%, #2563eb 100%); + color: white; + padding: 1.5rem; + position: relative; +} + +#workEntryModal .modal-header::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: url('data:image/svg+xml,'); + opacity: 0.3; +} + +#workEntryModal .modal-header h2 { + margin: 0; + font-size: 1.25rem; + font-weight: 700; + position: relative; + z-index: 1; +} + +#workEntryModal .modal-close-btn { + position: absolute; + top: 1rem; + right: 1rem; + background: rgba(255, 255, 255, 0.2); + border: 1px solid rgba(255, 255, 255, 0.3); + color: white; + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; + z-index: 2; +} + +#workEntryModal .modal-close-btn:hover { + background: rgba(255, 255, 255, 0.3); + transform: scale(1.1); +} + +#workEntryModal .modal-body { + padding: 1.5rem; + max-height: 60vh; + overflow-y: auto; +} + +.form-section { + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 2px solid #f3f4f6; +} + +.form-section:last-child { + border-bottom: none; + margin-bottom: 0; +} + +.form-section h3 { + margin-bottom: 1rem; + font-size: 1.125rem; + font-weight: 700; + color: #111827; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.form-section h3::before { + content: '📝'; + font-size: 1rem; +} + +.form-section:nth-child(1) h3::before { content: '👤'; } +.form-section:nth-child(2) h3::before { content: '📋'; } +.form-section:nth-child(3) h3::before { content: '🏖️'; } + +.form-group { + margin-bottom: 1rem; +} + +.form-label { + display: block; + font-weight: 600; + margin-bottom: 0.5rem; + color: #374151; + font-size: 0.875rem; +} + +.form-control { + width: 100%; + padding: 0.75rem; + border: 2px solid #e5e7eb; + border-radius: 0.5rem; + font-size: 0.875rem; + transition: all 0.2s ease; + background: white; +} + +.form-control:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +.form-control:read-only { + background: #f9fafb; + color: #6b7280; +} + +.vacation-buttons { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.75rem; +} + +.btn-vacation { + padding: 0.75rem 1rem; + background: linear-gradient(135deg, #fef3c7, #fde68a); + color: #92400e; + border: 2px solid #fcd34d; + border-radius: 0.5rem; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + position: relative; + overflow: hidden; +} + +.btn-vacation::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent); + transition: left 0.5s; +} + +.btn-vacation:hover::before { + left: 100%; +} + +.btn-vacation:hover { + background: linear-gradient(135deg, #fde68a, #fcd34d); + border-color: #f59e0b; + transform: translateY(-2px); + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + padding: 1.5rem; + border-top: 2px solid #f3f4f6; + background: linear-gradient(135deg, #f9fafb, white); +} + +.btn { + padding: 0.75rem 1.5rem; + border-radius: 0.5rem; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + border: 2px solid transparent; + position: relative; + overflow: hidden; +} + +.btn::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent); + transition: left 0.5s; +} + +.btn:hover::before { + left: 100%; +} + +.btn-primary { + background: linear-gradient(135deg, #2563eb, #1d4ed8); + color: white; + border-color: #3b82f6; +} + +.btn-primary:hover { + background: linear-gradient(135deg, #1d4ed8, #1e40af); + transform: translateY(-2px); + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); +} + +.btn-secondary { + background: linear-gradient(135deg, #6b7280, #4b5563); + color: white; + border-color: #9ca3af; +} + +.btn-secondary:hover { + background: linear-gradient(135deg, #4b5563, #374151); + transform: translateY(-2px); + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); +} + +/* 작업자 카드 스타일 (대시보드 스타일) */ +.worker-card { + background: white; + border-radius: 1rem; + padding: 1.5rem; + margin-bottom: 1rem; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + border: 2px solid transparent; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + gap: 1rem; +} + +.worker-card:hover { + transform: translateY(-2px); + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15); +} + +.worker-card.complete { + border-color: #10b981; + background: linear-gradient(135deg, #ecfdf5, #f0fdf4); +} + +.worker-card.overtime { + border-color: #06b6d4; + background: linear-gradient(135deg, #ecfeff, #f0fdff); +} + +.worker-card.incomplete { + border-color: #ef4444; + background: linear-gradient(135deg, #fef2f2, #fef7f7); +} + +.worker-card.partial { + border-color: #f59e0b; + background: linear-gradient(135deg, #fffbeb, #fefce8); +} + +.worker-card.vacation-full, +.worker-card.vacation-half, +.worker-card.vacation-quarter, +.worker-card.vacation-half-half { + border-color: #eab308; + background: linear-gradient(135deg, #fefce8, #fffbeb); +} + +.worker-avatar { + flex-shrink: 0; +} + +.avatar-circle { + width: 60px; + height: 60px; + border-radius: 50%; + background: linear-gradient(135deg, #667eea, #764ba2); + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: 700; + font-size: 1.25rem; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.worker-info { + flex: 1; + min-width: 0; +} + +.worker-name { + font-size: 1.125rem; + font-weight: 700; + color: #111827; + margin-bottom: 0.25rem; +} + +.worker-job { + font-size: 0.875rem; + color: #6b7280; + font-weight: 500; +} + +.worker-status { + flex-shrink: 0; + margin-right: 1rem; +} + +.worker-stats { + flex-shrink: 0; + text-align: right; + margin-right: 1rem; +} + +.stat-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + margin-bottom: 0.25rem; +} + +.stat-row:last-child { + margin-bottom: 0; +} + +.stat-label { + font-size: 0.75rem; + color: #6b7280; + font-weight: 500; +} + +.stat-value { + font-size: 0.875rem; + font-weight: 600; + color: #111827; +} + +.stat-value.error { + color: #dc2626; +} + +.worker-actions { + flex-shrink: 0; +} + +.btn-work-entry { + background: linear-gradient(135deg, #3b82f6, #2563eb); + color: white; + border: none; + padding: 0.5rem 1rem; + border-radius: 0.5rem; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.btn-work-entry:hover { + background: linear-gradient(135deg, #2563eb, #1d4ed8); + transform: scale(1.05); +} + +/* 캘린더 페이지 컨테이너 */ +.calendar-page-container { + max-width: 1000px; + margin: 0 auto; + padding: 2rem; +} + +/* 페이지 제목 */ +.page-title-section { + text-align: center; + margin-bottom: 2rem; +} + +.page-title { + font-size: 2rem; + font-weight: 700; + color: #111827; + margin-bottom: 0.5rem; +} + +.page-subtitle { + font-size: 1rem; + color: #6b7280; + margin: 0; +} + +/* 캘린더 카드 */ +.calendar-card { + background: white; + border-radius: 1rem; + padding: 2rem; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + border: 1px solid #e5e7eb; +} + +/* 캘린더 네비게이션 */ +.calendar-nav { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 2px solid #f3f4f6; +} + +.nav-btn { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + background: linear-gradient(135deg, #3b82f6, #2563eb); + color: white; + border: none; + border-radius: 0.5rem; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.nav-btn:hover { + background: linear-gradient(135deg, #2563eb, #1d4ed8); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4); +} + +.nav-icon { + font-size: 1.25rem; + font-weight: bold; +} + +.nav-text { + font-size: 0.875rem; +} + +.calendar-title { + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; +} + +.calendar-title h3 { + font-size: 1.5rem; + font-weight: 700; + color: #111827; + margin: 0; +} + +.today-btn { + padding: 0.25rem 0.75rem; + background: #f3f4f6; + color: #6b7280; + border: 1px solid #d1d5db; + border-radius: 0.375rem; + font-size: 0.75rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.today-btn:hover { + background: #e5e7eb; + color: #374151; +} + +/* 범례 */ +.calendar-legend { + display: flex; + justify-content: center; + gap: 2rem; + margin-bottom: 1.5rem; + padding: 1rem; + background: #f9fafb; + border-radius: 0.5rem; +} + +.legend-item { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + color: #374151; +} + +.legend-dot { + width: 12px; + height: 12px; + border-radius: 50%; +} + +.legend-dot.has-overtime-warning { + background: #8b5cf6; + box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.2); +} + +.legend-dot.has-normal { + background: #10b981; + box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2); +} + +.legend-dot.has-issues { + background: #f59e0b; + box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.2); +} + +.legend-dot.has-errors { + background: #ef4444; + box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.2); +} + +/* 캘린더 그리드 */ +.calendar-grid { + max-width: 800px; + margin: 0 auto; +} + +/* 캘린더 헤더 */ +.calendar-header { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 1px; + margin-bottom: 1px; + background: #e5e7eb; + border-radius: 0.5rem 0.5rem 0 0; + overflow: hidden; +} + +.day-header { + background: #f9fafb; + padding: 1rem 0.5rem; + text-align: center; + font-weight: 600; + font-size: 0.875rem; + color: #374151; +} + +.day-header.sunday { + color: #dc2626; +} + +.day-header.saturday { + color: #2563eb; +} + +/* 캘린더 날짜 */ +.calendar-days { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 1px; + background: #e5e7eb; + border-radius: 0 0 0.5rem 0.5rem; + overflow: hidden; +} + +.calendar-day { + background: white; + min-height: 80px; + padding: 0.5rem; + cursor: pointer; + transition: all 0.2s ease; + position: relative; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.calendar-day:hover { + background: #f3f4f6; + transform: scale(1.02); + z-index: 1; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.calendar-day.today { + background: linear-gradient(135deg, #dbeafe, #bfdbfe); + border: 2px solid #3b82f6; +} + +.calendar-day.other-month { + background: #f9fafb; + color: #9ca3af; +} + +.calendar-day.sunday { + color: #dc2626; +} + +.calendar-day.saturday { + color: #2563eb; +} + +.calendar-day.weekend { + background: #fefefe; +} + +/* 기본 정상 상태 (초록색) */ +.calendar-day.has-normal { + border-left: 4px solid #10b981; + background: linear-gradient(135deg, #ecfdf5, #f0fdf4); +} + +/* 문제 상태들 - 혼재 가능하므로 테두리를 여러 색으로 표시 */ +.calendar-day.has-overtime-warning { + border-left: 4px solid #8b5cf6; + background: linear-gradient(135deg, #f3e8ff, #faf5ff); +} + +.calendar-day.has-issues { + border-left: 4px solid #f59e0b; + background: linear-gradient(135deg, #fffbeb, #fefce8); +} + +.calendar-day.has-errors { + border-left: 4px solid #ef4444; + background: linear-gradient(135deg, #fef2f2, #fef7f7); +} + +/* 혼재 상태 - 여러 문제가 동시에 있을 때 */ +.calendar-day.has-overtime-warning.has-errors { + border-left: 4px solid; + border-image: linear-gradient(to bottom, #8b5cf6 50%, #ef4444 50%) 1; + background: linear-gradient(135deg, #f3e8ff, #fef2f2); +} + +.calendar-day.has-overtime-warning.has-issues { + border-left: 4px solid; + border-image: linear-gradient(to bottom, #8b5cf6 50%, #f59e0b 50%) 1; + background: linear-gradient(135deg, #f3e8ff, #fffbeb); +} + +.calendar-day.has-errors.has-issues { + border-left: 4px solid; + border-image: linear-gradient(to bottom, #ef4444 50%, #f59e0b 50%) 1; + background: linear-gradient(135deg, #fef2f2, #fffbeb); +} + +.calendar-day.has-overtime-warning.has-errors.has-issues { + border-left: 4px solid; + border-image: linear-gradient(to bottom, #8b5cf6 33%, #ef4444 33%, #ef4444 66%, #f59e0b 66%) 1; + background: linear-gradient(135deg, #f3e8ff, #fef2f2, #fffbeb); +} + +.day-number { + font-weight: 600; + font-size: 1rem; +} + +.worker-count { + position: absolute; + top: 0.5rem; + right: 0.5rem; + background: #6b7280; + color: white; + font-size: 0.75rem; + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; + font-weight: 500; +} + +.status-indicator { + position: absolute; + bottom: 0.5rem; + width: 18px; + height: 18px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.7rem; + font-weight: bold; + color: white; + margin-left: 2px; +} + +/* 첫 번째 아이콘 */ +.status-indicator:first-of-type { + right: 0.5rem; +} + +/* 두 번째 아이콘 */ +.status-indicator:nth-of-type(2) { + right: 1.75rem; +} + +/* 세 번째 아이콘 */ +.status-indicator:nth-of-type(3) { + right: 3rem; +} + +/* 범례와 동일한 아이콘 스타일 */ +.legend-icon { + position: absolute; + bottom: 0.5rem; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + font-size: 1rem; + font-weight: bold; + margin-left: 2px; +} + +/* 첫 번째 아이콘 */ +.legend-icon:first-of-type { + left: 0.5rem; +} + +/* 두 번째 아이콘 */ +.legend-icon:nth-of-type(2) { + left: 1.25rem; +} + +/* 세 번째 아이콘 */ +.legend-icon:nth-of-type(3) { + left: 2rem; +} + +.legend-icon.purple { + color: #8b5cf6; +} + +.legend-icon.red { + color: #ef4444; +} + +.legend-icon.orange { + color: #f59e0b; +} + +.legend-icon.green { + color: #10b981; +} + +/* 모달 스타일 */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: var(--space-4); +} + +.modal-container.large-modal { + width: 90vw; + max-width: 1200px; + max-height: 90vh; + background: var(--white); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-xl); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-6) var(--space-8); + border-bottom: 1px solid var(--gray-200); + background: linear-gradient(135deg, var(--blue-600), var(--indigo-600)); + position: relative; + overflow: hidden; +} + +.modal-header::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.05'%3E%3Ccircle cx='30' cy='30' r='4'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E") repeat; + opacity: 0.3; +} + +.modal-header h2 { + margin: 0; + font-size: var(--text-xl); + font-weight: 700; + color: var(--white); + display: flex; + align-items: center; + gap: var(--space-3); + position: relative; + z-index: 1; +} + +.modal-header h2::before { + content: '📊'; + font-size: var(--text-2xl); +} + +.modal-close-btn { + width: 40px; + height: 40px; + border: none; + background: var(--gray-200); + border-radius: 50%; + font-size: var(--text-xl); + font-weight: bold; + color: var(--gray-600); + cursor: pointer; + transition: var(--transition-normal); +} + +.modal-close-btn:hover { + background: var(--gray-300); + color: var(--gray-800); +} + +.modal-body { + flex: 1; + padding: var(--space-6); + overflow-y: auto; +} + +/* 일일 요약 - 기존 HTML 구조에 맞춘 스타일 */ +.daily-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: var(--space-4); + margin-bottom: var(--space-6); + padding: var(--space-4); + background: #f8fafc; + border-radius: var(--radius-lg); + border: 1px solid #e2e8f0; +} + +.summary-card { + background: white; + padding: var(--space-4); + border-radius: var(--radius-md); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + border: 1px solid #e5e7eb; + display: flex; + align-items: center; + gap: var(--space-3); + transition: all 0.2s ease; +} + +.summary-card:hover { + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + transform: translateY(-1px); +} + +.summary-icon { + width: 40px; + height: 40px; + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + font-size: var(--text-lg); + flex-shrink: 0; +} + +.summary-icon.success { + background: #d1fae5; + color: #065f46; +} + +.summary-icon.primary { + background: #dbeafe; + color: #1e40af; +} + +.summary-icon.warning { + background: #fef3c7; + color: #92400e; +} + +.summary-icon.error { + background: #fecaca; + color: #991b1b; +} + +.summary-content { + flex: 1; +} + +.summary-label { + font-size: var(--text-sm); + font-weight: 500; + color: #6b7280; + margin-bottom: var(--space-1); +} + +.summary-value { + font-size: var(--text-lg); + font-weight: 700; + color: #111827; +} + +.summary-value.error { + color: #dc2626; +} + +/* 작업자 현황 */ +.modal-work-status { + background: white; + border-radius: var(--radius-lg); + padding: var(--space-5); + border: 1px solid #e5e7eb; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.work-status-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-4); + padding-bottom: var(--space-3); + border-bottom: 1px solid #e5e7eb; +} + +.work-status-header h3 { + margin: 0; + font-size: var(--text-base); + font-weight: 600; + color: #111827; +} + +.status-filter select { + padding: var(--space-2) var(--space-3); + border: 1px solid #d1d5db; + border-radius: var(--radius-md); + background: white; + font-size: var(--text-sm); +} + +/* 작업자 리스트 - 대시보드와 동일한 스타일 */ +.worker-status-list { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +/* 한 줄 작업자 카드 - 대시보드와 동일 */ +.worker-status-row { + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: var(--radius-lg); + padding: var(--space-5) var(--space-6); + transition: all 0.2s ease; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1); + position: relative; + overflow: hidden; + display: flex; + align-items: center; + gap: var(--space-6); + min-height: 85px; +} + +.worker-status-row::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: var(--primary-500); + opacity: 0; + transition: opacity 0.3s ease; +} + +.worker-status-row:hover { + border-color: #d1d5db; + box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 0.15); + transform: translateY(-1px); +} + +.worker-status-row:hover::before { + opacity: 1; +} + +/* 한 줄 레이아웃 요소들 */ +.worker-basic-info { + display: flex; + align-items: center; + gap: var(--space-3); + flex: 0 0 200px; +} + +.worker-name { + font-size: var(--text-base); + font-weight: 600; + color: var(--gray-900); + margin: 0; +} + +.worker-job { + font-size: var(--text-sm); + color: var(--gray-500); + font-weight: 500; + margin: 0; +} + +.worker-stats-inline { + display: flex; + gap: var(--space-4); + flex: 1; + justify-content: center; +} + +.worker-stats-inline .stat-item { + text-align: center; + padding: var(--space-2) var(--space-3); + background: #f9fafb; + border-radius: var(--radius-md); + border: 1px solid #f3f4f6; + min-width: 70px; +} + +.stat-label { + display: block; + font-size: var(--text-xs); + color: #6b7280; + font-weight: 500; + margin-bottom: var(--space-1); +} + +.stat-value { + display: block; + font-size: var(--text-sm); + font-weight: 700; + color: #111827; +} + +.stat-value.error { + color: #dc2626; +} + +.worker-actions-inline { + display: flex; + align-items: center; + gap: var(--space-3); + flex: 0 0 auto; +} + +.btn-edit { + width: 32px; + height: 32px; + border: none; + background: #f3f4f6; + border-radius: var(--radius-md); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: var(--text-sm); + transition: all 0.2s ease; +} + +.btn-edit:hover { + background: #e5e7eb; + transform: scale(1.05); +} + +.worker-status-row { + cursor: pointer; +} + +.worker-status-row:hover { + background: #f9fafb; +} + +.status-badge { + padding: var(--space-2) var(--space-4); + border-radius: var(--radius-full); + font-size: var(--text-sm); + font-weight: 600; + text-align: center; + min-width: 90px; + border: 1px solid transparent; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); +} + +/* 상태별 색상 - 대시보드와 동일 */ +.worker-status-row.complete::before { background: #059669; } /* 정시근로 - 초록색 */ +.worker-status-row.overtime::before { background: #0891b2; } /* 연장근로 - 청록색 */ +.worker-status-row.vacation-full::before { background: #f59e0b; } /* 연차 - 주황색 */ +.worker-status-row.vacation-half::before { background: #fbbf24; } /* 반차 - 밝은 주황색 */ +.worker-status-row.vacation-half-half::before { background: #fcd34d; } /* 조퇴 - 노란색 */ +.worker-status-row.vacation-quarter::before { background: #fde047; } /* 반반차 - 밝은 노란색 */ +.worker-status-row.partial::before { background: #f97316; } /* 부분입력 - 주황색 */ +.worker-status-row.incomplete::before { background: #ef4444; } /* 미입력 - 빨간색 */ +.worker-status-row.error::before { background: #dc2626; } /* 오류 - 진한 빨간색 */ +.worker-status-row.overtime-warning::before { background: #dc2626; } /* 확인필요 - 진한 빨간색 */ + +/* 상태 배지 색상 - 대시보드와 동일 */ +.status-badge.complete { + background: #d1fae5; + color: #065f46; + border: 1px solid #6ee7b7; +} + +.status-badge.overtime { + background: #cffafe; + color: #164e63; + border: 1px solid #67e8f9; +} + +.status-badge.vacation-full { + background: #fef3c7; + color: #92400e; + border: 1px solid #fcd34d; +} + +.status-badge.vacation-half { + background: #fef3c7; + color: #9a3412; + border: 1px solid #fdba74; +} + +.status-badge.vacation-half-half { + background: #fefce8; + color: #a16207; + border: 1px solid #fde047; +} + +.status-badge.vacation-quarter { + background: #fefce8; + color: #a16207; + border: 1px solid #facc15; +} + +.status-badge.partial { + background: #fed7aa; + color: #9a3412; + border: 1px solid #fb923c; +} + +.status-badge.incomplete { + background: #fecaca; + color: #991b1b; + border: 1px solid #f87171; +} + +.status-badge.error { + background: #fecaca; + color: #7f1d1d; + border: 1px solid #ef4444; +} + +.status-badge.overtime-warning { + background: #fecaca; + color: #7f1d1d; + border: 1px solid #dc2626; + animation: pulse 2s infinite; +} + +/* 빈 상태 */ +.empty-state { + text-align: center; + padding: var(--space-12); + color: var(--gray-500); +} + +.empty-state::before { + content: '📭'; + display: block; + font-size: 4rem; + margin-bottom: var(--space-4); + opacity: 0.5; +} + +.work-status-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-4); +} + +.work-status-header h3 { + margin: 0; + font-size: var(--text-lg); + font-weight: 600; + color: var(--gray-900); +} + +.status-filter select { + padding: var(--space-2) var(--space-3); + border: 1px solid var(--gray-300); + border-radius: var(--radius-md); + background: var(--white); + font-size: var(--text-sm); +} + +/* 반응형 디자인 */ +@media (max-width: 768px) { + .month-navigation { + flex-direction: column; + gap: var(--space-4); + } + + .month-info { + flex-direction: column; + gap: var(--space-2); + } + + .calendar-legend { + flex-wrap: wrap; + gap: var(--space-3); + } + + .calendar-day { + min-height: 80px; + padding: var(--space-2); + } + + .modal-container.large-modal { + width: 95vw; + max-height: 95vh; + } + + .modal-body { + padding: var(--space-4); + } + + .daily-summary { + grid-template-columns: repeat(2, 1fr); + } + + .work-status-header { + flex-direction: column; + align-items: stretch; + gap: var(--space-3); + } +} + +@media (max-width: 480px) { + .calendar-days { + font-size: var(--text-xs); + } + + .calendar-day { + min-height: 60px; + padding: var(--space-1); + } + + .day-number { + width: 24px; + height: 24px; + font-size: var(--text-xs); + } + + .daily-summary { + grid-template-columns: 1fr; + } +} diff --git a/web-ui/js/api-config.js b/web-ui/js/api-config.js index a6881cd..fd73cbd 100644 --- a/web-ui/js/api-config.js +++ b/web-ui/js/api-config.js @@ -82,23 +82,26 @@ function getAuthHeaders() { } // 🔧 개선된 API 호출 함수 (에러 처리 강화) -async function apiCall(url, options = {}) { - const defaultOptions = { - headers: getAuthHeaders() - }; - - const finalOptions = { - ...defaultOptions, - ...options, +async function apiCall(url, method = 'GET', data = null) { + // 상대 경로를 절대 경로로 변환 + const fullUrl = url.startsWith('http') ? url : `${API}${url}`; + + const options = { + method: method, headers: { - ...defaultOptions.headers, - ...options.headers + 'Content-Type': 'application/json', + ...getAuthHeaders() } }; + // POST/PUT 요청시 데이터 추가 + if (data && (method === 'POST' || method === 'PUT' || method === 'PATCH')) { + options.body = JSON.stringify(data); + } + try { - console.log(`📡 API 호출: ${url}`); - const response = await fetch(url, finalOptions); + console.log(`📡 API 호출: ${fullUrl} (${method})`); + const response = await fetch(fullUrl, options); // 인증 만료 처리 if (response.status === 401) { @@ -122,11 +125,11 @@ async function apiCall(url, options = {}) { } const result = await response.json(); - console.log(`✅ API 성공: ${url}`); + console.log(`✅ API 성공: ${fullUrl}`); return result; } catch (error) { - console.error(`❌ API 오류 (${url}):`, error); + console.error(`❌ API 오류 (${fullUrl}):`, error); // 네트워크 오류 vs 서버 오류 구분 if (error.name === 'TypeError' && error.message.includes('fetch')) { diff --git a/web-ui/js/load-navbar.js b/web-ui/js/load-navbar.js index 5010a3e..3f62cf0 100644 --- a/web-ui/js/load-navbar.js +++ b/web-ui/js/load-navbar.js @@ -1,5 +1,5 @@ // js/load-navbar.js -import { getUser, clearAuthData } from './auth.js'; +// 브라우저 호환 버전 - ES6 모듈 제거 // 역할 이름을 한글로 변환하는 맵 const ROLE_NAMES = { diff --git a/web-ui/js/modern-dashboard.js b/web-ui/js/modern-dashboard.js index 5ffba6c..f0ded17 100644 --- a/web-ui/js/modern-dashboard.js +++ b/web-ui/js/modern-dashboard.js @@ -19,6 +19,14 @@ let workersData = []; let workData = []; let selectedDate = new Date().toISOString().split('T')[0]; +// 모달 관련 변수 +let currentModalWorker = null; +let modalWorkTypes = []; +let modalWorkStatusTypes = []; +let modalErrorTypes = []; +let modalProjects = []; +let modalExistingWork = []; + // DOM 요소 const elements = { currentTime: document.getElementById('currentTime'), @@ -207,7 +215,7 @@ async function loadDashboardData() { async function loadWorkers() { try { console.log('👥 작업자 데이터 로딩...'); - const response = await window.apiCall(`${window.API}/workers`); + const response = await window.apiCall('/workers'); workersData = Array.isArray(response) ? response : (response.data || []); console.log(`✅ 작업자 ${workersData.length}명 로드 완료`); return workersData; @@ -221,7 +229,7 @@ async function loadWorkers() { async function loadWorkData(date) { try { console.log(`📋 ${date} 작업 데이터 로딩...`); - const response = await window.apiCall(`${window.API}/daily-work-reports?date=${date}&view_all=true`); + const response = await window.apiCall(`/daily-work-reports?date=${date}&view_all=true`); workData = Array.isArray(response) ? response : (response.data || []); console.log(`✅ 작업 데이터 ${workData.length}건 로드 완료`); return workData; @@ -261,48 +269,199 @@ function updateSummaryCard(element, value, unit) { } } -// ========== 작업 현황 표시 ========== // +// ========== 작업 현황 표시 (작업자 중심) ========== // function displayWorkStatus() { if (!elements.workStatusContainer) return; - if (workData.length === 0) { + // 모든 작업자 데이터 가져오기 (작업이 없는 작업자도 포함) + const allWorkers = workersData || []; + + if (allWorkers.length === 0) { elements.workStatusContainer.innerHTML = `
-
📭
-

작업 데이터가 없습니다

-

${selectedDate}에 등록된 작업이 없습니다.

+
👥
+

등록된 작업자가 없습니다

+

시스템에 작업자가 등록되어 있지 않습니다.

`; return; } - // 프로젝트별 작업 현황 그룹화 - const projectGroups = groupWorkDataByProject(); + // 작업자별 상황 분석 + const workerStatusList = allWorkers.map(worker => { + const todayWork = workData.filter(w => w.worker_id === worker.worker_id); + const totalHours = todayWork.reduce((sum, w) => sum + parseFloat(w.work_hours || 0), 0); + + // 휴가/연차 제외한 실제 작업시간 계산 + const actualWorkHours = todayWork + .filter(w => w.project_id !== 13) // 연차/휴무 프로젝트 제외 + .reduce((sum, w) => sum + parseFloat(w.work_hours || 0), 0); + + const hasError = todayWork.some(w => w.work_status_id === 2); + + // 정규 작업과 에러 작업 건수 분리 + const regularWorkCount = todayWork.filter(w => w.project_id !== 13 && w.work_status_id !== 2).length; + const errorWorkCount = todayWork.filter(w => w.project_id !== 13 && w.work_status_id === 2).length; + + // 상태 판단 로직 (개선된 버전) + let status = 'incomplete'; + let statusText = '미입력'; + let statusBadge = '미입력'; + let vacationType = null; + + // 휴가 처리된 경우 확인 (프로젝트 ID 13 = "연차/휴무" 또는 설명에 휴가 키워드) + const hasVacationRecord = todayWork.some(w => + w.project_id === 13 || // 연차/휴무 프로젝트 + (w.description && ( + w.description.includes('연차') || + w.description.includes('반차') || + w.description.includes('휴가') + )) + ); + + // 연차/휴무 프로젝트의 시간 계산 + const vacationHours = todayWork + .filter(w => w.project_id === 13) + .reduce((sum, w) => sum + parseFloat(w.work_hours || 0), 0); + + if (totalHours > 12) { + status = 'overtime-warning'; + statusText = '초과근무 확인필요'; + statusBadge = '확인필요'; + } else if (hasVacationRecord && vacationHours > 0) { + // 연차/휴무 시간에 따른 상태 결정 + if (vacationHours === 8) { + status = 'vacation-full'; + statusText = '연차'; + statusBadge = '연차'; + } else if (vacationHours === 6) { + status = 'vacation-half-half'; + statusText = '조퇴'; + statusBadge = '조퇴'; + } else if (vacationHours === 4) { + status = 'vacation-half'; + statusText = '반차'; + statusBadge = '반차'; + } else if (vacationHours === 2) { + status = 'vacation-quarter'; + statusText = '반반차'; + statusBadge = '반반차'; + } + } else if (totalHours > 8) { + // 8시간 초과 - 연장근로 + status = 'overtime'; + statusText = '연장근로'; + statusBadge = '연장근로'; + } else if (totalHours === 8) { + // 정확히 8시간 - 정시근로 + status = 'complete'; + statusText = '정시근로'; + statusBadge = '정시근로'; + } else if (totalHours > 0) { + // 0시간 초과 8시간 미만 - 부분 입력 + status = 'partial'; + statusText = '부분 입력'; + statusBadge = '부분입력'; + + // 휴가 처리 필요 여부 판단 + if (totalHours === 0) { + vacationType = 'full'; + } else if (totalHours === 4) { + vacationType = 'half'; + } else if (totalHours === 6) { + vacationType = 'half-half'; // 2시간 더 추가해서 조퇴 처리 + } + } else { + // 0시간 - 미입력 + status = 'incomplete'; + statusText = '미입력'; + statusBadge = '미입력'; + vacationType = 'full'; + } + + return { + ...worker, + todayWork, + totalHours, + actualWorkHours, + regularWorkCount, + errorWorkCount, + hasError, + status, + statusText, + statusBadge, + vacationType + }; + }); elements.workStatusContainer.innerHTML = ` -
- ${Object.entries(projectGroups).map(([projectName, works]) => ` -
-
-

📁 ${projectName}

- ${works.length}건 -
-
-
- 총 시간 - ${works.reduce((sum, w) => sum + parseFloat(w.work_hours || 0), 0).toFixed(1)}h -
-
- 작업자 - ${new Set(works.map(w => w.worker_id)).size}명 -
-
- 오류 - ${works.filter(w => w.work_status_id === 2).length}건 -
-
+
+
+
+

작업자별 현황

+ ${selectedDate}
- `).join('')} +
+ 정시근로 + 연장근로 + 휴가 + 부분입력 + 미입력 + 오류 +
+
+
+ ${workerStatusList.map(worker => ` +
+
+
+ ${worker.worker_name.charAt(0)} +
+
+

${worker.worker_name}

+

${worker.job_type || '작업자'}

+
+
+ +
+ ${worker.statusBadge} +
+ +
+
+ 작업시간 + ${worker.actualWorkHours.toFixed(1)}h +
+
+ 정규 + ${worker.regularWorkCount}건 +
+ ${worker.errorWorkCount > 0 ? ` +
+ 에러 + ${worker.errorWorkCount}건 +
+ ` : ''} +
+ +
+ + ${worker.vacationType ? ` + + ` : ''} + ${worker.status === 'overtime-warning' ? ` + + ` : ''} +
+
+ `).join('')} +
`; } @@ -347,8 +506,18 @@ function displayWorkersAsCards(workers) { ${workers.map(worker => { const todayWork = workData.filter(w => w.worker_id === worker.worker_id); const totalHours = todayWork.reduce((sum, w) => sum + parseFloat(w.work_hours || 0), 0); + + // 휴가/연차 제외한 실제 작업시간 계산 + const actualWorkHours = todayWork + .filter(w => w.project_id !== 13) // 연차/휴무 프로젝트 제외 + .reduce((sum, w) => sum + parseFloat(w.work_hours || 0), 0); + const hasError = todayWork.some(w => w.work_status_id === 2); + // 정규 작업과 에러 작업 건수 분리 + const regularWorkCount = todayWork.filter(w => w.project_id !== 13 && w.work_status_id !== 2).length; + const errorWorkCount = todayWork.filter(w => w.project_id !== 13 && w.work_status_id === 2).length; + return `
@@ -366,17 +535,17 @@ function displayWorkersAsCards(workers) {
- 오늘 작업 - ${todayWork.length}건 + 작업시간 + ${actualWorkHours.toFixed(1)}h
- 작업 시간 - ${totalHours.toFixed(1)}h + 정규 + ${regularWorkCount}건
- ${hasError ? ` + ${errorWorkCount > 0 ? `
- 오류 - ⚠️ + 에러 + ${errorWorkCount}건
` : ''}
@@ -531,8 +700,566 @@ function showToast(message, type = 'info', duration = 3000) { }, duration); } +// ========== 작업자 관련 액션 함수들 ========== // +function openWorkerModal(workerId, workerName) { + console.log(`📝 ${workerName}(ID: ${workerId}) 작업 보고서 모달 열기`); + + // 모달 데이터 설정 + currentModalWorker = { + id: workerId, + name: workerName, + date: selectedDate + }; + + // 모달 표시 + showWorkerModal(); +} + +function handleVacation(workerId, vacationType) { + console.log(`🏖️ 작업자 ${workerId} 휴가 처리: ${vacationType}`); + + const vacationNames = { + 'full': '연차', + 'half': '반차', + 'half-half': '반반차' + }; + + const vacationHours = { + 'full': 8, + 'half': 4, + 'half-half': 2 + }; + + if (confirm(`${vacationNames[vacationType]} 처리하시겠습니까?\n(${vacationHours[vacationType]}시간으로 자동 입력됩니다)`)) { + // 휴가 처리 API 호출 + processVacation(workerId, vacationType, vacationHours[vacationType]); + } +} + +async function processVacation(workerId, vacationType, hours) { + try { + showToast(`휴가 처리 중...`, 'info'); + + // 휴가용 작업 보고서 생성 (특별한 작업 유형으로) + const vacationReport = { + report_date: selectedDate, + worker_id: workerId, + project_id: 1, // 기본 프로젝트 (휴가용) + work_type_id: 999, // 휴가 전용 작업 유형 (DB에 추가 필요) + work_status_id: 1, // 정상 상태 + error_type_id: null, + work_hours: hours, + created_by: currentUser?.user_id || 1 + }; + + const response = await window.apiCall(`${window.API}/daily-work-reports`, { + method: 'POST', + body: JSON.stringify(vacationReport) + }); + + showToast(`휴가 처리가 완료되었습니다.`, 'success'); + await loadDashboardData(); // 데이터 새로고침 + + } catch (error) { + console.error('휴가 처리 오류:', error); + showToast(`휴가 처리 중 오류가 발생했습니다: ${error.message}`, 'error'); + } +} + +function confirmOvertime(workerId) { + console.log(`⚠️ 작업자 ${workerId} 초과근무 확인`); + + if (confirm('12시간을 초과한 작업시간이 정상적인 입력인지 확인하시겠습니까?')) { + // 초과근무 확인 처리 + processOvertimeConfirmation(workerId); + } +} + +async function processOvertimeConfirmation(workerId) { + try { + showToast('초과근무 승인 처리 중...', 'info'); + + // 새로운 근태 관리 API 사용 + const overtimeData = { + worker_id: workerId, + date: selectedDate + }; + + const response = await window.apiCall('/attendance/overtime/approve', 'POST', overtimeData); + + if (response.success) { + showToast('초과근무가 정상으로 승인되었습니다.', 'success'); + await loadDashboardData(); // 데이터 새로고침 + } else { + throw new Error(response.message || '초과근무 승인에 실패했습니다.'); + } + + } catch (error) { + console.error('초과근무 승인 오류:', error); + showToast(`초과근무 승인 중 오류가 발생했습니다: ${error.message}`, 'error'); + } +} + +// ========== 모달 시스템 ========== // +function showWorkerModal() { + // 모달이 없으면 생성 + if (!document.getElementById('workerModal')) { + createWorkerModal(); + } + + // 모달 데이터 로드 및 표시 + loadModalData(); + document.getElementById('workerModal').style.display = 'flex'; + document.body.style.overflow = 'hidden'; // 배경 스크롤 방지 +} + +function hideWorkerModal() { + document.getElementById('workerModal').style.display = 'none'; + document.body.style.overflow = 'auto'; // 배경 스크롤 복원 + resetModalForm(); +} + +function createWorkerModal() { + const modalHTML = ` + + `; + + document.body.insertAdjacentHTML('beforeend', modalHTML); + setupModalEventListeners(); +} + +function setupModalEventListeners() { + // 모달 외부 클릭 시 닫기 + document.getElementById('workerModal').addEventListener('click', (e) => { + if (e.target.id === 'workerModal') { + hideWorkerModal(); + } + }); + + // 새 작업 추가/취소 버튼 + document.getElementById('modalAddWorkBtn').addEventListener('click', showModalNewWorkForm); + document.getElementById('modalCancelWorkBtn').addEventListener('click', hideModalNewWorkForm); + document.getElementById('modalSaveWorkBtn').addEventListener('click', saveModalNewWork); + + // 업무 상태 변경 시 에러 유형 토글 + document.getElementById('modalWorkStatusSelect').addEventListener('change', toggleModalErrorType); + + // 빠른 시간 버튼 + document.querySelectorAll('.modal-time-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + document.getElementById('modalWorkHours').value = e.target.dataset.hours; + }); + }); + + // 휴가 처리 버튼 + document.querySelectorAll('.modal-vacation-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + const vacationType = e.target.dataset.type; + handleModalVacation(vacationType); + }); + }); +} + +async function loadModalData() { + if (!currentModalWorker) return; + + try { + // 모달 헤더 업데이트 + document.getElementById('modalTitle').textContent = `${currentModalWorker.name} 작업 보고서`; + document.getElementById('modalWorkerName').textContent = currentModalWorker.name; + document.getElementById('modalWorkerDate').textContent = currentModalWorker.date; + document.getElementById('modalWorkerInitial').textContent = currentModalWorker.name.charAt(0); + + // 병렬로 데이터 로드 + await Promise.all([ + loadModalExistingWork(), + loadModalDropdownData() + ]); + + // UI 업데이트 + updateModalSummary(); + renderModalExistingWork(); + populateModalDropdowns(); + + } catch (error) { + console.error('모달 데이터 로드 오류:', error); + showToast('데이터 로드 중 오류가 발생했습니다.', 'error'); + } +} + +async function loadModalExistingWork() { + try { + const response = await window.apiCall(`${window.API}/daily-work-reports?date=${currentModalWorker.date}&worker_id=${currentModalWorker.id}`); + modalExistingWork = Array.isArray(response) ? response : (response.data || []); + } catch (error) { + console.error('기존 작업 로드 오류:', error); + modalExistingWork = []; + } +} + +async function loadModalDropdownData() { + try { + const [projectsRes, workTypesRes, workStatusRes, errorTypesRes] = await Promise.all([ + window.apiCall(`${window.API}/projects`), + window.apiCall(`${window.API}/daily-work-reports/work-types`), + window.apiCall(`${window.API}/daily-work-reports/work-status-types`), + window.apiCall(`${window.API}/daily-work-reports/error-types`) + ]); + + modalProjects = Array.isArray(projectsRes) ? projectsRes : (projectsRes.data || []); + modalWorkTypes = Array.isArray(workTypesRes) ? workTypesRes : (workTypesRes.data || []); + modalWorkStatusTypes = Array.isArray(workStatusRes) ? workStatusRes : (workStatusRes.data || []); + modalErrorTypes = Array.isArray(errorTypesRes) ? errorTypesRes : (errorTypesRes.data || []); + } catch (error) { + console.error('드롭다운 데이터 로드 오류:', error); + } +} + +function updateModalSummary() { + const totalHours = modalExistingWork.reduce((sum, work) => sum + parseFloat(work.work_hours || 0), 0); + const workCount = modalExistingWork.length; + + document.getElementById('modalTotalHours').textContent = `${totalHours.toFixed(1)}h`; + document.getElementById('modalWorkCount').textContent = `${workCount}건`; + + // 12시간 초과 경고 + if (totalHours > 12) { + document.getElementById('modalTotalHours').classList.add('warning'); + } else { + document.getElementById('modalTotalHours').classList.remove('warning'); + } +} + +function renderModalExistingWork() { + const container = document.getElementById('modalExistingWork'); + + if (modalExistingWork.length === 0) { + container.innerHTML = ` + + `; + return; + } + + container.innerHTML = modalExistingWork.map(work => ` + + `).join(''); +} + +function populateModalDropdowns() { + // 프로젝트 드롭다운 + const projectSelect = document.getElementById('modalProjectSelect'); + projectSelect.innerHTML = ''; + modalProjects.forEach(project => { + projectSelect.innerHTML += ``; + }); + + // 작업 유형 드롭다운 + const workTypeSelect = document.getElementById('modalWorkTypeSelect'); + workTypeSelect.innerHTML = ''; + modalWorkTypes.forEach(type => { + workTypeSelect.innerHTML += ``; + }); + + // 작업 상태 드롭다운 + const workStatusSelect = document.getElementById('modalWorkStatusSelect'); + workStatusSelect.innerHTML = ''; + modalWorkStatusTypes.forEach(status => { + workStatusSelect.innerHTML += ``; + }); + + // 에러 유형 드롭다운 + const errorTypeSelect = document.getElementById('modalErrorTypeSelect'); + errorTypeSelect.innerHTML = ''; + modalErrorTypes.forEach(error => { + errorTypeSelect.innerHTML += ``; + }); +} + +function showModalNewWorkForm() { + document.getElementById('modalNewWorkSection').style.display = 'block'; + document.getElementById('modalAddWorkBtn').style.display = 'none'; +} + +function hideModalNewWorkForm() { + document.getElementById('modalNewWorkSection').style.display = 'none'; + document.getElementById('modalAddWorkBtn').style.display = 'block'; + resetModalForm(); +} + +function resetModalForm() { + document.getElementById('modalProjectSelect').value = ''; + document.getElementById('modalWorkTypeSelect').value = ''; + document.getElementById('modalWorkStatusSelect').value = ''; + document.getElementById('modalErrorTypeSelect').value = ''; + document.getElementById('modalWorkHours').value = '1.00'; + document.getElementById('modalErrorTypeGroup').style.display = 'none'; +} + +function toggleModalErrorType() { + const workStatusSelect = document.getElementById('modalWorkStatusSelect'); + const errorTypeGroup = document.getElementById('modalErrorTypeGroup'); + + if (workStatusSelect.value === '2') { // 에러 상태 + errorTypeGroup.style.display = 'block'; + } else { + errorTypeGroup.style.display = 'none'; + document.getElementById('modalErrorTypeSelect').value = ''; + } +} + +async function saveModalNewWork() { + try { + const projectId = document.getElementById('modalProjectSelect').value; + const workTypeId = document.getElementById('modalWorkTypeSelect').value; + const workStatusId = document.getElementById('modalWorkStatusSelect').value; + const errorTypeId = document.getElementById('modalErrorTypeSelect').value; + const workHours = document.getElementById('modalWorkHours').value; + + // 유효성 검사 + if (!projectId || !workTypeId || !workStatusId || !workHours) { + showToast('모든 필수 필드를 입력해주세요.', 'error'); + return; + } + + if (workStatusId === '2' && !errorTypeId) { + showToast('에러 상태일 때는 에러 유형을 선택해야 합니다.', 'error'); + return; + } + + const workData = { + report_date: currentModalWorker.date, + worker_id: currentModalWorker.id, + project_id: parseInt(projectId), + work_type_id: parseInt(workTypeId), + work_status_id: parseInt(workStatusId), + error_type_id: workStatusId === '2' ? parseInt(errorTypeId) : null, + work_hours: parseFloat(workHours), + created_by: currentUser?.user_id || 1 + }; + + await window.apiCall(`${window.API}/daily-work-reports`, { + method: 'POST', + body: JSON.stringify(workData) + }); + + showToast('작업이 성공적으로 저장되었습니다.', 'success'); + + // 데이터 새로고침 + await loadModalExistingWork(); + updateModalSummary(); + renderModalExistingWork(); + hideModalNewWorkForm(); + + // 대시보드 데이터도 새로고침 + await loadDashboardData(); + + } catch (error) { + console.error('작업 저장 오류:', error); + showToast(`작업 저장 중 오류가 발생했습니다: ${error.message}`, 'error'); + } +} + +async function deleteModalWork(workId) { + if (!confirm('이 작업을 삭제하시겠습니까?')) { + return; + } + + try { + await window.apiCall(`${window.API}/daily-work-reports/${workId}`, { + method: 'DELETE' + }); + + showToast('작업이 성공적으로 삭제되었습니다.', 'success'); + + // 데이터 새로고침 + await loadModalExistingWork(); + updateModalSummary(); + renderModalExistingWork(); + + // 대시보드 데이터도 새로고침 + await loadDashboardData(); + + } catch (error) { + console.error('작업 삭제 오류:', error); + showToast(`작업 삭제 중 오류가 발생했습니다: ${error.message}`, 'error'); + } +} + +async function handleModalVacation(vacationType) { + const vacationTypeMap = { + 'full': { code: 'ANNUAL_FULL', name: '연차', hours: 8 }, + 'half': { code: 'ANNUAL_HALF', name: '반차', hours: 4 }, + 'half-half': { code: 'ANNUAL_QUARTER', name: '반반차', hours: 2 } + }; + + const vacation = vacationTypeMap[vacationType]; + if (!vacation) return; + + if (!confirm(`${vacation.name} 처리하시겠습니까?\n(${vacation.hours}시간으로 자동 입력됩니다)`)) { + return; + } + + try { + // 새로운 근태 관리 API 사용 + const vacationData = { + worker_id: currentModalWorker.id, + date: currentModalWorker.date, + vacation_type: vacation.code + }; + + const response = await window.apiCall('/attendance/vacation', 'POST', vacationData); + + if (response.success) { + showToast(`${vacation.name} 처리가 완료되었습니다.`, 'success'); + + // 데이터 새로고침 + await loadModalExistingWork(); + updateModalSummary(); + renderModalExistingWork(); + + // 대시보드 데이터도 새로고침 + await loadDashboardData(); + } else { + throw new Error(response.message || '휴가 처리에 실패했습니다.'); + } + + } catch (error) { + console.error('휴가 처리 오류:', error); + showToast(`휴가 처리 중 오류가 발생했습니다: ${error.message}`, 'error'); + } +} + // ========== 전역 함수 (HTML에서 호출) ========== // window.loadDashboardData = loadDashboardData; window.showToast = showToast; window.updateSummaryCards = updateSummaryCards; window.displayWorkers = displayWorkers; +window.openWorkerModal = openWorkerModal; +window.hideWorkerModal = hideWorkerModal; +window.deleteModalWork = deleteModalWork; +window.handleVacation = handleVacation; +window.confirmOvertime = confirmOvertime; diff --git a/web-ui/js/work-report-calendar.js b/web-ui/js/work-report-calendar.js new file mode 100644 index 0000000..2ba13f6 --- /dev/null +++ b/web-ui/js/work-report-calendar.js @@ -0,0 +1,1108 @@ +// 작업 현황 캘린더 JavaScript + +// 전역 변수 +let currentDate = new Date(); +let monthlyData = {}; // 월별 데이터 캐시 +// 작업자 데이터는 allWorkers 변수 사용 +let currentModalDate = null; + +// DOM 요소 +const elements = { + monthYearTitle: null, + calendarDays: null, + prevMonthBtn: null, + nextMonthBtn: null, + todayBtn: null, + dailyWorkModal: null, + modalTitle: null, + modalTotalWorkers: null, + modalTotalHours: null, + modalTotalTasks: null, + modalErrorCount: null, + modalWorkersList: null, + statusFilter: null, + loadingSpinner: null +}; + +// 초기화 +document.addEventListener('DOMContentLoaded', async function() { + console.log('🚀 작업 현황 캘린더 초기화 시작'); + + // DOM 요소 초기화 + initializeElements(); + + // 이벤트 리스너 등록 + setupEventListeners(); + + // 작업자 데이터 로드 (한 번만) + await loadWorkersData(); + + // 현재 월 캘린더 렌더링 + await renderCalendar(); + + console.log('✅ 작업 현황 캘린더 초기화 완료'); +}); + +// DOM 요소 초기화 +function initializeElements() { + elements.monthYearTitle = document.getElementById('monthYearTitle'); + elements.calendarDays = document.getElementById('calendarDays'); + elements.prevMonthBtn = document.getElementById('prevMonthBtn'); + elements.nextMonthBtn = document.getElementById('nextMonthBtn'); + elements.todayBtn = document.getElementById('todayBtn'); + elements.dailyWorkModal = document.getElementById('dailyWorkModal'); + elements.modalTitle = document.getElementById('modalTitle'); + elements.modalSummary = document.querySelector('.daily-summary'); // 요약 섹션 + elements.modalTotalWorkers = document.getElementById('modalTotalWorkers'); + elements.modalTotalHours = document.getElementById('modalTotalHours'); + elements.modalTotalTasks = document.getElementById('modalTotalTasks'); + elements.modalErrorCount = document.getElementById('modalErrorCount'); + elements.modalWorkersList = document.getElementById('modalWorkersList'); + elements.modalNoData = document.getElementById('modalNoData'); + elements.statusFilter = document.getElementById('statusFilter'); + elements.loadingSpinner = document.getElementById('loadingSpinner'); +} + +// 이벤트 리스너 설정 +function setupEventListeners() { + elements.prevMonthBtn.addEventListener('click', () => { + currentDate.setMonth(currentDate.getMonth() - 1); + renderCalendar(); + }); + + elements.nextMonthBtn.addEventListener('click', () => { + currentDate.setMonth(currentDate.getMonth() + 1); + renderCalendar(); + }); + + elements.todayBtn.addEventListener('click', () => { + currentDate = new Date(); + renderCalendar(); + }); + + elements.statusFilter.addEventListener('change', filterWorkersList); + + // 모달 외부 클릭 시 닫기 + elements.dailyWorkModal.addEventListener('click', (e) => { + if (e.target === elements.dailyWorkModal) { + closeDailyWorkModal(); + } + }); + + // ESC 키로 모달 닫기 + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && elements.dailyWorkModal.style.display !== 'none') { + closeDailyWorkModal(); + } + }); +} + +// 작업자 데이터 로드 (캐시) +async function loadWorkersData() { + if (allWorkers.length > 0) return allWorkers; + + try { + console.log('👥 작업자 데이터 로딩...'); + const response = await window.apiCall('/workers'); + allWorkers = Array.isArray(response) ? response : (response.data || []); + console.log(`✅ 작업자 ${allWorkers.length}명 로드 완료`); + return allWorkers; + } catch (error) { + console.error('작업자 데이터 로딩 오류:', error); + showToast('작업자 데이터를 불러오는데 실패했습니다.', 'error'); + return []; + } +} + +// 월별 작업 데이터 로드 (집계 테이블 사용으로 최적화) +async function loadMonthlyWorkData(year, month) { + const monthKey = `${year}-${String(month + 1).padStart(2, '0')}`; + + if (monthlyData[monthKey]) { + console.log(`📋 캐시된 ${monthKey} 데이터 사용`); + return monthlyData[monthKey]; + } + + try { + console.log(`📋 ${monthKey} 집계 데이터 로딩...`); + + // 새로운 월별 집계 API 사용 (단일 호출) + const response = await window.apiCall(`/monthly-status/calendar?year=${year}&month=${month + 1}`); + + if (response.success) { + const calendarData = response.data; + + console.log(`📊 ${monthKey} 집계 데이터:`, Object.keys(calendarData).length, '일'); + + // 날짜별 상태 데이터로 변환 + const monthData = {}; + + // 해당 월의 모든 날짜 초기화 + const firstDay = new Date(year, month, 1); + const lastDay = new Date(year, month + 1, 0); + const currentDay = new Date(firstDay); + + while (currentDay <= lastDay) { + const dateStr = currentDay.toISOString().split('T')[0]; + + if (calendarData[dateStr]) { + // 집계 데이터가 있는 경우 + const dayData = calendarData[dateStr]; + monthData[dateStr] = { + hasData: dayData.workingWorkers > 0, + hasIssues: dayData.hasIssues, + hasErrors: dayData.hasErrors, + hasOvertimeWarning: dayData.hasOvertimeWarning, + totalWorkers: dayData.totalWorkers, + workerCount: dayData.totalWorkers, + workingWorkers: dayData.workingWorkers, + incompleteWorkers: dayData.incompleteWorkers, + partialWorkers: dayData.partialWorkers, + errorWorkers: dayData.errorWorkers, + overtimeWarningWorkers: dayData.overtimeWarningWorkers, + totalHours: dayData.totalHours, + totalTasks: dayData.totalTasks, + errorCount: dayData.errorCount, + lastUpdated: dayData.lastUpdated + }; + } else { + // 집계 데이터가 없는 경우 (작업 없음) + monthData[dateStr] = { + hasData: false, + hasIssues: false, + hasErrors: false, + workerCount: 0, + workingWorkers: 0, + incompleteWorkers: 0, + partialWorkers: 0, + errorWorkers: 0, + totalHours: 0, + totalTasks: 0, + errorCount: 0 + }; + } + + currentDay.setDate(currentDay.getDate() + 1); + } + + // 캐시에 저장 + monthlyData[monthKey] = monthData; + + console.log(`✅ ${monthKey} 집계 데이터 로드 완료 (${Object.keys(monthData).length}일 데이터)`); + console.log('📊 월별 데이터 샘플:', Object.entries(monthData).slice(0, 5)); + return monthData; + } else { + throw new Error(response.message || '집계 데이터 조회 실패'); + } + + } catch (error) { + console.error(`${monthKey} 집계 데이터 로딩 오류:`, error); + + // 폴백: 기존 방식으로 순차 로딩 + console.log(`📋 폴백: ${monthKey} 기존 방식 로딩 시작...`); + return await loadMonthlyWorkDataFallback(year, month); + } +} + +// 폴백: 순차적 로딩 (지연 시간 포함) +async function loadMonthlyWorkDataFallback(year, month) { + const monthKey = `${year}-${String(month + 1).padStart(2, '0')}`; + const monthData = {}; + + try { + const firstDay = new Date(year, month, 1); + const lastDay = new Date(year, month + 1, 0); + const currentDay = new Date(firstDay); + + let loadedCount = 0; + const totalDays = lastDay.getDate(); + + while (currentDay <= lastDay) { + const dateStr = currentDay.toISOString().split('T')[0]; + + try { + const response = await window.apiCall(`/daily-work-reports?date=${dateStr}&view_all=true`); + monthData[dateStr] = Array.isArray(response) ? response : (response.data || []); + loadedCount++; + + // 진행률 표시 + if (loadedCount % 5 === 0) { + console.log(`📋 ${monthKey} 로딩 진행률: ${loadedCount}/${totalDays}`); + } + + // API 부하 방지를 위한 지연 (100ms) + await new Promise(resolve => setTimeout(resolve, 100)); + + } catch (error) { + console.warn(`${dateStr} 데이터 로딩 실패:`, error.message); + monthData[dateStr] = []; + } + + currentDay.setDate(currentDay.getDate() + 1); + } + + // 캐시에 저장 + monthlyData[monthKey] = monthData; + + console.log(`✅ ${monthKey} 순차 로딩 완료 (${loadedCount}/${totalDays}일)`); + return monthData; + + } catch (error) { + console.error(`${monthKey} 순차 로딩 오류:`, error); + showToast('작업 데이터를 불러오는데 실패했습니다.', 'error'); + return {}; + } +} + +// 캘린더 렌더링 +async function renderCalendar() { + const year = currentDate.getFullYear(); + const month = currentDate.getMonth(); + + // 헤더 업데이트 + const monthNames = ['1월', '2월', '3월', '4월', '5월', '6월', + '7월', '8월', '9월', '10월', '11월', '12월']; + const monthText = `${year}년 ${monthNames[month]}`; + + elements.monthYearTitle.textContent = monthText; + + // 로딩 표시 + showLoading(true); + + try { + // 월별 데이터 로드 + const monthData = await loadMonthlyWorkData(year, month); + + // 캘린더 날짜 생성 + const firstDay = new Date(year, month, 1); + const lastDay = new Date(year, month + 1, 0); + const startDate = new Date(firstDay); + startDate.setDate(startDate.getDate() - firstDay.getDay()); // 주의 시작일 (일요일) + + const today = new Date(); + const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; + + let calendarHTML = ''; + const currentDay = new Date(startDate); + + // 6주 * 7일 = 42일 렌더링 + for (let i = 0; i < 42; i++) { + // 로컬 시간대로 날짜 문자열 생성 (UTC 변환 문제 방지) + const year = currentDay.getFullYear(); + const month_num = String(currentDay.getMonth() + 1).padStart(2, '0'); + const day_num = String(currentDay.getDate()).padStart(2, '0'); + const dateStr = `${year}-${month_num}-${day_num}`; + + const dayNumber = currentDay.getDate(); + const isCurrentMonth = currentDay.getMonth() === month; + const isToday = dateStr === todayStr; + const isSunday = currentDay.getDay() === 0; + const isSaturday = currentDay.getDay() === 6; + + // 해당 날짜의 작업 데이터 (집계 데이터 구조) + let dayWorkData = monthData[dateStr] || { + hasData: false, + hasIssues: false, + hasErrors: false, + workerCount: 0 + }; + + // 실제 데이터 사용 (테스트 데이터 제거) + const dayStatus = analyzeDayStatus(dayWorkData); + + // 디버깅: 상태가 있는 날짜만 로그 + if (dayStatus.hasData || dayStatus.hasIssues || dayStatus.hasIncomplete || dayStatus.hasOvertimeWarning) { + let statusText = '이상없음'; + if (dayStatus.hasOvertimeWarning) statusText = '확인필요'; + else if (dayStatus.hasIncomplete) statusText = '미입력'; + else if (dayStatus.hasIssues) statusText = '부분입력'; + + console.log(`📅 ${dateStr} (${dayNumber}일):`, { + 상태: statusText, + 작업자수: dayStatus.workerCount, + dayStatus, + 원본데이터: dayWorkData + }); + } + + let dayClasses = ['calendar-day']; + if (!isCurrentMonth) dayClasses.push('other-month'); + if (isToday) dayClasses.push('today'); + if (isSunday) dayClasses.push('sunday'); + if (isSaturday) dayClasses.push('saturday'); + if (isSunday || isSaturday) dayClasses.push('weekend'); + + // 문제가 있는지 확인 + const hasAnyProblem = dayStatus.hasOvertimeWarning || dayStatus.hasIncomplete || dayStatus.hasIssues; + + // 문제가 없으면 초록색 배경 + if (dayStatus.hasData && !hasAnyProblem) { + dayClasses.push('has-normal'); // 이상없음 (초록) + } + + // 문제가 있으면 범례 아이콘들을 그대로 표시 + let statusIcons = ''; + if (hasAnyProblem) { + // 범례와 동일한 아이콘들 표시 + if (dayStatus.hasOvertimeWarning) { + statusIcons += '
'; + } + if (dayStatus.hasIncomplete) { + statusIcons += '
'; + } + if (dayStatus.hasIssues) { + statusIcons += '
'; + } + } + + calendarHTML += ` +
+
${dayNumber}
+ ${statusIcons} +
+ `; + + currentDay.setDate(currentDay.getDate() + 1); + } + + elements.calendarDays.innerHTML = calendarHTML; + + } catch (error) { + console.error('캘린더 렌더링 오류:', error); + showToast('캘린더를 불러오는데 실패했습니다.', 'error'); + } finally { + showLoading(false); + } +} + +// 일별 상태 분석 (집계 데이터 또는 원본 데이터 처리) +function analyzeDayStatus(dayData) { + // 새로운 집계 데이터 구조인지 확인 (monthly_summary에서 온 데이터) + if (dayData && typeof dayData === 'object' && 'totalWorkers' in dayData) { + // 미입력 판단: allWorkers 배열 길이와 실제 작업한 작업자 수 비교 + const totalRegisteredWorkers = allWorkers ? allWorkers.length : 10; // 실제 등록된 작업자 수 + const actualIncompleteWorkers = Math.max(0, totalRegisteredWorkers - dayData.workingWorkers); + + const result = { + hasData: dayData.totalWorkers > 0, + hasIssues: dayData.partialWorkers > 0, // 부분입력 작업자가 있으면 true + hasIncomplete: actualIncompleteWorkers > 0 || dayData.incompleteWorkers > 0, // 실제 미입력 작업자가 있으면 true + hasOvertimeWarning: dayData.hasOvertimeWarning || dayData.overtimeWarningWorkers > 0, // 12시간 초과 + workerCount: dayData.totalWorkers || 0 + }; + + // 디버깅: 모든 데이터 로그 (미입력 문제 해결용) + console.log('📊 analyzeDayStatus 결과:', { + dayData, + result, + actualIncompleteWorkers, + workingWorkers: dayData.workingWorkers, + totalRegisteredWorkers: totalRegisteredWorkers, + allWorkersLength: allWorkers ? allWorkers.length : 'undefined' + }); + + return result; + } + + // 기존 hasData 구조 확인 + if (dayData && typeof dayData === 'object' && dayData.hasData !== undefined) { + return { + hasData: dayData.hasData, + hasIssues: dayData.hasIssues, + hasErrors: dayData.hasErrors, + workerCount: dayData.workerCount || 0 + }; + } + + // 폴백: 기존 방식으로 분석 (원본 작업 데이터 배열) + if (!Array.isArray(dayData) || dayData.length === 0) { + return { + hasData: false, + hasIssues: false, + hasErrors: false, + workerCount: 0 + }; + } + + // 작업자별로 그룹화 + const workerGroups = {}; + dayData.forEach(work => { + if (!workerGroups[work.worker_id]) { + workerGroups[work.worker_id] = []; + } + workerGroups[work.worker_id].push(work); + }); + + const workerCount = Object.keys(workerGroups).length; + let hasIssues = false; + let hasErrors = false; + + // 각 작업자의 상태 분석 - 문제가 있는지만 확인 + Object.values(workerGroups).forEach(workerWork => { + const totalHours = workerWork.reduce((sum, w) => sum + parseFloat(w.work_hours || 0), 0); + const hasError = workerWork.some(w => w.work_status_id === 2); + const hasVacation = workerWork.some(w => w.project_id === 13); + + // 오류가 있는 경우 + if (hasError) { + hasErrors = true; + } + // 휴가가 아닌데 미입력이거나 부분입력인 경우 + else if (!hasVacation && (totalHours === 0 || totalHours < 8)) { + hasIssues = true; + } + }); + + return { + hasData: true, + hasIssues, + hasErrors, + workerCount + }; +} + +// 일일 작업 현황 모달 열기 +async function openDailyWorkModal(dateStr) { + console.log(`🗓️ 클릭된 날짜: ${dateStr}`); + currentModalDate = dateStr; + + // 날짜 포맷팅 + const date = new Date(dateStr + 'T00:00:00'); + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + + console.log(`📅 파싱된 날짜: ${year}년 ${month}월 ${day}일`); + const dayNames = ['일', '월', '화', '수', '목', '금', '토']; + const dayName = dayNames[date.getDay()]; + + elements.modalTitle.textContent = `${year}년 ${month}월 ${day}일 (${dayName}) 작업 현황`; + + try { + // 새로운 집계 API로 작업자별 상세 정보 조회 + const response = await window.apiCall(`/monthly-status/daily-details?date=${dateStr}`); + + if (response.success) { + const { workers, summary } = response.data; + renderModalDataFromSummary(workers, summary); + } else { + // 폴백: 기존 API 사용 + console.log('집계 API 실패, 기존 API로 폴백'); + const fallbackResponse = await window.apiCall(`/daily-work-reports?date=${dateStr}&view_all=true`); + const workData = Array.isArray(fallbackResponse) ? fallbackResponse : (fallbackResponse.data || []); + renderModalData(workData); + } + + // 모달 표시 + elements.dailyWorkModal.style.display = 'flex'; + document.body.style.overflow = 'hidden'; + + } catch (error) { + console.error('일일 작업 데이터 로딩 오류:', error); + showToast('해당 날짜의 작업 데이터를 불러오는데 실패했습니다.', 'error'); + } +} + +// 집계 데이터로 모달 렌더링 (최적화된 버전) +async function renderModalDataFromSummary(workers, summary) { + // 전체 작업자 목록 가져오기 + const allWorkers = await loadWorkersData(); + + // 작업한 작업자 ID 목록 + const workedWorkerIds = new Set(workers.map(w => w.workerId)); + + // 미기입 작업자 추가 (대시보드와 동일한 상태 판단 로직 적용) + const missingWorkers = allWorkers + .filter(worker => !workedWorkerIds.has(worker.worker_id)) + .map(worker => { + return { + workerId: worker.worker_id, + workerName: worker.worker_name, + jobType: worker.job_type, + totalHours: 0, + actualWorkHours: 0, + vacationHours: 0, + totalWorkCount: 0, + regularWorkCount: 0, + errorWorkCount: 0, + status: 'incomplete', + hasVacation: false, + hasError: false, + hasIssues: true + }; + }); + + // 전체 작업자 목록 (작업한 사람 + 미기입 사람) + const allWorkersList = [...workers, ...missingWorkers]; + + // 요약 정보 업데이트 (전체 작업자 수 포함) + if (elements.modalTotalWorkers) { + elements.modalTotalWorkers.textContent = `${allWorkersList.length}명`; + } + if (elements.modalTotalHours) { + elements.modalTotalHours.textContent = `${summary.totalHours.toFixed(1)}h`; + } + if (elements.modalTotalTasks) { + elements.modalTotalTasks.textContent = `${summary.totalTasks}건`; + } + if (elements.modalErrorCount) { + elements.modalErrorCount.textContent = `${summary.errorCount}건`; + elements.modalErrorCount.className = summary.errorCount > 0 ? 'summary-value error' : 'summary-value'; + } + + // 작업자 리스트 렌더링 + if (allWorkersList.length === 0) { + elements.modalWorkersList.innerHTML = '
등록된 작업자가 없습니다.
'; + return; + } + + const workersHtml = allWorkersList.map(worker => { + // 상태 텍스트 및 색상 결정 (에러가 있어도 작업시간 기준으로 판단) + let statusText = '미입력'; + let statusClass = 'incomplete'; + + // 에러 여부와 관계없이 작업시간 기준으로 상태 결정 + const totalHours = worker.totalHours || 0; + const hasVacation = worker.hasVacation || false; + const vacationHours = worker.vacationHours || 0; + + if (totalHours > 12) { + statusText = '확인필요'; statusClass = 'overtime-warning'; + } else if (hasVacation && vacationHours > 0) { + switch (vacationHours) { + case 8: statusText = '연차'; statusClass = 'vacation-full'; break; + case 6: statusText = '조퇴'; statusClass = 'vacation-half-half'; break; + case 4: statusText = '반차'; statusClass = 'vacation-half'; break; + case 2: statusText = '반반차'; statusClass = 'vacation-quarter'; break; + default: statusText = '연차'; statusClass = 'vacation-full'; + } + } else if (totalHours > 8) { + statusText = '연장근로'; statusClass = 'overtime'; + } else if (totalHours === 8) { + statusText = '정시근로'; statusClass = 'complete'; + } else if (totalHours > 0) { + statusText = '부분입력'; statusClass = 'partial'; + } else { + statusText = '미입력'; statusClass = 'incomplete'; + } + + // 작업자 이름의 첫 글자 추출 + const initial = worker.workerName ? worker.workerName.charAt(0) : '?'; + + return ` +
+
+
+ ${initial} +
+
+
+
${worker.workerName}
+
${worker.jobType || '일반'}
+
+
+
${statusText}
+
+
+
+ 작업시간 + ${worker.actualWorkHours.toFixed(1)}h +
+
+ 정규 + ${worker.regularWorkCount}건 + 에러 + ${worker.errorWorkCount}건 +
+
+
+ +
+
+ `; + }).join(''); + + elements.modalWorkersList.innerHTML = workersHtml; +} + +// 모달 데이터 렌더링 (폴백용 - 기존 방식) +function renderModalData(workData) { + // 작업자별로 그룹화 + const workerGroups = {}; + workData.forEach(work => { + if (!workerGroups[work.worker_id]) { + workerGroups[work.worker_id] = { + worker_id: work.worker_id, + worker_name: work.worker_name, + job_type: work.job_type, + works: [] + }; + } + workerGroups[work.worker_id].works.push(work); + }); + + // 요약 정보 계산 + const totalWorkers = Object.keys(workerGroups).length; + const totalHours = workData.reduce((sum, w) => sum + parseFloat(w.work_hours || 0), 0); + const totalTasks = workData.length; + const errorCount = workData.filter(w => w.work_status_id === 2).length; + + // 요약 정보 업데이트 + elements.modalTotalWorkers.textContent = `${totalWorkers}명`; + elements.modalTotalHours.textContent = `${totalHours.toFixed(1)}h`; + elements.modalTotalTasks.textContent = `${totalTasks}건`; + elements.modalErrorCount.textContent = `${errorCount}건`; + + // 작업자 리스트 렌더링 + renderWorkersList(Object.values(workerGroups)); +} + +// 작업자 리스트 렌더링 +function renderWorkersList(workerGroups) { + const workersHTML = workerGroups.map(workerGroup => { + const totalHours = workerGroup.works.reduce((sum, w) => sum + parseFloat(w.work_hours || 0), 0); + const hasError = workerGroup.works.some(w => w.work_status_id === 2); + const hasVacation = workerGroup.works.some(w => w.project_id === 13); + const regularWorkCount = workerGroup.works.filter(w => w.project_id !== 13 && w.work_status_id !== 2).length; + const errorWorkCount = workerGroup.works.filter(w => w.project_id !== 13 && w.work_status_id === 2).length; + + // 상태 결정 + let status, statusText, statusBadge; + if (hasVacation) { + const vacationHours = workerGroup.works + .filter(w => w.project_id === 13) + .reduce((sum, w) => sum + parseFloat(w.work_hours || 0), 0); + + if (vacationHours === 8) { + status = 'vacation-full'; + statusText = '연차'; + statusBadge = '연차'; + } else if (vacationHours === 6) { + status = 'vacation-half-half'; + statusText = '조퇴'; + statusBadge = '조퇴'; + } else if (vacationHours === 4) { + status = 'vacation-half'; + statusText = '반차'; + statusBadge = '반차'; + } else if (vacationHours === 2) { + status = 'vacation-quarter'; + statusText = '반반차'; + statusBadge = '반반차'; + } + } else if (totalHours > 8) { + status = 'overtime'; + statusText = '연장근로'; + statusBadge = '연장근로'; + } else if (totalHours === 8) { + status = 'complete'; + statusText = '정시근로'; + statusBadge = '정시근로'; + } else if (totalHours > 0) { + status = 'partial'; + statusText = '부분입력'; + statusBadge = '부분입력'; + } else { + status = 'incomplete'; + statusText = '미입력'; + statusBadge = '미입력'; + } + + return ` +
+
+
+ ${workerGroup.worker_name.charAt(0)} +
+
+

${workerGroup.worker_name}

+

${workerGroup.job_type || '작업자'}

+
+
+ +
+ ${statusBadge} +
+ +
+
+ 작업시간 + ${totalHours.toFixed(1)}h +
+
+ 정규 + ${regularWorkCount}건 +
+ ${errorWorkCount > 0 ? ` +
+ 에러 + ${errorWorkCount}건 +
+ ` : ''} +
+
+ `; + }).join(''); + + elements.modalWorkersList.innerHTML = workersHTML; +} + +// 작업자 리스트 필터링 +function filterWorkersList() { + const filterValue = elements.statusFilter.value; + const workerRows = elements.modalWorkersList.querySelectorAll('.worker-status-row'); + + workerRows.forEach(row => { + const status = row.dataset.status; + if (filterValue === 'all' || status === filterValue || + (filterValue === 'vacation' && status.startsWith('vacation'))) { + row.style.display = 'flex'; + } else { + row.style.display = 'none'; + } + }); +} + +// 모달 닫기 +function closeDailyWorkModal() { + elements.dailyWorkModal.style.display = 'none'; + document.body.style.overflow = ''; + currentModalDate = null; +} + +// 로딩 표시 +function showLoading(show) { + if (elements.loadingSpinner) { + elements.loadingSpinner.style.display = show ? 'flex' : 'none'; + } +} + +// 토스트 메시지 (간단한 구현) +function showToast(message, type = 'info') { + // 기존 토스트가 있으면 제거 + const existingToast = document.querySelector('.toast-message'); + if (existingToast) { + existingToast.remove(); + } + + const toast = document.createElement('div'); + toast.className = `toast-message toast-${type}`; + toast.textContent = message; + toast.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + padding: 12px 24px; + background: ${type === 'error' ? '#ef4444' : type === 'success' ? '#10b981' : '#3b82f6'}; + color: white; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + z-index: 10000; + font-weight: 500; + max-width: 400px; + `; + + document.body.appendChild(toast); + + setTimeout(() => { + toast.remove(); + }, 3000); +} + +// 작업자 개별 작업 모달 열기 +async function openWorkerModal(workerId, date) { + try { + // 작업자 정보 찾기 + const worker = allWorkers.find(w => w.worker_id === workerId); + if (!worker) { + showToast('작업자 정보를 찾을 수 없습니다.', 'error'); + return; + } + + // 작업 입력 모달 열기 + await openWorkEntryModal(workerId, worker.worker_name, date); + + } catch (error) { + console.error('작업자 모달 열기 오류:', error); + showToast('작업 입력 모달을 여는데 실패했습니다.', 'error'); + } +} + +// 작업 입력 모달 열기 +async function openWorkEntryModal(workerId, workerName, date) { + try { + // 모달 요소들 가져오기 + const modal = document.getElementById('workEntryModal'); + const titleElement = document.getElementById('workEntryModalTitle'); + const workerNameDisplay = document.getElementById('workerNameDisplay'); + const workerIdInput = document.getElementById('workerId'); + const workDateInput = document.getElementById('workDate'); + + if (!modal) { + showToast('작업 입력 모달을 찾을 수 없습니다.', 'error'); + return; + } + + // 모달 제목 및 정보 설정 + titleElement.textContent = `${workerName} - 작업 입력`; + workerNameDisplay.value = workerName; + workerIdInput.value = workerId; + workDateInput.value = date; + + // 프로젝트 및 상태 데이터 로드 + await loadModalData(); + + // 모달 표시 + modal.style.display = 'flex'; + document.body.style.overflow = 'hidden'; + + } catch (error) { + console.error('작업 입력 모달 열기 오류:', error); + showToast('작업 입력 모달을 여는데 실패했습니다.', 'error'); + } +} + +// 모달 데이터 로드 (프로젝트, 작업 상태) +async function loadModalData() { + try { + // 프로젝트 목록 로드 + const projectsResponse = await window.apiCall('/projects'); + const projects = Array.isArray(projectsResponse) ? projectsResponse : (projectsResponse.data || []); + + const projectSelect = document.getElementById('projectSelect'); + projectSelect.innerHTML = ''; + projects.forEach(project => { + const option = document.createElement('option'); + option.value = project.project_id; + option.textContent = project.project_name; + projectSelect.appendChild(option); + }); + + // 작업 상태 목록 로드 (하드코딩으로 대체) + const statuses = [ + { status_id: 1, status_name: '완료' }, + { status_id: 2, status_name: '오류' }, + { status_id: 3, status_name: '진행중' } + ]; + + const statusSelect = document.getElementById('workStatusSelect'); + statusSelect.innerHTML = ''; + statuses.forEach(status => { + const option = document.createElement('option'); + option.value = status.status_id; + option.textContent = status.status_name; + statusSelect.appendChild(option); + }); + + } catch (error) { + console.error('모달 데이터 로드 오류:', error); + showToast('데이터를 불러오는데 실패했습니다.', 'error'); + } +} + +// 작업 입력 모달 닫기 +function closeWorkEntryModal() { + const modal = document.getElementById('workEntryModal'); + if (modal) { + modal.style.display = 'none'; + document.body.style.overflow = 'auto'; + + // 폼 초기화 + const form = document.getElementById('workEntryForm'); + if (form) { + form.reset(); + } + } +} + +// 휴가 처리 +function handleVacation(type) { + const projectSelect = document.getElementById('projectSelect'); + const workHours = document.getElementById('workHours'); + const workStatusSelect = document.getElementById('workStatusSelect'); + const workDescription = document.getElementById('workDescription'); + + // 연차/휴무 프로젝트 선택 (project_id: 13) + projectSelect.value = '13'; + + // 휴가 유형에 따른 시간 설정 + switch (type) { + case 'full': // 연차 + workHours.value = '8'; + workDescription.value = '연차'; + break; + case 'half': // 반차 + workHours.value = '4'; + workDescription.value = '반차'; + break; + case 'quarter': // 반반차 + workHours.value = '2'; + workDescription.value = '반반차'; + break; + case 'early': // 조퇴 + workHours.value = '6'; + workDescription.value = '조퇴'; + break; + } + + // 완료 상태로 설정 (status_id: 1) + workStatusSelect.value = '1'; +} + +// 작업 저장 +async function saveWorkEntry() { + try { + const form = document.getElementById('workEntryForm'); + const formData = new FormData(form); + + const workData = { + worker_id: document.getElementById('workerId').value, + project_id: document.getElementById('projectSelect').value, + work_hours: document.getElementById('workHours').value, + work_status_id: document.getElementById('workStatusSelect').value, + description: document.getElementById('workDescription').value, + report_date: document.getElementById('workDate').value + }; + + // 필수 필드 검증 + if (!workData.project_id || !workData.work_hours || !workData.work_status_id) { + showToast('필수 항목을 모두 입력해주세요.', 'error'); + return; + } + + // API 호출 + const response = await window.apiCall('/daily-work-reports', 'POST', workData); + + if (response.success || response.id) { + showToast('작업이 성공적으로 저장되었습니다.', 'success'); + closeWorkEntryModal(); + + // 캘린더 새로고침 + await renderCalendar(); + + // 현재 열린 모달이 있다면 새로고침 + if (currentModalDate) { + await openDailyWorkModal(currentModalDate); + } + } else { + throw new Error(response.message || '저장에 실패했습니다.'); + } + + } catch (error) { + console.error('작업 저장 오류:', error); + showToast(error.message || '작업 저장에 실패했습니다.', 'error'); + } +} + +// 모달 닫기 함수 +function closeDailyWorkModal() { + if (elements.dailyWorkModal) { + elements.dailyWorkModal.style.display = 'none'; + document.body.style.overflow = 'auto'; + } +} + +// 전역 변수로 작업자 목록 저장 +let allWorkers = []; + +// 시간 업데이트 함수 +function updateCurrentTime() { + const now = new Date(); + const timeString = now.toLocaleTimeString('ko-KR', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + + const timeValueElement = document.getElementById('timeValue'); + if (timeValueElement) { + timeValueElement.textContent = timeString; + } +} + +// 사용자 정보 업데이트 함수 +function updateUserInfo() { + const userInfo = JSON.parse(localStorage.getItem('userInfo') || '{}'); + console.log('👤 localStorage userInfo:', userInfo); + + const userNameElement = document.getElementById('userName'); + const userRoleElement = document.getElementById('userRole'); + const userInitialElement = document.getElementById('userInitial'); + + if (userNameElement) { + if (userInfo.worker_name) { + userNameElement.textContent = userInfo.worker_name; + } else { + userNameElement.textContent = '사용자'; + } + } + + if (userRoleElement) { + if (userInfo.job_type) { + userRoleElement.textContent = userInfo.job_type; + } else { + userRoleElement.textContent = '작업자'; + } + } + + if (userInitialElement) { + if (userInfo.worker_name) { + userInitialElement.textContent = userInfo.worker_name.charAt(0); + } else { + userInitialElement.textContent = '사'; + } + } +} + +// 페이지 초기화 개선 +function initializePage() { + // 시간 업데이트 시작 + updateCurrentTime(); + setInterval(updateCurrentTime, 1000); + + // 사용자 정보 업데이트 + updateUserInfo(); + + // 프로필 메뉴 토글 + const userProfile = document.getElementById('userProfile'); + const profileMenu = document.getElementById('profileMenu'); + + if (userProfile && profileMenu) { + userProfile.addEventListener('click', (e) => { + e.stopPropagation(); + profileMenu.style.display = profileMenu.style.display === 'block' ? 'none' : 'block'; + }); + + // 외부 클릭 시 메뉴 닫기 + document.addEventListener('click', () => { + profileMenu.style.display = 'none'; + }); + } + + // 로그아웃 버튼 + const logoutBtn = document.getElementById('logoutBtn'); + if (logoutBtn) { + logoutBtn.addEventListener('click', () => { + localStorage.removeItem('token'); + localStorage.removeItem('userInfo'); + window.location.href = '/pages/auth/login.html'; + }); + } +} + +// DOMContentLoaded 이벤트에 초기화 함수 추가 +document.addEventListener('DOMContentLoaded', function() { + initializePage(); +}); + +// 전역 함수로 노출 +window.openDailyWorkModal = openDailyWorkModal; +window.closeDailyWorkModal = closeDailyWorkModal; +window.openWorkerModal = openWorkerModal; +window.openWorkEntryModal = openWorkEntryModal; +window.closeWorkEntryModal = closeWorkEntryModal; +window.handleVacation = handleVacation; +window.saveWorkEntry = saveWorkEntry; diff --git a/web-ui/js/worker-individual-report.js b/web-ui/js/worker-individual-report.js new file mode 100644 index 0000000..8d2e39a --- /dev/null +++ b/web-ui/js/worker-individual-report.js @@ -0,0 +1,512 @@ +// worker-individual-report.js - 작업자별 개별 보고서 관리 + +// 전역 변수 +let currentWorkerId = null; +let currentWorkerName = ''; +let selectedDate = ''; +let currentUser = null; +let workTypes = []; +let workStatusTypes = []; +let errorTypes = []; +let projects = []; +let existingWork = []; + +// URL 파라미터에서 정보 추출 +function getUrlParams() { + const urlParams = new URLSearchParams(window.location.search); + return { + worker_id: urlParams.get('worker_id'), + worker_name: decodeURIComponent(urlParams.get('worker_name') || ''), + date: urlParams.get('date') || new Date().toISOString().split('T')[0] + }; +} + +// 현재 로그인한 사용자 정보 가져오기 +function getCurrentUser() { + try { + const token = localStorage.getItem('token'); + if (!token) return null; + + const payloadBase64 = token.split('.')[1]; + if (payloadBase64) { + const payload = JSON.parse(atob(payloadBase64)); + return payload; + } + } catch (error) { + console.log('토큰에서 사용자 정보 추출 실패:', error); + } + + try { + const userInfo = localStorage.getItem('user'); + if (userInfo) { + return JSON.parse(userInfo); + } + } catch (error) { + console.log('localStorage에서 사용자 정보 파싱 실패:', error); + } + return null; +} + +// 메시지 표시 함수 +function showMessage(msg, type = 'info') { + const container = document.getElementById('message-container'); + if (container) { + container.innerHTML = `
${msg}
`; + setTimeout(() => { + container.innerHTML = ''; + }, 5000); + } +} + +// 페이지 초기화 +document.addEventListener('DOMContentLoaded', async () => { + // API 함수가 로드될 때까지 기다림 + let retryCount = 0; + const maxRetries = 50; + + while (!window.apiCall && retryCount < maxRetries) { + await new Promise(resolve => setTimeout(resolve, 100)); + retryCount++; + } + + if (!window.apiCall) { + console.error('❌ API 함수를 로드할 수 없습니다.'); + showMessage('시스템을 초기화할 수 없습니다. 페이지를 새로고침해주세요.', 'error'); + return; + } + + try { + await initializePage(); + } catch (error) { + console.error('페이지 초기화 오류:', error); + showMessage('페이지를 불러오는 중 오류가 발생했습니다.', 'error'); + } +}); + +async function initializePage() { + console.log('🚀 개별 작업 보고서 페이지 초기화 시작'); + + // URL 파라미터 추출 + const params = getUrlParams(); + currentWorkerId = parseInt(params.worker_id); + currentWorkerName = params.worker_name; + selectedDate = params.date; + + // 사용자 정보 설정 + currentUser = getCurrentUser(); + + if (!currentWorkerId || !currentWorkerName) { + showMessage('잘못된 접근입니다. 작업자 정보가 없습니다.', 'error'); + setTimeout(() => { + window.history.back(); + }, 2000); + return; + } + + // 페이지 제목 설정 + updatePageHeader(); + + // 이벤트 리스너 설정 + setupEventListeners(); + + // 초기 데이터 로드 + await loadInitialData(); + + console.log('✅ 개별 작업 보고서 페이지 초기화 완료'); +} + +function updatePageHeader() { + document.getElementById('pageTitle').textContent = `👤 ${currentWorkerName} 작업 보고서`; + document.getElementById('pageSubtitle').textContent = `${selectedDate} 작업 내용을 관리합니다.`; + + // 작업자 정보 카드 업데이트 + document.getElementById('workerInitial').textContent = currentWorkerName.charAt(0); + document.getElementById('workerName').textContent = currentWorkerName; + document.getElementById('selectedDate').textContent = selectedDate; +} + +function setupEventListeners() { + // 새 작업 추가 버튼 + document.getElementById('addNewWorkBtn').addEventListener('click', showNewWorkForm); + document.getElementById('cancelNewWorkBtn').addEventListener('click', hideNewWorkForm); + document.getElementById('saveNewWorkBtn').addEventListener('click', saveNewWork); + + // 업무 상태 변경 시 에러 유형 섹션 토글 + document.getElementById('newWorkStatusSelect').addEventListener('change', toggleErrorTypeSection); + + // 빠른 시간 버튼 + document.querySelectorAll('.quick-time-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + document.getElementById('newWorkHours').value = e.target.dataset.hours; + }); + }); + + // 휴가 처리 버튼들 + document.querySelectorAll('.vacation-process-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + const vacationType = e.target.dataset.type; + handleVacationProcess(vacationType); + }); + }); +} + +async function loadInitialData() { + try { + showMessage('데이터를 불러오는 중...', 'loading'); + + // 병렬로 데이터 로드 + await Promise.all([ + loadWorkerInfo(), + loadExistingWork(), + loadProjects(), + loadWorkTypes(), + loadWorkStatusTypes(), + loadErrorTypes() + ]); + + // UI 업데이트 + updateWorkerSummary(); + renderExistingWork(); + populateDropdowns(); + + showMessage('데이터 로드 완료', 'success'); + + } catch (error) { + console.error('초기 데이터 로드 실패:', error); + showMessage('데이터 로드 중 오류가 발생했습니다: ' + error.message, 'error'); + } +} + +async function loadWorkerInfo() { + try { + const response = await window.apiCall(`${window.API}/workers/${currentWorkerId}`); + const worker = response.data || response; + document.getElementById('workerJob').textContent = worker.job_type || '작업자'; + } catch (error) { + console.error('작업자 정보 로드 오류:', error); + } +} + +async function loadExistingWork() { + try { + const response = await window.apiCall(`${window.API}/daily-work-reports?date=${selectedDate}&worker_id=${currentWorkerId}`); + existingWork = Array.isArray(response) ? response : (response.data || []); + console.log(`✅ 기존 작업 ${existingWork.length}건 로드 완료`); + } catch (error) { + console.error('기존 작업 로드 오류:', error); + existingWork = []; + } +} + +async function loadProjects() { + try { + const response = await window.apiCall(`${window.API}/projects`); + projects = Array.isArray(response) ? response : (response.data || []); + } catch (error) { + console.error('프로젝트 로드 오류:', error); + projects = []; + } +} + +async function loadWorkTypes() { + try { + const response = await window.apiCall(`${window.API}/daily-work-reports/work-types`); + workTypes = Array.isArray(response) ? response : (response.data || []); + } catch (error) { + console.error('작업 유형 로드 오류:', error); + workTypes = []; + } +} + +async function loadWorkStatusTypes() { + try { + const response = await window.apiCall(`${window.API}/daily-work-reports/work-status-types`); + workStatusTypes = Array.isArray(response) ? response : (response.data || []); + } catch (error) { + console.error('작업 상태 유형 로드 오류:', error); + workStatusTypes = []; + } +} + +async function loadErrorTypes() { + try { + const response = await window.apiCall(`${window.API}/daily-work-reports/error-types`); + errorTypes = Array.isArray(response) ? response : (response.data || []); + } catch (error) { + console.error('에러 유형 로드 오류:', error); + errorTypes = []; + } +} + +function updateWorkerSummary() { + const totalHours = existingWork.reduce((sum, work) => sum + parseFloat(work.work_hours || 0), 0); + const workCount = existingWork.length; + + document.getElementById('totalHours').textContent = `${totalHours.toFixed(1)}h`; + document.getElementById('workCount').textContent = `${workCount}건`; + + // 12시간 초과 경고 + if (totalHours > 12) { + document.getElementById('totalHours').classList.add('warning'); + showMessage(`⚠️ 총 작업시간이 ${totalHours.toFixed(1)}시간으로 12시간을 초과했습니다.`, 'warning'); + } +} + +function renderExistingWork() { + const container = document.getElementById('existingWorkList'); + + if (existingWork.length === 0) { + container.innerHTML = ` +
+
📭
+

등록된 작업이 없습니다

+

${selectedDate}에 ${currentWorkerName}님의 작업이 등록되지 않았습니다.

+
+ `; + return; + } + + container.innerHTML = existingWork.map(work => ` +
+
+
+

${work.project_name || '미지정 프로젝트'}

+

${work.work_type_name || '미지정 작업'}

+
+
+ + ${work.work_status_name || '정상'} + + ${work.work_hours}h +
+
+ ${work.work_status_id === 2 && work.error_type_name ? ` +
+ 오류: + ${work.error_type_name} +
+ ` : ''} +
+ + +
+
+ `).join(''); +} + +function populateDropdowns() { + // 프로젝트 드롭다운 + const projectSelect = document.getElementById('newProjectSelect'); + projectSelect.innerHTML = ''; + projects.forEach(project => { + const option = document.createElement('option'); + option.value = project.project_id; + option.textContent = project.project_name; + projectSelect.appendChild(option); + }); + + // 작업 유형 드롭다운 + const workTypeSelect = document.getElementById('newWorkTypeSelect'); + workTypeSelect.innerHTML = ''; + workTypes.forEach(type => { + const option = document.createElement('option'); + option.value = type.id; + option.textContent = type.name; + workTypeSelect.appendChild(option); + }); + + // 작업 상태 드롭다운 + const workStatusSelect = document.getElementById('newWorkStatusSelect'); + workStatusSelect.innerHTML = ''; + workStatusTypes.forEach(status => { + const option = document.createElement('option'); + option.value = status.id; + option.textContent = status.name; + workStatusSelect.appendChild(option); + }); + + // 에러 유형 드롭다운 + const errorTypeSelect = document.getElementById('newErrorTypeSelect'); + errorTypeSelect.innerHTML = ''; + errorTypes.forEach(error => { + const option = document.createElement('option'); + option.value = error.id; + option.textContent = error.name; + errorTypeSelect.appendChild(option); + }); +} + +function showNewWorkForm() { + document.getElementById('newWorkSection').style.display = 'block'; + document.getElementById('addNewWorkBtn').style.display = 'none'; +} + +function hideNewWorkForm() { + document.getElementById('newWorkSection').style.display = 'none'; + document.getElementById('addNewWorkBtn').style.display = 'block'; + resetNewWorkForm(); +} + +function resetNewWorkForm() { + document.getElementById('newProjectSelect').value = ''; + document.getElementById('newWorkTypeSelect').value = ''; + document.getElementById('newWorkStatusSelect').value = ''; + document.getElementById('newErrorTypeSelect').value = ''; + document.getElementById('newWorkHours').value = '1.00'; + document.getElementById('newErrorTypeSection').classList.remove('visible'); +} + +function toggleErrorTypeSection() { + const workStatusSelect = document.getElementById('newWorkStatusSelect'); + const errorSection = document.getElementById('newErrorTypeSection'); + const errorTypeSelect = document.getElementById('newErrorTypeSelect'); + + if (workStatusSelect.value === '2') { // 에러 상태 + errorSection.classList.add('visible'); + errorTypeSelect.setAttribute('required', 'true'); + } else { + errorSection.classList.remove('visible'); + errorTypeSelect.removeAttribute('required'); + errorTypeSelect.value = ''; + } +} + +async function saveNewWork() { + try { + const projectId = document.getElementById('newProjectSelect').value; + const workTypeId = document.getElementById('newWorkTypeSelect').value; + const workStatusId = document.getElementById('newWorkStatusSelect').value; + const errorTypeId = document.getElementById('newErrorTypeSelect').value; + const workHours = document.getElementById('newWorkHours').value; + + // 유효성 검사 + if (!projectId || !workTypeId || !workStatusId || !workHours) { + showMessage('모든 필수 필드를 입력해주세요.', 'error'); + return; + } + + if (workStatusId === '2' && !errorTypeId) { + showMessage('에러 상태일 때는 에러 유형을 선택해야 합니다.', 'error'); + return; + } + + showMessage('작업을 저장하는 중...', 'loading'); + + const workData = { + report_date: selectedDate, + worker_id: currentWorkerId, + project_id: parseInt(projectId), + work_type_id: parseInt(workTypeId), + work_status_id: parseInt(workStatusId), + error_type_id: workStatusId === '2' ? parseInt(errorTypeId) : null, + work_hours: parseFloat(workHours), + created_by: currentUser?.user_id || 1 + }; + + const response = await window.apiCall(`${window.API}/daily-work-reports`, { + method: 'POST', + body: JSON.stringify(workData) + }); + + showMessage('작업이 성공적으로 저장되었습니다.', 'success'); + + // 데이터 새로고침 + await loadExistingWork(); + updateWorkerSummary(); + renderExistingWork(); + hideNewWorkForm(); + + } catch (error) { + console.error('작업 저장 오류:', error); + showMessage(`작업 저장 중 오류가 발생했습니다: ${error.message}`, 'error'); + } +} + +async function editWork(workId) { + // TODO: 작업 수정 모달 또는 인라인 편집 구현 + console.log(`작업 ${workId} 수정`); + showMessage('작업 수정 기능은 곧 구현될 예정입니다.', 'info'); +} + +async function deleteWork(workId) { + if (!confirm('이 작업을 삭제하시겠습니까?')) { + return; + } + + try { + showMessage('작업을 삭제하는 중...', 'loading'); + + await window.apiCall(`${window.API}/daily-work-reports/${workId}`, { + method: 'DELETE' + }); + + showMessage('작업이 성공적으로 삭제되었습니다.', 'success'); + + // 데이터 새로고침 + await loadExistingWork(); + updateWorkerSummary(); + renderExistingWork(); + + } catch (error) { + console.error('작업 삭제 오류:', error); + showMessage(`작업 삭제 중 오류가 발생했습니다: ${error.message}`, 'error'); + } +} + +async function handleVacationProcess(vacationType) { + const vacationNames = { + 'full': '연차', + 'half-half': '반반차', + 'half': '반차' + }; + + const vacationHours = { + 'full': 8, + 'half-half': 6, + 'half': 4 + }; + + if (!confirm(`${vacationNames[vacationType]} 처리하시겠습니까?\n(${vacationHours[vacationType]}시간으로 자동 입력됩니다)`)) { + return; + } + + try { + showMessage(`${vacationNames[vacationType]} 처리 중...`, 'loading'); + + // 휴가용 작업 보고서 생성 + const vacationWork = { + report_date: selectedDate, + worker_id: currentWorkerId, + project_id: 1, // 기본 프로젝트 (휴가용) + work_type_id: 999, // 휴가 전용 작업 유형 (DB에 추가 필요) + work_status_id: 1, // 정상 상태 + error_type_id: null, + work_hours: vacationHours[vacationType], + created_by: currentUser?.user_id || 1 + }; + + const response = await window.apiCall(`${window.API}/daily-work-reports`, { + method: 'POST', + body: JSON.stringify(vacationWork) + }); + + showMessage(`${vacationNames[vacationType]} 처리가 완료되었습니다.`, 'success'); + + // 데이터 새로고침 + await loadExistingWork(); + updateWorkerSummary(); + renderExistingWork(); + + } catch (error) { + console.error('휴가 처리 오류:', error); + showMessage(`휴가 처리 중 오류가 발생했습니다: ${error.message}`, 'error'); + } +} + +// 전역 함수로 등록 +window.editWork = editWork; +window.deleteWork = deleteWork; diff --git a/web-ui/pages/common/daily-work-report-viewer.html b/web-ui/pages/common/daily-work-report-viewer.html index c125528..42785f7 100644 --- a/web-ui/pages/common/daily-work-report-viewer.html +++ b/web-ui/pages/common/daily-work-report-viewer.html @@ -3,98 +3,284 @@ - 일일 작업보고서 조회 - + 작업 현황 확인 - TK 건설 + + + -
- - -
-
- - - - + +
+
+
+
+ +
+

테크니컬코리아

+

작업 현황 확인

+
+
+
+ +
+
+ 현재 시각 + --:--:-- +
+
+ +
+
+
-