feat: 캘린더 기반 작업 현황 확인 시스템 구현
- 월별 캘린더 UI로 작업 현황을 한눈에 확인 가능 - 미입력(빨강), 부분입력(주황), 확인필요(보라), 이상없음(초록) 상태 표시 - 범례 아이콘(●)을 사용한 직관적인 상태 표시 - 날짜 클릭 시 해당일 작업자별 상세 현황 모달 - 작업자 클릭 시 개별 작업 입력/수정 모달 - 휴가 처리 기능 (연차, 반차, 반반차, 조퇴) - 월별 집계 데이터 최적화로 API 호출 최소화 백엔드: - monthly_worker_status, monthly_summary 테이블 추가 - 자동 집계 stored procedure 및 trigger 구현 - 확인필요(12시간 초과) 상태 감지 로직 - 출석 관리 시스템 확장 프론트엔드: - 캘린더 그리드 UI 구현 - 상태별 색상 및 아이콘 표시 - 모달 기반 상세 정보 표시 - 반응형 디자인 적용
This commit is contained in:
303
api.hyungi.net/models/attendanceModel.js
Normal file
303
api.hyungi.net/models/attendanceModel.js
Normal 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;
|
||||
@@ -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 등)
|
||||
|
||||
|
||||
156
api.hyungi.net/models/monthlyStatusModel.js
Normal file
156
api.hyungi.net/models/monthlyStatusModel.js
Normal 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;
|
||||
Reference in New Issue
Block a user