feat: 캘린더 기반 작업 현황 확인 시스템 구현

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

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

프론트엔드:
- 캘린더 그리드 UI 구현
- 상태별 색상 및 아이콘 표시
- 모달 기반 상세 정보 표시
- 반응형 디자인 적용
This commit is contained in:
Hyungi Ahn
2025-11-04 10:12:07 +09:00
parent 33307bb243
commit 746e09420b
29 changed files with 8815 additions and 251 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; }
}

View File

@@ -817,4 +817,346 @@
.guide-item strong {
font-size: var(--text-sm);
}
}
/* ========== 개별 작업 보고서 전용 스타일 ========== */
/* 작업자 정보 카드 */
.worker-info-card {
background: linear-gradient(135deg, var(--primary-50) 0%, var(--secondary-50) 100%);
border: 2px solid var(--primary-200);
border-radius: var(--radius-2xl);
padding: var(--space-8);
margin-bottom: var(--space-8);
display: flex;
align-items: center;
gap: var(--space-6);
box-shadow: var(--shadow-lg);
position: relative;
overflow: hidden;
}
.worker-info-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, var(--primary-500), var(--secondary-500));
}
.worker-avatar-large {
width: 80px;
height: 80px;
border-radius: var(--radius-full);
background: linear-gradient(135deg, var(--primary-500), var(--secondary-500));
display: flex;
align-items: center;
justify-content: center;
color: var(--text-inverse);
font-weight: var(--font-bold);
font-size: var(--text-3xl);
box-shadow: var(--shadow-lg);
flex-shrink: 0;
}
.worker-info-details {
flex: 1;
}
.worker-info-details h2 {
font-size: var(--text-2xl);
font-weight: var(--font-bold);
color: var(--text-primary);
margin: 0 0 var(--space-2) 0;
}
.worker-info-details p {
font-size: var(--text-base);
color: var(--text-secondary);
margin: 0 0 var(--space-1) 0;
}
.worker-status-summary {
display: flex;
gap: var(--space-6);
}
.status-item {
text-align: center;
padding: var(--space-4);
background: var(--bg-primary);
border-radius: var(--radius-xl);
border: 1px solid var(--border-light);
min-width: 100px;
}
.status-label {
display: block;
font-size: var(--text-sm);
color: var(--text-secondary);
margin-bottom: var(--space-1);
}
.status-value {
display: block;
font-size: var(--text-xl);
font-weight: var(--font-bold);
color: var(--text-primary);
}
.status-value.warning {
color: var(--error-600);
}
/* 섹션 헤더 */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-6);
padding-bottom: var(--space-4);
border-bottom: 2px solid var(--border-light);
}
.section-header h3 {
font-size: var(--text-xl);
font-weight: var(--font-bold);
color: var(--text-primary);
margin: 0;
}
/* 기존 작업 목록 */
.existing-work-section {
margin-bottom: var(--space-8);
}
.existing-work-item {
background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-tertiary) 100%);
border: 2px solid var(--border-light);
border-radius: var(--radius-xl);
padding: var(--space-6);
margin-bottom: var(--space-4);
transition: var(--transition-normal);
box-shadow: var(--shadow-md);
position: relative;
overflow: hidden;
}
.existing-work-item::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: var(--success-500);
opacity: 0;
transition: opacity 0.3s ease;
}
.existing-work-item:hover {
border-color: var(--primary-300);
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
}
.existing-work-item:hover::before {
opacity: 1;
}
.work-item-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--space-4);
}
.work-item-info h4 {
font-size: var(--text-lg);
font-weight: var(--font-semibold);
color: var(--text-primary);
margin: 0 0 var(--space-1) 0;
}
.work-item-info p {
font-size: var(--text-sm);
color: var(--text-secondary);
margin: 0;
}
.work-item-status {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--space-2);
}
.status-badge {
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: var(--font-medium);
}
.status-badge.normal {
background: var(--success-100);
color: var(--success-700);
border: 1px solid var(--success-300);
}
.status-badge.error {
background: var(--error-100);
color: var(--error-700);
border: 1px solid var(--error-300);
}
.work-hours {
font-size: var(--text-lg);
font-weight: var(--font-bold);
color: var(--primary-600);
}
.work-item-error {
background: var(--error-50);
border: 1px solid var(--error-200);
border-radius: var(--radius-lg);
padding: var(--space-3);
margin-bottom: var(--space-4);
}
.error-label {
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--error-600);
}
.error-type {
font-size: var(--text-sm);
color: var(--error-700);
margin-left: var(--space-2);
}
.work-item-actions {
display: flex;
gap: var(--space-2);
justify-content: flex-end;
}
/* 새 작업 추가 섹션 */
.new-work-section {
margin-bottom: var(--space-8);
}
/* 휴가 처리 섹션 */
.vacation-section {
margin-bottom: var(--space-8);
}
.vacation-buttons {
display: flex;
gap: var(--space-4);
flex-wrap: wrap;
}
.vacation-process-btn {
flex: 1;
min-width: 150px;
padding: var(--space-4) var(--space-6);
font-size: var(--text-base);
font-weight: var(--font-medium);
background: linear-gradient(135deg, var(--warning-500), var(--warning-600));
border: none;
color: var(--text-inverse);
border-radius: var(--radius-xl);
transition: var(--transition-normal);
box-shadow: var(--shadow-md);
}
.vacation-process-btn:hover {
background: linear-gradient(135deg, var(--warning-600), var(--warning-700));
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
/* 빈 상태 */
.empty-state {
text-align: center;
padding: var(--space-12);
background: var(--bg-secondary);
border-radius: var(--radius-xl);
border: 2px dashed var(--border-light);
}
.empty-icon {
font-size: var(--text-6xl);
margin-bottom: var(--space-4);
}
.empty-state h3 {
font-size: var(--text-xl);
font-weight: var(--font-semibold);
color: var(--text-primary);
margin: 0 0 var(--space-2) 0;
}
.empty-state p {
font-size: var(--text-base);
color: var(--text-secondary);
margin: 0;
}
/* 개별 보고서 반응형 디자인 */
@media (max-width: 768px) {
.worker-info-card {
flex-direction: column;
text-align: center;
gap: var(--space-4);
}
.worker-status-summary {
justify-content: center;
gap: var(--space-4);
}
.section-header {
flex-direction: column;
align-items: stretch;
gap: var(--space-3);
}
.work-item-header {
flex-direction: column;
gap: var(--space-3);
}
.work-item-status {
align-items: flex-start;
flex-direction: row;
justify-content: space-between;
}
.vacation-buttons {
flex-direction: column;
}
.vacation-process-btn {
min-width: auto;
}
}
@media (max-width: 480px) {
.worker-status-summary {
flex-direction: column;
gap: var(--space-3);
}
.status-item {
min-width: auto;
}
.work-item-actions {
flex-direction: column;
}
}

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

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

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>