Compare commits

...

3 Commits

Author SHA1 Message Date
Hyungi Ahn
746e09420b feat: 캘린더 기반 작업 현황 확인 시스템 구현
- 월별 캘린더 UI로 작업 현황을 한눈에 확인 가능
- 미입력(빨강), 부분입력(주황), 확인필요(보라), 이상없음(초록) 상태 표시
- 범례 아이콘(●)을 사용한 직관적인 상태 표시
- 날짜 클릭 시 해당일 작업자별 상세 현황 모달
- 작업자 클릭 시 개별 작업 입력/수정 모달
- 휴가 처리 기능 (연차, 반차, 반반차, 조퇴)
- 월별 집계 데이터 최적화로 API 호출 최소화

백엔드:
- monthly_worker_status, monthly_summary 테이블 추가
- 자동 집계 stored procedure 및 trigger 구현
- 확인필요(12시간 초과) 상태 감지 로직
- 출석 관리 시스템 확장

프론트엔드:
- 캘린더 그리드 UI 구현
- 상태별 색상 및 아이콘 표시
- 모달 기반 상세 정보 표시
- 반응형 디자인 적용
2025-11-04 10:12:07 +09:00
Hyungi Ahn
33307bb243 enhance: 작업 입력 UI 대폭 개선 - 모던하고 직관적인 인터페이스 구현
🎨 모던한 카드형 레이아웃:
1. 작업 항목 디자인 완전 개편:
   - 그라데이션 배경 (primary → tertiary)
   - 상단 컬러 바 호버 효과
   - 향상된 그림자 및 호버 애니메이션
   - 2xl 테두리 반경으로 부드러운 외관

2. 폼 필드 그룹화:
   - form-field-group 컨테이너 도입
   - 아이콘과 라벨 조합으로 직관성 향상
   - 포커스 상태 시각적 피드백
   - 호버 효과로 상호작용성 증대

 에러 유형 조건부 표시 개선:
1. 스마트한 UI 로직:
   - 업무 상태가 '에러'일 때만 에러 유형 섹션 표시
   - 부드러운 슬라이드 다운 애니메이션 (0.4s cubic-bezier)
   - opacity, max-height, transform 조합 애니메이션

2. 시각적 구분:
   - 에러 섹션: error-50 → warning-50 그라데이션
   - 에러 테두리: error-200 컬러
   - 에러 아이콘 및 라벨: error-500/700 컬러

🚀 빠른 시간 버튼 고도화:
1. 프리미엄 디자인:
   - 그라데이션 배경 (primary-100 → primary-200)
   - 호버 시: primary-500 → primary-600 그라데이션
   - 반짝이는 효과 (::before 슬라이드 애니메이션)
   - 3D 변형 효과 (scale, translateY)

2. 향상된 상호작용:
   - 30분 옵션 추가 (0.5시간)
   - 클릭 시 스케일 애니메이션
   - 중앙 정렬 및 최소 너비 설정
   - cubic-bezier 전환 효과

🎯 사용자 경험 개선:
1. 직관적인 인터페이스:
   - 아이콘으로 필드 구분 (🏗️ 프로젝트, ⚙️ 작업유형, 📊 업무상태, ⚠️ 에러유형,  시간)
   - 명확한 플레이스홀더 텍스트
   - 논리적인 필드 배치 (2열 그리드)

2. 반응형 최적화:
   - 모바일에서 적절한 패딩 및 폰트 크기
   - 터치 친화적 버튼 크기
   - 유연한 그리드 레이아웃

🔧 기술적 개선:
1. JavaScript 로직 강화:
   - setupWorkEntryEvents 함수 완전 재작성
   - 폼 필드 포커스 효과 추가
   - 에러 타입 조건부 표시 로직 개선
   - 버튼 클릭 피드백 애니메이션

2. CSS 아키텍처:
   - 컴포넌트 기반 스타일링
   - CSS 변수 활용한 일관된 디자인
   - 애니메이션 키프레임 정의
   - 계층적 스타일 구조

🎯 결과:
- 허접했던 UI → 프로페셔널한 모던 인터페이스
- 에러 유형 조건부 표시로 사용성 대폭 향상
- 직관적이고 아름다운 작업 입력 경험
- 대시보드와 완벽한 디자인 일관성

테스트: http://localhost:20000/pages/common/daily-work-report.html
2025-11-03 13:02:30 +09:00
Hyungi Ahn
bad5584988 enhance: daily-work-report.html 모던 UI 대폭 개선 - 대시보드 일관성 및 사용성 향상
🎨 모던 디자인 시스템 적용:
1. 대시보드와 일관된 디자인 언어:
   - design-system.css 활용한 통일된 색상, 타이포그래피
   - 동일한 카드, 버튼, 애니메이션 스타일
   - 일관된 간격, 그림자, 테두리 반경

2. 새로운 CSS 파일 분리:
   - daily-work-report.css 생성 (673줄)
   - 인라인 스타일 완전 제거 (926줄 → 0줄)
   - 모듈화된 스타일 관리

🚀 사용자 경험 대폭 개선:
1. 모던한 진행 단계 표시:
   - 상단 3단계 진행 바 추가
   - 실시간 단계 상태 표시 (활성/완료)
   - 시각적 진행도 피드백

2. 개선된 레이아웃 구조:
   - 헤더: 그라데이션 배경, 중앙 정렬
   - 메인: 최대 너비 1200px, 중앙 배치
   - 카드: 일관된 패딩, 그림자, 테두리

