feat: 페이지 구조 재구성 및 사이드바 네비게이션 구현
- 페이지 폴더 재구성: 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>
This commit is contained in:
@@ -1,44 +0,0 @@
|
||||
<section>
|
||||
<h2>📄 작업 보고서</h2>
|
||||
<ul>
|
||||
<li><a href="/pages/work-reports/create.html">작업보고서 입력</a></li>
|
||||
<li><a href="/pages/work-reports/manage.html">작업보고서 수정/삭제</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>📊 출근/공수 관리</h2>
|
||||
<ul>
|
||||
<li><a href="/pages/common/attendance.html">출근부</a></li>
|
||||
<li><a href="/pages/work-reports/project-labor-summary.html">프로젝트별 공수 계산</a></li>
|
||||
<li><a href="/pages/work-reports/monthly-labor-report.html">월간 공수 보고서</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>🔧 관리콘솔</h2>
|
||||
<ul>
|
||||
<li><a href="/pages/admin/manage-user.html">👤 사용자 관리</a></li>
|
||||
<li><a href="/pages/admin/manage-project.html">📁 프로젝트 관리</a></li>
|
||||
<li><a href="/pages/admin/manage-worker.html">👷 작업자 관리</a></li>
|
||||
<li><a href="/pages/admin/manage-task.html">📋 작업 유형 관리</a></li>
|
||||
<li><a href="/pages/admin/manage-issue.html">🚨 이슈 유형 관리</a></li>
|
||||
<li><a href="/pages/admin/manage-pipespec.html">🔧 배관 스펙 관리</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>🏭 공장 정보</h2>
|
||||
<ul>
|
||||
<li><a href="/pages/common/factory-upload.html">공장 정보 등록</a></li>
|
||||
<li><a href="/pages/common/factory-list.html">공장 목록 보기</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>📊 이슈 리포트</h2>
|
||||
<ul>
|
||||
<li><a href="/pages/issue-reports/daily-issue.html">일일 이슈 보고</a></li>
|
||||
<li><a href="/pages/issue-reports/issue-summary.html">이슈 현황 요약</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
@@ -1,35 +0,0 @@
|
||||
<section>
|
||||
<h2>📄 작업 보고서</h2>
|
||||
<ul>
|
||||
<li><a href="/pages/work/work-report-create.html">작업보고서 입력</a></li>
|
||||
<li><a href="/pages/work/work-report-manage.html">작업보고서 수정/삭제</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>📊 출근/공수 관리</h2>
|
||||
<ul>
|
||||
<li><a href="/pages/attendance/attendance.html">출근부</a></li>
|
||||
<li><a href="/pages/attendance/project-labor-summary.html">프로젝트별 공수 계산</a></li>
|
||||
<li><a href="/pages/attendance/monthly-labor-report.html">월간 공수 보고서</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>관리콘솔</h2>
|
||||
<ul>
|
||||
<li><a href="/pages/admin/manage-all.html">📋 기본정보 관리</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>🏭 공장 정보</h2>
|
||||
<ul>
|
||||
<li><a href="/pages/factory/factory-map-upload.html">공장 지도 업로드</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>🗂 기타 관리</h2>
|
||||
<p>프로젝트 및 작업자 관련 기능은 추후 확장 예정</p>
|
||||
</section>
|
||||
@@ -1,667 +0,0 @@
|
||||
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;
|
||||
@@ -1,62 +0,0 @@
|
||||
<!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/main-layout.css">
|
||||
<link rel="stylesheet" href="/css/admin.css">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-layout">
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div id="content-container">
|
||||
<div class="page-header">
|
||||
<h1>⚙️ 이슈 유형 관리</h1>
|
||||
<p class="subtitle">프로젝트에서 발생하는 이슈 유형을 관리합니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>새 이슈 유형 등록</h3>
|
||||
<form id="issueTypeForm" class="form-horizontal">
|
||||
<div class="form-row">
|
||||
<input type="text" id="category" placeholder="카테고리" required>
|
||||
<input type="text" id="subcategory" placeholder="서브카테고리" required>
|
||||
<button type="submit" class="btn btn-primary">등록</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>등록된 이슈 유형</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>카테고리</th>
|
||||
<th>서브카테고리</th>
|
||||
<th>작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="issueTypeTableBody">
|
||||
<tr><td colspan="4" class="text-center">불러오는 중...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module" src="/js/manage-issue.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,76 +0,0 @@
|
||||
<!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/main-layout.css">
|
||||
<link rel="stylesheet" href="/css/admin.css">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-layout">
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div id="content-container">
|
||||
<div class="page-header">
|
||||
<h1>🏗 프로젝트 관리</h1>
|
||||
<p class="subtitle">진행 중인 프로젝트를 등록하고 관리합니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>새 프로젝트 등록</h3>
|
||||
<form id="projectForm" class="form-vertical">
|
||||
<div class="form-row">
|
||||
<input type="text" id="job_no" placeholder="공사번호" required>
|
||||
<input type="text" id="project_name" placeholder="프로젝트명" required>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<input type="date" id="contract_date" placeholder="계약일">
|
||||
<input type="date" id="due_date" placeholder="납기일">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<input type="text" id="delivery_method" placeholder="납품방식">
|
||||
<input type="text" id="site" placeholder="현장명">
|
||||
<input type="text" id="pm" placeholder="담당 PM">
|
||||
<button type="submit" class="btn btn-primary">등록</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>등록된 프로젝트</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>공사번호</th>
|
||||
<th>프로젝트명</th>
|
||||
<th>계약일</th>
|
||||
<th>납기일</th>
|
||||
<th>납품방식</th>
|
||||
<th>현장</th>
|
||||
<th>PM</th>
|
||||
<th>작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="projectTableBody">
|
||||
<tr><td colspan="9" class="text-center">불러오는 중...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module" src="/js/manage-project.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,68 +0,0 @@
|
||||
<!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/main-layout.css">
|
||||
<link rel="stylesheet" href="/css/admin.css">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-layout">
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div id="content-container">
|
||||
<div class="page-header">
|
||||
<h1>🛠 작업 항목 관리</h1>
|
||||
<p class="subtitle">프로젝트에서 수행되는 작업 항목을 관리합니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>새 작업 항목 등록</h3>
|
||||
<form id="taskForm" class="form-vertical">
|
||||
<div class="form-row">
|
||||
<input type="text" id="category" placeholder="카테고리" required>
|
||||
<input type="text" id="subcategory" placeholder="서브카테고리" required>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<input type="text" id="task_name" placeholder="작업명" required>
|
||||
<input type="text" id="description" placeholder="설명">
|
||||
<button type="submit" class="btn btn-primary">등록</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>등록된 작업 항목</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>카테고리</th>
|
||||
<th>서브카테고리</th>
|
||||
<th>작업명</th>
|
||||
<th>설명</th>
|
||||
<th>작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="taskTableBody">
|
||||
<tr><td colspan="6" class="text-center">불러오는 중...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module" src="/js/manage-task.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,287 +0,0 @@
|
||||
<!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/main-layout.css">
|
||||
<link rel="stylesheet" href="/css/admin.css">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-layout">
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div id="content-container">
|
||||
<div class="page-header">
|
||||
<h1>👤 사용자 관리</h1>
|
||||
<p class="subtitle">시스템 사용자를 등록하고 관리합니다.</p>
|
||||
</div>
|
||||
|
||||
<!-- 내 비밀번호 변경 -->
|
||||
<div class="card">
|
||||
<h3>🔐 내 비밀번호 변경</h3>
|
||||
<form id="myPasswordForm" class="form-horizontal">
|
||||
<div class="form-row">
|
||||
<input type="password" id="currentPassword" placeholder="현재 비밀번호" required />
|
||||
<input type="password" id="newPassword" placeholder="새 비밀번호" required />
|
||||
<input type="password" id="confirmPassword" placeholder="새 비밀번호 확인" required />
|
||||
<button type="submit" class="btn btn-warning">내 비밀번호 변경</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 등록 폼 -->
|
||||
<div class="card">
|
||||
<h3>새 사용자 등록</h3>
|
||||
<form id="userForm" class="form-horizontal">
|
||||
<div class="form-row">
|
||||
<input type="text" id="username" placeholder="아이디" required />
|
||||
<input type="password" id="password" placeholder="비밀번호" required />
|
||||
<input type="text" id="name" placeholder="이름" required />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<select id="access_level" required>
|
||||
<option value="">권한 선택</option>
|
||||
<option value="worker">작업자</option>
|
||||
<option value="group_leader">그룹장</option>
|
||||
<option value="support_team">지원팀</option>
|
||||
<option value="admin">관리자</option>
|
||||
<option value="system">시스템</option>
|
||||
</select>
|
||||
<select id="worker_id">
|
||||
<option value="">작업자 연결 (선택)</option>
|
||||
</select>
|
||||
<button type="submit" class="btn btn-primary">등록</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 사용자 비밀번호 변경 (시스템 권한자만) -->
|
||||
<div class="card" id="systemPasswordChangeCard" style="display: none;">
|
||||
<h3>🔑 사용자 비밀번호 변경 (시스템 권한자 전용)</h3>
|
||||
<form id="userPasswordForm" class="form-horizontal">
|
||||
<div class="form-row">
|
||||
<select id="targetUserId" required>
|
||||
<option value="">사용자 선택</option>
|
||||
</select>
|
||||
<input type="password" id="targetNewPassword" placeholder="새 비밀번호" required />
|
||||
<button type="submit" class="btn btn-danger">비밀번호 변경</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 사용자 목록 -->
|
||||
<div class="card">
|
||||
<h3>등록된 사용자</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>아이디</th>
|
||||
<th>이름</th>
|
||||
<th>권한</th>
|
||||
<th>연결 작업자</th>
|
||||
<th>작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="userTableBody">
|
||||
<tr><td colspan="6" class="text-center">불러오는 중...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 페이지 권한 관리 모달 -->
|
||||
<div id="pageAccessModal" class="modal" style="display: none;">
|
||||
<div class="modal-content large">
|
||||
<div class="modal-header">
|
||||
<h2>🔐 페이지 접근 권한 관리</h2>
|
||||
<span class="close">×</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="user-info-section">
|
||||
<h3 id="modalUserInfo">사용자 정보</h3>
|
||||
<p id="modalUserRole" class="text-muted"></p>
|
||||
</div>
|
||||
|
||||
<div class="page-access-grid">
|
||||
<div class="category-section" id="dashboardPages">
|
||||
<h4>📊 대시보드</h4>
|
||||
<div class="page-list" id="dashboardPageList"></div>
|
||||
</div>
|
||||
|
||||
<div class="category-section" id="managementPages">
|
||||
<h4>⚙️ 관리</h4>
|
||||
<div class="page-list" id="managementPageList"></div>
|
||||
</div>
|
||||
|
||||
<div class="category-section" id="commonPages">
|
||||
<h4>📝 공통</h4>
|
||||
<div class="page-list" id="commonPageList"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closePageAccessModal()">취소</button>
|
||||
<button type="button" class="btn btn-primary" onclick="savePageAccessChanges()">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* 모달 스타일 */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
background-color: rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: #fefefe;
|
||||
margin: 5% auto;
|
||||
padding: 0;
|
||||
border: 1px solid #888;
|
||||
width: 90%;
|
||||
max-width: 800px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.modal-content.large {
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 20px;
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
border-radius: 8px 8px 0 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 30px;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 15px 20px;
|
||||
background-color: #f1f1f1;
|
||||
border-radius: 0 0 8px 8px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.close {
|
||||
color: white;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.close:hover,
|
||||
.close:focus {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.user-info-section {
|
||||
margin-bottom: 30px;
|
||||
padding: 15px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.user-info-section h3 {
|
||||
margin: 0 0 5px 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.page-access-grid {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.category-section h4 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #4CAF50;
|
||||
font-size: 1.1rem;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid #4CAF50;
|
||||
}
|
||||
|
||||
.page-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.page-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.page-item:hover {
|
||||
background-color: #f8f9fa;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.page-item input[type="checkbox"] {
|
||||
margin-right: 15px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.page-item label {
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-item .page-path {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #6c757d;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module" src="/js/manage-user.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,62 +0,0 @@
|
||||
<!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/main-layout.css">
|
||||
<link rel="stylesheet" href="/css/admin.css">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-layout">
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div id="content-container">
|
||||
<div class="page-header">
|
||||
<h1>👷 작업자 관리</h1>
|
||||
<p class="subtitle">프로젝트에 참여하는 작업자를 관리합니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>새 작업자 등록</h3>
|
||||
<form id="workerForm" class="form-horizontal">
|
||||
<div class="form-row">
|
||||
<input type="text" id="workerName" placeholder="작업자명" required>
|
||||
<input type="text" id="position" placeholder="직책">
|
||||
<button type="submit" class="btn btn-primary">등록</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>등록된 작업자</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>이름</th>
|
||||
<th>직책</th>
|
||||
<th>작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="workerTableBody">
|
||||
<tr><td colspan="4" class="text-center">불러오는 중...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module" src="/js/manage-worker.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,363 +0,0 @@
|
||||
<!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="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/css/work-analysis.css?v=42">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=1" defer></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="analysis-container">
|
||||
<!-- 페이지 헤더 -->
|
||||
<header class="page-header fade-in">
|
||||
<h1 class="page-title">
|
||||
<span class="icon">📊</span>
|
||||
작업 분석
|
||||
</h1>
|
||||
<p class="page-subtitle">기간별/프로젝트별 작업 현황을 분석하고 통계를 확인합니다</p>
|
||||
</header>
|
||||
|
||||
<!-- 분석 모드 탭 -->
|
||||
<nav class="analysis-tabs fade-in">
|
||||
<button class="tab-button active" data-mode="period">
|
||||
📅 기간별 분석
|
||||
</button>
|
||||
<button class="tab-button" data-mode="project">
|
||||
🏗️ 프로젝트별 분석
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- 분석 조건 설정 -->
|
||||
<section class="analysis-controls fade-in">
|
||||
<div class="controls-grid">
|
||||
<!-- 기간 설정 -->
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="startDate">
|
||||
<span class="icon">📅</span>
|
||||
시작일
|
||||
</label>
|
||||
<input type="date" id="startDate" class="form-input" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="endDate">
|
||||
<span class="icon">📅</span>
|
||||
종료일
|
||||
</label>
|
||||
<input type="date" id="endDate" class="form-input" required>
|
||||
</div>
|
||||
|
||||
<!-- 기간 확정 버튼 -->
|
||||
<div class="form-group">
|
||||
<button class="confirm-period-button" id="confirmPeriodBtn">
|
||||
<span class="icon">✅</span>
|
||||
기간 확정
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 기간 상태 표시 -->
|
||||
<div class="form-group" id="periodStatusGroup" style="display: none;">
|
||||
<div class="period-status">
|
||||
<span class="icon">✅</span>
|
||||
<div>
|
||||
<div style="font-size: 0.8rem; opacity: 0.8; margin-bottom: 2px;">분석 기간</div>
|
||||
<div id="periodStatus">기간이 설정되지 않았습니다</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 분석 결과 영역 -->
|
||||
<main id="analysisResults" class="fade-in">
|
||||
<!-- 로딩 상태 -->
|
||||
<div id="loadingState" class="loading-container" style="display: none;">
|
||||
<div class="loading-spinner"></div>
|
||||
<p class="loading-text">분석 중입니다...</p>
|
||||
</div>
|
||||
|
||||
<!-- 분석 탭 네비게이션 -->
|
||||
<div id="analysisTabNavigation" class="tab-navigation" style="display: none;">
|
||||
<div class="tab-buttons">
|
||||
<button class="tab-button active" data-tab="work-status">
|
||||
<span class="icon">📈</span>
|
||||
기간별 작업 현황
|
||||
</button>
|
||||
<button class="tab-button" data-tab="project-distribution">
|
||||
<span class="icon">🥧</span>
|
||||
프로젝트별 분포
|
||||
</button>
|
||||
<button class="tab-button" data-tab="worker-performance">
|
||||
<span class="icon">👤</span>
|
||||
작업자별 성과
|
||||
</button>
|
||||
<button class="tab-button" data-tab="error-analysis">
|
||||
<span class="icon">⚠️</span>
|
||||
오류 분석
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 결과 카드 그리드 -->
|
||||
<div id="resultsGrid" class="results-grid" style="display: none;">
|
||||
|
||||
<!-- 통계 카드들 -->
|
||||
<div class="stats-cards">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">⏰</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">총 작업시간</div>
|
||||
<div class="stat-value" id="totalHours">0</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">✅</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">정상 시간</div>
|
||||
<div class="stat-value" id="normalHours">0</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">⚠️</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">오류 시간</div>
|
||||
<div class="stat-value" id="errorHours">0</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">👥</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">참여 작업자</div>
|
||||
<div class="stat-value" id="workerCount">0</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">📊</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">오류율</div>
|
||||
<div class="stat-value" id="errorRate">0%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 분석 탭 컨텐츠 -->
|
||||
<div class="tab-contents">
|
||||
|
||||
<!-- 기간별 작업 현황 -->
|
||||
<div id="work-status-tab" class="tab-content active">
|
||||
<div class="chart-container table-type">
|
||||
<div class="chart-header">
|
||||
<h3 class="chart-title">
|
||||
<span class="icon">📈</span>
|
||||
기간별 작업 현황
|
||||
</h3>
|
||||
<button class="chart-analyze-btn" onclick="analyzeWorkStatus()" disabled>
|
||||
<span class="icon">🔍</span>
|
||||
분석 실행
|
||||
</button>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<!-- 테이블이 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 프로젝트별 분포 -->
|
||||
<div id="project-distribution-tab" class="tab-content">
|
||||
<div class="chart-container table-type">
|
||||
<div class="chart-header">
|
||||
<h3 class="chart-title">
|
||||
<span class="icon">🥧</span>
|
||||
프로젝트별 분포
|
||||
</h3>
|
||||
<button class="chart-analyze-btn" onclick="analyzeProjectDistribution()" disabled>
|
||||
<span class="icon">🔍</span>
|
||||
분석 실행
|
||||
</button>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="production-report-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="job-no-header">Job No.</th>
|
||||
<th class="work-content-header">작업내용</th>
|
||||
<th class="man-days-header">공수</th>
|
||||
<th class="load-rate-header">전체 부하율</th>
|
||||
<th class="labor-cost-header">인건비</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="projectDistributionTableBody">
|
||||
<tr>
|
||||
<td colspan="5" style="text-align: center; padding: 2rem; color: #666;">
|
||||
분석을 실행해주세요
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot id="projectDistributionTableFooter" style="display: none;">
|
||||
<tr class="total-row">
|
||||
<td colspan="2"><strong>총계</strong></td>
|
||||
<td><strong id="totalManDays">0</strong></td>
|
||||
<td><strong>100%</strong></td>
|
||||
<td><strong id="totalLaborCost">₩0</strong></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업자별 성과 -->
|
||||
<div id="worker-performance-tab" class="tab-content">
|
||||
<div class="chart-container chart-type">
|
||||
<div class="chart-header">
|
||||
<h3 class="chart-title">
|
||||
<span class="icon">👤</span>
|
||||
작업자별 성과
|
||||
</h3>
|
||||
<button class="chart-analyze-btn" onclick="analyzeWorkerPerformance()" disabled>
|
||||
<span class="icon">🔍</span>
|
||||
분석 실행
|
||||
</button>
|
||||
</div>
|
||||
<canvas id="workerPerformanceChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 오류 분석 -->
|
||||
<div id="error-analysis-tab" class="tab-content">
|
||||
<div class="chart-container table-type">
|
||||
<div class="chart-header">
|
||||
<h3 class="chart-title">
|
||||
<span class="icon">⚠️</span>
|
||||
오류 분석
|
||||
</h3>
|
||||
<button class="chart-analyze-btn" onclick="analyzeErrorAnalysis()" disabled>
|
||||
<span class="icon">🔍</span>
|
||||
분석 실행
|
||||
</button>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="error-analysis-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Job No.</th>
|
||||
<th>작업내용</th>
|
||||
<th>총 시간</th>
|
||||
<th>세부시간</th>
|
||||
<th>작업 타입</th>
|
||||
<th>오류율</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="errorAnalysisTableBody">
|
||||
<tr>
|
||||
<td colspan="6" style="text-align: center; padding: 2rem; color: #666;">
|
||||
분석을 실행해주세요
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot id="errorAnalysisTableFooter" style="display: none;">
|
||||
<tr class="total-row">
|
||||
<td colspan="2"><strong>총계</strong></td>
|
||||
<td><strong id="totalErrorHours">0h</strong></td>
|
||||
<td><strong>-</strong></td>
|
||||
<td><strong>-</strong></td>
|
||||
<td><strong>0.0%</strong></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 모듈화된 JavaScript 로딩 -->
|
||||
<script src="/js/work-analysis/module-loader.js?v=1" defer></script>
|
||||
|
||||
<script>
|
||||
// 서울 표준시(KST) 기준 날짜 함수들 (하위 호환성 유지)
|
||||
function getKSTDate() {
|
||||
const now = new Date();
|
||||
// UTC 시간에 9시간 추가 (KST = UTC+9)
|
||||
const kstOffset = 9 * 60; // 9시간을 분으로 변환
|
||||
const utc = now.getTime() + (now.getTimezoneOffset() * 60000);
|
||||
const kst = new Date(utc + (kstOffset * 60000));
|
||||
return kst;
|
||||
}
|
||||
|
||||
function formatDateToString(date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// 날짜 문자열을 간단한 형식으로 변환하는 함수 (하위 호환성 유지)
|
||||
function formatSimpleDate(dateStr) {
|
||||
if (!dateStr) return '날짜 없음';
|
||||
if (typeof dateStr === 'string' && dateStr.includes('T')) {
|
||||
return dateStr.split('T')[0]; // 2025-11-01T00:00:00.000Z → 2025-11-01
|
||||
}
|
||||
return dateStr;
|
||||
}
|
||||
|
||||
// 현재 시간 업데이트 (하위 호환성 유지)
|
||||
function updateTime() {
|
||||
const now = new Date();
|
||||
const timeString = now.toLocaleTimeString('ko-KR', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
|
||||
// 시간 표시 요소가 있다면 업데이트
|
||||
const timeElement = document.querySelector('.time-value');
|
||||
if (timeElement) {
|
||||
timeElement.textContent = timeString;
|
||||
}
|
||||
}
|
||||
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('📦 작업 분석 모듈 로딩 시작...');
|
||||
|
||||
// 서울 표준시(KST) 기준 날짜 설정
|
||||
const today = getKSTDate();
|
||||
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1); // 이번 달 1일
|
||||
const monthEnd = new Date(today.getFullYear(), today.getMonth() + 1, 0); // 이번 달 마지막 날
|
||||
|
||||
document.getElementById('startDate').value = formatDateToString(monthStart);
|
||||
document.getElementById('endDate').value = formatDateToString(monthEnd);
|
||||
|
||||
// 시간 업데이트 시작
|
||||
updateTime();
|
||||
setInterval(updateTime, 1000);
|
||||
});
|
||||
|
||||
// 모듈 로딩 완료 후 초기화
|
||||
window.addEventListener('workAnalysisModulesLoaded', function(event) {
|
||||
console.log('🎉 작업 분석 모듈 로딩 완료:', event.detail.modules);
|
||||
|
||||
// 모듈 로딩 완료 후 추가 초기화 작업이 있다면 여기에 추가
|
||||
});
|
||||
|
||||
// 초기 모드 설정 (하위 호환성 유지)
|
||||
window.currentAnalysisMode = 'period';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,890 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>작업 현황 분석</title>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/3.9.1/chart.min.js"></script>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: #f5f7fa;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 28px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.date-selector {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.date-selector input {
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: rgba(255,255,255,0.2);
|
||||
color: white;
|
||||
border: 1px solid rgba(255,255,255,0.3);
|
||||
padding: 8px 16px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: rgba(255,255,255,0.3);
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
padding: 25px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
text-align: center;
|
||||
transition: transform 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 5px 20px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.stat-change {
|
||||
font-size: 12px;
|
||||
margin-top: 5px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 15px;
|
||||
}
|
||||
|
||||
.stat-change.positive {
|
||||
background: #e8f5e8;
|
||||
color: #2e7d2e;
|
||||
}
|
||||
|
||||
.stat-change.negative {
|
||||
background: #ffeaea;
|
||||
color: #c53030;
|
||||
}
|
||||
|
||||
.charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 15px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.chart-canvas {
|
||||
position: relative;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
padding: 15px 20px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 12px 15px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.table th {
|
||||
background: #f8f9fa;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.table tr:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-completed {
|
||||
background: #e8f5e8;
|
||||
color: #2e7d2e;
|
||||
}
|
||||
|
||||
.status-progress {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin: 10px 0;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.debug-info {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
margin: 10px 0;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.charts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.date-selector {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>📊 일일 작업 현황 분석</h1>
|
||||
<p>실시간 작업 현황과 주요 지표를 확인하세요</p>
|
||||
<div class="date-selector">
|
||||
<label>조회 기간:</label>
|
||||
<input type="date" id="startDate">
|
||||
<span>~</span>
|
||||
<input type="date" id="endDate">
|
||||
<button class="btn" onclick="loadData()">조회</button>
|
||||
<button class="btn" onclick="setToday()">오늘</button>
|
||||
<button class="btn" onclick="setThisWeek()">이번주</button>
|
||||
<button class="btn" onclick="setThisMonth()">이번달</button>
|
||||
<button class="btn" onclick="toggleDebug()">디버그</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="errorContainer"></div>
|
||||
<div id="debugInfo" class="debug-info"></div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-number" id="totalHours">0</div>
|
||||
<div class="stat-label">총 작업시간</div>
|
||||
<div class="stat-change positive" id="hoursChange">+0%</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-number" id="totalReports">0</div>
|
||||
<div class="stat-label">보고서 건수</div>
|
||||
<div class="stat-change positive" id="reportsChange">+0%</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-number" id="activeProjects">0</div>
|
||||
<div class="stat-label">진행 프로젝트</div>
|
||||
<div class="stat-change" id="projectsChange">+0%</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-number" id="errorRate">0%</div>
|
||||
<div class="stat-label">에러율</div>
|
||||
<div class="stat-change negative" id="errorChange">+0%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="charts-grid">
|
||||
<div class="chart-container">
|
||||
<div class="chart-title">📈 일별 작업시간 추이</div>
|
||||
<div class="chart-canvas">
|
||||
<canvas id="dailyHoursChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<div class="chart-title">👥 작업자별 작업량</div>
|
||||
<div class="chart-canvas">
|
||||
<canvas id="workerChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="charts-grid">
|
||||
<div class="chart-container">
|
||||
<div class="chart-title">🏗️ 프로젝트별 투입시간</div>
|
||||
<div class="chart-canvas">
|
||||
<canvas id="projectChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<div class="chart-title">⚙️ 작업 유형별 분포</div>
|
||||
<div class="chart-canvas">
|
||||
<canvas id="workTypeChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<div class="table-header">🔍 최근 작업 현황</div>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>날짜</th>
|
||||
<th>작업자</th>
|
||||
<th>프로젝트</th>
|
||||
<th>작업유형</th>
|
||||
<th>작업시간</th>
|
||||
<th>상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="recentWorkTable">
|
||||
<tr>
|
||||
<td colspan="6" class="loading">데이터를 불러오는 중...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 전역 변수
|
||||
let API_URL = 'http://192.168.0.3:3005';
|
||||
let dailyChart, workerChart, projectChart, workTypeChart;
|
||||
let debugMode = false;
|
||||
|
||||
// 디버그 함수
|
||||
function log(message, data = null) {
|
||||
console.log(message, data);
|
||||
if (debugMode) {
|
||||
const debugDiv = document.getElementById('debugInfo');
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
debugDiv.innerHTML += `[${timestamp}] ${message}${data ? ': ' + JSON.stringify(data, null, 2) : ''}<br>`;
|
||||
debugDiv.scrollTop = debugDiv.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDebug() {
|
||||
debugMode = !debugMode;
|
||||
const debugDiv = document.getElementById('debugInfo');
|
||||
debugDiv.style.display = debugMode ? 'block' : 'none';
|
||||
if (debugMode) {
|
||||
debugDiv.innerHTML = '=== 디버그 모드 활성화 ===<br>';
|
||||
}
|
||||
}
|
||||
|
||||
function showError(message, details = null) {
|
||||
const errorContainer = document.getElementById('errorContainer');
|
||||
errorContainer.innerHTML = `
|
||||
<div class="error-message">
|
||||
<strong>오류:</strong> ${message}
|
||||
${details ? `<br><small>${details}</small>` : ''}
|
||||
</div>
|
||||
`;
|
||||
log('ERROR: ' + message, details);
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
document.getElementById('errorContainer').innerHTML = '';
|
||||
}
|
||||
|
||||
// API 설정 초기화
|
||||
async function initializeAPI() {
|
||||
log('API 초기화 시작');
|
||||
|
||||
// 기존 API 설정 파일 시도
|
||||
try {
|
||||
const module = await import('/js/api-config.js');
|
||||
if (module.API && module.API.BASE_URL) {
|
||||
API_URL = module.API.BASE_URL;
|
||||
log('API config 파일에서 설정 로드', API_URL);
|
||||
} else {
|
||||
log('API config 파일에서 BASE_URL을 찾을 수 없음');
|
||||
}
|
||||
} catch (error) {
|
||||
log('API config 파일 로드 실패, 기본값 사용', error.message);
|
||||
}
|
||||
|
||||
// API 연결 테스트
|
||||
await testAPIConnection();
|
||||
}
|
||||
|
||||
// API 연결 테스트 함수
|
||||
async function testAPIConnection() {
|
||||
const testUrls = [
|
||||
{ url: 'http://192.168.0.3:3005/api', hasApi: false }, // 직접 연결 우선
|
||||
{ url: 'http://192.168.0.3:3001', hasApi: true } // nginx 프록시
|
||||
];
|
||||
|
||||
for (const testConfig of testUrls) {
|
||||
try {
|
||||
const healthUrl = testConfig.hasApi
|
||||
? `${testConfig.url}/health`
|
||||
: `${testConfig.url}/health`;
|
||||
|
||||
log(`API 연결 테스트: ${healthUrl}`);
|
||||
const response = await fetch(healthUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.text();
|
||||
// HTML 응답이면 실패로 간주
|
||||
if (data.includes('<!DOCTYPE html>')) {
|
||||
log(`❌ HTML 응답 (로그인 페이지): ${healthUrl}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
API_URL = testConfig.hasApi ? testConfig.url : testConfig.url;
|
||||
log(`✅ API 연결 성공: ${API_URL}`);
|
||||
return;
|
||||
} else {
|
||||
log(`❌ API 연결 실패 (${response.status}): ${healthUrl}`);
|
||||
}
|
||||
} catch (error) {
|
||||
log(`❌ API 연결 오류: ${testConfig.url}`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 모든 URL 실패 시 직접 연결 사용
|
||||
API_URL = 'http://192.168.0.3:3005/api';
|
||||
log(`⚠️ 모든 연결 실패, 직접 연결 사용: ${API_URL}`);
|
||||
}
|
||||
|
||||
// 차트 초기화
|
||||
function initializeCharts() {
|
||||
log('차트 초기화 시작');
|
||||
|
||||
try {
|
||||
// 일별 작업시간 차트
|
||||
const dailyCtx = document.getElementById('dailyHoursChart').getContext('2d');
|
||||
dailyChart = new Chart(dailyCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
label: '작업시간',
|
||||
data: [],
|
||||
borderColor: '#667eea',
|
||||
backgroundColor: 'rgba(102, 126, 234, 0.1)',
|
||||
tension: 0.4,
|
||||
fill: true
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: '시간'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 작업자별 차트
|
||||
const workerCtx = document.getElementById('workerChart').getContext('2d');
|
||||
workerChart = new Chart(workerCtx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
data: [],
|
||||
backgroundColor: [
|
||||
'#667eea',
|
||||
'#764ba2',
|
||||
'#f093fb',
|
||||
'#f5576c',
|
||||
'#4facfe',
|
||||
'#43e97b'
|
||||
]
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 프로젝트별 차트
|
||||
const projectCtx = document.getElementById('projectChart').getContext('2d');
|
||||
projectChart = new Chart(projectCtx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
label: '투입시간',
|
||||
data: [],
|
||||
backgroundColor: '#667eea'
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: '시간'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 작업유형별 차트
|
||||
const workTypeCtx = document.getElementById('workTypeChart').getContext('2d');
|
||||
workTypeChart = new Chart(workTypeCtx, {
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
data: [],
|
||||
backgroundColor: [
|
||||
'#667eea',
|
||||
'#764ba2',
|
||||
'#f093fb',
|
||||
'#f5576c',
|
||||
'#4facfe',
|
||||
'#43e97b',
|
||||
'#f6d55c',
|
||||
'#ed4a7b'
|
||||
]
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
log('차트 초기화 완료');
|
||||
} catch (error) {
|
||||
log('차트 초기화 실패', error);
|
||||
showError('차트 초기화에 실패했습니다.', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 날짜 설정 함수들
|
||||
function setToday() {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
document.getElementById('startDate').value = today;
|
||||
document.getElementById('endDate').value = today;
|
||||
log('오늘 날짜 설정', today);
|
||||
loadData();
|
||||
}
|
||||
|
||||
function setThisWeek() {
|
||||
const today = new Date();
|
||||
const monday = new Date(today.setDate(today.getDate() - today.getDay() + 1));
|
||||
const sunday = new Date(today.setDate(today.getDate() - today.getDay() + 7));
|
||||
|
||||
const mondayStr = monday.toISOString().split('T')[0];
|
||||
const sundayStr = sunday.toISOString().split('T')[0];
|
||||
|
||||
document.getElementById('startDate').value = mondayStr;
|
||||
document.getElementById('endDate').value = sundayStr;
|
||||
log('이번주 날짜 설정', { start: mondayStr, end: sundayStr });
|
||||
loadData();
|
||||
}
|
||||
|
||||
function setThisMonth() {
|
||||
const today = new Date();
|
||||
const firstDay = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
const lastDay = new Date(today.getFullYear(), today.getMonth() + 1, 0);
|
||||
|
||||
const firstDayStr = firstDay.toISOString().split('T')[0];
|
||||
const lastDayStr = lastDay.toISOString().split('T')[0];
|
||||
|
||||
document.getElementById('startDate').value = firstDayStr;
|
||||
document.getElementById('endDate').value = lastDayStr;
|
||||
log('이번달 날짜 설정', { start: firstDayStr, end: lastDayStr });
|
||||
loadData();
|
||||
}
|
||||
|
||||
// 토큰 확인 함수
|
||||
function checkToken() {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
showError('로그인이 필요합니다.');
|
||||
setTimeout(() => {
|
||||
location.href = '/login';
|
||||
}, 2000);
|
||||
return null;
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
// API 호출 헬퍼 함수
|
||||
async function makeAPICall(url, options = {}) {
|
||||
log('API 호출', url);
|
||||
|
||||
const token = checkToken();
|
||||
if (!token) return null;
|
||||
|
||||
const defaultOptions = {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
};
|
||||
|
||||
const finalOptions = { ...defaultOptions, ...options };
|
||||
log('요청 옵션', finalOptions);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, finalOptions);
|
||||
log('응답 상태', { status: response.status, statusText: response.statusText });
|
||||
|
||||
const responseText = await response.text();
|
||||
log('응답 내용 (텍스트)', responseText);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${responseText}`);
|
||||
}
|
||||
|
||||
const data = JSON.parse(responseText);
|
||||
log('파싱된 데이터', data);
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
log('API 호출 실패', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 데이터 로딩
|
||||
async function loadData() {
|
||||
clearError();
|
||||
log('데이터 로딩 시작');
|
||||
|
||||
const startDate = document.getElementById('startDate').value;
|
||||
const endDate = document.getElementById('endDate').value;
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
showError('조회 기간을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
log('조회 기간', { startDate, endDate });
|
||||
|
||||
try {
|
||||
// 기본 통계 조회
|
||||
log('기본 통계 조회 시작');
|
||||
const statsUrl = `${API_URL}/work-analysis/stats?start=${startDate}&end=${endDate}`;
|
||||
const statsData = await makeAPICall(statsUrl);
|
||||
|
||||
if (statsData) {
|
||||
updateStats(statsData.data || statsData);
|
||||
}
|
||||
|
||||
// 일별 추이 데이터
|
||||
log('일별 추이 조회 시작');
|
||||
const dailyUrl = `${API_URL}/work-analysis/daily-trend?start=${startDate}&end=${endDate}`;
|
||||
const dailyData = await makeAPICall(dailyUrl);
|
||||
|
||||
if (dailyData) {
|
||||
updateDailyChart(dailyData.data || dailyData);
|
||||
}
|
||||
|
||||
// 작업자별 데이터
|
||||
log('작업자별 통계 조회 시작');
|
||||
const workerUrl = `${API_URL}/work-analysis/worker-stats?start=${startDate}&end=${endDate}`;
|
||||
const workerData = await makeAPICall(workerUrl);
|
||||
|
||||
if (workerData) {
|
||||
updateWorkerChart(workerData.data || workerData);
|
||||
}
|
||||
|
||||
// 프로젝트별 데이터
|
||||
log('프로젝트별 통계 조회 시작');
|
||||
const projectUrl = `${API_URL}/work-analysis/project-stats?start=${startDate}&end=${endDate}`;
|
||||
const projectData = await makeAPICall(projectUrl);
|
||||
|
||||
if (projectData) {
|
||||
updateProjectChart(projectData.data || projectData);
|
||||
}
|
||||
|
||||
// 작업유형별 데이터
|
||||
log('작업유형별 통계 조회 시작');
|
||||
const workTypeUrl = `${API_URL}/work-analysis/worktype-stats?start=${startDate}&end=${endDate}`;
|
||||
const workTypeData = await makeAPICall(workTypeUrl);
|
||||
|
||||
if (workTypeData) {
|
||||
updateWorkTypeChart(workTypeData.data || workTypeData);
|
||||
}
|
||||
|
||||
// 최근 작업 현황
|
||||
log('최근 작업 현황 조회 시작');
|
||||
const recentUrl = `${API_URL}/work-analysis/recent-work?start=${startDate}&end=${endDate}&limit=10`;
|
||||
const recentData = await makeAPICall(recentUrl);
|
||||
|
||||
if (recentData) {
|
||||
updateRecentWorkTable(recentData.data || recentData);
|
||||
}
|
||||
|
||||
log('모든 데이터 로딩 완료');
|
||||
|
||||
} catch (error) {
|
||||
log('데이터 로딩 오류', error);
|
||||
|
||||
if (error.message.includes('403')) {
|
||||
showError('접근 권한이 없습니다. 관리자에게 문의하세요.');
|
||||
} else if (error.message.includes('401')) {
|
||||
showError('로그인이 만료되었습니다. 다시 로그인해주세요.');
|
||||
setTimeout(() => {
|
||||
location.href = '/login';
|
||||
}, 2000);
|
||||
} else {
|
||||
showError('데이터를 불러오는 중 오류가 발생했습니다.', error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 통계 업데이트
|
||||
function updateStats(stats) {
|
||||
log('통계 업데이트', stats);
|
||||
|
||||
document.getElementById('totalHours').textContent = (stats.totalHours || 0).toFixed(1);
|
||||
document.getElementById('totalReports').textContent = stats.totalReports || 0;
|
||||
document.getElementById('activeProjects').textContent = stats.activeProjects || 0;
|
||||
document.getElementById('errorRate').textContent = (stats.errorRate || 0).toFixed(1) + '%';
|
||||
|
||||
// 변화율 표시 (임시 데이터)
|
||||
document.getElementById('hoursChange').textContent = '+12.5%';
|
||||
document.getElementById('reportsChange').textContent = '+8.3%';
|
||||
document.getElementById('projectsChange').textContent = '+2';
|
||||
document.getElementById('errorChange').textContent = '-0.5%';
|
||||
}
|
||||
|
||||
// 차트 업데이트 함수들
|
||||
function updateDailyChart(data) {
|
||||
log('일별 차트 업데이트', data);
|
||||
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
log('일별 차트 데이터 없음');
|
||||
return;
|
||||
}
|
||||
|
||||
dailyChart.data.labels = data.map(item => item.date);
|
||||
dailyChart.data.datasets[0].data = data.map(item => parseFloat(item.hours) || 0);
|
||||
dailyChart.update();
|
||||
}
|
||||
|
||||
function updateWorkerChart(data) {
|
||||
log('작업자 차트 업데이트', data);
|
||||
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
log('작업자 차트 데이터 없음');
|
||||
return;
|
||||
}
|
||||
|
||||
workerChart.data.labels = data.map(item => item.workerName || `작업자 ${item.worker_id}`);
|
||||
workerChart.data.datasets[0].data = data.map(item => parseFloat(item.totalHours) || 0);
|
||||
workerChart.update();
|
||||
}
|
||||
|
||||
function updateProjectChart(data) {
|
||||
log('프로젝트 차트 업데이트', data);
|
||||
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
log('프로젝트 차트 데이터 없음');
|
||||
return;
|
||||
}
|
||||
|
||||
projectChart.data.labels = data.map(item => item.projectName || `프로젝트 ${item.project_id}`);
|
||||
projectChart.data.datasets[0].data = data.map(item => parseFloat(item.totalHours) || 0);
|
||||
projectChart.update();
|
||||
}
|
||||
|
||||
function updateWorkTypeChart(data) {
|
||||
log('작업유형 차트 업데이트', data);
|
||||
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
log('작업유형 차트 데이터 없음');
|
||||
return;
|
||||
}
|
||||
|
||||
workTypeChart.data.labels = data.map(item => item.workTypeName || `작업유형 ${item.work_type_id}`);
|
||||
workTypeChart.data.datasets[0].data = data.map(item => parseFloat(item.totalHours) || 0);
|
||||
workTypeChart.update();
|
||||
}
|
||||
|
||||
// 테이블 업데이트
|
||||
function updateRecentWorkTable(data) {
|
||||
log('테이블 업데이트', data);
|
||||
|
||||
const tbody = document.getElementById('recentWorkTable');
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="text-align: center; color: #666;">조회된 데이터가 없습니다.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = data.map(item => `
|
||||
<tr>
|
||||
<td>${item.report_date}</td>
|
||||
<td>작업자 ${item.worker_id}</td>
|
||||
<td>프로젝트 ${item.project_id}</td>
|
||||
<td>작업유형 ${item.work_type_id}</td>
|
||||
<td>${item.work_hours}시간</td>
|
||||
<td><span class="status-badge ${getStatusClass(item.work_status_id)}">${getStatusText(item.work_status_id)}</span></td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function getStatusClass(statusId) {
|
||||
switch(statusId) {
|
||||
case 1: return 'status-completed';
|
||||
case 2: return 'status-progress';
|
||||
default: return 'status-pending';
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusText(statusId) {
|
||||
switch(statusId) {
|
||||
case 1: return '완료';
|
||||
case 2: return '진행중';
|
||||
default: return '대기';
|
||||
}
|
||||
}
|
||||
|
||||
// 페이지 초기화
|
||||
async function initializePage() {
|
||||
log('페이지 초기화 시작');
|
||||
|
||||
try {
|
||||
await initializeAPI();
|
||||
initializeCharts();
|
||||
setToday(); // 오늘 날짜로 초기 설정 및 데이터 로드
|
||||
|
||||
log('페이지 초기화 완료');
|
||||
} catch (error) {
|
||||
log('페이지 초기화 실패', error);
|
||||
showError('페이지 초기화에 실패했습니다.', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', initializePage);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,300 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>시스템 관리자 대시보드 - TK Portal</title>
|
||||
<link rel="stylesheet" href="/css/main-layout.css">
|
||||
<link rel="stylesheet" href="/css/admin.css">
|
||||
<link rel="stylesheet" href="/css/system-dashboard.css">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script type="module" src="/js/auth-check.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-layout">
|
||||
<!-- 기존 네비게이션 바 사용 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<!-- 시스템 관리자 배너 -->
|
||||
<div class="system-banner">
|
||||
<div class="banner-content">
|
||||
<div class="banner-left">
|
||||
<div class="system-icon">🔧</div>
|
||||
<div class="banner-text">
|
||||
<h1>시스템 관리자</h1>
|
||||
<p>시스템 전반의 설정, 모니터링 및 관리를 담당합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="banner-right">
|
||||
<span class="system-badge">SYSTEM</span>
|
||||
<div class="system-status">
|
||||
<span class="status-dot online"></span>
|
||||
<span>시스템 정상</span>
|
||||
</div>
|
||||
<div class="quick-actions">
|
||||
<button class="quick-btn" onclick="window.location.href='/pages/admin/manage-user.html'" title="사용자 관리">
|
||||
👤
|
||||
</button>
|
||||
<button class="quick-btn" onclick="window.location.href='/pages/analysis/work-report-analytics.html'" title="분석 대시보드">
|
||||
📊
|
||||
</button>
|
||||
<button class="quick-btn" onclick="window.location.href='/pages/analysis/project-worktype-analysis.html'" title="프로젝트별 작업 시간 분석">
|
||||
🏗️
|
||||
</button>
|
||||
<button class="quick-btn" onclick="refreshSystemStatus()" title="시스템 새로고침">
|
||||
🔄
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="main-content">
|
||||
<!-- 시스템 상태 개요 -->
|
||||
<section class="system-overview">
|
||||
<h2><i class="fas fa-tachometer-alt"></i> 시스템 상태</h2>
|
||||
<div class="status-grid">
|
||||
<div class="status-card">
|
||||
<div class="status-info">
|
||||
<h3>서버 상태</h3>
|
||||
<p class="status-value online">온라인</p>
|
||||
<small>마지막 확인: <span id="server-check-time">--</span></small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<div class="status-info">
|
||||
<h3>데이터베이스</h3>
|
||||
<p class="status-value online">정상</p>
|
||||
<small>연결 수: <span id="db-connections">--</span></small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<div class="status-info">
|
||||
<h3>활성 사용자</h3>
|
||||
<p class="status-value" id="active-users">--</p>
|
||||
<small>총 사용자: <span id="total-users">--</span></small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<div class="status-info">
|
||||
<h3>시스템 알림</h3>
|
||||
<p class="status-value warning" id="system-alerts">--</p>
|
||||
<small>미처리 알림</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 주요 관리 기능 -->
|
||||
<section class="management-section">
|
||||
<h2><i class="fas fa-tools"></i> 시스템 관리</h2>
|
||||
<div class="management-grid">
|
||||
<!-- 계정 관리 -->
|
||||
<div class="management-card primary">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-user-cog"></i>
|
||||
<h3>계정 관리</h3>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<p>사용자 계정 생성, 수정, 삭제 및 권한 관리</p>
|
||||
<div class="card-actions">
|
||||
<button class="btn btn-primary" data-action="account-management">
|
||||
<i class="fas fa-users"></i> 계정 관리
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 시스템 로그 -->
|
||||
<div class="management-card">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-file-alt"></i>
|
||||
<h3>시스템 로그</h3>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<p>로그인 이력, 시스템 활동 및 오류 로그 조회</p>
|
||||
<div class="card-actions">
|
||||
<button class="btn btn-secondary" data-action="system-logs">
|
||||
<i class="fas fa-search"></i> 로그 조회
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 데이터베이스 관리 -->
|
||||
<div class="management-card">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-database"></i>
|
||||
<h3>데이터베이스</h3>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<p>데이터베이스 백업, 복원 및 최적화</p>
|
||||
<div class="card-actions">
|
||||
<button class="btn btn-secondary" data-action="database-management">
|
||||
<i class="fas fa-cog"></i> DB 관리
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 시스템 설정 -->
|
||||
<div class="management-card">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-sliders-h"></i>
|
||||
<h3>시스템 설정</h3>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<p>전역 설정, 보안 정책 및 시스템 매개변수</p>
|
||||
<div class="card-actions">
|
||||
<button class="btn btn-secondary" data-action="system-settings">
|
||||
<i class="fas fa-wrench"></i> 설정
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 백업 관리 -->
|
||||
<div class="management-card">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-shield-alt"></i>
|
||||
<h3>백업 관리</h3>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<p>자동 백업 설정 및 복원 관리</p>
|
||||
<div class="card-actions">
|
||||
<button class="btn btn-secondary" data-action="backup-management">
|
||||
<i class="fas fa-download"></i> 백업
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 프로젝트별 작업 시간 분석 -->
|
||||
<div class="management-card primary">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-project-diagram"></i>
|
||||
<h3>프로젝트 작업 분석</h3>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<p>프로젝트별-작업별 시간 분석 및 에러율 모니터링</p>
|
||||
<div class="card-actions">
|
||||
<button class="btn btn-primary" onclick="window.location.href='/pages/analysis/project-worktype-analysis.html'">
|
||||
<i class="fas fa-chart-bar"></i> 분석 보기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 모니터링 -->
|
||||
<div class="management-card">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-chart-line"></i>
|
||||
<h3>시스템 모니터링</h3>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<p>성능 지표, 리소스 사용량 및 트래픽 분석</p>
|
||||
<div class="card-actions">
|
||||
<button class="btn btn-secondary" data-action="monitoring">
|
||||
<i class="fas fa-eye"></i> 모니터링
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 최근 활동 -->
|
||||
<section class="recent-activity">
|
||||
<h2><i class="fas fa-history"></i> 최근 시스템 활동</h2>
|
||||
<div class="activity-container">
|
||||
<div class="activity-list" id="recent-activities">
|
||||
<!-- 동적으로 로드됨 -->
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- 계정 관리 모달 -->
|
||||
<div id="account-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3><i class="fas fa-user-cog"></i> 계정 관리</h3>
|
||||
<button class="close-btn" data-action="close-modal">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="account-management-content">
|
||||
<!-- 계정 관리 내용이 여기에 로드됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 시스템 관리자 스크립트 -->
|
||||
<script>
|
||||
console.log('🔧 시스템 관리자 대시보드 로드됨');
|
||||
|
||||
// 시스템 상태 새로고침 함수
|
||||
function refreshSystemStatus() {
|
||||
console.log('🔄 시스템 상태 새로고침 중...');
|
||||
|
||||
// 시각적 피드백
|
||||
const statusDot = document.querySelector('.status-dot');
|
||||
const refreshBtn = document.querySelector('.quick-btn[title="시스템 새로고침"]');
|
||||
|
||||
if (refreshBtn) {
|
||||
refreshBtn.style.transform = 'rotate(360deg)';
|
||||
setTimeout(() => {
|
||||
refreshBtn.style.transform = '';
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// 실제 상태 업데이트 (시뮬레이션)
|
||||
setTimeout(() => {
|
||||
updateSystemTime();
|
||||
console.log('✅ 시스템 상태 업데이트 완료');
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// 시스템 시간 업데이트
|
||||
function updateSystemTime() {
|
||||
const timeElement = document.getElementById('server-check-time');
|
||||
if (timeElement) {
|
||||
timeElement.textContent = new Date().toLocaleTimeString('ko-KR');
|
||||
}
|
||||
}
|
||||
|
||||
// 간단한 테스트 함수
|
||||
function testClick() {
|
||||
console.log('🎯 버튼 클릭 테스트 성공!');
|
||||
alert('버튼이 정상적으로 작동합니다!');
|
||||
}
|
||||
|
||||
// DOM 로드 후 초기화
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('📄 시스템 대시보드 DOM 로드 완료');
|
||||
|
||||
// 초기 시간 설정
|
||||
updateSystemTime();
|
||||
|
||||
// 주기적 시간 업데이트 (30초마다)
|
||||
setInterval(updateSystemTime, 30000);
|
||||
|
||||
// 계정 관리 버튼 이벤트
|
||||
const accountBtn = document.querySelector('[data-action="account-management"]');
|
||||
if (accountBtn) {
|
||||
accountBtn.addEventListener('click', testClick);
|
||||
console.log('✅ 계정 관리 버튼 이벤트 설정 완료');
|
||||
}
|
||||
|
||||
console.log('🚀 시스템 관리자 대시보드 초기화 완료');
|
||||
});
|
||||
</script>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/system-dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,70 +0,0 @@
|
||||
<!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/main-layout.css">
|
||||
<link rel="stylesheet" href="/css/user.css">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<!-- ✅ auth-check를 가장 먼저 로딩 -->
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-layout">
|
||||
<!-- ✅ ID는 이미 올바름: navbar-container -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div id="content-container">
|
||||
<header class="user-header">
|
||||
<h1>👷 내 작업 정보</h1>
|
||||
<p id="welcome-message">환영합니다. 개인 작업 포털입니다.</p>
|
||||
</header>
|
||||
|
||||
<main id="user-sections">
|
||||
<section class="card">
|
||||
<h2>📅 오늘의 작업 일정</h2>
|
||||
<div id="today-schedule">
|
||||
<p>작업 일정을 불러오는 중...</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>🔧 빠른 메뉴</h2>
|
||||
<div class="quick-menu">
|
||||
<a href="/pages/work-reports/create.html" class="menu-item">
|
||||
<span class="icon">📝</span>
|
||||
<span>작업 일보 작성</span>
|
||||
</a>
|
||||
<a href="/pages/issue-reports/daily-issue.html" class="menu-item">
|
||||
<span class="icon">📊</span>
|
||||
<span>일일 이슈 보고</span>
|
||||
</a>
|
||||
<a href="/pages/common/my-attendance.html" class="menu-item">
|
||||
<span class="icon">📋</span>
|
||||
<span>출근부 확인</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>📈 내 작업 현황</h2>
|
||||
<div id="work-stats">
|
||||
<p>통계를 불러오는 중...</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ✅ 스크립트 로딩 순서 최적화 -->
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module" src="/js/load-sections.js"></script>
|
||||
<script type="module" src="/js/user-dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,215 +0,0 @@
|
||||
<!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/main-layout.css">
|
||||
<link rel="stylesheet" href="/css/management-dashboard.css">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script type="module" src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-layout-with-navbar">
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<div class="dashboard-container">
|
||||
<!-- 뒤로가기 버튼 -->
|
||||
<a href="javascript:history.back()" class="back-btn">
|
||||
← 뒤로가기
|
||||
</a>
|
||||
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="page-header">
|
||||
<h1>📊 관리자 대시보드</h1>
|
||||
<p class="subtitle">팀 전체의 일일 작업 입력 현황을 한눈에 확인하세요</p>
|
||||
</div>
|
||||
|
||||
<!-- 권한 체크 메시지 -->
|
||||
<div id="permission-check-message" class="message warning" style="display: none;">
|
||||
⚠️ 권한을 확인하는 중입니다...
|
||||
</div>
|
||||
|
||||
<!-- 메시지 영역 -->
|
||||
<div id="message-container"></div>
|
||||
|
||||
<!-- 날짜 선택 섹션 -->
|
||||
<div class="date-selection-card">
|
||||
<div class="date-selection-header">
|
||||
<h3>📅 조회 날짜 선택</h3>
|
||||
<button class="refresh-btn" id="refreshBtn">
|
||||
🔄 새로고침
|
||||
</button>
|
||||
</div>
|
||||
<div class="date-selection-body">
|
||||
<input type="date" id="selectedDate" class="date-input">
|
||||
<button class="btn btn-primary" id="loadDataBtn">📊 현황 조회</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 요약 대시보드 -->
|
||||
<div id="summarySection" class="summary-section" style="display: none;">
|
||||
<h3>📈 전체 현황 요약</h3>
|
||||
<div class="summary-grid">
|
||||
<div class="summary-card total-workers">
|
||||
<div class="summary-icon">👥</div>
|
||||
<div class="summary-content">
|
||||
<div class="summary-number" id="totalWorkers">0</div>
|
||||
<div class="summary-label">전체 작업자</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-card completed-workers">
|
||||
<div class="summary-icon">✅</div>
|
||||
<div class="summary-content">
|
||||
<div class="summary-number" id="completedWorkers">0</div>
|
||||
<div class="summary-label">입력 완료</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-card missing-workers">
|
||||
<div class="summary-icon">❌</div>
|
||||
<div class="summary-content">
|
||||
<div class="summary-number" id="missingWorkers">0</div>
|
||||
<div class="summary-label">입력 미완료</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-card total-hours">
|
||||
<div class="summary-icon">⏰</div>
|
||||
<div class="summary-content">
|
||||
<div class="summary-number" id="totalHours">0</div>
|
||||
<div class="summary-label">총 작업시간</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-card total-entries">
|
||||
<div class="summary-icon">📝</div>
|
||||
<div class="summary-content">
|
||||
<div class="summary-number" id="totalEntries">0</div>
|
||||
<div class="summary-label">총 작업항목</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-card error-count">
|
||||
<div class="summary-icon">⚠️</div>
|
||||
<div class="summary-content">
|
||||
<div class="summary-number" id="errorCount">0</div>
|
||||
<div class="summary-label">에러 발생</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 필터 및 액션 바 -->
|
||||
<div id="actionBar" class="action-bar" style="display: none;">
|
||||
<div class="filter-section">
|
||||
<label class="filter-checkbox">
|
||||
<input type="checkbox" id="showOnlyMissing">
|
||||
<span class="checkmark"></span>
|
||||
미입력자만 보기
|
||||
</label>
|
||||
</div>
|
||||
<div class="action-section">
|
||||
<button class="btn btn-secondary" id="exportBtn">
|
||||
📥 엑셀 다운로드
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업자 현황 테이블 -->
|
||||
<div id="workersSection" class="workers-section" style="display: none;">
|
||||
<div class="section-header">
|
||||
<h3>👥 작업자별 입력 현황</h3>
|
||||
<div class="legend">
|
||||
<span class="legend-item completed">✅ 입력완료</span>
|
||||
<span class="legend-item missing">❌ 미입력</span>
|
||||
<span class="legend-item partial">⚠️ 부분입력</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table class="workers-table" id="workersTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>작업자</th>
|
||||
<th>상태</th>
|
||||
<th>총시간</th>
|
||||
<th>항목수</th>
|
||||
<th>작업유형</th>
|
||||
<th>프로젝트</th>
|
||||
<th>기여자</th>
|
||||
<th>최근업데이트</th>
|
||||
<th>상세</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="workersTableBody">
|
||||
<!-- 작업자 데이터가 여기에 동적으로 추가됩니다 -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 로딩 스피너 -->
|
||||
<div id="loadingSpinner" class="loading-spinner" style="display: none;">
|
||||
<div class="spinner"></div>
|
||||
<p>데이터를 불러오는 중...</p>
|
||||
</div>
|
||||
|
||||
<!-- 데이터 없음 메시지 -->
|
||||
<div id="noDataMessage" class="no-data-message" style="display: none;">
|
||||
<div class="no-data-icon">📭</div>
|
||||
<h3>표시할 데이터가 없습니다</h3>
|
||||
<p>선택한 날짜에 입력된 작업 데이터가 없거나<br>조회 권한이 없습니다.</p>
|
||||
</div>
|
||||
|
||||
<!-- 사용법 안내 -->
|
||||
<div class="guide-section">
|
||||
<h3>📖 사용 가이드</h3>
|
||||
<div class="guide-grid">
|
||||
<div class="guide-item">
|
||||
<div class="guide-icon">📅</div>
|
||||
<strong>날짜 선택</strong><br>
|
||||
확인하고 싶은 날짜를 선택하세요
|
||||
</div>
|
||||
<div class="guide-item">
|
||||
<div class="guide-icon">📊</div>
|
||||
<strong>현황 확인</strong><br>
|
||||
팀 전체의 입력 현황을 확인하세요
|
||||
</div>
|
||||
<div class="guide-item">
|
||||
<div class="guide-icon">🔍</div>
|
||||
<strong>필터링</strong><br>
|
||||
미입력자만 따로 확인할 수 있습니다
|
||||
</div>
|
||||
<div class="guide-item">
|
||||
<div class="guide-icon">📥</div>
|
||||
<strong>내보내기</strong><br>
|
||||
엑셀로 데이터를 다운로드하세요
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업자 상세 모달 -->
|
||||
<div id="workerDetailModal" class="worker-detail-modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="modalWorkerName">작업자 상세</h3>
|
||||
<button class="close-modal-btn" onclick="closeWorkerDetailModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="modalWorkerDetails">
|
||||
<!-- 작업자 상세 정보가 여기에 표시됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 스크립트 -->
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/management-dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,151 +0,0 @@
|
||||
<!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/main-layout.css">
|
||||
<link rel="stylesheet" href="/css/attendance.css">
|
||||
<link rel="stylesheet" href="/css/my-attendance.css">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-layout">
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div id="content-container">
|
||||
<!-- 페이지 헤더 -->
|
||||
<header class="page-header">
|
||||
<div class="page-title-section">
|
||||
<h1 class="page-title">
|
||||
<span class="title-icon">📊</span>
|
||||
나의 출근 현황
|
||||
</h1>
|
||||
<p class="page-description">나의 출근 기록과 근태 현황을 확인할 수 있습니다</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 필터 섹션 -->
|
||||
<div class="controls">
|
||||
<label for="yearSelect">연도:</label>
|
||||
<select id="yearSelect"></select>
|
||||
|
||||
<label for="monthSelect">월:</label>
|
||||
<select id="monthSelect"></select>
|
||||
|
||||
<button id="loadAttendance" class="btn-primary">조회</button>
|
||||
</div>
|
||||
|
||||
<!-- 통계 카드 섹션 -->
|
||||
<section class="stats-section">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">⏱️</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value" id="totalHours">-</div>
|
||||
<div class="stat-label">총 근무시간</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">📅</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value" id="totalDays">-</div>
|
||||
<div class="stat-label">근무일수</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">🌴</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value" id="remainingLeave">-</div>
|
||||
<div class="stat-label">잔여 연차</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 탭 섹션 -->
|
||||
<div class="tab-container">
|
||||
<button class="tab-btn active" data-tab="list">
|
||||
<span class="tab-icon">📋</span> 리스트 보기
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="calendar">
|
||||
<span class="tab-icon">📅</span> 달력 보기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 리스트 뷰 -->
|
||||
<div id="listView" class="tab-content active">
|
||||
<div id="attendanceTableContainer">
|
||||
<table id="attendanceTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>날짜</th>
|
||||
<th>요일</th>
|
||||
<th>출근시간</th>
|
||||
<th>퇴근시간</th>
|
||||
<th>근무시간</th>
|
||||
<th>상태</th>
|
||||
<th>비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="attendanceTableBody">
|
||||
<tr>
|
||||
<td colspan="7" class="loading-cell">데이터를 불러오는 중...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 달력 뷰 -->
|
||||
<div id="calendarView" class="tab-content">
|
||||
<div id="calendarContainer">
|
||||
<div class="calendar-header">
|
||||
<button id="prevMonth" class="calendar-nav-btn">◀</button>
|
||||
<h3 id="calendarTitle">2026년 1월</h3>
|
||||
<button id="nextMonth" class="calendar-nav-btn">▶</button>
|
||||
</div>
|
||||
<div id="calendarGrid" class="calendar-grid">
|
||||
<!-- 달력이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
<div class="calendar-legend">
|
||||
<span class="legend-item"><span class="legend-dot normal"></span> 정상</span>
|
||||
<span class="legend-item"><span class="legend-dot late"></span> 지각</span>
|
||||
<span class="legend-item"><span class="legend-dot early"></span> 조퇴</span>
|
||||
<span class="legend-item"><span class="legend-dot absent"></span> 결근</span>
|
||||
<span class="legend-item"><span class="legend-dot vacation"></span> 휴가</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 일별 상세 모달 -->
|
||||
<div id="detailModal" class="modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 id="modalTitle">출근 상세 정보</h2>
|
||||
<button class="modal-close-btn" onclick="closeDetailModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="modalBody">
|
||||
<!-- 상세 정보가 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" onclick="closeDetailModal()">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 스크립트 로딩 -->
|
||||
<script type="module" src="/js/api-config.js"></script>
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script src="/js/my-attendance.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,111 +0,0 @@
|
||||
<!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/common.css?v=2">
|
||||
<link rel="stylesheet" href="/css/my-dashboard.css?v=1">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<main class="dashboard-container">
|
||||
<a href="javascript:history.back()" class="back-button">
|
||||
← 뒤로가기
|
||||
</a>
|
||||
|
||||
<header class="page-header">
|
||||
<h1>📊 나의 대시보드</h1>
|
||||
<p>안녕하세요, <span id="userName"></span>님!</p>
|
||||
</header>
|
||||
|
||||
<!-- 사용자 정보 카드 -->
|
||||
<section class="user-info-card">
|
||||
<div class="info-row">
|
||||
<div class="info-item">
|
||||
<span class="label">부서:</span>
|
||||
<span id="department">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">직책:</span>
|
||||
<span id="jobType">-</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">입사일:</span>
|
||||
<span id="hireDate">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 연차 정보 위젯 -->
|
||||
<section class="vacation-widget">
|
||||
<h2>💼 연차 정보</h2>
|
||||
<div class="vacation-summary">
|
||||
<div class="stat">
|
||||
<span class="label">총 연차</span>
|
||||
<span class="value" id="totalLeave">15</span>일
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="label">사용</span>
|
||||
<span class="value used" id="usedLeave">0</span>일
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="label">잔여</span>
|
||||
<span class="value remaining" id="remainingLeave">15</span>일
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-bar">
|
||||
<div class="progress" id="vacationProgress" style="width: 0%"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 월별 출근 캘린더 -->
|
||||
<section class="calendar-section">
|
||||
<h2>📅 이번 달 출근 현황</h2>
|
||||
<div class="calendar-controls">
|
||||
<button onclick="previousMonth()">◀</button>
|
||||
<span id="currentMonth">2026년 1월</span>
|
||||
<button onclick="nextMonth()">▶</button>
|
||||
</div>
|
||||
<div id="calendar" class="calendar-grid">
|
||||
<!-- 동적 생성 -->
|
||||
</div>
|
||||
<div class="calendar-legend">
|
||||
<span><span class="dot normal"></span> 정상</span>
|
||||
<span><span class="dot late"></span> 지각</span>
|
||||
<span><span class="dot vacation"></span> 휴가</span>
|
||||
<span><span class="dot absent"></span> 결근</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 근무 시간 통계 -->
|
||||
<section class="work-hours-stats">
|
||||
<h2>⏱️ 근무 시간 통계</h2>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<span class="label">이번 달</span>
|
||||
<span class="value" id="monthHours">0</span>시간
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="label">근무 일수</span>
|
||||
<span class="value" id="workDays">0</span>일
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 최근 작업 보고서 -->
|
||||
<section class="recent-reports">
|
||||
<h2>📝 최근 작업 보고서</h2>
|
||||
<div id="recentReportsList">
|
||||
<p class="empty-message">최근 7일간의 작업 보고서가 없습니다.</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js?v=5"></script>
|
||||
<script type="module" src="/js/my-dashboard.js?v=1"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,304 +0,0 @@
|
||||
<!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/main-layout.css">
|
||||
<link rel="stylesheet" href="/css/admin.css">
|
||||
<link rel="stylesheet" href="/css/work-report.css">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
<style>
|
||||
.period-selector {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.period-selector label {
|
||||
font-weight: bold;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.period-selector input[type="date"] {
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.analysis-tabs {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
background: #f5f5f5;
|
||||
cursor: pointer;
|
||||
border-radius: 4px 4px 0 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.analysis-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.analysis-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.summary-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
border-left: 4px solid #007bff;
|
||||
}
|
||||
|
||||
.summary-card h4 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.summary-card .value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #007bff;
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background: #f8f9fa;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.data-table .project-col {
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.data-table .worker-col {
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.data-table .task-col {
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.data-table .hours-col {
|
||||
text-align: right;
|
||||
font-weight: bold;
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
background: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-row select {
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
min-width: 120px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-layout">
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div id="content-container">
|
||||
<div class="page-header">
|
||||
<h1>📊 프로젝트 투입 분석</h1>
|
||||
<p class="subtitle">기간별 프로젝트/작업자/작업 투입 현황을 분석합니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>📅 분석 기간 설정</h3>
|
||||
<div class="period-selector">
|
||||
<label for="startDate">시작일:</label>
|
||||
<input type="date" id="startDate">
|
||||
|
||||
<label for="endDate">종료일:</label>
|
||||
<input type="date" id="endDate">
|
||||
|
||||
<button id="analyzeBtn" class="btn btn-primary">분석 실행</button>
|
||||
<button id="quickMonth" class="btn btn-secondary">이번 달</button>
|
||||
<button id="quickLastMonth" class="btn btn-secondary">지난 달</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="analysisCard" style="display: none;">
|
||||
<div class="summary-cards" id="summaryCards">
|
||||
<!-- 요약 정보가 여기에 동적으로 추가됩니다 -->
|
||||
</div>
|
||||
|
||||
<div class="filter-section">
|
||||
<h4>🔍 필터 옵션</h4>
|
||||
<div class="filter-row">
|
||||
<label>프로젝트:</label>
|
||||
<select id="projectFilter">
|
||||
<option value="">전체</option>
|
||||
</select>
|
||||
|
||||
<label>작업자:</label>
|
||||
<select id="workerFilter">
|
||||
<option value="">전체</option>
|
||||
</select>
|
||||
|
||||
<label>작업 분류:</label>
|
||||
<select id="taskFilter">
|
||||
<option value="">전체</option>
|
||||
</select>
|
||||
|
||||
<button id="applyFilter" class="btn btn-primary">필터 적용</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="analysis-tabs">
|
||||
<button class="tab-button active" data-tab="project">프로젝트별</button>
|
||||
<button class="tab-button" data-tab="worker">작업자별</button>
|
||||
<button class="tab-button" data-tab="task">작업별</button>
|
||||
<button class="tab-button" data-tab="detail">상세내역</button>
|
||||
</div>
|
||||
|
||||
<div id="projectTab" class="analysis-content active">
|
||||
<h4>📋 프로젝트별 투입 현황</h4>
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="50">순번</th>
|
||||
<th>프로젝트명</th>
|
||||
<th width="100">투입 시간</th>
|
||||
<th width="80">비율</th>
|
||||
<th width="100">참여 인원</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="projectTableBody">
|
||||
<tr><td colspan="5" class="no-data">분석을 실행해주세요</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="workerTab" class="analysis-content">
|
||||
<h4>👥 작업자별 투입 현황</h4>
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="50">순번</th>
|
||||
<th>작업자명</th>
|
||||
<th width="100">투입 시간</th>
|
||||
<th width="80">비율</th>
|
||||
<th width="100">참여 프로젝트</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="workerTableBody">
|
||||
<tr><td colspan="5" class="no-data">분석을 실행해주세요</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="taskTab" class="analysis-content">
|
||||
<h4>⚙️ 작업별 투입 현황</h4>
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="50">순번</th>
|
||||
<th>작업 분류</th>
|
||||
<th width="100">투입 시간</th>
|
||||
<th width="80">비율</th>
|
||||
<th width="100">참여 인원</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="taskTableBody">
|
||||
<tr><td colspan="5" class="no-data">분석을 실행해주세요</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="detailTab" class="analysis-content">
|
||||
<h4>📄 상세 내역</h4>
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="50">순번</th>
|
||||
<th width="100">날짜</th>
|
||||
<th>프로젝트</th>
|
||||
<th>작업자</th>
|
||||
<th>작업 분류</th>
|
||||
<th width="80">시간</th>
|
||||
<th>메모</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="detailTableBody">
|
||||
<tr><td colspan="7" class="no-data">분석을 실행해주세요</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module" src="/js/project-analysis.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,672 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>프로젝트별 작업 시간 분석</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
.project-card {
|
||||
transition: all 0.3s ease;
|
||||
border-left: 4px solid #3B82F6;
|
||||
}
|
||||
.project-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
|
||||
}
|
||||
.work-type-row {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
.work-type-row:hover {
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
.error-high { border-left-color: #EF4444; }
|
||||
.error-medium { border-left-color: #F59E0B; }
|
||||
.error-low { border-left-color: #10B981; }
|
||||
|
||||
.progress-bar {
|
||||
transition: width 0.8s ease-in-out;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
border: 3px solid #f3f3f3;
|
||||
border-top: 3px solid #3498db;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stat-card.error {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
}
|
||||
|
||||
.stat-card.regular {
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
}
|
||||
|
||||
.stat-card.total {
|
||||
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
<!-- 헤더 -->
|
||||
<header class="bg-white shadow-lg border-b">
|
||||
<div class="container mx-auto px-6 py-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-800">🏗️ 프로젝트별 작업 시간 분석</h1>
|
||||
<p class="text-gray-600 mt-1">총시간 · 정규시간 · 에러시간 상세 분석</p>
|
||||
<div class="mt-2">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
|
||||
🔧 시스템 관리자 전용
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="text-sm text-gray-500">
|
||||
<span id="last-updated">마지막 업데이트: -</span>
|
||||
</div>
|
||||
<button id="refresh-btn" class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-lg transition-colors">
|
||||
🔄 새로고침
|
||||
</button>
|
||||
<button onclick="history.back()" class="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded-lg transition-colors">
|
||||
← 뒤로가기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 날짜 선택 -->
|
||||
<div class="container mx-auto px-6 py-6">
|
||||
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<div class="flex items-center space-x-2">
|
||||
<label for="start-date" class="text-sm font-medium text-gray-700">시작일:</label>
|
||||
<input type="date" id="start-date" class="border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<label for="end-date" class="text-sm font-medium text-gray-700">종료일:</label>
|
||||
<input type="date" id="end-date" class="border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
</div>
|
||||
<button id="analyze-btn" class="bg-green-500 hover:bg-green-600 text-white px-6 py-2 rounded-md text-sm font-medium transition-colors">
|
||||
📊 분석 실행
|
||||
</button>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button id="preset-week" class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-3 py-1 rounded text-xs">최근 1주일</button>
|
||||
<button id="preset-month" class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-3 py-1 rounded text-xs">최근 1개월</button>
|
||||
<button id="preset-august" class="bg-gray-200 hover:bg-gray-300 text-gray-700 px-3 py-1 rounded text-xs">8월 전체</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 로딩 화면 -->
|
||||
<div id="loading" class="hidden fixed inset-0 bg-white bg-opacity-90 flex items-center justify-center z-50">
|
||||
<div class="text-center">
|
||||
<div class="loading-spinner mx-auto mb-4"></div>
|
||||
<p class="text-gray-600">데이터를 분석하는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 메인 컨테이너 -->
|
||||
<div class="container mx-auto px-6 pb-8" id="main-content">
|
||||
|
||||
<!-- 전체 요약 통계 -->
|
||||
<div id="summary-stats" class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8 hidden">
|
||||
<div class="stat-card total rounded-lg shadow-md p-6 text-center">
|
||||
<div class="text-3xl font-bold" id="total-hours">-</div>
|
||||
<div class="text-sm opacity-90">총 작업시간</div>
|
||||
</div>
|
||||
<div class="stat-card regular rounded-lg shadow-md p-6 text-center">
|
||||
<div class="text-3xl font-bold" id="regular-hours">-</div>
|
||||
<div class="text-sm opacity-90">정규 시간</div>
|
||||
</div>
|
||||
<div class="stat-card error rounded-lg shadow-md p-6 text-center">
|
||||
<div class="text-3xl font-bold" id="error-hours">-</div>
|
||||
<div class="text-sm opacity-90">에러 시간</div>
|
||||
</div>
|
||||
<div class="stat-card rounded-lg shadow-md p-6 text-center">
|
||||
<div class="text-3xl font-bold" id="error-rate">-</div>
|
||||
<div class="text-sm opacity-90">전체 에러율</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 차트 섹션 -->
|
||||
<div id="charts-section" class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8 hidden">
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<h3 class="text-lg font-semibold mb-4">프로젝트별 시간 분포</h3>
|
||||
<canvas id="project-chart"></canvas>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<h3 class="text-lg font-semibold mb-4">에러율 분석</h3>
|
||||
<canvas id="error-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 프로젝트별 상세 데이터 -->
|
||||
<div id="projects-container" class="space-y-6">
|
||||
<!-- 프로젝트 카드들이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
|
||||
<!-- 데이터 없음 메시지 -->
|
||||
<div id="no-data" class="hidden text-center py-12">
|
||||
<div class="text-gray-400 text-6xl mb-4">📊</div>
|
||||
<h3 class="text-xl font-semibold text-gray-600 mb-2">분석할 데이터가 없습니다</h3>
|
||||
<p class="text-gray-500">날짜 범위를 선택하고 분석을 실행해주세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 기존 인증 시스템 사용 -->
|
||||
<script>
|
||||
// 전역 변수
|
||||
let analysisData = null;
|
||||
let charts = {};
|
||||
|
||||
// API 호출 함수 (토큰 포함)
|
||||
async function apiCall(endpoint, options = {}) {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
throw new Error('인증 토큰이 없습니다.');
|
||||
}
|
||||
|
||||
const defaultHeaders = {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
const response = await fetch(`http://localhost:20005/api${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
...defaultHeaders,
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// DOM 요소들
|
||||
const elements = {
|
||||
startDate: document.getElementById('start-date'),
|
||||
endDate: document.getElementById('end-date'),
|
||||
analyzeBtn: document.getElementById('analyze-btn'),
|
||||
refreshBtn: document.getElementById('refresh-btn'),
|
||||
loading: document.getElementById('loading'),
|
||||
mainContent: document.getElementById('main-content'),
|
||||
summaryStats: document.getElementById('summary-stats'),
|
||||
chartsSection: document.getElementById('charts-section'),
|
||||
projectsContainer: document.getElementById('projects-container'),
|
||||
noData: document.getElementById('no-data'),
|
||||
lastUpdated: document.getElementById('last-updated'),
|
||||
presetWeek: document.getElementById('preset-week'),
|
||||
presetMonth: document.getElementById('preset-month'),
|
||||
presetAugust: document.getElementById('preset-august')
|
||||
};
|
||||
|
||||
// 초기화
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 로그인 확인
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
alert('로그인이 필요합니다.');
|
||||
window.location.href = '/index.html';
|
||||
return;
|
||||
}
|
||||
|
||||
// 사용자 정보 및 권한 확인
|
||||
const userStr = localStorage.getItem('user');
|
||||
if (!userStr) {
|
||||
alert('사용자 정보를 찾을 수 없습니다.');
|
||||
window.location.href = '/index.html';
|
||||
return;
|
||||
}
|
||||
|
||||
const user = JSON.parse(userStr);
|
||||
|
||||
// 시스템 권한 확인 (system 역할만 접근 가능)
|
||||
if (user.role !== 'system') {
|
||||
alert('시스템 관리자 권한이 필요합니다.');
|
||||
window.location.href = '/pages/dashboard/user.html'; // 일반 사용자 대시보드로 리디렉션
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('시스템 관리자 인증 완료:', user.name || user.username);
|
||||
|
||||
initializeDateInputs();
|
||||
bindEventListeners();
|
||||
|
||||
// 8월 전체를 기본값으로 설정
|
||||
setDatePreset('august');
|
||||
});
|
||||
|
||||
// 날짜 입력 초기화
|
||||
function initializeDateInputs() {
|
||||
const today = new Date();
|
||||
const oneMonthAgo = new Date(today.getFullYear(), today.getMonth() - 1, today.getDate());
|
||||
|
||||
elements.endDate.value = today.toISOString().split('T')[0];
|
||||
elements.startDate.value = oneMonthAgo.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
// 이벤트 리스너 바인딩
|
||||
function bindEventListeners() {
|
||||
elements.analyzeBtn.addEventListener('click', performAnalysis);
|
||||
elements.refreshBtn.addEventListener('click', performAnalysis);
|
||||
|
||||
// 날짜 프리셋 버튼들
|
||||
elements.presetWeek.addEventListener('click', () => setDatePreset('week'));
|
||||
elements.presetMonth.addEventListener('click', () => setDatePreset('month'));
|
||||
elements.presetAugust.addEventListener('click', () => setDatePreset('august'));
|
||||
|
||||
// Enter 키로 분석 실행
|
||||
elements.startDate.addEventListener('keypress', handleEnterKey);
|
||||
elements.endDate.addEventListener('keypress', handleEnterKey);
|
||||
}
|
||||
|
||||
// Enter 키 처리
|
||||
function handleEnterKey(event) {
|
||||
if (event.key === 'Enter') {
|
||||
performAnalysis();
|
||||
}
|
||||
}
|
||||
|
||||
// 날짜 프리셋 설정
|
||||
function setDatePreset(preset) {
|
||||
const today = new Date();
|
||||
let startDate, endDate;
|
||||
|
||||
switch (preset) {
|
||||
case 'week':
|
||||
startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 7);
|
||||
endDate = today;
|
||||
break;
|
||||
case 'month':
|
||||
startDate = new Date(today.getFullYear(), today.getMonth() - 1, today.getDate());
|
||||
endDate = today;
|
||||
break;
|
||||
case 'august':
|
||||
startDate = new Date(2025, 7, 1); // 2025년 8월 1일
|
||||
endDate = new Date(2025, 7, 31); // 2025년 8월 31일
|
||||
break;
|
||||
}
|
||||
|
||||
elements.startDate.value = startDate.toISOString().split('T')[0];
|
||||
elements.endDate.value = endDate.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
// 분석 실행
|
||||
async function performAnalysis() {
|
||||
const startDate = elements.startDate.value;
|
||||
const endDate = elements.endDate.value;
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
alert('시작일과 종료일을 모두 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (new Date(startDate) > new Date(endDate)) {
|
||||
alert('시작일이 종료일보다 늦을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 로그인 확인
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
alert('로그인이 필요합니다.');
|
||||
window.location.href = '/index.html';
|
||||
return;
|
||||
}
|
||||
|
||||
showLoading(true);
|
||||
|
||||
try {
|
||||
const response = await apiCall(`/work-analysis/project-worktype-analysis?start=${startDate}&end=${endDate}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
analysisData = result.data;
|
||||
renderAnalysisResults();
|
||||
updateLastUpdated();
|
||||
} else {
|
||||
throw new Error(result.error || '데이터 조회에 실패했습니다.');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('분석 실패:', error);
|
||||
showError(`분석 실패: ${error.message}`);
|
||||
} finally {
|
||||
showLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 로딩 표시/숨김
|
||||
function showLoading(show) {
|
||||
elements.loading.classList.toggle('hidden', !show);
|
||||
}
|
||||
|
||||
// 에러 표시
|
||||
function showError(message) {
|
||||
alert(message);
|
||||
}
|
||||
|
||||
// 분석 결과 렌더링
|
||||
function renderAnalysisResults() {
|
||||
if (!analysisData || !analysisData.projects || analysisData.projects.length === 0) {
|
||||
showNoData();
|
||||
return;
|
||||
}
|
||||
|
||||
hideNoData();
|
||||
renderSummaryStats();
|
||||
renderCharts();
|
||||
renderProjectCards();
|
||||
}
|
||||
|
||||
// 데이터 없음 표시
|
||||
function showNoData() {
|
||||
elements.summaryStats.classList.add('hidden');
|
||||
elements.chartsSection.classList.add('hidden');
|
||||
elements.projectsContainer.innerHTML = '';
|
||||
elements.noData.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// 데이터 없음 숨김
|
||||
function hideNoData() {
|
||||
elements.noData.classList.add('hidden');
|
||||
elements.summaryStats.classList.remove('hidden');
|
||||
elements.chartsSection.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// 요약 통계 렌더링
|
||||
function renderSummaryStats() {
|
||||
const summary = analysisData.summary;
|
||||
|
||||
document.getElementById('total-hours').textContent = `${(summary.grand_total_hours || 0).toFixed(1)}h`;
|
||||
document.getElementById('regular-hours').textContent = `${(summary.grand_regular_hours || 0).toFixed(1)}h`;
|
||||
document.getElementById('error-hours').textContent = `${(summary.grand_error_hours || 0).toFixed(1)}h`;
|
||||
document.getElementById('error-rate').textContent = `${summary.grand_error_rate || 0}%`;
|
||||
}
|
||||
|
||||
// 차트 렌더링
|
||||
function renderCharts() {
|
||||
renderProjectChart();
|
||||
renderErrorChart();
|
||||
}
|
||||
|
||||
// 프로젝트별 시간 분포 차트
|
||||
function renderProjectChart() {
|
||||
const ctx = document.getElementById('project-chart').getContext('2d');
|
||||
|
||||
// 기존 차트 삭제
|
||||
if (charts.project) {
|
||||
charts.project.destroy();
|
||||
}
|
||||
|
||||
const projects = analysisData.projects;
|
||||
const labels = projects.map(p => p.project_name || 'Unknown Project');
|
||||
const totalHours = projects.map(p => p.total_project_hours || 0);
|
||||
const regularHours = projects.map(p => p.total_regular_hours || 0);
|
||||
const errorHours = projects.map(p => p.total_error_hours || 0);
|
||||
|
||||
charts.project = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{
|
||||
label: '정규 시간',
|
||||
data: regularHours,
|
||||
backgroundColor: '#10B981',
|
||||
borderColor: '#059669',
|
||||
borderWidth: 1
|
||||
},
|
||||
{
|
||||
label: '에러 시간',
|
||||
data: errorHours,
|
||||
backgroundColor: '#EF4444',
|
||||
borderColor: '#DC2626',
|
||||
borderWidth: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true,
|
||||
ticks: {
|
||||
maxRotation: 45
|
||||
}
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: '시간 (h)'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top'
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
footer: function(tooltipItems) {
|
||||
const index = tooltipItems[0].dataIndex;
|
||||
const total = totalHours[index];
|
||||
return `총 시간: ${total.toFixed(1)}h`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 에러율 분석 차트
|
||||
function renderErrorChart() {
|
||||
const ctx = document.getElementById('error-chart').getContext('2d');
|
||||
|
||||
// 기존 차트 삭제
|
||||
if (charts.error) {
|
||||
charts.error.destroy();
|
||||
}
|
||||
|
||||
const projects = analysisData.projects;
|
||||
const labels = projects.map(p => p.project_name || 'Unknown Project');
|
||||
const errorRates = projects.map(p => p.project_error_rate || 0);
|
||||
|
||||
// 에러율에 따른 색상 결정
|
||||
const colors = errorRates.map(rate => {
|
||||
if (rate >= 10) return '#EF4444'; // 높음 (빨강)
|
||||
if (rate >= 5) return '#F59E0B'; // 중간 (주황)
|
||||
return '#10B981'; // 낮음 (초록)
|
||||
});
|
||||
|
||||
charts.error = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
data: errorRates,
|
||||
backgroundColor: colors,
|
||||
borderColor: '#ffffff',
|
||||
borderWidth: 2
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom'
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
return `${context.label}: ${context.parsed}%`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 프로젝트 카드 렌더링
|
||||
function renderProjectCards() {
|
||||
const container = elements.projectsContainer;
|
||||
container.innerHTML = '';
|
||||
|
||||
analysisData.projects.forEach(project => {
|
||||
const card = createProjectCard(project);
|
||||
container.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
// 프로젝트 카드 생성
|
||||
function createProjectCard(project) {
|
||||
const card = document.createElement('div');
|
||||
|
||||
// 에러율에 따른 카드 스타일 결정
|
||||
let errorClass = 'error-low';
|
||||
const errorRate = project.project_error_rate || 0;
|
||||
if (errorRate >= 10) errorClass = 'error-high';
|
||||
else if (errorRate >= 5) errorClass = 'error-medium';
|
||||
|
||||
card.className = `project-card ${errorClass} bg-white rounded-lg shadow-md p-6`;
|
||||
|
||||
// 프로젝트 헤더
|
||||
const header = `
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 class="text-xl font-bold text-gray-800">${project.project_name || 'Unknown Project'}</h3>
|
||||
<p class="text-sm text-gray-600">Job No: ${project.job_no || 'N/A'}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-2xl font-bold text-blue-600">${(project.total_project_hours || 0).toFixed(1)}h</div>
|
||||
<div class="text-sm text-gray-500">총 시간</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 프로젝트 요약 통계
|
||||
const summary = `
|
||||
<div class="grid grid-cols-3 gap-4 mb-6 p-4 bg-gray-50 rounded-lg">
|
||||
<div class="text-center">
|
||||
<div class="text-lg font-semibold text-green-600">${(project.total_regular_hours || 0).toFixed(1)}h</div>
|
||||
<div class="text-xs text-gray-600">정규 시간</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-lg font-semibold text-red-600">${(project.total_error_hours || 0).toFixed(1)}h</div>
|
||||
<div class="text-xs text-gray-600">에러 시간</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-lg font-semibold text-purple-600">${errorRate}%</div>
|
||||
<div class="text-xs text-gray-600">에러율</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 작업 유형별 테이블
|
||||
let workTypesTable = `
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full">
|
||||
<thead class="bg-gray-100">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">작업 유형</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">총 시간</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">정규 시간</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">에러 시간</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">에러율</th>
|
||||
<th class="px-4 py-2 text-center text-xs font-medium text-gray-500 uppercase">진행률</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
`;
|
||||
|
||||
if (project.work_types && project.work_types.length > 0) {
|
||||
project.work_types.forEach(workType => {
|
||||
const totalHours = workType.total_hours || 0;
|
||||
const regularHours = workType.regular_hours || 0;
|
||||
const errorHours = workType.error_hours || 0;
|
||||
const errorRatePercent = workType.error_rate_percent || 0;
|
||||
|
||||
const regularPercent = totalHours > 0 ? (regularHours / totalHours) * 100 : 0;
|
||||
const errorPercent = totalHours > 0 ? (errorHours / totalHours) * 100 : 0;
|
||||
|
||||
workTypesTable += `
|
||||
<tr class="work-type-row">
|
||||
<td class="px-4 py-3 text-sm font-medium text-gray-900">${workType.work_type_name || 'Unknown'}</td>
|
||||
<td class="px-4 py-3 text-sm text-right font-semibold">${totalHours.toFixed(1)}h</td>
|
||||
<td class="px-4 py-3 text-sm text-right text-green-600">${regularHours.toFixed(1)}h</td>
|
||||
<td class="px-4 py-3 text-sm text-right text-red-600">${errorHours.toFixed(1)}h</td>
|
||||
<td class="px-4 py-3 text-sm text-right font-medium ${errorRatePercent >= 10 ? 'text-red-600' : errorRatePercent >= 5 ? 'text-yellow-600' : 'text-green-600'}">${errorRatePercent}%</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="flex-1 bg-gray-200 rounded-full h-2">
|
||||
<div class="bg-green-500 h-2 rounded-full progress-bar" style="width: ${regularPercent}%"></div>
|
||||
</div>
|
||||
<div class="flex-1 bg-gray-200 rounded-full h-2">
|
||||
<div class="bg-red-500 h-2 rounded-full progress-bar" style="width: ${errorPercent}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
} else {
|
||||
workTypesTable += `
|
||||
<tr>
|
||||
<td colspan="6" class="px-4 py-3 text-center text-gray-500">작업 유형 데이터가 없습니다.</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
workTypesTable += `
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
|
||||
card.innerHTML = header + summary + workTypesTable;
|
||||
return card;
|
||||
}
|
||||
|
||||
// 마지막 업데이트 시간 갱신
|
||||
function updateLastUpdated() {
|
||||
const now = new Date();
|
||||
const timeString = now.toLocaleString('ko-KR');
|
||||
elements.lastUpdated.textContent = `마지막 업데이트: ${timeString}`;
|
||||
}
|
||||
|
||||
// 유틸리티 함수들
|
||||
function formatNumber(num) {
|
||||
return new Intl.NumberFormat('ko-KR').format(num);
|
||||
}
|
||||
|
||||
function formatHours(hours) {
|
||||
return `${hours.toFixed(1)}시간`;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,723 +0,0 @@
|
||||
<!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/main-layout.css">
|
||||
<link rel="stylesheet" href="/css/admin.css">
|
||||
<link rel="stylesheet" href="/css/work-report.css">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
<style>
|
||||
/* 검토 페이지 전용 스타일 */
|
||||
.review-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 350px;
|
||||
gap: 24px;
|
||||
min-height: calc(100vh - 200px);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* 상단 대시보드 */
|
||||
.dashboard-section {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dashboard-card {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e1e5e9;
|
||||
text-align: center;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.dashboard-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.dashboard-number {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.dashboard-label {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dashboard-card.total .dashboard-number { color: #007bff; }
|
||||
.dashboard-card.error .dashboard-number { color: #dc3545; }
|
||||
.dashboard-card.warning .dashboard-number { color: #ffc107; }
|
||||
.dashboard-card.missing .dashboard-number { color: #6c757d; }
|
||||
|
||||
/* 필터 섹션 */
|
||||
.filter-section {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e1e5e9;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.filter-input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 2px solid #e1e5e9;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.filter-input:focus {
|
||||
outline: none;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 12px 24px;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
|
||||
/* 알림 영역 */
|
||||
.alerts-section {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e1e5e9;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.alerts-header {
|
||||
background: #f8f9fa;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid #e1e5e9;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.alert-item {
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.alert-item:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.alert-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.alert-type {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.alert-type.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.alert-type.warning {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.alert-type.missing {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
}
|
||||
|
||||
.alert-type.pending {
|
||||
background: #e2e3e5;
|
||||
color: #383d41;
|
||||
}
|
||||
|
||||
.alert-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.alert-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.alert-time {
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 메인 테이블 */
|
||||
.table-section {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e1e5e9;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
background: #f8f9fa;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid #e1e5e9;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.table-title {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.table-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #dee2e6;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
border-color: #007bff;
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background: #f8f9fa;
|
||||
padding: 16px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
border-bottom: 2px solid #e1e5e9;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.data-table tr {
|
||||
transition: background 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.data-table tr:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.data-table tr.selected {
|
||||
background: #e7f3ff;
|
||||
border-left: 4px solid #007bff;
|
||||
}
|
||||
|
||||
/* 상태 표시 */
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-badge.normal {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.status-badge.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.row-normal { background: #fff; }
|
||||
.row-warning { background: #fffbf0; border-left: 4px solid #ffc107; }
|
||||
.row-error { background: #fef5f5; border-left: 4px solid #dc3545; }
|
||||
.row-missing { background: #f0f8ff; border-left: 4px solid #6c757d; }
|
||||
.row-reviewed { background: #f0f9ff; border-left: 4px solid #28a745; }
|
||||
|
||||
/* 새로운 배지 스타일 */
|
||||
.attendance-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.attendance-badge.NORMAL {
|
||||
background: #e3f2fd;
|
||||
color: #1565c0;
|
||||
}
|
||||
|
||||
.attendance-badge.HALF_DAY {
|
||||
background: #fff3e0;
|
||||
color: #ef6c00;
|
||||
}
|
||||
|
||||
.attendance-badge.HALF_HALF_DAY {
|
||||
background: #f3e5f5;
|
||||
color: #7b1fa2;
|
||||
}
|
||||
|
||||
.attendance-badge.EARLY_LEAVE {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.hours-status-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hours-status-badge.NORMAL {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.hours-status-badge.UNDER {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.hours-status-badge.OVER {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.review-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.review-badge.reviewed {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.review-badge.pending {
|
||||
background: #ffeaa7;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.review-complete-btn {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.review-complete-btn:hover {
|
||||
background: #1e7e34;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* 우측 수정 패널 */
|
||||
.edit-panel {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e1e5e9;
|
||||
position: sticky;
|
||||
top: 24px;
|
||||
height: fit-content;
|
||||
max-height: calc(100vh - 48px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
background: #f8f9fa;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #e1e5e9;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.panel-subtitle {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.panel-empty {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 60px 20px;
|
||||
}
|
||||
|
||||
.panel-empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 2px solid #e1e5e9;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
padding: 20px;
|
||||
border-top: 1px solid #e1e5e9;
|
||||
background: #f8f9fa;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel-btn {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.panel-btn.save {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.panel-btn.save:hover {
|
||||
background: #1e7e34;
|
||||
}
|
||||
|
||||
.panel-btn.delete {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.panel-btn.delete:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.panel-btn.cancel {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.panel-btn.cancel:hover {
|
||||
background: #545b62;
|
||||
}
|
||||
|
||||
/* 로딩 및 메시지 */
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 16px 24px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 24px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.message.success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.message.info {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
border: 1px solid #bee5eb;
|
||||
}
|
||||
|
||||
.message.warning {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
border: 1px solid #ffeaa7;
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 1200px) {
|
||||
.review-container {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.edit-panel {
|
||||
position: relative;
|
||||
top: 0;
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-section {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.table-section {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
min-width: 800px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-layout">
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div id="content-container">
|
||||
<div class="page-header">
|
||||
<h1>🔍 작업보고서 검토</h1>
|
||||
<p class="subtitle">전체 현황을 파악하고 이상 사항을 빠르게 처리하세요.</p>
|
||||
</div>
|
||||
|
||||
<!-- 메시지 영역 -->
|
||||
<div id="message-container"></div>
|
||||
|
||||
<div class="review-container">
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<div class="main-content">
|
||||
<!-- 상단 대시보드 -->
|
||||
<div class="dashboard-section">
|
||||
<div class="dashboard-card total">
|
||||
<div class="dashboard-number" id="totalReports">-</div>
|
||||
<div class="dashboard-label">총 보고서</div>
|
||||
</div>
|
||||
<div class="dashboard-card error">
|
||||
<div class="dashboard-number" id="errorReports">-</div>
|
||||
<div class="dashboard-label">에러 발생</div>
|
||||
</div>
|
||||
<div class="dashboard-card warning">
|
||||
<div class="dashboard-number" id="warningReports">-</div>
|
||||
<div class="dashboard-label">주의 필요</div>
|
||||
</div>
|
||||
<div class="dashboard-card missing">
|
||||
<div class="dashboard-number" id="missingReports">-</div>
|
||||
<div class="dashboard-label">미검토</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 필터 섹션 -->
|
||||
<div class="filter-section">
|
||||
<div class="filter-row">
|
||||
<div class="filter-group">
|
||||
<label>시작 날짜</label>
|
||||
<input type="date" id="startDate" class="filter-input">
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>종료 날짜</label>
|
||||
<input type="date" id="endDate" class="filter-input">
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>작업자</label>
|
||||
<select id="workerFilter" class="filter-input">
|
||||
<option value="">전체 작업자</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>프로젝트</label>
|
||||
<select id="projectFilter" class="filter-input">
|
||||
<option value="">전체 프로젝트</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<button type="button" id="applyFilter" class="filter-btn">필터 적용</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 알림 영역 -->
|
||||
<div class="alerts-section">
|
||||
<div class="alerts-header">
|
||||
🚨 주의 필요 항목
|
||||
</div>
|
||||
<div id="alertsList">
|
||||
<!-- 알림 항목들이 여기에 표시됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 메인 테이블 -->
|
||||
<div class="table-section">
|
||||
<div class="table-header">
|
||||
<div class="table-title">작업보고서 목록</div>
|
||||
<div class="table-actions">
|
||||
<button class="action-btn" id="refreshBtn">🔄 새로고침</button>
|
||||
<button class="action-btn" id="exportBtn">📊 내보내기</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="overflow-x: auto;">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>날짜</th>
|
||||
<th>작업자</th>
|
||||
<th>출근형태</th>
|
||||
<th>기대시간</th>
|
||||
<th>실제시간</th>
|
||||
<th>시간상태</th>
|
||||
<th>프로젝트</th>
|
||||
<th>작업유형</th>
|
||||
<th>상태</th>
|
||||
<th>검토상태</th>
|
||||
<th>액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="reportsTableBody">
|
||||
<!-- 데이터가 여기에 표시됩니다 -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 우측 수정 패널 -->
|
||||
<div class="edit-panel">
|
||||
<div class="panel-header">
|
||||
<div class="panel-title">빠른 수정</div>
|
||||
<div class="panel-subtitle">항목을 선택하여 수정하세요</div>
|
||||
</div>
|
||||
<div class="panel-content" id="editPanelContent">
|
||||
<div class="panel-empty">
|
||||
<div class="panel-empty-icon">📝</div>
|
||||
<div>수정할 항목을 선택해주세요</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 로딩 오버레이 -->
|
||||
<div id="loadingOverlay" class="loading-overlay" style="display: none;">
|
||||
<div class="loading-spinner">
|
||||
<div style="font-size: 24px; margin-bottom: 16px;">⏳</div>
|
||||
<div>데이터를 처리하는 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module" src="/js/work-report-review.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,733 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>작업 보고서 입력 검증</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
// 날짜 범위별로 보고서 데이터 조회하는 헬퍼 함수
|
||||
async function getReportsByDateRange(startDate, endDate, workerId, projectId) {
|
||||
const allReports = [];
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
|
||||
// 날짜별로 개별 조회 (백엔드 API 구조상 날짜별 조회가 주된 방법)
|
||||
while (start <= end) {
|
||||
const dateStr = start.toISOString().split('T')[0];
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
date: dateStr,
|
||||
view_all: 'true' // 전체 조회 권한 요청
|
||||
});
|
||||
|
||||
if (workerId) params.append('worker_id', workerId);
|
||||
|
||||
const dayReports = await API.get(`/api/daily-work-reports?${params}`);
|
||||
|
||||
// 프로젝트 필터링 (클라이언트 사이드에서)
|
||||
let filteredReports = dayReports;
|
||||
if (projectId) {
|
||||
filteredReports = dayReports.filter(report =>
|
||||
report.project_id == projectId
|
||||
);
|
||||
}
|
||||
|
||||
allReports.push(...filteredReports);
|
||||
} catch (error) {
|
||||
console.warn(`${dateStr} 데이터 조회 실패:`, error);
|
||||
}
|
||||
|
||||
start.setDate(start.getDate() + 1);
|
||||
}
|
||||
|
||||
return allReports;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: #f5f7fa;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 30px;
|
||||
border-radius: 15px;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2.5em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.header p {
|
||||
font-size: 1.1em;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
background: white;
|
||||
padding: 25px;
|
||||
border-radius: 15px;
|
||||
margin-bottom: 25px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.filter-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.filter-group input, .filter-group select {
|
||||
padding: 12px;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.filter-group input:focus, .filter-group select:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 12px 24px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.validation-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||
gap: 25px;
|
||||
}
|
||||
|
||||
.validation-card {
|
||||
background: white;
|
||||
border-radius: 15px;
|
||||
padding: 25px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.validation-card:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 15px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
background: #fed7d7;
|
||||
color: #e53e3e;
|
||||
}
|
||||
|
||||
.warning-icon {
|
||||
background: #feebc8;
|
||||
color: #dd6b20;
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
background: #bee3f8;
|
||||
color: #3182ce;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
background: #c6f6d5;
|
||||
color: #38a169;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.3em;
|
||||
font-weight: 700;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2.5em;
|
||||
font-weight: 900;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.error-stat { color: #e53e3e; }
|
||||
.warning-stat { color: #dd6b20; }
|
||||
.info-stat { color: #3182ce; }
|
||||
.success-stat { color: #38a169; }
|
||||
|
||||
.issue-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.issue-item {
|
||||
padding: 12px;
|
||||
border-left: 4px solid #e2e8f0;
|
||||
margin-bottom: 10px;
|
||||
background: #f7fafc;
|
||||
border-radius: 0 8px 8px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.issue-item.error {
|
||||
border-left-color: #e53e3e;
|
||||
background: #fef5f5;
|
||||
}
|
||||
|
||||
.issue-item.warning {
|
||||
border-left-color: #dd6b20;
|
||||
background: #fffaf0;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 50px;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 5px solid #e2e8f0;
|
||||
border-top: 5px solid #667eea;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.summary-section {
|
||||
background: white;
|
||||
padding: 25px;
|
||||
border-radius: 15px;
|
||||
margin-bottom: 25px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
background: #f7fafc;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: 2em;
|
||||
font-weight: 900;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
color: #718096;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>📊 작업 보고서 입력 검증</h1>
|
||||
<p>일일 작업 보고서의 데이터 품질을 확인하고 누락된 정보를 찾아보세요</p>
|
||||
</div>
|
||||
|
||||
<div class="filter-section">
|
||||
<div class="filter-grid">
|
||||
<div class="filter-group">
|
||||
<label for="startDate">시작 날짜</label>
|
||||
<input type="date" id="startDate" value="">
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="endDate">종료 날짜</label>
|
||||
<input type="date" id="endDate" value="">
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="workerFilter">작업자</label>
|
||||
<select id="workerFilter">
|
||||
<option value="">전체</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="projectFilter">프로젝트</label>
|
||||
<select id="projectFilter">
|
||||
<option value="">전체</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<button class="btn" onclick="validateReports()">검증 실행</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="summarySection" class="summary-section" style="display: none;">
|
||||
<h3 style="margin-bottom: 20px;">📋 검증 요약</h3>
|
||||
<div class="summary-grid">
|
||||
<div class="summary-item">
|
||||
<div class="summary-value" id="totalReports">0</div>
|
||||
<div class="summary-label">총 보고서 수</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="summary-value error-stat" id="errorCount">0</div>
|
||||
<div class="summary-label">오류 항목</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="summary-value warning-stat" id="warningCount">0</div>
|
||||
<div class="summary-label">경고 항목</div>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<div class="summary-value success-stat" id="validPercent">0%</div>
|
||||
<div class="summary-label">정상 비율</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="loadingSection" class="loading" style="display: none;">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>데이터를 검증하고 있습니다...</p>
|
||||
</div>
|
||||
|
||||
<div id="validationResults" class="validation-grid">
|
||||
<!-- 검증 결과가 여기에 표시됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
// API 설정
|
||||
import { API } from './js/api-config.js';
|
||||
|
||||
// 페이지 로드 시 초기화
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initializePage();
|
||||
});
|
||||
|
||||
async function initializePage() {
|
||||
// 기본 날짜 설정 (최근 30일)
|
||||
const endDate = new Date();
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - 30);
|
||||
|
||||
document.getElementById('startDate').value = startDate.toISOString().split('T')[0];
|
||||
document.getElementById('endDate').value = endDate.toISOString().split('T')[0];
|
||||
|
||||
// 필터 옵션 로드
|
||||
await loadFilterOptions();
|
||||
}
|
||||
|
||||
async function loadFilterOptions() {
|
||||
try {
|
||||
// 작업자 목록은 별도 API로 로드해야 함 (Workers 테이블)
|
||||
// 임시로 하드코딩된 데이터 사용
|
||||
const workerSelect = document.getElementById('workerFilter');
|
||||
const workers = [
|
||||
{ worker_id: 1, worker_name: '작업자1' },
|
||||
{ worker_id: 2, worker_name: '작업자2' },
|
||||
{ worker_id: 3, worker_name: '작업자3' }
|
||||
];
|
||||
|
||||
workers.forEach(worker => {
|
||||
const option = document.createElement('option');
|
||||
option.value = worker.worker_id;
|
||||
option.textContent = worker.worker_name;
|
||||
workerSelect.appendChild(option);
|
||||
});
|
||||
|
||||
// 프로젝트 목록도 별도 API로 로드해야 함 (Projects 테이블)
|
||||
// 임시로 하드코딩된 데이터 사용
|
||||
const projectSelect = document.getElementById('projectFilter');
|
||||
const projects = [
|
||||
{ project_id: 1, project_name: '프로젝트A' },
|
||||
{ project_id: 2, project_name: '프로젝트B' },
|
||||
{ project_id: 3, project_name: '프로젝트C' }
|
||||
];
|
||||
|
||||
projects.forEach(project => {
|
||||
const option = document.createElement('option');
|
||||
option.value = project.project_id;
|
||||
option.textContent = project.project_name;
|
||||
projectSelect.appendChild(option);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('필터 옵션 로드 실패:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateReports() {
|
||||
const startDate = document.getElementById('startDate').value;
|
||||
const endDate = document.getElementById('endDate').value;
|
||||
const workerId = document.getElementById('workerFilter').value;
|
||||
const projectId = document.getElementById('projectFilter').value;
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
alert('시작 날짜와 종료 날짜를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 로딩 표시
|
||||
document.getElementById('loadingSection').style.display = 'block';
|
||||
document.getElementById('validationResults').innerHTML = '';
|
||||
document.getElementById('summarySection').style.display = 'none';
|
||||
|
||||
try {
|
||||
// 보고서 데이터 조회 - 백엔드 API 구조에 맞게 수정
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (workerId && projectId) {
|
||||
// 작업자와 프로젝트가 모두 선택된 경우
|
||||
params.append('start_date', startDate);
|
||||
params.append('end_date', endDate);
|
||||
params.append('worker_id', workerId);
|
||||
params.append('project_id', projectId);
|
||||
params.append('view_all', 'true'); // 전체 조회 권한 요청
|
||||
|
||||
const reports = await API.get(`/api/daily-work-reports/search?${params}`);
|
||||
const reportData = reports.reports || [];
|
||||
|
||||
// 날짜별로 개별 조회하여 통합
|
||||
const allReports = await getReportsByDateRange(startDate, endDate, workerId, projectId);
|
||||
|
||||
// 검증 실행
|
||||
const validationResults = await performValidation(allReports, startDate, endDate);
|
||||
|
||||
// 결과 표시
|
||||
displayValidationResults(validationResults);
|
||||
updateSummary(validationResults, allReports.length);
|
||||
} else {
|
||||
// 날짜 범위로 조회
|
||||
const allReports = await getReportsByDateRange(startDate, endDate, workerId, projectId);
|
||||
|
||||
// 검증 실행
|
||||
const validationResults = await performValidation(allReports, startDate, endDate);
|
||||
|
||||
// 결과 표시
|
||||
displayValidationResults(validationResults);
|
||||
updateSummary(validationResults, allReports.length);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('검증 실행 실패:', error);
|
||||
alert('검증 실행 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
document.getElementById('loadingSection').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function performValidation(reports, startDate, endDate) {
|
||||
const results = {
|
||||
missingDates: [],
|
||||
invalidWorkHours: [],
|
||||
missingFields: [],
|
||||
duplicateEntries: [],
|
||||
unusualPatterns: [],
|
||||
dataConsistency: []
|
||||
};
|
||||
|
||||
// 1. 누락된 날짜 확인
|
||||
const expectedDates = getDateRange(startDate, endDate);
|
||||
const reportDates = [...new Set(reports.map(r => r.report_date))];
|
||||
results.missingDates = expectedDates.filter(date =>
|
||||
!reportDates.includes(date) && isWorkingDay(date)
|
||||
);
|
||||
|
||||
// 2. 잘못된 작업시간 확인
|
||||
results.invalidWorkHours = reports.filter(report => {
|
||||
const hours = parseFloat(report.work_hours);
|
||||
return isNaN(hours) || hours <= 0 || hours > 24;
|
||||
});
|
||||
|
||||
// 3. 필수 필드 누락 확인
|
||||
results.missingFields = reports.filter(report => {
|
||||
return !report.worker_id || !report.project_id ||
|
||||
!report.work_type_id || !report.work_status_id;
|
||||
});
|
||||
|
||||
// 4. 중복 항목 확인
|
||||
const reportKeys = new Map();
|
||||
reports.forEach(report => {
|
||||
const key = `${report.report_date}-${report.worker_id}-${report.project_id}`;
|
||||
if (reportKeys.has(key)) {
|
||||
results.duplicateEntries.push({
|
||||
...report,
|
||||
duplicateKey: key
|
||||
});
|
||||
} else {
|
||||
reportKeys.set(key, report);
|
||||
}
|
||||
});
|
||||
|
||||
// 5. 비정상적인 패턴 확인
|
||||
results.unusualPatterns = findUnusualPatterns(reports);
|
||||
|
||||
// 6. 데이터 일관성 확인
|
||||
results.dataConsistency = checkDataConsistency(reports);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function getDateRange(startDate, endDate) {
|
||||
const dates = [];
|
||||
const current = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
|
||||
while (current <= end) {
|
||||
dates.push(current.toISOString().split('T')[0]);
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
|
||||
return dates;
|
||||
}
|
||||
|
||||
function isWorkingDay(dateString) {
|
||||
const date = new Date(dateString);
|
||||
const dayOfWeek = date.getDay();
|
||||
return dayOfWeek >= 1 && dayOfWeek <= 5; // 월~금
|
||||
}
|
||||
|
||||
function findUnusualPatterns(reports) {
|
||||
const unusual = [];
|
||||
|
||||
// 작업자별 일일 총 작업시간이 8시간을 크게 초과하는 경우
|
||||
const dailyHours = {};
|
||||
reports.forEach(report => {
|
||||
const key = `${report.report_date}-${report.worker_id}`;
|
||||
dailyHours[key] = (dailyHours[key] || 0) + parseFloat(report.work_hours);
|
||||
});
|
||||
|
||||
Object.entries(dailyHours).forEach(([key, hours]) => {
|
||||
if (hours > 12) {
|
||||
const [date, workerId] = key.split('-');
|
||||
unusual.push({
|
||||
type: 'excessive_hours',
|
||||
date: date,
|
||||
worker_id: workerId,
|
||||
total_hours: hours,
|
||||
message: `${date} 작업자 ${workerId}의 총 작업시간이 ${hours}시간입니다`
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return unusual;
|
||||
}
|
||||
|
||||
function checkDataConsistency(reports) {
|
||||
const inconsistencies = [];
|
||||
|
||||
// 같은 프로젝트에서 완료 상태 이후 진행중 상태가 있는지 확인
|
||||
const projectStatus = {};
|
||||
reports.forEach(report => {
|
||||
const key = `${report.project_id}-${report.worker_id}`;
|
||||
if (!projectStatus[key]) {
|
||||
projectStatus[key] = [];
|
||||
}
|
||||
projectStatus[key].push({
|
||||
date: report.report_date,
|
||||
status: report.work_status_id,
|
||||
report: report
|
||||
});
|
||||
});
|
||||
|
||||
Object.entries(projectStatus).forEach(([key, statuses]) => {
|
||||
statuses.sort((a, b) => new Date(a.date) - new Date(b.date));
|
||||
// 여기서 상태 변화의 논리적 일관성을 확인할 수 있습니다
|
||||
});
|
||||
|
||||
return inconsistencies;
|
||||
}
|
||||
|
||||
function displayValidationResults(results) {
|
||||
const container = document.getElementById('validationResults');
|
||||
|
||||
// 누락된 날짜
|
||||
if (results.missingDates.length > 0) {
|
||||
container.appendChild(createValidationCard(
|
||||
'📅 누락된 작업일',
|
||||
'error',
|
||||
results.missingDates.length,
|
||||
results.missingDates.map(date => ({
|
||||
message: `${date} (${getDayName(date)}) - 작업 보고서 없음`
|
||||
}))
|
||||
));
|
||||
}
|
||||
|
||||
// 잘못된 작업시간
|
||||
if (results.invalidWorkHours.length > 0) {
|
||||
container.appendChild(createValidationCard(
|
||||
'⏰ 잘못된 작업시간',
|
||||
'error',
|
||||
results.invalidWorkHours.length,
|
||||
results.invalidWorkHours.map(report => ({
|
||||
message: `${report.report_date} - 작업자 ${report.worker_id}: ${report.work_hours}시간`
|
||||
}))
|
||||
));
|
||||
}
|
||||
|
||||
// 필수 필드 누락
|
||||
if (results.missingFields.length > 0) {
|
||||
container.appendChild(createValidationCard(
|
||||
'❗ 필수 필드 누락',
|
||||
'error',
|
||||
results.missingFields.length,
|
||||
results.missingFields.map(report => ({
|
||||
message: `${report.report_date} - ID: ${report.id} - 필수 정보 누락`
|
||||
}))
|
||||
));
|
||||
}
|
||||
|
||||
// 중복 항목
|
||||
if (results.duplicateEntries.length > 0) {
|
||||
container.appendChild(createValidationCard(
|
||||
'🔄 중복 항목',
|
||||
'warning',
|
||||
results.duplicateEntries.length,
|
||||
results.duplicateEntries.map(report => ({
|
||||
message: `${report.report_date} - 작업자 ${report.worker_id}, 프로젝트 ${report.project_id}`
|
||||
}))
|
||||
));
|
||||
}
|
||||
|
||||
// 비정상적인 패턴
|
||||
if (results.unusualPatterns.length > 0) {
|
||||
container.appendChild(createValidationCard(
|
||||
'⚠️ 비정상적인 패턴',
|
||||
'warning',
|
||||
results.unusualPatterns.length,
|
||||
results.unusualPatterns.map(pattern => ({
|
||||
message: pattern.message
|
||||
}))
|
||||
));
|
||||
}
|
||||
|
||||
// 검증 완료 메시지
|
||||
if (container.children.length === 0) {
|
||||
container.appendChild(createValidationCard(
|
||||
'✅ 검증 완료',
|
||||
'success',
|
||||
0,
|
||||
[{ message: '모든 데이터가 정상적으로 입력되었습니다!' }]
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
function createValidationCard(title, type, count, issues) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'validation-card';
|
||||
|
||||
const iconClass = type === 'error' ? 'error-icon' :
|
||||
type === 'warning' ? 'warning-icon' :
|
||||
type === 'success' ? 'success-icon' : 'info-icon';
|
||||
|
||||
const statClass = type === 'error' ? 'error-stat' :
|
||||
type === 'warning' ? 'warning-stat' :
|
||||
type === 'success' ? 'success-stat' : 'info-stat';
|
||||
|
||||
const icon = type === 'error' ? '❌' :
|
||||
type === 'warning' ? '⚠️' :
|
||||
type === 'success' ? '✅' : 'ℹ️';
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="card-header">
|
||||
<div class="card-icon ${iconClass}">${icon}</div>
|
||||
<div class="card-title">${title}</div>
|
||||
</div>
|
||||
<div class="stat-number ${statClass}">${count}</div>
|
||||
<div class="issue-list">
|
||||
${issues.map(issue => `
|
||||
<div class="issue-item ${type}">
|
||||
${issue.message}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
function updateSummary(results, totalReports) {
|
||||
const errorCount = results.missingDates.length +
|
||||
results.invalidWorkHours.length +
|
||||
results.missingFields.length;
|
||||
|
||||
const warningCount = results.duplicateEntries.length +
|
||||
results.unusualPatterns.length +
|
||||
results.dataConsistency.length;
|
||||
|
||||
const totalIssues = errorCount + warningCount;
|
||||
const validPercent = totalReports > 0 ?
|
||||
Math.round(((totalReports - totalIssues) / totalReports) * 100) : 100;
|
||||
|
||||
document.getElementById('totalReports').textContent = totalReports;
|
||||
document.getElementById('errorCount').textContent = errorCount;
|
||||
document.getElementById('warningCount').textContent = warningCount;
|
||||
document.getElementById('validPercent').textContent = validPercent + '%';
|
||||
|
||||
document.getElementById('summarySection').style.display = 'block';
|
||||
}
|
||||
|
||||
function getDayName(dateString) {
|
||||
const date = new Date(dateString);
|
||||
const days = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
return days[date.getDay()];
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,65 +0,0 @@
|
||||
<!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/main-layout.css">
|
||||
<link rel="stylesheet" href="/css/admin.css">
|
||||
<link rel="stylesheet" href="/css/work-report.css">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-layout">
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div id="content-container">
|
||||
<div class="page-header">
|
||||
<h1>📝 작업보고서 입력</h1>
|
||||
<p class="subtitle">일일 작업 내용을 입력합니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>📅 날짜 선택</h3>
|
||||
<div id="calendar"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>📋 작업 내용</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="50">No</th>
|
||||
<th width="100">작업자</th>
|
||||
<th width="150">프로젝트</th>
|
||||
<th width="150">작업</th>
|
||||
<th width="80">잔업</th>
|
||||
<th width="100">근무형태</th>
|
||||
<th>메모</th>
|
||||
<th width="60">삭제</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="reportBody">
|
||||
<tr><td colspan="8" class="text-center">날짜를 먼저 선택하세요</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="form-actions" style="margin-top: 20px;">
|
||||
<button id="submitBtn" class="btn btn-primary">전체 등록</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module" src="/js/work-report-create.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,61 +0,0 @@
|
||||
<!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/main-layout.css">
|
||||
<link rel="stylesheet" href="/css/admin.css">
|
||||
<link rel="stylesheet" href="/css/work-report.css">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-layout">
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<div id="sidebar-container"></div>
|
||||
|
||||
<div id="content-container">
|
||||
<div class="page-header">
|
||||
<h1>📋 작업보고서 관리</h1>
|
||||
<p class="subtitle">등록된 작업보고서를 수정하거나 삭제할 수 있습니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>📅 날짜 선택</h3>
|
||||
<div id="calendar"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>📋 선택된 날짜 보고서</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="50">No</th>
|
||||
<th width="100">작업자</th>
|
||||
<th width="150">프로젝트</th>
|
||||
<th width="150">작업</th>
|
||||
<th width="80">잔업</th>
|
||||
<th width="100">근무형태</th>
|
||||
<th>메모</th>
|
||||
<th width="120">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="reportBody">
|
||||
<tr><td colspan="8" class="text-center">날짜를 선택하면 보고서가 나타납니다.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module" src="/js/work-report-manage.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,164 +0,0 @@
|
||||
<!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 type="module" src="/js/api-config.js?v=3"></script>
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="work-report-container">
|
||||
<!-- 네비게이션 바 -->
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<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 type="module" src="/js/load-navbar.js?v=5"></script>
|
||||
<script type="module" src="/js/worker-individual-report.js?v=3"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -102,7 +102,7 @@
|
||||
<div class="form-group">
|
||||
<label class="form-label">아이디 *</label>
|
||||
<input type="text" id="userId" class="form-control" required>
|
||||
<small class="form-help">영문, 숫자만 사용 가능 (4-20자)</small>
|
||||
<small class="form-help">영문, 숫자, 한글, 특수문자(._-) 사용 가능 (3-20자)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="passwordGroup">
|
||||
|
||||
@@ -169,7 +169,7 @@
|
||||
// 관리자 권한 체크
|
||||
if (currentUser.access_level !== 'system' && currentUser.access_level !== 'admin') {
|
||||
alert('관리자만 접근할 수 있습니다.');
|
||||
window.location.href = '/pages/common/my-vacation.html';
|
||||
window.location.href = '/pages/attendance/vacation-request.html';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -167,7 +167,7 @@
|
||||
// 관리자 권한 체크
|
||||
if (currentUser.access_level !== 'system' && currentUser.access_level !== 'admin') {
|
||||
alert('관리자만 접근할 수 있습니다.');
|
||||
window.location.href = '/pages/common/my-vacation.html';
|
||||
window.location.href = '/pages/attendance/vacation-request.html';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -251,7 +251,7 @@
|
||||
// 관리자 권한 체크
|
||||
if (currentUser.access_level !== 'system' && currentUser.access_level !== 'admin') {
|
||||
alert('관리자만 접근할 수 있습니다.');
|
||||
window.location.href = '/pages/common/vacation-request.html';
|
||||
window.location.href = '/pages/attendance/vacation-request.html';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<script type="module" src="/js/api-config.js"></script>
|
||||
<script type="module" src="/js/auth-check.js" defer></script>
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/load-sidebar.js"></script>
|
||||
<script type="module" src="/js/modern-dashboard.js?v=10" defer></script>
|
||||
<script type="module" src="/js/group-leader-dashboard.js?v=1" defer></script>
|
||||
<script src="/js/workplace-status.js" defer></script>
|
||||
@@ -47,7 +48,7 @@
|
||||
<div class="action-arrow" style="color: white;">→</div>
|
||||
</a>
|
||||
|
||||
<a href="/pages/work/visit-request.html" class="quick-action-card" style="background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); color: white;">
|
||||
<a href="/pages/safety/visit-request.html" class="quick-action-card" style="background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); color: white;">
|
||||
<div class="action-content">
|
||||
<h3 style="color: white;">🚪 출입 신청</h3>
|
||||
<p style="color: rgba(255, 255, 255, 0.9);">작업장 출입 및 안전교육을 신청합니다</p>
|
||||
@@ -55,7 +56,7 @@
|
||||
<div class="action-arrow" style="color: white;">→</div>
|
||||
</a>
|
||||
|
||||
<a href="/pages/admin/safety-management.html" class="quick-action-card admin-only" style="background: linear-gradient(135deg, #ec4899 0%, #db2777 100%); color: white;">
|
||||
<a href="/pages/safety/management.html" class="quick-action-card admin-only" style="background: linear-gradient(135deg, #ec4899 0%, #db2777 100%); color: white;">
|
||||
<div class="action-content">
|
||||
<h3 style="color: white;">🛡️ 안전관리</h3>
|
||||
<p style="color: rgba(255, 255, 255, 0.9);">출입 신청 승인 및 안전교육 관리</p>
|
||||
@@ -63,6 +64,30 @@
|
||||
<div class="action-arrow" style="color: white;">→</div>
|
||||
</a>
|
||||
|
||||
<a href="/pages/safety/checklist-manage.html" class="quick-action-card admin-only" style="background: linear-gradient(135deg, #14b8a6 0%, #0d9488 100%); color: white;">
|
||||
<div class="action-content">
|
||||
<h3 style="color: white;">📋 안전 체크리스트 관리</h3>
|
||||
<p style="color: rgba(255, 255, 255, 0.9);">TBM 안전 체크 항목 관리 (기본/날씨/작업별)</p>
|
||||
</div>
|
||||
<div class="action-arrow" style="color: white;">→</div>
|
||||
</a>
|
||||
|
||||
<a href="/pages/safety/issue-report.html" class="quick-action-card" style="background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); color: white;">
|
||||
<div class="action-content">
|
||||
<h3 style="color: white;">⚠️ 문제 신고</h3>
|
||||
<p style="color: rgba(255, 255, 255, 0.9);">작업 중 발생한 문제를 신고합니다</p>
|
||||
</div>
|
||||
<div class="action-arrow" style="color: white;">→</div>
|
||||
</a>
|
||||
|
||||
<a href="/pages/safety/issue-list.html" class="quick-action-card" style="background: linear-gradient(135deg, #f97316 0%, #ea580c 100%); color: white;">
|
||||
<div class="action-content">
|
||||
<h3 style="color: white;">📋 신고 현황</h3>
|
||||
<p style="color: rgba(255, 255, 255, 0.9);">신고 목록 및 처리 현황을 확인합니다</p>
|
||||
</div>
|
||||
<div class="action-arrow" style="color: white;">→</div>
|
||||
</a>
|
||||
|
||||
<a href="/pages/work/report-create.html" class="quick-action-card">
|
||||
<div class="action-content">
|
||||
<h3>작업 보고서 작성</h3>
|
||||
@@ -95,7 +120,7 @@
|
||||
<div class="action-arrow">→</div>
|
||||
</a>
|
||||
|
||||
<a href="/pages/common/daily-attendance.html" class="quick-action-card" style="background: linear-gradient(135deg, #06b6d4 0%, #0891b2 100%); color: white;">
|
||||
<a href="/pages/attendance/daily.html" class="quick-action-card" style="background: linear-gradient(135deg, #06b6d4 0%, #0891b2 100%); color: white;">
|
||||
<div class="action-content">
|
||||
<h3 style="color: white;">📅 일일 출퇴근 입력</h3>
|
||||
<p style="color: rgba(255, 255, 255, 0.9);">오늘의 출퇴근 기록을 입력합니다</p>
|
||||
@@ -103,7 +128,7 @@
|
||||
<div class="action-arrow" style="color: white;">→</div>
|
||||
</a>
|
||||
|
||||
<a href="/pages/common/monthly-attendance.html" class="quick-action-card">
|
||||
<a href="/pages/attendance/monthly.html" class="quick-action-card">
|
||||
<div class="action-content">
|
||||
<h3>📆 월별 출퇴근 현황</h3>
|
||||
<p>이번 달 출퇴근 현황을 조회합니다</p>
|
||||
@@ -111,7 +136,7 @@
|
||||
<div class="action-arrow">→</div>
|
||||
</a>
|
||||
|
||||
<a href="/pages/common/vacation-request.html" class="quick-action-card">
|
||||
<a href="/pages/attendance/vacation-request.html" class="quick-action-card">
|
||||
<div class="action-content">
|
||||
<h3>📝 휴가 신청</h3>
|
||||
<p>휴가를 신청하고 신청 내역을 확인합니다</p>
|
||||
@@ -119,7 +144,7 @@
|
||||
<div class="action-arrow">→</div>
|
||||
</a>
|
||||
|
||||
<a href="/pages/common/vacation-management.html" class="quick-action-card admin-only">
|
||||
<a href="/pages/attendance/vacation-management.html" class="quick-action-card admin-only">
|
||||
<div class="action-content">
|
||||
<h3>🏖️ 휴가 관리</h3>
|
||||
<p>휴가 승인, 직접 입력, 전체 내역을 관리합니다</p>
|
||||
@@ -127,7 +152,7 @@
|
||||
<div class="action-arrow">→</div>
|
||||
</a>
|
||||
|
||||
<a href="/pages/common/annual-vacation-overview.html" class="quick-action-card admin-only">
|
||||
<a href="/pages/attendance/annual-overview.html" class="quick-action-card admin-only">
|
||||
<div class="action-content">
|
||||
<h3>📊 연간 연차 현황</h3>
|
||||
<p>모든 작업자의 연간 휴가 현황을 차트로 확인합니다</p>
|
||||
@@ -135,7 +160,7 @@
|
||||
<div class="action-arrow">→</div>
|
||||
</a>
|
||||
|
||||
<a href="/pages/common/vacation-allocation.html" class="quick-action-card admin-only">
|
||||
<a href="/pages/attendance/vacation-allocation.html" class="quick-action-card admin-only">
|
||||
<div class="action-content">
|
||||
<h3>➕ 휴가 발생 입력</h3>
|
||||
<p>작업자별 휴가를 입력하고 특별 휴가를 관리합니다</p>
|
||||
@@ -143,7 +168,7 @@
|
||||
<div class="action-arrow">→</div>
|
||||
</a>
|
||||
|
||||
<a href="/pages/admin/attendance-report-comparison.html" class="quick-action-card admin-only">
|
||||
<a href="/pages/admin/attendance-report.html" class="quick-action-card admin-only">
|
||||
<div class="action-content">
|
||||
<h3>🔍 출퇴근-작업보고서 대조</h3>
|
||||
<p>출퇴근 기록과 작업보고서를 비교 분석합니다</p>
|
||||
|
||||
596
web-ui/pages/safety/checklist-manage.html
Normal file
596
web-ui/pages/safety/checklist-manage.html
Normal file
@@ -0,0 +1,596 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>안전 체크리스트 관리 - TK-FB</title>
|
||||
<link rel="stylesheet" href="/css/design-system.css">
|
||||
<link rel="stylesheet" href="/css/common.css">
|
||||
<style>
|
||||
.page-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #111827);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: linear-gradient(135deg, #3b82f6, #2563eb);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-add:hover {
|
||||
background: linear-gradient(135deg, #2563eb, #1d4ed8);
|
||||
}
|
||||
|
||||
/* 탭 메뉴 */
|
||||
.tab-menu {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
transition: all 0.2s;
|
||||
margin-bottom: -2px;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
color: #3b82f6;
|
||||
border-bottom-color: #3b82f6;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 체크리스트 카드 */
|
||||
.checklist-group {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
margin-bottom: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.25rem;
|
||||
background: #f9fafb;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.group-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.group-count {
|
||||
background: #e5e7eb;
|
||||
color: #6b7280;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.checklist-items {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.checklist-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.875rem 1.25rem;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.checklist-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.item-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-weight: 500;
|
||||
color: #111827;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.item-meta {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.item-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-required {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.badge-optional {
|
||||
background: #e5e7eb;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
background: #eff6ff;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.btn-edit:hover {
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
/* 필터/검색 */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
/* 모달 */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 1000;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-input, .form-select, .form-textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.form-input:focus, .form-select:focus, .form-textarea:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
min-height: 80px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-radio-group {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.form-radio {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-radio input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-checkbox input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.conditional-fields {
|
||||
display: none;
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.conditional-fields.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #e5e7eb;
|
||||
color: #374151;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #d1d5db;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* 날씨 아이콘 */
|
||||
.weather-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.weather-icon.rain::before { content: '🌧️'; }
|
||||
.weather-icon.snow::before { content: '❄️'; }
|
||||
.weather-icon.heat::before { content: '🔥'; }
|
||||
.weather-icon.cold::before { content: '🥶'; }
|
||||
.weather-icon.wind::before { content: '💨'; }
|
||||
.weather-icon.fog::before { content: '🌫️'; }
|
||||
.weather-icon.dust::before { content: '😷'; }
|
||||
.weather-icon.clear::before { content: '☀️'; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.checklist-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">안전 체크리스트 관리</h1>
|
||||
<button class="btn-add" onclick="openAddModal()">
|
||||
<span>+</span> 항목 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 탭 메뉴 -->
|
||||
<div class="tab-menu">
|
||||
<button class="tab-btn active" data-tab="basic" onclick="switchTab('basic')">
|
||||
기본 사항
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="weather" onclick="switchTab('weather')">
|
||||
날씨별
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="task" onclick="switchTab('task')">
|
||||
작업별
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 기본 사항 탭 -->
|
||||
<div id="basicTab" class="tab-content active">
|
||||
<div id="basicChecklistContainer">
|
||||
<!-- 동적 생성 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 날씨별 탭 -->
|
||||
<div id="weatherTab" class="tab-content">
|
||||
<div class="filter-bar">
|
||||
<select id="weatherFilter" class="filter-select" onchange="filterByWeather()">
|
||||
<option value="">모든 날씨 조건</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="weatherChecklistContainer">
|
||||
<!-- 동적 생성 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업별 탭 -->
|
||||
<div id="taskTab" class="tab-content">
|
||||
<div class="filter-bar">
|
||||
<select id="workTypeFilter" class="filter-select" onchange="filterByWorkType()">
|
||||
<option value="">공정 선택</option>
|
||||
</select>
|
||||
<select id="taskFilter" class="filter-select" onchange="filterByTask()">
|
||||
<option value="">작업 선택</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="taskChecklistContainer">
|
||||
<!-- 동적 생성 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 추가/수정 모달 -->
|
||||
<div id="checkModal" class="modal-overlay">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title" id="modalTitle">체크 항목 추가</h2>
|
||||
<button class="modal-close" onclick="closeModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="checkForm">
|
||||
<input type="hidden" id="checkId">
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">유형</label>
|
||||
<div class="form-radio-group">
|
||||
<label class="form-radio">
|
||||
<input type="radio" name="checkType" value="basic" checked onchange="toggleConditionalFields()">
|
||||
<span>기본</span>
|
||||
</label>
|
||||
<label class="form-radio">
|
||||
<input type="radio" name="checkType" value="weather" onchange="toggleConditionalFields()">
|
||||
<span>날씨별</span>
|
||||
</label>
|
||||
<label class="form-radio">
|
||||
<input type="radio" name="checkType" value="task" onchange="toggleConditionalFields()">
|
||||
<span>작업별</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 기본 유형: 카테고리 선택 -->
|
||||
<div id="basicFields" class="conditional-fields show">
|
||||
<div class="form-group">
|
||||
<label class="form-label">카테고리</label>
|
||||
<select id="checkCategory" class="form-select">
|
||||
<option value="PPE">PPE (개인보호장비)</option>
|
||||
<option value="EQUIPMENT">EQUIPMENT (장비점검)</option>
|
||||
<option value="ENVIRONMENT">ENVIRONMENT (작업환경)</option>
|
||||
<option value="EMERGENCY">EMERGENCY (비상대응)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 날씨별 유형: 날씨 조건 선택 -->
|
||||
<div id="weatherFields" class="conditional-fields">
|
||||
<div class="form-group">
|
||||
<label class="form-label">날씨 조건</label>
|
||||
<select id="weatherCondition" class="form-select">
|
||||
<!-- 동적 로드 -->
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업별 유형: 공정/작업 선택 -->
|
||||
<div id="taskFields" class="conditional-fields">
|
||||
<div class="form-group">
|
||||
<label class="form-label">공정</label>
|
||||
<select id="modalWorkType" class="form-select" onchange="loadModalTasks()">
|
||||
<option value="">공정 선택</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">작업</label>
|
||||
<select id="modalTask" class="form-select">
|
||||
<option value="">작업 선택</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">체크 항목</label>
|
||||
<input type="text" id="checkItem" class="form-input" placeholder="예: 안전모 착용 확인" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">설명 (선택)</label>
|
||||
<textarea id="checkDescription" class="form-textarea" placeholder="항목에 대한 상세 설명"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-checkbox">
|
||||
<input type="checkbox" id="isRequired" checked>
|
||||
<span>필수 체크 항목</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">표시 순서</label>
|
||||
<input type="number" id="displayOrder" class="form-input" value="0" min="0">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn-secondary" onclick="closeModal()">취소</button>
|
||||
<button type="button" class="btn-primary" onclick="saveCheck()">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/api-config.js"></script>
|
||||
<script src="/js/auth-check.js" defer></script>
|
||||
<script type="module" src="/js/load-navbar.js"></script>
|
||||
<script type="module" src="/js/safety-checklist-manage.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
457
web-ui/pages/safety/issue-detail.html
Normal file
457
web-ui/pages/safety/issue-detail.html
Normal file
@@ -0,0 +1,457 @@
|
||||
<!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/common.css?v=2">
|
||||
<link rel="stylesheet" href="/css/project-management.css?v=3">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<style>
|
||||
.detail-container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detail-id {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-500);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 8px 20px;
|
||||
border-radius: 9999px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-badge.reported { background: var(--blue-100); color: var(--blue-700); }
|
||||
.status-badge.received { background: var(--orange-100); color: var(--orange-700); }
|
||||
.status-badge.in_progress { background: var(--purple-100); color: var(--purple-700); }
|
||||
.status-badge.completed { background: var(--green-100); color: var(--green-700); }
|
||||
.status-badge.closed { background: var(--gray-100); color: var(--gray-700); }
|
||||
|
||||
.detail-section {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
padding: 12px;
|
||||
background: var(--gray-50);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--gray-500);
|
||||
margin-bottom: 4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.type-badge.nonconformity {
|
||||
background: var(--orange-100);
|
||||
color: var(--orange-700);
|
||||
}
|
||||
|
||||
.type-badge.safety {
|
||||
background: var(--red-100);
|
||||
color: var(--red-700);
|
||||
}
|
||||
|
||||
.severity-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.severity-badge.critical { background: var(--red-100); color: var(--red-700); }
|
||||
.severity-badge.high { background: var(--orange-100); color: var(--orange-700); }
|
||||
.severity-badge.medium { background: var(--yellow-100); color: var(--yellow-700); }
|
||||
.severity-badge.low { background: var(--gray-100); color: var(--gray-700); }
|
||||
|
||||
.photo-gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.photo-item {
|
||||
aspect-ratio: 1;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.photo-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.description-text {
|
||||
padding: 16px;
|
||||
background: var(--gray-50);
|
||||
border-radius: var(--radius-md);
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 12px 24px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--gray-300);
|
||||
background: white;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--gray-50);
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
background: var(--primary-500);
|
||||
color: white;
|
||||
border-color: var(--primary-500);
|
||||
}
|
||||
|
||||
.action-btn.primary:hover {
|
||||
background: var(--primary-600);
|
||||
}
|
||||
|
||||
.action-btn.success {
|
||||
background: var(--green-500);
|
||||
color: white;
|
||||
border-color: var(--green-500);
|
||||
}
|
||||
|
||||
.action-btn.success:hover {
|
||||
background: var(--green-600);
|
||||
}
|
||||
|
||||
.action-btn.danger {
|
||||
background: var(--red-500);
|
||||
color: white;
|
||||
border-color: var(--red-500);
|
||||
}
|
||||
|
||||
.action-btn.danger:hover {
|
||||
background: var(--red-600);
|
||||
}
|
||||
|
||||
/* 상태 변경 이력 */
|
||||
.status-timeline {
|
||||
position: relative;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.status-timeline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--gray-200);
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
position: relative;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.timeline-item:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.timeline-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -18px;
|
||||
top: 4px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-500);
|
||||
}
|
||||
|
||||
.timeline-status {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.timeline-meta {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
/* 담당자 배정 모달 */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-overlay.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: var(--radius-lg);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.modal-form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.modal-form-group label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.modal-form-group input,
|
||||
.modal-form-group select,
|
||||
.modal-form-group textarea {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--gray-300);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* 사진 확대 모달 */
|
||||
.photo-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
z-index: 1001;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.photo-modal.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.photo-modal img {
|
||||
max-width: 90%;
|
||||
max-height: 90%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.photo-modal-close {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
color: white;
|
||||
font-size: 32px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.info-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<main class="main-content">
|
||||
<div class="detail-container">
|
||||
<a href="/pages/safety/issue-list.html" style="color: var(--primary-600); text-decoration: none; margin-bottom: 16px; display: inline-block;">
|
||||
← 목록으로
|
||||
</a>
|
||||
|
||||
<div class="detail-header">
|
||||
<div>
|
||||
<div class="detail-id" id="reportId"></div>
|
||||
<h1 class="detail-title" id="reportTitle">로딩 중...</h1>
|
||||
</div>
|
||||
<span class="status-badge" id="statusBadge"></span>
|
||||
</div>
|
||||
|
||||
<!-- 기본 정보 -->
|
||||
<div class="detail-section">
|
||||
<h2 class="section-title">신고 정보</h2>
|
||||
<div class="info-grid" id="basicInfo"></div>
|
||||
</div>
|
||||
|
||||
<!-- 신고 내용 -->
|
||||
<div class="detail-section">
|
||||
<h2 class="section-title">신고 내용</h2>
|
||||
<div id="issueContent"></div>
|
||||
</div>
|
||||
|
||||
<!-- 사진 -->
|
||||
<div class="detail-section" id="photoSection" style="display: none;">
|
||||
<h2 class="section-title">첨부 사진</h2>
|
||||
<div class="photo-gallery" id="photoGallery"></div>
|
||||
</div>
|
||||
|
||||
<!-- 처리 정보 (담당자 배정 시) -->
|
||||
<div class="detail-section" id="processSection" style="display: none;">
|
||||
<h2 class="section-title">처리 정보</h2>
|
||||
<div id="processInfo"></div>
|
||||
</div>
|
||||
|
||||
<!-- 상태 이력 -->
|
||||
<div class="detail-section">
|
||||
<h2 class="section-title">상태 변경 이력</h2>
|
||||
<div class="status-timeline" id="statusTimeline"></div>
|
||||
</div>
|
||||
|
||||
<!-- 액션 버튼 -->
|
||||
<div class="action-buttons" id="actionButtons"></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 담당자 배정 모달 -->
|
||||
<div class="modal-overlay" id="assignModal">
|
||||
<div class="modal-content">
|
||||
<h3 class="modal-title">담당자 배정</h3>
|
||||
<div class="modal-form-group">
|
||||
<label>담당 부서</label>
|
||||
<input type="text" id="assignDepartment" placeholder="담당 부서 입력">
|
||||
</div>
|
||||
<div class="modal-form-group">
|
||||
<label>담당자</label>
|
||||
<select id="assignUser">
|
||||
<option value="">담당자 선택</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="action-btn" onclick="closeAssignModal()">취소</button>
|
||||
<button class="action-btn primary" onclick="submitAssign()">배정</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 처리 완료 모달 -->
|
||||
<div class="modal-overlay" id="completeModal">
|
||||
<div class="modal-content">
|
||||
<h3 class="modal-title">처리 완료</h3>
|
||||
<div class="modal-form-group">
|
||||
<label>처리 내용</label>
|
||||
<textarea id="resolutionNotes" rows="4" placeholder="처리 내용을 입력하세요"></textarea>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="action-btn" onclick="closeCompleteModal()">취소</button>
|
||||
<button class="action-btn success" onclick="submitComplete()">완료 처리</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 사진 확대 모달 -->
|
||||
<div class="photo-modal" id="photoModal" onclick="closePhotoModal()">
|
||||
<span class="photo-modal-close">×</span>
|
||||
<img id="photoModalImg" src="" alt="">
|
||||
</div>
|
||||
|
||||
<script src="/js/load-navbar.js?v=1"></script>
|
||||
<script src="/js/work-issue-detail.js?v=1"></script>
|
||||
</body>
|
||||
</html>
|
||||
301
web-ui/pages/safety/issue-list.html
Normal file
301
web-ui/pages/safety/issue-list.html
Normal file
@@ -0,0 +1,301 @@
|
||||
<!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/common.css?v=2">
|
||||
<link rel="stylesheet" href="/css/project-management.css?v=3">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<style>
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
text-align: center;
|
||||
border: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
.stat-card.reported .stat-number { color: var(--blue-600); }
|
||||
.stat-card.received .stat-number { color: var(--orange-600); }
|
||||
.stat-card.in_progress .stat-number { color: var(--purple-600); }
|
||||
.stat-card.completed .stat-number { color: var(--green-600); }
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
padding: 16px;
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.filter-bar select,
|
||||
.filter-bar input {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--gray-300);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.filter-bar select:focus,
|
||||
.filter-bar input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-500);
|
||||
}
|
||||
|
||||
.btn-new-report {
|
||||
margin-left: auto;
|
||||
padding: 10px 20px;
|
||||
background: var(--primary-500);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-new-report:hover {
|
||||
background: var(--primary-600);
|
||||
}
|
||||
|
||||
.issue-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.issue-card {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--gray-200);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.issue-card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
border-color: var(--primary-300);
|
||||
}
|
||||
|
||||
.issue-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.issue-id {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
.issue-status {
|
||||
padding: 4px 12px;
|
||||
border-radius: 9999px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.issue-status.reported {
|
||||
background: var(--blue-100);
|
||||
color: var(--blue-700);
|
||||
}
|
||||
|
||||
.issue-status.received {
|
||||
background: var(--orange-100);
|
||||
color: var(--orange-700);
|
||||
}
|
||||
|
||||
.issue-status.in_progress {
|
||||
background: var(--purple-100);
|
||||
color: var(--purple-700);
|
||||
}
|
||||
|
||||
.issue-status.completed {
|
||||
background: var(--green-100);
|
||||
color: var(--green-700);
|
||||
}
|
||||
|
||||
.issue-status.closed {
|
||||
background: var(--gray-100);
|
||||
color: var(--gray-700);
|
||||
}
|
||||
|
||||
.issue-title {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.issue-type-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.issue-type-badge.nonconformity {
|
||||
background: var(--orange-100);
|
||||
color: var(--orange-700);
|
||||
}
|
||||
|
||||
.issue-type-badge.safety {
|
||||
background: var(--red-100);
|
||||
color: var(--red-700);
|
||||
}
|
||||
|
||||
.issue-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
.issue-meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.issue-photos {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.issue-photos img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
.empty-state-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.filter-bar {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn-new-report {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<main class="main-content">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">문제 신고 목록</h1>
|
||||
</div>
|
||||
|
||||
<!-- 통계 카드 -->
|
||||
<div class="stats-grid" id="statsGrid">
|
||||
<div class="stat-card reported">
|
||||
<div class="stat-number" id="statReported">-</div>
|
||||
<div class="stat-label">신고</div>
|
||||
</div>
|
||||
<div class="stat-card received">
|
||||
<div class="stat-number" id="statReceived">-</div>
|
||||
<div class="stat-label">접수</div>
|
||||
</div>
|
||||
<div class="stat-card in_progress">
|
||||
<div class="stat-number" id="statProgress">-</div>
|
||||
<div class="stat-label">처리중</div>
|
||||
</div>
|
||||
<div class="stat-card completed">
|
||||
<div class="stat-number" id="statCompleted">-</div>
|
||||
<div class="stat-label">완료</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 필터 바 -->
|
||||
<div class="filter-bar">
|
||||
<select id="filterStatus">
|
||||
<option value="">전체 상태</option>
|
||||
<option value="reported">신고</option>
|
||||
<option value="received">접수</option>
|
||||
<option value="in_progress">처리중</option>
|
||||
<option value="completed">완료</option>
|
||||
<option value="closed">종료</option>
|
||||
</select>
|
||||
|
||||
<select id="filterType">
|
||||
<option value="">전체 유형</option>
|
||||
<option value="nonconformity">부적합</option>
|
||||
<option value="safety">안전</option>
|
||||
</select>
|
||||
|
||||
<input type="date" id="filterStartDate" title="시작일">
|
||||
<input type="date" id="filterEndDate" title="종료일">
|
||||
|
||||
<a href="/pages/safety/issue-report.html" class="btn-new-report">
|
||||
+ 새 신고
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 신고 목록 -->
|
||||
<div class="issue-list" id="issueList">
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-title">로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="/js/load-navbar.js?v=1"></script>
|
||||
<script src="/js/work-issue-list.js?v=1"></script>
|
||||
</body>
|
||||
</html>
|
||||
618
web-ui/pages/safety/issue-report.html
Normal file
618
web-ui/pages/safety/issue-report.html
Normal file
@@ -0,0 +1,618 @@
|
||||
<!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/common.css?v=2">
|
||||
<link rel="stylesheet" href="/css/project-management.css?v=3">
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<style>
|
||||
.issue-form-container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.step-indicator {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 32px;
|
||||
padding: 16px;
|
||||
background: var(--gray-50);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--gray-400);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.step.active {
|
||||
color: var(--primary-600);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.step.completed {
|
||||
color: var(--green-600);
|
||||
}
|
||||
|
||||
.step-number {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--gray-200);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.step.active .step-number {
|
||||
background: var(--primary-500);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.step.completed .step-number {
|
||||
background: var(--green-500);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
.form-section-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--gray-200);
|
||||
}
|
||||
|
||||
/* 지도 선택 영역 */
|
||||
.map-container {
|
||||
position: relative;
|
||||
min-height: 400px;
|
||||
background: var(--gray-100);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#issueMapCanvas {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.selected-location-info {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
background: var(--primary-50);
|
||||
border-radius: var(--radius-md);
|
||||
border-left: 4px solid var(--primary-500);
|
||||
}
|
||||
|
||||
.selected-location-info.empty {
|
||||
background: var(--gray-50);
|
||||
border-left-color: var(--gray-300);
|
||||
color: var(--gray-500);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.custom-location-toggle {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.custom-location-toggle input[type="checkbox"] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.custom-location-input {
|
||||
margin-top: 12px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.custom-location-input.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 유형 선택 버튼 */
|
||||
.type-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.type-btn {
|
||||
padding: 24px;
|
||||
border: 2px solid var(--gray-200);
|
||||
border-radius: var(--radius-lg);
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.type-btn:hover {
|
||||
border-color: var(--primary-300);
|
||||
background: var(--primary-50);
|
||||
}
|
||||
|
||||
.type-btn.selected {
|
||||
border-color: var(--primary-500);
|
||||
background: var(--primary-50);
|
||||
}
|
||||
|
||||
.type-btn.nonconformity.selected {
|
||||
border-color: var(--orange-500);
|
||||
background: var(--orange-50);
|
||||
}
|
||||
|
||||
.type-btn.safety.selected {
|
||||
border-color: var(--red-500);
|
||||
background: var(--red-50);
|
||||
}
|
||||
|
||||
.type-btn-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.type-btn-desc {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
/* 카테고리 선택 */
|
||||
.category-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.category-btn {
|
||||
padding: 16px;
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: var(--radius-md);
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
text-align: center;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.category-btn:hover {
|
||||
border-color: var(--primary-300);
|
||||
background: var(--gray-50);
|
||||
}
|
||||
|
||||
.category-btn.selected {
|
||||
border-color: var(--primary-500);
|
||||
background: var(--primary-50);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 사전 정의 항목 선택 */
|
||||
.item-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.item-btn {
|
||||
padding: 12px 20px;
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: 9999px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.item-btn:hover {
|
||||
border-color: var(--primary-300);
|
||||
background: var(--gray-50);
|
||||
}
|
||||
|
||||
.item-btn.selected {
|
||||
border-color: var(--primary-500);
|
||||
background: var(--primary-500);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.item-btn[data-severity="critical"] {
|
||||
border-color: var(--red-300);
|
||||
}
|
||||
|
||||
.item-btn[data-severity="critical"].selected {
|
||||
background: var(--red-500);
|
||||
border-color: var(--red-500);
|
||||
}
|
||||
|
||||
.item-btn[data-severity="high"] {
|
||||
border-color: var(--orange-300);
|
||||
}
|
||||
|
||||
.item-btn[data-severity="high"].selected {
|
||||
background: var(--orange-500);
|
||||
border-color: var(--orange-500);
|
||||
}
|
||||
|
||||
/* 사진 업로드 */
|
||||
.photo-upload-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.photo-slot {
|
||||
aspect-ratio: 1;
|
||||
border: 2px dashed var(--gray-300);
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--gray-50);
|
||||
}
|
||||
|
||||
.photo-slot:hover {
|
||||
border-color: var(--primary-500);
|
||||
background: var(--primary-50);
|
||||
}
|
||||
|
||||
.photo-slot.has-photo {
|
||||
border-style: solid;
|
||||
border-color: var(--green-500);
|
||||
}
|
||||
|
||||
.photo-slot img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.photo-slot .add-icon {
|
||||
font-size: 24px;
|
||||
color: var(--gray-400);
|
||||
}
|
||||
|
||||
.photo-slot .remove-btn {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--red-500);
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.photo-slot.has-photo .remove-btn {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.photo-slot .add-icon {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.photo-slot.has-photo .add-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 추가 설명 */
|
||||
.additional-textarea {
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--gray-300);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-base);
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.additional-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-500);
|
||||
box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.1);
|
||||
}
|
||||
|
||||
/* 제출 버튼 */
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
padding: 16px 48px;
|
||||
background: var(--primary-500);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-submit:hover {
|
||||
background: var(--primary-600);
|
||||
}
|
||||
|
||||
.btn-submit:disabled {
|
||||
background: var(--gray-300);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
padding: 16px 32px;
|
||||
background: white;
|
||||
color: var(--gray-600);
|
||||
border: 1px solid var(--gray-300);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-base);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
background: var(--gray-50);
|
||||
}
|
||||
|
||||
/* 작업 선택 모달 */
|
||||
.work-selection-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.work-selection-modal.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.work-selection-content {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: var(--radius-lg);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.work-selection-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.work-option {
|
||||
padding: 16px;
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 12px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.work-option:hover {
|
||||
border-color: var(--primary-500);
|
||||
background: var(--primary-50);
|
||||
}
|
||||
|
||||
.work-option-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.work-option-desc {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 768px) {
|
||||
.type-buttons {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.photo-upload-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.step-indicator {
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.step-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="navbar-container"></div>
|
||||
|
||||
<main class="main-content">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">문제 신고</h1>
|
||||
<p class="page-description">작업 중 발견된 부적합 사항 또는 안전 문제를 신고합니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="issue-form-container">
|
||||
<!-- 단계 표시 -->
|
||||
<div class="step-indicator">
|
||||
<div class="step active" data-step="1">
|
||||
<span class="step-number">1</span>
|
||||
<span class="step-text">위치 선택</span>
|
||||
</div>
|
||||
<div class="step" data-step="2">
|
||||
<span class="step-number">2</span>
|
||||
<span class="step-text">유형 선택</span>
|
||||
</div>
|
||||
<div class="step" data-step="3">
|
||||
<span class="step-number">3</span>
|
||||
<span class="step-text">항목 선택</span>
|
||||
</div>
|
||||
<div class="step" data-step="4">
|
||||
<span class="step-number">4</span>
|
||||
<span class="step-text">사진/설명</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 1: 위치 선택 -->
|
||||
<div class="form-section" id="step1Section">
|
||||
<h2 class="form-section-title">1. 발생 위치 선택</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="factorySelect">공장 선택</label>
|
||||
<select id="factorySelect">
|
||||
<option value="">공장을 선택하세요</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="map-container">
|
||||
<canvas id="issueMapCanvas"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="selected-location-info empty" id="selectedLocationInfo">
|
||||
지도에서 작업장을 클릭하여 위치를 선택하세요
|
||||
</div>
|
||||
|
||||
<div class="custom-location-toggle">
|
||||
<input type="checkbox" id="useCustomLocation">
|
||||
<label for="useCustomLocation">지도에 없는 위치 직접 입력</label>
|
||||
</div>
|
||||
|
||||
<div class="custom-location-input" id="customLocationInput">
|
||||
<input type="text" id="customLocation" placeholder="위치를 입력하세요 (예: 야적장 입구, 주차장 등)">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: 문제 유형 선택 -->
|
||||
<div class="form-section" id="step2Section">
|
||||
<h2 class="form-section-title">2. 문제 유형 선택</h2>
|
||||
|
||||
<div class="type-buttons">
|
||||
<div class="type-btn nonconformity" data-type="nonconformity">
|
||||
<div class="type-btn-title">부적합 사항</div>
|
||||
<div class="type-btn-desc">자재, 설계, 검사 관련 문제</div>
|
||||
</div>
|
||||
<div class="type-btn safety" data-type="safety">
|
||||
<div class="type-btn-title">안전 관련</div>
|
||||
<div class="type-btn-desc">보호구, 위험구역, 안전수칙 관련</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="categoryContainer" style="display: none;">
|
||||
<label style="font-weight: 600; margin-bottom: 12px; display: block;">세부 카테고리</label>
|
||||
<div class="category-grid" id="categoryGrid"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: 신고 항목 선택 -->
|
||||
<div class="form-section" id="step3Section">
|
||||
<h2 class="form-section-title">3. 신고 항목 선택</h2>
|
||||
<p style="color: var(--gray-500); margin-bottom: 16px;">해당하는 항목을 선택하세요. 여러 개 선택 가능합니다.</p>
|
||||
|
||||
<div class="item-grid" id="itemGrid">
|
||||
<p style="color: var(--gray-400);">먼저 카테고리를 선택하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: 사진 및 추가 설명 -->
|
||||
<div class="form-section" id="step4Section">
|
||||
<h2 class="form-section-title">4. 사진 및 추가 설명</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label>사진 첨부 (최대 5장)</label>
|
||||
<div class="photo-upload-grid">
|
||||
<div class="photo-slot" data-index="0">
|
||||
<span class="add-icon">+</span>
|
||||
<button class="remove-btn" type="button">×</button>
|
||||
</div>
|
||||
<div class="photo-slot" data-index="1">
|
||||
<span class="add-icon">+</span>
|
||||
<button class="remove-btn" type="button">×</button>
|
||||
</div>
|
||||
<div class="photo-slot" data-index="2">
|
||||
<span class="add-icon">+</span>
|
||||
<button class="remove-btn" type="button">×</button>
|
||||
</div>
|
||||
<div class="photo-slot" data-index="3">
|
||||
<span class="add-icon">+</span>
|
||||
<button class="remove-btn" type="button">×</button>
|
||||
</div>
|
||||
<div class="photo-slot" data-index="4">
|
||||
<span class="add-icon">+</span>
|
||||
<button class="remove-btn" type="button">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" id="photoInput" accept="image/*" capture="environment" style="display: none;">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="additionalDescription">추가 설명 (선택)</label>
|
||||
<textarea id="additionalDescription" class="additional-textarea" placeholder="추가로 설명이 필요한 내용을 입력하세요..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 제출 버튼 -->
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn-cancel" onclick="history.back()">취소</button>
|
||||
<button type="button" class="btn-submit" id="submitBtn" onclick="submitReport()">신고 제출</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 작업 선택 모달 -->
|
||||
<div class="work-selection-modal" id="workSelectionModal">
|
||||
<div class="work-selection-content">
|
||||
<h3 class="work-selection-title">작업 선택</h3>
|
||||
<p style="margin-bottom: 16px; color: var(--gray-600);">이 위치에 등록된 작업이 있습니다. 연결할 작업을 선택하세요.</p>
|
||||
<div id="workOptionsList"></div>
|
||||
<button type="button" onclick="closeWorkModal()" style="width: 100%; padding: 12px; margin-top: 8px; background: var(--gray-100); border: none; border-radius: var(--radius-md); cursor: pointer;">
|
||||
작업 연결 없이 진행
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/load-navbar.js?v=1"></script>
|
||||
<script src="/js/work-issue-report.js?v=1"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -10,6 +10,52 @@
|
||||
<link rel="icon" type="image/png" href="/img/favicon.png">
|
||||
<script src="/js/auth-check.js?v=1" defer></script>
|
||||
<script type="module" src="/js/api-config.js?v=3"></script>
|
||||
<style>
|
||||
.date-group {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.date-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
border-left: 4px solid var(--primary-500);
|
||||
}
|
||||
.date-group-header.today {
|
||||
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
|
||||
border-left-color: #3b82f6;
|
||||
}
|
||||
.date-group-date {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
.date-group-day {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: white;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.date-group-count {
|
||||
margin-left: auto;
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
.date-group-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.date-group-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="work-report-container">
|
||||
@@ -98,17 +144,12 @@
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">
|
||||
<span class="section-icon">📚</span>
|
||||
전체 TBM 기록
|
||||
TBM 기록
|
||||
</h2>
|
||||
<div class="section-actions">
|
||||
<input type="date" id="tbmDate" class="form-control" style="display: inline-block; width: auto; margin-right: 0.5rem;">
|
||||
<button class="btn btn-secondary" onclick="loadTodayTbm()">
|
||||
<button class="btn btn-secondary" onclick="loadMoreTbmDays()" id="loadMoreBtn">
|
||||
<span class="btn-icon">📅</span>
|
||||
오늘
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="loadAllTbm()">
|
||||
<span class="btn-icon">🔄</span>
|
||||
전체 보기
|
||||
더 보기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -122,10 +163,15 @@
|
||||
<span class="stat-icon">✅</span>
|
||||
완료 <span id="completedSessions">0</span>개
|
||||
</span>
|
||||
<span class="stat-item" id="viewModeIndicator" style="display: none;">
|
||||
<span class="stat-icon">👤</span>
|
||||
<span id="viewModeText">내 TBM만</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="code-grid" id="tbmSessionsGrid">
|
||||
<!-- 전체 TBM 세션 카드들이 여기에 동적으로 생성됩니다 -->
|
||||
<!-- 날짜별 그룹 컨테이너 -->
|
||||
<div id="tbmDateGroupsContainer">
|
||||
<!-- 날짜별 TBM 그룹이 여기에 동적으로 생성됩니다 -->
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
|
||||
Reference in New Issue
Block a user