- 페이지 폴더 재구성: safety/, attendance/ 폴더 신규 생성 - work/ → safety/: 이슈 신고, 출입 신청 관련 페이지 이동 - common/ → attendance/: 근태/휴가 관련 페이지 이동 - admin/ 정리: safety-* 파일들을 safety/로 이동 - 사이드바 네비게이션 메뉴 구현 - 카테고리별 메뉴: 작업관리, 안전관리, 근태관리, 시스템관리 - 접기/펼치기 기능 및 상태 저장 - 관리자 전용 메뉴 자동 표시/숨김 - 날씨 API 연동 (기상청 단기예보) - TBM 및 navbar에 현재 날씨 표시 - weatherService.js 추가 - 안전 체크리스트 확장 - 기본/날씨별/작업별 체크 유형 추가 - checklist-manage.html 페이지 추가 - 이슈 신고 시스템 구현 - workIssueController, workIssueModel, workIssueRoutes 추가 - DB 마이그레이션 파일 추가 (실행 대기) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
667 lines
26 KiB
HTML
667 lines
26 KiB
HTML
import React, { useState, useEffect } from 'react';
|
|
import { Calendar, Clock, Users, AlertTriangle, CheckCircle, Edit3, Filter } from 'lucide-react';
|
|
|
|
const AttendanceValidationPage = () => {
|
|
const [currentDate, setCurrentDate] = useState(new Date());
|
|
const [selectedDate, setSelectedDate] = useState(null);
|
|
const [attendanceData, setAttendanceData] = useState({});
|
|
const [selectedDateWorkers, setSelectedDateWorkers] = useState([]);
|
|
const [filter, setFilter] = useState('all'); // all, needsReview, normal, missing
|
|
const [monthlyData, setMonthlyData] = useState({ workReports: [], dailyReports: [] });
|
|
const [loading, setLoading] = useState(false);
|
|
const [isAuthorized, setIsAuthorized] = useState(true); // TODO: 실제 권한 체크
|
|
|
|
// 월이 변경될 때마다 해당 월의 전체 데이터 로드
|
|
useEffect(() => {
|
|
loadMonthlyData();
|
|
}, [currentDate]);
|
|
|
|
const loadMonthlyData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const year = currentDate.getFullYear();
|
|
const month = currentDate.getMonth() + 1;
|
|
|
|
console.log(`${year}년 ${month}월 데이터 로딩 중...`);
|
|
const data = await fetchMonthlyData(year, month);
|
|
setMonthlyData(data);
|
|
|
|
} catch (error) {
|
|
console.error('월간 데이터 로딩 실패:', error);
|
|
// 실패 시 빈 데이터로 설정
|
|
setMonthlyData({ workReports: [], dailyReports: [] });
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// 실제 API 호출 함수들
|
|
const fetchWorkReports = async (date) => {
|
|
try {
|
|
const response = await fetch(`/api/workreports/date/${date}`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
|
}
|
|
});
|
|
return await response.json();
|
|
} catch (error) {
|
|
console.error('WorkReports API 호출 오류:', error);
|
|
return [];
|
|
}
|
|
};
|
|
|
|
const fetchDailyWorkReports = async (date) => {
|
|
try {
|
|
const response = await fetch(`/api/daily-work-reports/date/${date}`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
|
}
|
|
});
|
|
return await response.json();
|
|
} catch (error) {
|
|
console.error('DailyWorkReports API 호출 오류:', error);
|
|
return [];
|
|
}
|
|
};
|
|
|
|
// 월간 데이터 가져오기 (캘린더용)
|
|
const fetchMonthlyData = async (year, month) => {
|
|
const start = `${year}-${month.toString().padStart(2, '0')}-01`;
|
|
const end = `${year}-${month.toString().padStart(2, '0')}-31`;
|
|
|
|
try {
|
|
const [workReports, dailyReports] = await Promise.all([
|
|
fetch(`/api/workreports?start=${start}&end=${end}`, {
|
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
|
}).then(res => res.json()),
|
|
fetch(`/api/daily-work-reports/search?start_date=${start}&end_date=${end}`, {
|
|
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
|
}).then(res => res.json())
|
|
]);
|
|
|
|
return { workReports, dailyReports: dailyReports.reports || [] };
|
|
} catch (error) {
|
|
console.error('월간 데이터 가져오기 오류:', error);
|
|
return { workReports: [], dailyReports: [] };
|
|
}
|
|
};
|
|
|
|
// 목업 데이터 (개발/테스트용)
|
|
const mockWorkReports = {
|
|
'2025-06-16': [
|
|
{ worker_id: 1, worker_name: '김철수', overtime_hours: 1, status: 'normal' },
|
|
{ worker_id: 2, worker_name: '이영희', overtime_hours: 0, status: 'half_day' },
|
|
{ worker_id: 3, worker_name: '박민수', overtime_hours: 0, status: 'vacation' },
|
|
],
|
|
'2025-06-17': [
|
|
{ worker_id: 1, worker_name: '김철수', overtime_hours: 2, status: 'normal' },
|
|
{ worker_id: 2, worker_name: '이영희', overtime_hours: 0, status: 'normal' },
|
|
{ worker_id: 4, worker_name: '정수현', overtime_hours: 0, status: 'early_leave' },
|
|
],
|
|
'2025-06-19': [
|
|
{ worker_id: 1, worker_name: '김철수', overtime_hours: 1, status: 'normal' },
|
|
{ worker_id: 2, worker_name: '이영희', overtime_hours: 0, status: 'half_day' },
|
|
{ worker_id: 3, worker_name: '박민수', overtime_hours: 0, status: 'vacation' },
|
|
{ worker_id: 4, worker_name: '정수현', overtime_hours: 0, status: 'normal' },
|
|
]
|
|
};
|
|
|
|
const mockDailyReports = {
|
|
'2025-06-16': [
|
|
{ worker_id: 1, worker_name: '김철수', work_hours: 9 },
|
|
{ worker_id: 2, worker_name: '이영희', work_hours: 4 },
|
|
{ worker_id: 3, worker_name: '박민수', work_hours: 0 },
|
|
],
|
|
'2025-06-17': [
|
|
{ worker_id: 1, worker_name: '김철수', work_hours: 10 },
|
|
{ worker_id: 2, worker_name: '이영희', work_hours: 8 },
|
|
{ worker_id: 4, worker_name: '정수현', work_hours: 6 },
|
|
],
|
|
'2025-06-19': [
|
|
{ worker_id: 1, worker_name: '김철수', work_hours: 9 },
|
|
{ worker_id: 2, worker_name: '이영희', work_hours: 4 },
|
|
{ worker_id: 3, worker_name: '박민수', work_hours: 0 },
|
|
// 정수현 데이터 누락 - 미입력 상태
|
|
]
|
|
};
|
|
|
|
// 시간 계산 함수
|
|
const calculateExpectedHours = (status, overtime_hours = 0) => {
|
|
const baseHours = {
|
|
'normal': 8,
|
|
'half_day': 4,
|
|
'early_leave': 6,
|
|
'quarter_day': 2,
|
|
'vacation': 0,
|
|
'sick_leave': 0
|
|
};
|
|
return (baseHours[status] || 8) + (overtime_hours || 0);
|
|
};
|
|
|
|
// 날짜별 상태 계산 (월간 데이터 기반)
|
|
const calculateDateStatus = (dateStr) => {
|
|
const workReports = monthlyData.workReports.filter(wr => wr.date === dateStr);
|
|
const dailyReports = monthlyData.dailyReports.filter(dr => dr.report_date === dateStr);
|
|
|
|
if (workReports.length === 0 && dailyReports.length === 0) {
|
|
return 'no-data';
|
|
}
|
|
|
|
if (workReports.length === 0 || dailyReports.length === 0) {
|
|
return 'missing';
|
|
}
|
|
|
|
// 작업자별 시간 집계
|
|
const dailyGrouped = dailyReports.reduce((acc, dr) => {
|
|
if (!acc[dr.worker_id]) {
|
|
acc[dr.worker_id] = 0;
|
|
}
|
|
acc[dr.worker_id] += parseFloat(dr.work_hours || 0);
|
|
return acc;
|
|
}, {});
|
|
|
|
// 불일치 검사
|
|
const hasDiscrepancy = workReports.some(wr => {
|
|
const reportedHours = dailyGrouped[wr.worker_id] || 0;
|
|
const expectedHours = calculateExpectedHours('normal', wr.overtime_hours || 0);
|
|
return Math.abs(reportedHours - expectedHours) > 0;
|
|
});
|
|
|
|
return hasDiscrepancy ? 'needs-review' : 'normal';
|
|
};
|
|
|
|
// 권한 체크 (실제 구현 시)
|
|
const checkPermission = () => {
|
|
// TODO: 실제 권한 체크 로직
|
|
const userRole = localStorage.getItem('userRole') || 'user';
|
|
return userRole === 'admin' || userRole === 'manager';
|
|
};
|
|
|
|
// 권한 없음 UI
|
|
if (!isAuthorized) {
|
|
return (
|
|
<div className="max-w-7xl mx-auto p-6 bg-gray-50 min-h-screen flex items-center justify-center">
|
|
<div className="text-center">
|
|
<div className="text-6xl mb-4">🔒</div>
|
|
<h1 className="text-2xl font-bold text-gray-900 mb-2">접근 권한이 없습니다</h1>
|
|
<p className="text-gray-600">이 페이지는 관리자(Admin) 이상만 접근 가능합니다.</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 로딩 중 UI
|
|
if (loading) {
|
|
return (
|
|
<div className="max-w-7xl mx-auto p-6 bg-gray-50 min-h-screen">
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
<div className="flex items-center justify-center py-12">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
|
<span className="ml-3 text-gray-600">데이터를 불러오는 중...</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 캘린더 생성
|
|
const generateCalendar = () => {
|
|
const year = currentDate.getFullYear();
|
|
const month = currentDate.getMonth();
|
|
const firstDay = new Date(year, month, 1);
|
|
const lastDay = new Date(year, month + 1, 0);
|
|
const startDate = new Date(firstDay);
|
|
startDate.setDate(startDate.getDate() - firstDay.getDay());
|
|
|
|
const calendar = [];
|
|
const current = new Date(startDate);
|
|
|
|
for (let week = 0; week < 6; week++) {
|
|
const weekDays = [];
|
|
for (let day = 0; day < 7; day++) {
|
|
const dateStr = current.toISOString().split('T')[0];
|
|
const isCurrentMonth = current.getMonth() === month;
|
|
const status = isCurrentMonth ? calculateDateStatus(dateStr) : 'no-data';
|
|
|
|
weekDays.push({
|
|
date: new Date(current),
|
|
dateStr,
|
|
isCurrentMonth,
|
|
status
|
|
});
|
|
current.setDate(current.getDate() + 1);
|
|
}
|
|
calendar.push(weekDays);
|
|
}
|
|
return calendar;
|
|
};
|
|
|
|
// 실제 API를 사용한 작업자 데이터 조합
|
|
const getWorkersForDate = async (dateStr) => {
|
|
try {
|
|
// 실제 API 호출
|
|
const [workReports, dailyReports] = await Promise.all([
|
|
fetchWorkReports(dateStr),
|
|
fetchDailyWorkReports(dateStr)
|
|
]);
|
|
|
|
console.log('API 응답:', { workReports, dailyReports });
|
|
|
|
const workerMap = new Map();
|
|
|
|
// WorkReports 데이터 추가 (생산지원팀 입력)
|
|
workReports.forEach(wr => {
|
|
workerMap.set(wr.worker_id, {
|
|
worker_id: wr.worker_id,
|
|
worker_name: wr.worker_name,
|
|
overtime_hours: wr.overtime_hours || 0,
|
|
status: 'normal', // 실제 테이블 구조에 맞게 수정 필요
|
|
expected_hours: calculateExpectedHours('normal', wr.overtime_hours),
|
|
reported_hours: null,
|
|
hasWorkReport: true,
|
|
hasDailyReport: false
|
|
});
|
|
});
|
|
|
|
// DailyReports 데이터 추가 (그룹장 입력) - 작업자별 총 시간 집계
|
|
const dailyGrouped = dailyReports.reduce((acc, dr) => {
|
|
if (!acc[dr.worker_id]) {
|
|
acc[dr.worker_id] = {
|
|
worker_id: dr.worker_id,
|
|
worker_name: dr.worker_name,
|
|
total_work_hours: 0
|
|
};
|
|
}
|
|
acc[dr.worker_id].total_work_hours += parseFloat(dr.work_hours || 0);
|
|
return acc;
|
|
}, {});
|
|
|
|
Object.values(dailyGrouped).forEach(dr => {
|
|
if (workerMap.has(dr.worker_id)) {
|
|
const worker = workerMap.get(dr.worker_id);
|
|
worker.reported_hours = dr.total_work_hours;
|
|
worker.hasDailyReport = true;
|
|
} else {
|
|
workerMap.set(dr.worker_id, {
|
|
worker_id: dr.worker_id,
|
|
worker_name: dr.worker_name,
|
|
overtime_hours: 0,
|
|
status: 'normal',
|
|
expected_hours: 8,
|
|
reported_hours: dr.total_work_hours,
|
|
hasWorkReport: false,
|
|
hasDailyReport: true
|
|
});
|
|
}
|
|
});
|
|
|
|
return Array.from(workerMap.values()).map(worker => ({
|
|
...worker,
|
|
difference: worker.reported_hours !== null ? worker.reported_hours - worker.expected_hours : -worker.expected_hours,
|
|
validationStatus: getValidationStatus(worker)
|
|
}));
|
|
|
|
} catch (error) {
|
|
console.error('데이터 조합 오류:', error);
|
|
// 오류 시 목업 데이터 사용
|
|
return getWorkersForDateMock(dateStr);
|
|
}
|
|
};
|
|
|
|
// 목업 데이터용 함수 (개발/테스트)
|
|
const getWorkersForDateMock = (dateStr) => {
|
|
const workReports = mockWorkReports[dateStr] || [];
|
|
const dailyReports = mockDailyReports[dateStr] || [];
|
|
|
|
const workerMap = new Map();
|
|
|
|
// WorkReports 데이터 추가
|
|
workReports.forEach(wr => {
|
|
workerMap.set(wr.worker_id, {
|
|
worker_id: wr.worker_id,
|
|
worker_name: wr.worker_name,
|
|
overtime_hours: wr.overtime_hours,
|
|
status: wr.status,
|
|
expected_hours: calculateExpectedHours(wr.status, wr.overtime_hours),
|
|
reported_hours: null,
|
|
hasWorkReport: true,
|
|
hasDailyReport: false
|
|
});
|
|
});
|
|
|
|
// DailyReports 데이터 추가
|
|
dailyReports.forEach(dr => {
|
|
if (workerMap.has(dr.worker_id)) {
|
|
const worker = workerMap.get(dr.worker_id);
|
|
worker.reported_hours = dr.work_hours;
|
|
worker.hasDailyReport = true;
|
|
} else {
|
|
workerMap.set(dr.worker_id, {
|
|
worker_id: dr.worker_id,
|
|
worker_name: dr.worker_name,
|
|
overtime_hours: 0,
|
|
status: 'normal',
|
|
expected_hours: 8,
|
|
reported_hours: dr.work_hours,
|
|
hasWorkReport: false,
|
|
hasDailyReport: true
|
|
});
|
|
}
|
|
});
|
|
|
|
return Array.from(workerMap.values()).map(worker => ({
|
|
...worker,
|
|
difference: worker.reported_hours !== null ? worker.reported_hours - worker.expected_hours : -worker.expected_hours,
|
|
validationStatus: getValidationStatus(worker)
|
|
}));
|
|
};
|
|
|
|
const getValidationStatus = (worker) => {
|
|
if (!worker.hasWorkReport || !worker.hasDailyReport) return 'missing';
|
|
if (Math.abs(worker.difference) > 0) return 'needs-review';
|
|
return 'normal';
|
|
};
|
|
|
|
// 작업자 수정 핸들러
|
|
const handleEditWorker = (worker) => {
|
|
// TODO: 수정 모달 또는 인라인 편집 구현
|
|
const newHours = prompt(
|
|
`${worker.worker_name}의 근무시간을 수정하세요.\n현재: ${worker.reported_hours || 0}시간`,
|
|
worker.reported_hours || 0
|
|
);
|
|
|
|
if (newHours !== null && !isNaN(newHours)) {
|
|
updateWorkerHours(worker.worker_id, parseFloat(newHours));
|
|
}
|
|
};
|
|
|
|
// 작업자 시간 업데이트 (실제 API 호출)
|
|
const updateWorkerHours = async (workerId, newHours) => {
|
|
try {
|
|
// TODO: 실제 수정 API 호출
|
|
console.log(`작업자 ${workerId}의 시간을 ${newHours}시간으로 수정`);
|
|
|
|
// 임시: 로컬 상태 업데이트
|
|
setSelectedDateWorkers(prev =>
|
|
prev.map(worker =>
|
|
worker.worker_id === workerId
|
|
? {
|
|
...worker,
|
|
reported_hours: newHours,
|
|
difference: newHours - worker.expected_hours,
|
|
validationStatus: Math.abs(newHours - worker.expected_hours) > 0 ? 'needs-review' : 'normal'
|
|
}
|
|
: worker
|
|
)
|
|
);
|
|
|
|
alert('수정이 완료되었습니다.');
|
|
|
|
} catch (error) {
|
|
console.error('수정 실패:', error);
|
|
alert('수정 중 오류가 발생했습니다.');
|
|
}
|
|
};
|
|
|
|
// 날짜 클릭 핸들러 (실제 API 호출)
|
|
const handleDateClick = async (dateInfo) => {
|
|
if (!dateInfo.isCurrentMonth) return;
|
|
|
|
setSelectedDate(dateInfo.dateStr);
|
|
|
|
try {
|
|
// 로딩 상태 표시
|
|
setSelectedDateWorkers([]);
|
|
|
|
// 실제 API에서 데이터 가져오기
|
|
const workers = await getWorkersForDate(dateInfo.dateStr);
|
|
setSelectedDateWorkers(workers);
|
|
|
|
} catch (error) {
|
|
console.error('날짜별 데이터 로딩 오류:', error);
|
|
// 오류 시 목업 데이터 사용
|
|
const workers = getWorkersForDateMock(dateInfo.dateStr);
|
|
setSelectedDateWorkers(workers);
|
|
}
|
|
};
|
|
|
|
// 필터링된 작업자 목록
|
|
const filteredWorkers = selectedDateWorkers.filter(worker => {
|
|
if (filter === 'all') return true;
|
|
if (filter === 'needsReview') return worker.validationStatus === 'needs-review';
|
|
if (filter === 'normal') return worker.validationStatus === 'normal';
|
|
if (filter === 'missing') return worker.validationStatus === 'missing';
|
|
return true;
|
|
});
|
|
|
|
// 상태별 아이콘 및 색상
|
|
const getStatusIcon = (status) => {
|
|
switch (status) {
|
|
case 'normal': return <CheckCircle className="w-4 h-4 text-green-500" />;
|
|
case 'needs-review': return <AlertTriangle className="w-4 h-4 text-yellow-500" />;
|
|
case 'missing': return <Clock className="w-4 h-4 text-red-500" />;
|
|
default: return null;
|
|
}
|
|
};
|
|
|
|
const getStatusColor = (status) => {
|
|
switch (status) {
|
|
case 'normal': return 'bg-green-100 text-green-800';
|
|
case 'needs-review': return 'bg-yellow-100 text-yellow-800';
|
|
case 'missing': return 'bg-red-100 text-red-800';
|
|
default: return 'bg-gray-100 text-gray-800';
|
|
}
|
|
};
|
|
|
|
const calendar = generateCalendar();
|
|
const monthNames = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'];
|
|
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
|
|
|
|
return (
|
|
<div className="max-w-7xl mx-auto p-6 bg-gray-50 min-h-screen">
|
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
|
{/* 헤더 */}
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div className="flex items-center space-x-3">
|
|
<Users className="w-6 h-6 text-blue-600" />
|
|
<h1 className="text-2xl font-bold text-gray-900">근태 검증 관리</h1>
|
|
</div>
|
|
<div className="text-sm text-gray-500">
|
|
Admin 전용 페이지
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
{/* 캘린더 섹션 */}
|
|
<div className="lg:col-span-2">
|
|
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
|
{/* 캘린더 헤더 */}
|
|
<div className="flex items-center justify-between mb-4">
|
|
<button
|
|
onClick={() => {
|
|
const newDate = new Date(currentDate.getFullYear(), currentDate.getMonth() - 1);
|
|
setCurrentDate(newDate);
|
|
}}
|
|
className="p-2 hover:bg-gray-100 rounded-md"
|
|
disabled={loading}
|
|
>
|
|
◀
|
|
</button>
|
|
<h2 className="text-xl font-semibold">
|
|
{currentDate.getFullYear()}년 {monthNames[currentDate.getMonth()]}
|
|
</h2>
|
|
<button
|
|
onClick={() => {
|
|
const newDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1);
|
|
setCurrentDate(newDate);
|
|
}}
|
|
className="p-2 hover:bg-gray-100 rounded-md"
|
|
disabled={loading}
|
|
>
|
|
▶
|
|
</button>
|
|
</div>
|
|
|
|
{/* 월간 요약 정보 */}
|
|
<div className="mb-4 p-3 bg-gray-50 rounded-lg">
|
|
<div className="grid grid-cols-3 gap-4 text-sm">
|
|
<div className="text-center">
|
|
<div className="text-green-600 font-semibold">
|
|
{calendar.flat().filter(d => d.isCurrentMonth && d.status === 'normal').length}
|
|
</div>
|
|
<div className="text-gray-600">정상</div>
|
|
</div>
|
|
<div className="text-center">
|
|
<div className="text-yellow-600 font-semibold">
|
|
{calendar.flat().filter(d => d.isCurrentMonth && d.status === 'needs-review').length}
|
|
</div>
|
|
<div className="text-gray-600">검토필요</div>
|
|
</div>
|
|
<div className="text-center">
|
|
<div className="text-red-600 font-semibold">
|
|
{calendar.flat().filter(d => d.isCurrentMonth && d.status === 'missing').length}
|
|
</div>
|
|
<div className="text-gray-600">미입력</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 요일 헤더 */}
|
|
<div className="grid grid-cols-7 gap-1 mb-2">
|
|
{dayNames.map(day => (
|
|
<div key={day} className="p-2 text-center text-sm font-medium text-gray-500">
|
|
{day}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* 캘린더 본체 */}
|
|
<div className="grid grid-cols-7 gap-1">
|
|
{calendar.flat().map((dateInfo, index) => (
|
|
<button
|
|
key={index}
|
|
onClick={() => handleDateClick(dateInfo)}
|
|
className={`
|
|
p-2 text-sm rounded-md h-12 relative transition-colors
|
|
${dateInfo.isCurrentMonth ? 'text-gray-900' : 'text-gray-400'}
|
|
${selectedDate === dateInfo.dateStr ? 'bg-blue-100 border-2 border-blue-500' : 'hover:bg-gray-50'}
|
|
${dateInfo.status === 'needs-review' ? 'bg-yellow-50 border border-yellow-200' : ''}
|
|
${dateInfo.status === 'missing' ? 'bg-red-50 border border-red-200' : ''}
|
|
${dateInfo.status === 'normal' ? 'bg-green-50 border border-green-200' : ''}
|
|
`}
|
|
>
|
|
<div className="flex flex-col items-center">
|
|
<span>{dateInfo.date.getDate()}</span>
|
|
{dateInfo.isCurrentMonth && dateInfo.status !== 'no-data' && (
|
|
<div className="absolute top-1 right-1">
|
|
{dateInfo.status === 'needs-review' && <div className="w-2 h-2 bg-yellow-500 rounded-full"></div>}
|
|
{dateInfo.status === 'missing' && <div className="w-2 h-2 bg-red-500 rounded-full"></div>}
|
|
{dateInfo.status === 'normal' && <div className="w-2 h-2 bg-green-500 rounded-full"></div>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* 범례 */}
|
|
<div className="flex items-center justify-center space-x-4 mt-4 text-xs">
|
|
<div className="flex items-center space-x-1">
|
|
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
|
|
<span>정상</span>
|
|
</div>
|
|
<div className="flex items-center space-x-1">
|
|
<div className="w-3 h-3 bg-yellow-500 rounded-full"></div>
|
|
<span>검토필요</span>
|
|
</div>
|
|
<div className="flex items-center space-x-1">
|
|
<div className="w-3 h-3 bg-red-500 rounded-full"></div>
|
|
<span>미입력</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 작업자 리스트 섹션 */}
|
|
<div className="lg:col-span-1">
|
|
{selectedDate ? (
|
|
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-lg font-semibold">
|
|
📅 {selectedDate}
|
|
</h3>
|
|
<select
|
|
value={filter}
|
|
onChange={(e) => setFilter(e.target.value)}
|
|
className="text-sm border border-gray-300 rounded-md px-2 py-1"
|
|
>
|
|
<option value="all">전체</option>
|
|
<option value="needsReview">검토필요</option>
|
|
<option value="normal">정상</option>
|
|
<option value="missing">미입력</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
{filteredWorkers.map(worker => (
|
|
<div key={worker.worker_id} className={`p-3 rounded-lg border ${getStatusColor(worker.validationStatus)}`}>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="font-medium">{worker.worker_name}</span>
|
|
{getStatusIcon(worker.validationStatus)}
|
|
</div>
|
|
|
|
<div className="text-xs space-y-1">
|
|
<div className="flex justify-between">
|
|
<span>그룹장 입력:</span>
|
|
<span className="font-mono">
|
|
{worker.reported_hours !== null ? `${worker.reported_hours}시간` : '미입력'}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span>시스템 계산:</span>
|
|
<span className="font-mono">{worker.expected_hours}시간</span>
|
|
</div>
|
|
{worker.difference !== 0 && (
|
|
<div className="flex justify-between font-semibold">
|
|
<span>차이:</span>
|
|
<span className={`font-mono ${worker.difference > 0 ? 'text-red-600' : 'text-blue-600'}`}>
|
|
{worker.difference > 0 ? '+' : ''}{worker.difference}시간
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{worker.validationStatus === 'needs-review' && (
|
|
<button
|
|
onClick={() => handleEditWorker(worker)}
|
|
className="mt-2 w-full text-xs bg-blue-600 text-white px-2 py-1 rounded-md hover:bg-blue-700 flex items-center justify-center space-x-1"
|
|
>
|
|
<Edit3 className="w-3 h-3" />
|
|
<span>수정</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{filteredWorkers.length === 0 && (
|
|
<div className="text-center text-gray-500 py-8">
|
|
해당 조건의 작업자가 없습니다.
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="bg-white border border-gray-200 rounded-lg p-4 text-center text-gray-500">
|
|
<Calendar className="w-12 h-12 mx-auto mb-3 text-gray-300" />
|
|
<p>날짜를 선택하면</p>
|
|
<p>작업자 검증 내역을 확인할 수 있습니다.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default AttendanceValidationPage; |