3. 향상된 작업자 선택 UI:
   - worker-btn → worker-card 클래스 변경
   - 카드형 디자인으로 시각적 개선
   - 호버 효과: 상단 컬러 바, 배경 변화
   - 선택 상태: 그라데이션 배경, 흰색 텍스트

 인터랙션 개선:
1. 버튼 디자인 통일:
   - 일관된 패딩, 높이 (48px)
   - 호버 효과: translateY(-2px), 그림자 확대
   - 색상: primary, success, secondary 통일

2. 폼 요소 개선:
   - form-input 클래스로 통일된 스타일
   - 포커스 상태: 테두리 색상, 그림자 효과
   - 라벨: 명확한 계층 구조

3. 애니메이션 효과:
   - 단계 전환: opacity, transform 애니메이션
   - 카드 호버: translateY, 그림자 변화
   - 부드러운 전환: var(--transition-normal)

📱 반응형 디자인 최적화:
1. 모바일 (768px 이하):
   - 진행 단계: 세로 배치
   - 작업자 그리드: 1열로 변경
   - 패딩, 폰트 크기 조정

2. 태블릿 (1024px 이하):
   - 적절한 그리드 컬럼 수 조정
   - 터치 친화적 버튼 크기

🔧 기술적 개선:
1. JavaScript 업데이트:
   - updateProgressSteps() 함수 추가
   - 진행 단계 실시간 업데이트
   - CSS 클래스명 변경 반영

2. HTML 구조 개선:
   - 시맨틱 태그 활용 (header, main)
   - 접근성 향상 (label, aria 속성)
   - 깔끔한 마크업 구조

🎯 결과:
- 대시보드와 완벽한 디자인 일관성
- 직관적이고 사용하기 쉬운 인터페이스
- 모든 디바이스에서 최적화된 경험
- 프로페셔널한 시각적 품질

테스트: http://localhost:20000/pages/common/daily-work-report.html
2025-11-03 12:56:45 +09:00
31 changed files with 9707 additions and 1916 deletions

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 };

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 ;

View File

@@ -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 ;

View File

@@ -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 ;

View File

@@ -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;

View File

@@ -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 등)

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -86,11 +86,12 @@ const createDailyWorkReportService = async (reportData) => {
* @returns {Promise<Array>} 조회된 작업 보고서 배열
*/
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);

View File

@@ -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 };

166
web-ui/css/common.css Normal file
View File

@@ -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; }
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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')) {

View File

@@ -86,9 +86,29 @@ function goToStep(stepNumber) {
}
}
}
// 진행 단계 표시 업데이트
updateProgressSteps(stepNumber);
currentStep = stepNumber;
}
// 진행 단계 표시 업데이트
function updateProgressSteps(currentStepNumber) {
for (let i = 1; i <= 3; i++) {
const progressStep = document.getElementById(`progressStep${i}`);
if (progressStep) {
progressStep.classList.remove('active', 'completed');
if (i < currentStepNumber) {
progressStep.classList.add('completed');
} else if (i === currentStepNumber) {
progressStep.classList.add('active');
}
}
}
}
// 초기 데이터 로드 (통합 API 사용)
async function loadData() {
try {
@@ -203,7 +223,7 @@ function populateWorkerGrid() {
workers.forEach(worker => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'worker-btn';
btn.className = 'worker-card';
btn.textContent = worker.worker_name;
btn.dataset.id = worker.worker_id;
@@ -240,61 +260,78 @@ function addWorkEntry() {
entryDiv.innerHTML = `
<div class="work-entry-header">
<div class="work-entry-title">작업 ${workEntryCounter}</div>
<button type="button" class="remove-work-btn" onclick="removeWorkEntry(${workEntryCounter})">×</button>
<div class="work-entry-title">작업 항목 #${workEntryCounter}</div>
<button type="button" class="remove-work-btn" onclick="removeWorkEntry(${workEntryCounter})">
<i class="fas fa-times"></i>
</button>
</div>
<div class="work-entry-row">
<div class="form-group">
<label>🏗️ 프로젝트</label>
<select class="large-select project-select" required>
<option value="">프로젝트 선택</option>
<div class="work-entry-grid">
<div class="form-field-group">
<div class="form-field-label">
<span class="form-field-icon">🏗️</span>
프로젝트
</div>
<select class="form-select project-select" required>
<option value="">프로젝트를 선택하세요</option>
${projects.map(p => `<option value="${p.project_id}">${p.project_name}</option>`).join('')}
</select>
</div>
<div class="form-group">
<label>⚙️ 작업 유형</label>
<select class="large-select work-type-select" required>
<option value="">작업 유형 선택</option>
<div class="form-field-group">
<div class="form-field-label">
<span class="form-field-icon">⚙️</span>
작업 유형
</div>
<select class="form-select work-type-select" required>
<option value="">작업 유형을 선택하세요</option>
${workTypes.map(wt => `<option value="${wt.id}">${wt.name}</option>`).join('')}
</select>
</div>
</div>
<div class="work-entry-row">
<div class="form-group">
<label>📊 업무 상태</label>
<select class="large-select work-status-select" required>
<option value="">업무 상태 선택</option>
<div class="work-entry-full">
<div class="form-field-group">
<div class="form-field-label">
<span class="form-field-icon">📊</span>
업무 상태
</div>
<select class="form-select work-status-select" required>
<option value="">업무 상태를 선택하세요</option>
${workStatusTypes.map(ws => `<option value="${ws.id}">${ws.name}</option>`).join('')}
</select>
</div>
<div class="form-group error-type-section">
<label>❌ 에러 유형</label>
<select class="large-select error-type-select">
<option value="">에러 유형 선택</option>
${errorTypes.map(et => `<option value="${et.id}">${et.name}</option>`).join('')}
</select>
</div>
</div>
<div class="time-input-row">
<div class="form-group">
<label>⏰ 작업 시간</label>
<input type="number" class="large-select time-input"
placeholder="시간 입력"
min="0"
max="24"
step="0.5"
required>
<div class="quick-time-buttons">
<div class="quick-time-btn" data-hours="1">1시간</div>
<div class="quick-time-btn" data-hours="2">2시간</div>
<div class="quick-time-btn" data-hours="4">4시간</div>
<div class="quick-time-btn" data-hours="8">8시간</div>
</div>
<div class="error-type-section work-entry-full">
<div class="form-field-label">
<span class="form-field-icon">⚠️</span>
에러 유형
</div>
<select class="form-select error-type-select">
<option value="">에러 유형을 선택하세요</option>
${errorTypes.map(et => `<option value="${et.id}">${et.name}</option>`).join('')}
</select>
</div>
<div class="time-input-section work-entry-full">
<div class="form-field-label">
<span class="form-field-icon">⏰</span>
작업 시간 (시간)
</div>
<input type="number" class="form-select time-input"
placeholder="작업 시간을 입력하세요"
min="0.25"
max="24"
step="0.25"
value="1.00"
required>
<div class="quick-time-buttons">
<button type="button" class="quick-time-btn" data-hours="0.5">30분</button>
<button type="button" class="quick-time-btn" data-hours="1">1시간</button>
<button type="button" class="quick-time-btn" data-hours="2">2시간</button>
<button type="button" class="quick-time-btn" data-hours="4">4시간</button>
<button type="button" class="quick-time-btn" data-hours="8">8시간</button>
</div>
</div>
`;
@@ -306,26 +343,56 @@ function addWorkEntry() {
// 작업 항목 이벤트 설정
function setupWorkEntryEvents(entryDiv) {
const timeInput = entryDiv.querySelector('.time-input');
const workStatusSelect = entryDiv.querySelector('.work-status-select');
const errorTypeSection = entryDiv.querySelector('.error-type-section');
const errorTypeSelect = entryDiv.querySelector('.error-type-select');
// 시간 입력 이벤트
timeInput.addEventListener('input', updateTotalHours);
// 빠른 시간 버튼 이벤트
entryDiv.querySelectorAll('.quick-time-btn').forEach(btn => {
btn.addEventListener('click', () => {
btn.addEventListener('click', (e) => {
e.preventDefault();
timeInput.value = btn.dataset.hours;
updateTotalHours();
// 버튼 클릭 효과
btn.style.transform = 'scale(0.95)';
setTimeout(() => {
btn.style.transform = '';
}, 150);
});
});
const workStatusSelect = entryDiv.querySelector('.work-status-select');
const errorTypeSection = entryDiv.querySelector('.error-type-section');
// 업무 상태 변경 시 에러 유형 섹션 토글
workStatusSelect.addEventListener('change', (e) => {
if (e.target.value === '2') {
const isError = e.target.value === '2'; // 에러 상태 ID가 2라고 가정
if (isError) {
errorTypeSection.classList.add('visible');
errorTypeSection.querySelector('.error-type-select').required = true;
errorTypeSelect.required = true;
// 에러 상태일 때 시각적 피드백
errorTypeSection.style.animation = 'slideDown 0.4s ease-out';
} else {
errorTypeSection.classList.remove('visible');
errorTypeSection.querySelector('.error-type-select').required = false;
errorTypeSection.querySelector('.error-type-select').value = '';
errorTypeSelect.required = false;
errorTypeSelect.value = '';
}
});
// 폼 필드 포커스 효과
entryDiv.querySelectorAll('.form-field-group').forEach(group => {
const input = group.querySelector('select, input');
if (input) {
input.addEventListener('focus', () => {
group.classList.add('focused');
});
input.addEventListener('blur', () => {
group.classList.remove('focused');
});
}
});
}
@@ -470,7 +537,7 @@ function resetForm() {
goToStep(1);
selectedWorkers.clear();
document.querySelectorAll('.worker-btn.selected').forEach(btn => {
document.querySelectorAll('.worker-card.selected').forEach(btn => {
btn.classList.remove('selected');
});

View File

@@ -1,5 +1,5 @@
// js/load-navbar.js
import { getUser, clearAuthData } from './auth.js';
// 브라우저 호환 버전 - ES6 모듈 제거
// 역할 이름을 한글로 변환하는 맵
const ROLE_NAMES = {

View File

@@ -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 = `
<div class="empty-state">
<div class="empty-icon">📭</div>
<h3>작업 데이터가 없습니다</h3>
<p>${selectedDate}에 등록된 작업이 없습니다.</p>
<div class="empty-icon">👥</div>
<h3>등록된 작업자가 없습니다</h3>
<p>시스템에 작업자가 등록되어 있지 않습니다.</p>
</div>
`;
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 = `
<div class="work-status-grid">
${Object.entries(projectGroups).map(([projectName, works]) => `
<div class="project-status-card">
<div class="project-header">
<h4 class="project-name">📁 ${projectName}</h4>
<span class="work-count badge badge-primary">${works.length}건</span>
</div>
<div class="project-stats">
<div class="stat-item">
<span class="stat-label">총 시간</span>
<span class="stat-value">${works.reduce((sum, w) => sum + parseFloat(w.work_hours || 0), 0).toFixed(1)}h</span>
</div>
<div class="stat-item">
<span class="stat-label">작업자</span>
<span class="stat-value">${new Set(works.map(w => w.worker_id)).size}명</span>
</div>
<div class="stat-item">
<span class="stat-label">오류</span>
<span class="stat-value ${works.filter(w => w.work_status_id === 2).length > 0 ? 'error' : ''}">${works.filter(w => w.work_status_id === 2).length}건</span>
</div>
</div>
<div class="worker-status-list">
<div class="worker-status-header">
<div class="header-title">
<h3>작업자별 현황</h3>
<span class="header-date">${selectedDate}</span>
</div>
`).join('')}
<div class="status-legend">
<span class="legend-item legend-complete">정시근로</span>
<span class="legend-item legend-overtime">연장근로</span>
<span class="legend-item legend-vacation">휴가</span>
<span class="legend-item legend-partial">부분입력</span>
<span class="legend-item legend-incomplete">미입력</span>
<span class="legend-item legend-error">오류</span>
</div>
</div>
<div class="worker-status-rows">
${workerStatusList.map(worker => `
<div class="worker-status-row ${worker.status}" data-worker-id="${worker.worker_id}">
<div class="worker-basic-info">
<div class="worker-avatar">
<span>${worker.worker_name.charAt(0)}</span>
</div>
<div class="worker-details">
<h4 class="worker-name">${worker.worker_name}</h4>
<p class="worker-job">${worker.job_type || '작업자'}</p>
</div>
</div>
<div class="worker-status-indicator">
<span class="status-badge status-${worker.status}">${worker.statusBadge}</span>
</div>
<div class="worker-stats-inline">
<div class="stat-item">
<span class="stat-label">작업시간</span>
<span class="stat-value ${worker.actualWorkHours > 12 ? 'warning' : ''}">${worker.actualWorkHours.toFixed(1)}h</span>
</div>
<div class="stat-item">
<span class="stat-label">정규</span>
<span class="stat-value">${worker.regularWorkCount}건</span>
</div>
${worker.errorWorkCount > 0 ? `
<div class="stat-item error">
<span class="stat-label">에러</span>
<span class="stat-value">${worker.errorWorkCount}건</span>
</div>
` : ''}
</div>
<div class="worker-actions-inline">
<button class="btn btn-sm btn-primary worker-edit-btn" onclick="openWorkerModal(${worker.worker_id}, '${worker.worker_name}')">
작업입력
</button>
${worker.vacationType ? `
<button class="btn btn-sm btn-secondary vacation-btn" onclick="handleVacation(${worker.worker_id}, '${worker.vacationType}')">
${worker.vacationType === 'full' ? '연차처리' : worker.vacationType === 'half' ? '반차처리' : '반반차처리'}
</button>
` : ''}
${worker.status === 'overtime-warning' ? `
<button class="btn btn-sm btn-warning confirm-overtime-btn" onclick="confirmOvertime(${worker.worker_id})">
정상확인
</button>
` : ''}
</div>
</div>
`).join('')}
</div>
</div>
`;
}
@@ -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 `
<div class="worker-card card">
<div class="card-body">
@@ -366,17 +535,17 @@ function displayWorkersAsCards(workers) {
</div>
<div class="worker-stats">
<div class="stat">
<span class="stat-label">오늘 작업</span>
<span class="stat-value">${todayWork.length}</span>
<span class="stat-label">작업시간</span>
<span class="stat-value">${actualWorkHours.toFixed(1)}h</span>
</div>
<div class="stat">
<span class="stat-label">작업 시간</span>
<span class="stat-value">${totalHours.toFixed(1)}h</span>
<span class="stat-label">정규</span>
<span class="stat-value">${regularWorkCount}</span>
</div>
${hasError ? `
${errorWorkCount > 0 ? `
<div class="stat error">
<span class="stat-label">오류</span>
<span class="stat-value">⚠️</span>
<span class="stat-label">에러</span>
<span class="stat-value">${errorWorkCount}</span>
</div>
` : ''}
</div>
@@ -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 = `
<div id="workerModal" class="modal-overlay">
<div class="modal-container">
<div class="modal-header">
<h2 id="modalTitle">작업자 보고서</h2>
<button class="modal-close-btn" onclick="hideWorkerModal()">×</button>
</div>
<div class="modal-body">
<!-- 작업자 정보 -->
<div class="modal-worker-info">
<div class="modal-worker-avatar">
<span id="modalWorkerInitial">작</span>
</div>
<div class="modal-worker-details">
<h3 id="modalWorkerName">작업자명</h3>
<p id="modalWorkerDate">날짜</p>
</div>
<div class="modal-worker-summary">
<div class="modal-stat">
<span class="modal-stat-label">총 시간</span>
<span class="modal-stat-value" id="modalTotalHours">0h</span>
</div>
<div class="modal-stat">
<span class="modal-stat-label">작업 건수</span>
<span class="modal-stat-value" id="modalWorkCount">0건</span>
</div>
</div>
</div>
<!-- 기존 작업 목록 -->
<div class="modal-section">
<div class="modal-section-header">
<h4>기존 작업 목록</h4>
<button class="btn btn-sm btn-primary" id="modalAddWorkBtn">새 작업 추가</button>
</div>
<div id="modalExistingWork" class="modal-existing-work">
<!-- 기존 작업들이 여기에 표시됩니다 -->
</div>
</div>
<!-- 새 작업 추가 폼 -->
<div class="modal-section" id="modalNewWorkSection" style="display: none;">
<div class="modal-section-header">
<h4>새 작업 추가</h4>
<button class="btn btn-sm btn-secondary" id="modalCancelWorkBtn">취소</button>
</div>
<div class="modal-work-form">
<div class="modal-form-row">
<div class="modal-form-group">
<label>프로젝트</label>
<select id="modalProjectSelect" class="modal-select">
<option value="">프로젝트 선택</option>
</select>
</div>
<div class="modal-form-group">
<label>작업 유형</label>
<select id="modalWorkTypeSelect" class="modal-select">
<option value="">작업 유형 선택</option>
</select>
</div>
</div>
<div class="modal-form-group">
<label>업무 상태</label>
<select id="modalWorkStatusSelect" class="modal-select">
<option value="">업무 상태 선택</option>
</select>
</div>
<div class="modal-form-group" id="modalErrorTypeGroup" style="display: none;">
<label>에러 유형</label>
<select id="modalErrorTypeSelect" class="modal-select">
<option value="">에러 유형 선택</option>
</select>
</div>
<div class="modal-form-group">
<label>작업 시간</label>
<input type="number" id="modalWorkHours" class="modal-input" step="0.25" min="0.25" max="24" value="1.00">
<div class="modal-quick-time">
<button type="button" class="modal-time-btn" data-hours="0.5">0.5h</button>
<button type="button" class="modal-time-btn" data-hours="1">1h</button>
<button type="button" class="modal-time-btn" data-hours="2">2h</button>
<button type="button" class="modal-time-btn" data-hours="4">4h</button>
<button type="button" class="modal-time-btn" data-hours="8">8h</button>
</div>
</div>
<button class="btn btn-success modal-save-btn" id="modalSaveWorkBtn">작업 저장</button>
</div>
</div>
<!-- 휴가 처리 -->
<div class="modal-section">
<div class="modal-section-header">
<h4>휴가 처리</h4>
</div>
<div class="modal-vacation-buttons">
<button class="btn btn-warning modal-vacation-btn" data-type="full">연차 (8시간)</button>
<button class="btn btn-warning modal-vacation-btn" data-type="half-half">반반차 (6시간)</button>
<button class="btn btn-warning modal-vacation-btn" data-type="half">반차 (4시간)</button>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="hideWorkerModal()">닫기</button>
</div>
</div>
</div>
`;
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 = `
<div class="modal-empty-state">
<div class="modal-empty-icon">—</div>
<p>등록된 작업이 없습니다.</p>
</div>
`;
return;
}
container.innerHTML = modalExistingWork.map(work => `
<div class="modal-work-item">
<div class="modal-work-info">
<h5>${work.project_name || '미지정 프로젝트'}</h5>
<p>${work.work_type_name || '미지정 작업'}</p>
${work.work_status_id === 2 && work.error_type_name ? `
<span class="modal-error-badge">${work.error_type_name}</span>
` : ''}
</div>
<div class="modal-work-actions">
<span class="modal-work-hours">${work.work_hours}h</span>
<button class="btn btn-xs btn-danger" onclick="deleteModalWork(${work.id})">삭제</button>
</div>
</div>
`).join('');
}
function populateModalDropdowns() {
// 프로젝트 드롭다운
const projectSelect = document.getElementById('modalProjectSelect');
projectSelect.innerHTML = '<option value="">프로젝트 선택</option>';
modalProjects.forEach(project => {
projectSelect.innerHTML += `<option value="${project.project_id}">${project.project_name}</option>`;
});
// 작업 유형 드롭다운
const workTypeSelect = document.getElementById('modalWorkTypeSelect');
workTypeSelect.innerHTML = '<option value="">작업 유형 선택</option>';
modalWorkTypes.forEach(type => {
workTypeSelect.innerHTML += `<option value="${type.id}">${type.name}</option>`;
});
// 작업 상태 드롭다운
const workStatusSelect = document.getElementById('modalWorkStatusSelect');
workStatusSelect.innerHTML = '<option value="">업무 상태 선택</option>';
modalWorkStatusTypes.forEach(status => {
workStatusSelect.innerHTML += `<option value="${status.id}">${status.name}</option>`;
});
// 에러 유형 드롭다운
const errorTypeSelect = document.getElementById('modalErrorTypeSelect');
errorTypeSelect.innerHTML = '<option value="">에러 유형 선택</option>';
modalErrorTypes.forEach(error => {
errorTypeSelect.innerHTML += `<option value="${error.id}">${error.name}</option>`;
});
}
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;

File diff suppressed because it is too large Load Diff

View File

@@ -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 = `<div class="message ${type}">${msg}</div>`;
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 = `
<div class="empty-state">
<div class="empty-icon">📭</div>
<h3>등록된 작업이 없습니다</h3>
<p>${selectedDate}${currentWorkerName}님의 작업이 등록되지 않았습니다.</p>
</div>
`;
return;
}
container.innerHTML = existingWork.map(work => `
<div class="existing-work-item" data-work-id="${work.id}">
<div class="work-item-header">
<div class="work-item-info">
<h4>${work.project_name || '미지정 프로젝트'}</h4>
<p>${work.work_type_name || '미지정 작업'}</p>
</div>
<div class="work-item-status">
<span class="status-badge ${work.work_status_id === 2 ? 'error' : 'normal'}">
${work.work_status_name || '정상'}
</span>
<span class="work-hours">${work.work_hours}h</span>
</div>
</div>
${work.work_status_id === 2 && work.error_type_name ? `
<div class="work-item-error">
<span class="error-label">오류:</span>
<span class="error-type">${work.error_type_name}</span>
</div>
` : ''}
<div class="work-item-actions">
<button class="btn btn-sm btn-primary" onclick="editWork(${work.id})">
✏️ 수정
</button>
<button class="btn btn-sm btn-danger" onclick="deleteWork(${work.id})">
🗑️ 삭제
</button>
</div>
</div>
`).join('');
}
function populateDropdowns() {
// 프로젝트 드롭다운
const projectSelect = document.getElementById('newProjectSelect');
projectSelect.innerHTML = '<option value="">프로젝트를 선택하세요</option>';
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 = '<option value="">작업 유형을 선택하세요</option>';
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 = '<option value="">업무 상태를 선택하세요</option>';
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 = '<option value="">에러 유형을 선택하세요</option>';
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;

View File

@@ -3,98 +3,284 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>일일 작업보고서 조회</title>
<link rel="stylesheet" href="/css/daily-report-viewer.css">
<title>작업 현황 확인 - TK 건설</title>
<link rel="stylesheet" href="/css/common.css?v=13">
<link rel="stylesheet" href="/css/modern-dashboard.css?v=13">
<link rel="stylesheet" href="/css/work-report-calendar.css?v=22">
</head>
<body>
<div class="container">
<header class="page-header">
<h1>📊 일일 작업보고서 조회</h1>
<p class="subtitle">날짜를 선택하여 해당일의 작업 현황을 확인하세요</p>
</header>
<div class="date-selector">
<div class="date-input-group">
<label for="reportDate">📅 조회 날짜:</label>
<input type="date" id="reportDate" class="date-input">
<button id="searchBtn" class="search-btn">조회</button>
<button id="todayBtn" class="today-btn">오늘</button>
<!-- 대시보드 헤더 -->
<header class="dashboard-header">
<div class="header-content">
<div class="header-left">
<div class="brand">
<img src="/img/logo.png" alt="테크니컬코리아" class="brand-logo">
<div class="brand-text">
<h1 class="brand-title">테크니컬코리아</h1>
<p class="brand-subtitle">작업 현황 확인</p>
</div>
</div>
</div>
<div class="header-center">
<div class="current-time" id="currentTime">
<span class="time-label">현재 시각</span>
<span class="time-value" id="timeValue">--:--:--</span>
</div>
</div>
<div class="header-right">
<div class="user-profile" id="userProfile">
<div class="user-avatar">
<span class="avatar-text" id="userInitial"></span>
</div>
<div class="user-info">
<span class="user-name" id="userName">사용자</span>
<span class="user-role" id="userRole">작업자</span>
</div>
<div class="profile-menu" id="profileMenu">
<a href="/pages/profile/my-profile.html" class="menu-item">
<span class="menu-icon">👤</span>
내 프로필
</a>
<a href="/pages/profile/change-password.html" class="menu-item">
<span class="menu-icon">🔐</span>
비밀번호 변경
</a>
<a href="/pages/dashboard/group-leader.html" class="menu-item">
<span class="menu-icon">📊</span>
대시보드
</a>
<button class="menu-item logout-btn" id="logoutBtn">
<span class="menu-icon">🚪</span>
로그아웃
</button>
</div>
</div>
</div>
</div>
</header>
<div id="loadingSpinner" class="loading-spinner" style="display: none;">
<!-- 메인 콘텐츠 -->
<main class="dashboard-main">
<div class="calendar-page-container">
<!-- 페이지 제목 -->
<div class="page-title-section">
<h2 class="page-title">📅 작업 현황 확인</h2>
<p class="page-subtitle">월별 작업자 현황을 한눈에 확인하세요</p>
</div>
<!-- 캘린더 카드 -->
<div class="calendar-card">
<!-- 월 네비게이션 -->
<div class="calendar-nav">
<button id="prevMonthBtn" class="nav-btn prev-btn">
<span class="nav-icon"></span>
<span class="nav-text">이전</span>
</button>
<div class="calendar-title">
<h3 id="monthYearTitle">2025년 11월</h3>
<button id="todayBtn" class="today-btn">오늘</button>
</div>
<button id="nextMonthBtn" class="nav-btn next-btn">
<span class="nav-text">다음</span>
<span class="nav-icon"></span>
</button>
</div>
<!-- 범례 -->
<div class="calendar-legend">
<div class="legend-item">
<div class="legend-dot has-overtime-warning"></div>
<span>확인필요</span>
</div>
<div class="legend-item">
<div class="legend-dot has-errors"></div>
<span>미입력</span>
</div>
<div class="legend-item">
<div class="legend-dot has-issues"></div>
<span>부분입력</span>
</div>
<div class="legend-item">
<div class="legend-dot has-normal"></div>
<span>이상 없음</span>
</div>
</div>
<!-- 캘린더 -->
<div class="calendar-grid">
<div class="calendar-header">
<div class="day-header sunday"></div>
<div class="day-header"></div>
<div class="day-header"></div>
<div class="day-header"></div>
<div class="day-header"></div>
<div class="day-header"></div>
<div class="day-header saturday"></div>
</div>
<div class="calendar-days" id="calendarDays">
<!-- 캘린더 날짜들이 여기에 동적으로 생성됩니다 -->
</div>
</div>
</div>
</div>
</main>
<!-- 로딩 스피너 -->
<div id="loadingSpinner" class="loading-overlay" style="display: none;">
<div class="loading-content">
<div class="spinner"></div>
<p>데이터를 불러오는 중...</p>
</div>
</div>
<div id="errorMessage" class="error-message" style="display: none;">
<div class="error-content">
<span class="error-icon">⚠️</span>
<span class="error-text"></span>
<!-- 일일 작업 현황 모달 -->
<div id="dailyWorkModal" class="modal-overlay" style="display: none;">
<div class="modal-container large-modal">
<div class="modal-header">
<h2 id="modalTitle">2025년 11월 3일 작업 현황</h2>
<button class="modal-close-btn" onclick="closeDailyWorkModal()">×</button>
</div>
</div>
<div id="noDataMessage" class="no-data-message" style="display: none;">
<div class="no-data-content">
<span class="no-data-icon">📭</span>
<h3>해당 날짜의 작업보고서가 없습니다</h3>
<p>다른 날짜를 선택해 주세요.</p>
</div>
</div>
<div id="reportSummary" class="report-summary" style="display: none;">
<div class="summary-cards">
<div class="summary-card">
<div class="card-header">
<span class="card-icon">👥</span>
<span class="card-title">작업자 수</span>
<div class="modal-body">
<!-- 요약 정보 -->
<div class="daily-summary">
<div class="summary-card">
<div class="summary-icon success">👥</div>
<div class="summary-content">
<div class="summary-label">총 작업자</div>
<div class="summary-value" id="modalTotalWorkers">0명</div>
</div>
</div>
<div class="card-value" id="totalWorkers">0</div>
</div>
<div class="summary-card">
<div class="card-header">
<span class="card-icon"></span>
<span class="card-title">총 작업시간</span>
<div class="summary-card">
<div class="summary-icon primary"></div>
<div class="summary-content">
<div class="summary-label">총 작업시간</div>
<div class="summary-value" id="modalTotalHours">0.0h</div>
</div>
</div>
<div class="card-value" id="totalHours">0시간</div>
</div>
<div class="summary-card">
<div class="card-header">
<span class="card-icon">📝</span>
<span class="card-title">작업 항목</span>
<div class="summary-card">
<div class="summary-icon warning">📝</div>
<div class="summary-content">
<div class="summary-label">작업 건수</div>
<div class="summary-value" id="modalTotalTasks">0건</div>
</div>
</div>
<div class="card-value" id="totalEntries">0개</div>
</div>
<div class="summary-card error-card">
<div class="card-header">
<span class="card-icon">⚠️</span>
<span class="card-title">에러 항목</span>
<div class="summary-card">
<div class="summary-icon error">⚠️</div>
<div class="summary-content">
<div class="summary-label">오류 건수</div>
<div class="summary-value" id="modalErrorCount">0건</div>
</div>
</div>
<div class="card-value" id="errorCount">0개</div>
</div>
</div>
</div>
<div id="workersReport" class="workers-report" style="display: none;">
<h2 class="section-title">👥 작업자별 상세 현황</h2>
<div id="workersList" class="workers-list">
<!-- 작업자별 데이터가 여기에 표시됩니다 -->
</div>
</div>
<div id="exportSection" class="export-section" style="display: none;">
<h3>📤 데이터 내보내기</h3>
<div class="export-buttons">
<button id="exportExcelBtn" class="export-btn excel-btn">
📊 Excel로 내보내기
</button>
<button id="printBtn" class="export-btn print-btn">
🖨️ 인쇄
</button>
<!-- 작업자 현황 리스트 -->
<div class="modal-work-status">
<div class="work-status-header">
<h3>작업자별 현황</h3>
<div class="status-filter">
<select id="statusFilter">
<option value="all">전체</option>
<option value="incomplete">미입력</option>
<option value="partial">부분입력</option>
<option value="complete">완료</option>
<option value="overtime">연장근로</option>
<option value="error">오류</option>
</select>
</div>
</div>
<div id="modalWorkersList" class="worker-status-list">
<!-- 작업자 리스트가 여기에 동적으로 생성됩니다 -->
</div>
<div id="modalNoData" class="empty-state" style="display: none;">
<div class="empty-icon">📭</div>
<h3>해당 날짜의 작업 보고서가 없습니다</h3>
<p>다른 날짜를 선택해 주세요.</p>
</div>
</div>
</div>
</div>
</div>
<script src="/js/daily-report-viewer.js"></script>
<!-- 작업 입력 모달 -->
<div id="workEntryModal" class="modal-overlay" style="display: none;">
<div class="modal-container">
<div class="modal-header">
<h2 id="workEntryModalTitle">작업 입력</h2>
<button class="modal-close-btn" onclick="closeWorkEntryModal()">×</button>
</div>
<div class="modal-body">
<form id="workEntryForm">
<!-- 작업자 정보 -->
<div class="form-section">
<h3>작업자 정보</h3>
<div class="form-group">
<label class="form-label">작업자</label>
<input type="text" id="workerNameDisplay" class="form-control" readonly>
<input type="hidden" id="workerId">
</div>
<div class="form-group">
<label class="form-label">작업 날짜</label>
<input type="date" id="workDate" class="form-control" readonly>
</div>
</div>
<!-- 작업 내용 -->
<div class="form-section">
<h3>작업 내용</h3>
<div class="form-group">
<label class="form-label">프로젝트 *</label>
<select id="projectSelect" class="form-control" required>
<option value="">프로젝트를 선택하세요</option>
</select>
</div>
<div class="form-group">
<label class="form-label">작업 시간 (시간) *</label>
<input type="number" id="workHours" class="form-control" min="0" max="24" step="0.5" required>
</div>
<div class="form-group">
<label class="form-label">작업 상태 *</label>
<select id="workStatusSelect" class="form-control" required>
<option value="">상태를 선택하세요</option>
</select>
</div>
<div class="form-group">
<label class="form-label">작업 설명</label>
<textarea id="workDescription" class="form-control" rows="3" placeholder="작업 내용을 상세히 입력하세요"></textarea>
</div>
</div>
<!-- 휴가 처리 -->
<div class="form-section">
<h3>휴가 처리</h3>
<div class="vacation-buttons">
<button type="button" class="btn-vacation" onclick="handleVacation('full')">연차 (8시간)</button>
<button type="button" class="btn-vacation" onclick="handleVacation('half')">반차 (4시간)</button>
<button type="button" class="btn-vacation" onclick="handleVacation('quarter')">반반차 (2시간)</button>
<button type="button" class="btn-vacation" onclick="handleVacation('early')">조퇴 (6시간)</button>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeWorkEntryModal()">취소</button>
<button type="button" class="btn btn-primary" onclick="saveWorkEntry()">저장</button>
</div>
</div>
</div>
<!-- JavaScript -->
<script src="/js/api-config.js?v=13"></script>
<script src="/js/auth-check.js?v=13"></script>
<script src="/js/load-navbar.js?v=13"></script>
<script src="/js/work-report-calendar.js?v=27"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,170 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>개별 작업 보고서 | 테크니컬코리아</title>
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/daily-work-report.css">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-config.js"></script>
<script src="/js/auth-check.js" defer></script>
</head>
<body>
<div class="work-report-container">
<!-- 네비게이션 바 -->
<div id="navbar-container"></div>
<!-- 헤더 -->
<header class="work-report-header">
<h1 id="pageTitle">👤 개별 작업 보고서</h1>
<p class="subtitle" id="pageSubtitle">작업자의 일일 작업 내용을 입력하고 수정합니다.</p>
</header>
<!-- 메인 콘텐츠 -->
<main class="work-report-main">
<!-- 뒤로가기 버튼 -->
<a href="javascript:history.back()" class="back-button">
← 뒤로가기
</a>
<!-- 작업자 정보 카드 -->
<div class="worker-info-card" id="workerInfoCard">
<div class="worker-avatar-large">
<span id="workerInitial"></span>
</div>
<div class="worker-info-details">
<h2 id="workerName">작업자명</h2>
<p id="workerJob">직종</p>
<p id="selectedDate">날짜</p>
</div>
<div class="worker-status-summary" id="workerStatusSummary">
<div class="status-item">
<span class="status-label">총 작업시간</span>
<span class="status-value" id="totalHours">0h</span>
</div>
<div class="status-item">
<span class="status-label">작업 건수</span>
<span class="status-value" id="workCount">0건</span>
</div>
</div>
</div>
<!-- 메시지 영역 -->
<div id="message-container"></div>
<!-- 기존 작업 목록 -->
<div class="existing-work-section" id="existingWorkSection">
<div class="section-header">
<h3>📋 기존 작업 목록</h3>
<button class="btn btn-primary" id="addNewWorkBtn">
새 작업 추가
</button>
</div>
<div id="existingWorkList">
<!-- 기존 작업들이 여기에 표시됩니다 -->
</div>
</div>
<!-- 새 작업 추가 폼 -->
<div class="new-work-section" id="newWorkSection" style="display: none;">
<div class="section-header">
<h3> 새 작업 추가</h3>
<button class="btn btn-secondary" id="cancelNewWorkBtn">
✖️ 취소
</button>
</div>
<div class="work-entry" id="newWorkEntry">
<div class="work-entry-grid">
<div class="form-field-group">
<label class="form-field-label">
<span class="form-field-icon">🏗️</span>
프로젝트
</label>
<select id="newProjectSelect" class="form-select" required>
<option value="">프로젝트를 선택하세요</option>
</select>
</div>
<div class="form-field-group">
<label class="form-field-label">
<span class="form-field-icon">⚙️</span>
작업 유형
</label>
<select id="newWorkTypeSelect" class="form-select" required>
<option value="">작업 유형을 선택하세요</option>
</select>
</div>
</div>
<div class="form-field-group">
<label class="form-field-label">
<span class="form-field-icon">📊</span>
업무 상태
</label>
<select id="newWorkStatusSelect" class="form-select" required>
<option value="">업무 상태를 선택하세요</option>
</select>
</div>
<div class="error-type-section" id="newErrorTypeSection">
<label class="form-field-label">
<span class="form-field-icon">⚠️</span>
에러 유형
</label>
<select id="newErrorTypeSelect" class="form-select">
<option value="">에러 유형을 선택하세요</option>
</select>
</div>
<div class="time-input-section">
<label class="form-field-label">
<span class="form-field-icon"></span>
작업 시간 (시간)
</label>
<input type="number" id="newWorkHours" class="time-input" step="0.25" min="0.25" max="24" value="1.00" required>
<div class="quick-time-buttons">
<button type="button" class="quick-time-btn" data-hours="0.5">0.5h</button>
<button type="button" class="quick-time-btn" data-hours="1">1h</button>
<button type="button" class="quick-time-btn" data-hours="2">2h</button>
<button type="button" class="quick-time-btn" data-hours="4">4h</button>
<button type="button" class="quick-time-btn" data-hours="8">8h</button>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-success" id="saveNewWorkBtn">
💾 작업 저장
</button>
</div>
</div>
</div>
<!-- 휴가 처리 섹션 -->
<div class="vacation-section" id="vacationSection">
<div class="section-header">
<h3>🏖️ 휴가 처리</h3>
</div>
<div class="vacation-buttons">
<button class="btn btn-warning vacation-process-btn" data-type="full">
🏖️ 연차 (8시간)
</button>
<button class="btn btn-warning vacation-process-btn" data-type="half-half">
🌤️ 반반차 (6시간)
</button>
<button class="btn btn-warning vacation-process-btn" data-type="half">
🌅 반차 (4시간)
</button>
</div>
</div>
</main>
</div>
<!-- 스크립트 -->
<script src="/js/load-navbar.js"></script>
<script src="/js/worker-individual-report.js"></script>
</body>
</html>