해당 서비스 도커화 성공, 룰 추가, 로그인 오류 수정, 소문자 룰 어느정도 해결

This commit is contained in:
Hyungi Ahn
2025-08-01 15:55:27 +09:00
parent ef06cec8d6
commit 809b2af53e
6418 changed files with 1922672 additions and 69 deletions

View File

@@ -0,0 +1,44 @@
<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>

View File

@@ -0,0 +1,181 @@
<!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.tailwindcss.com"></script>
<link rel="stylesheet" href="/css/attendance-validation.css">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/auth-check.js" defer></script>
</head>
<body class="bg-gray-50 min-h-screen">
<!-- 네비게이션 바 -->
<div id="navbar-container"></div>
<div class="max-w-7xl mx-auto p-6">
<!-- 뒤로가기 버튼 -->
<a href="javascript:history.back()" class="back-btn">
← 뒤로가기
</a>
<!-- 헤더 -->
<div class="page-header">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<span class="text-4xl">👥</span>
<div>
<h1 class="text-3xl font-bold text-white">근태 검증 관리</h1>
<p class="text-lg text-white/90 mt-2">작업자별 근무시간을 검증하고 관리합니다</p>
</div>
</div>
<div class="text-sm text-white/80 bg-white/20 px-4 py-2 rounded-lg">
🔒 Admin 전용
</div>
</div>
</div>
<!-- 메시지 영역 -->
<div id="message-container"></div>
<!-- 로딩 화면 -->
<div id="loadingScreen" class="hidden">
<div class="loading-card">
<div class="flex items-center justify-center">
<div class="loading-spinner"></div>
<span class="ml-3 text-gray-600">데이터를 불러오는 중...</span>
</div>
</div>
</div>
<!-- 메인 콘텐츠 -->
<div id="mainContent">
<!-- 캘린더 섹션 -->
<div class="main-card">
<!-- 캘린더 헤더 -->
<div class="calendar-header">
<button id="prevMonth" class="nav-btn">
<span class="text-xl"></span>
</button>
<h2 id="currentMonthYear" class="text-2xl font-bold text-gray-800">
2025년 6월
</h2>
<button id="nextMonth" class="nav-btn">
<span class="text-xl"></span>
</button>
</div>
<!-- 월간 요약 정보 -->
<div class="summary-section">
<h3 class="summary-title">이번 달 요약</h3>
<div class="summary-grid">
<div class="summary-card normal">
<div id="normalCount" class="summary-number">0</div>
<div class="summary-label">✅ 정상</div>
</div>
<div class="summary-card warning">
<div id="reviewCount" class="summary-number">0</div>
<div class="summary-label">⚠️ 검토필요</div>
</div>
<div class="summary-card error">
<div id="missingCount" class="summary-number">0</div>
<div class="summary-label">❌ 미입력</div>
</div>
</div>
</div>
<!-- 요일 헤더 -->
<div class="weekday-header">
<div class="weekday sunday"></div>
<div class="weekday"></div>
<div class="weekday"></div>
<div class="weekday"></div>
<div class="weekday"></div>
<div class="weekday"></div>
<div class="weekday saturday"></div>
</div>
<!-- 캘린더 본체 -->
<div id="calendarGrid" class="calendar-grid">
<!-- 동적으로 생성됨 -->
</div>
<!-- 범례 -->
<div class="legend">
<div class="legend-item">
<div class="legend-dot normal"></div>
<span>정상</span>
</div>
<div class="legend-item">
<div class="legend-dot warning"></div>
<span>검토필요</span>
</div>
<div class="legend-item">
<div class="legend-dot error"></div>
<span>미입력</span>
</div>
</div>
<!-- 성능 상태 표시 (개발용) -->
<div id="performanceStatus" class="text-xs text-gray-500 text-center mt-4 hidden">
<div class="bg-gray-100 rounded-lg p-2">
🔄 순차 호출: <span id="activeReq">0</span>개 처리 중 |
📦 캐시: <span id="cacheCount">0</span>개 저장됨 |
⏳ 대기: <span id="queueCount">0</span>개 |
🚫 429 에러 방지 (2초 딜레이)
</div>
</div>
</div>
<!-- 작업자 리스트 섹션 -->
<div id="workersList" class="main-card">
<div class="empty-state">
<div class="empty-icon">🔄</div>
<h3 class="empty-title">날짜를 클릭해주세요</h3>
<p class="empty-description">
캘린더에서 날짜를 클릭하면 해당 날짜의 작업자 검증 내역을 확인할 수 있습니다.<br>
<strong>순차 호출 방식</strong>으로 안정적이지만 약 5초의 로딩 시간이 있습니다.
</p>
</div>
</div>
</div>
</div>
<!-- 수정 모달 -->
<div id="editModal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h3>✏️ 근무시간 수정</h3>
<button class="close-btn" onclick="closeEditModal()">×</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>👤 작업자</label>
<input type="text" id="editWorkerName" class="form-input" readonly>
</div>
<div class="form-group">
<label>📊 현재 상태</label>
<input type="text" id="editWorkerStatus" class="form-input" readonly>
</div>
<div class="form-group">
<label>⏰ 근무시간 (시간)</label>
<input type="number" id="editWorkHours" class="form-input"
min="0" max="24" step="0.5" placeholder="근무시간을 입력하세요">
</div>
<div class="form-group">
<label>📝 수정 사유</label>
<textarea id="editReason" class="form-textarea"
placeholder="수정 사유를 입력하세요 (선택사항)"></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" onclick="closeEditModal()">취소</button>
<button class="btn-primary" onclick="saveEditedWork()">💾 저장</button>
</div>
</div>
</div>
<script type="module" src="/js/load-navbar.js"></script>
<script src="/js/attendance-validation.js"></script>
</body>
</html>

View File

@@ -0,0 +1,35 @@
<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>

View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>공장 지도 등록</title>
<link rel="stylesheet" href="/css/admin.css">
</head>
<body>
<div id="navbar-placeholder"></div>
<div class="container">
<h2>공장 지도 등록</h2>
<form id="uploadForm">
<input type="text" name="factory_name" placeholder="공장명" required>
<input type="text" name="address" placeholder="주소" required>
<textarea name="description" placeholder="설명" required></textarea>
<input type="file" name="map_image" accept="image/*" required>
<button type="submit">등록</button>
</form>
</div>
<script src="/js/load-navbar.js"></script>
<script src="/js/admin/factory-upload.js"></script>
</body>
</html>

View File

@@ -0,0 +1,667 @@
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;

View File

@@ -0,0 +1,62 @@
<!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>

View File

@@ -0,0 +1,62 @@
<!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="specForm" class="form-horizontal">
<div class="form-row">
<input type="text" id="material" placeholder="재질 (예: STS304)" required />
<input type="text" id="diameter_in" placeholder="직경 (예: 1, 1/2)" required />
<input type="text" id="schedule" placeholder="스케줄 (예: SCH40)" 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>
</tr>
</thead>
<tbody id="specTableBody">
<tr><td colspan="3" 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-pipespec.js"></script>
</body>
</html>

View File

@@ -0,0 +1,76 @@
<!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>

View File

@@ -0,0 +1,68 @@
<!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>

View File

@@ -0,0 +1,106 @@
<!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>
<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>

View File

@@ -0,0 +1,62 @@
<!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>

View File

@@ -0,0 +1,109 @@
<!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/work-review.css">
<script 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="review-container">
<!-- 뒤로가기 버튼 -->
<a href="javascript:history.back()" class="back-btn">
← 뒤로가기
</a>
<!-- 페이지 헤더 -->
<div class="page-header">
<h1>📋 작업 검토 대시보드</h1>
<p class="subtitle">캘린더에서 날짜를 클릭하여 해당 날짜의 작업 현황을 확인하고 검토하세요</p>
</div>
<!-- 메시지 영역 -->
<div id="message-container"></div>
<!-- 컨트롤 패널 -->
<div class="control-panel">
<div class="month-navigation">
<button class="nav-btn" id="prevMonth"> 이전</button>
<div class="current-month" id="currentMonth">2025년 6월</div>
<button class="nav-btn" id="nextMonth">다음 </button>
</div>
<div class="control-actions">
<button class="today-btn" id="goToday">📅 오늘</button>
</div>
</div>
<!-- 사용법 안내 -->
<div class="usage-guide">
<h3>📖 사용법</h3>
<div class="guide-grid">
<div class="guide-item">
<div class="guide-icon">👆</div>
<div class="guide-text">
<strong>날짜 클릭</strong><br>
캘린더에서 날짜를 클릭하면 해당 날짜의 작업 정보를 확인할 수 있습니다.
</div>
</div>
<div class="guide-item">
<div class="guide-icon">✏️</div>
<div class="guide-text">
<strong>작업 수정</strong><br>
각 작업 항목의 수정 버튼을 클릭하여 프로젝트, 작업 유형, 시간 등을 수정할 수 있습니다.
</div>
</div>
<div class="guide-item">
<div class="guide-icon">🗑️</div>
<div class="guide-text">
<strong>작업 삭제</strong><br>
개별 작업 또는 작업자의 모든 작업을 삭제할 수 있습니다.
</div>
</div>
<div class="guide-item">
<div class="guide-icon"></div>
<div class="guide-text">
<strong>검토 완료</strong><br>
작업 검토가 완료되면 검토 완료 버튼을 클릭하여 상태를 변경할 수 있습니다.
</div>
</div>
</div>
</div>
<!-- 캘린더 -->
<div class="calendar-container">
<h3 style="margin-bottom: 1rem;">📅 캘린더</h3>
<div class="calendar-grid" id="calendar">
<!-- 요일 헤더 -->
<div class="day-header"></div>
<div class="day-header"></div>
<div class="day-header"></div>
<div class="day-header"></div>
<div class="day-header"></div>
<div class="day-header"></div>
<div class="day-header"></div>
<!-- 날짜 셀들이 여기에 동적으로 추가됩니다 -->
</div>
</div>
<!-- 선택된 날짜 정보 -->
<div class="day-info-panel">
<div id="day-info-container">
<!-- 날짜별 정보가 여기에 동적으로 추가됩니다 -->
</div>
</div>
</div>
</div>
</div>
<!-- 스크립트 -->
<script type="module" src="/js/load-navbar.js"></script>
<script src="/js/work-review.js"></script>
</body>
</html>

View File

@@ -0,0 +1,890 @@
<!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>

View File

@@ -0,0 +1,497 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rate Limit 관리</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #f5f5f5;
padding: 20px;
}
.container {
max-width: 1000px;
margin: 0 auto;
background: white;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
display: flex;
align-items: center;
justify-content: space-between;
}
.header h1 {
font-size: 24px;
display: flex;
align-items: center;
gap: 10px;
}
.shield-icon {
width: 24px;
height: 24px;
fill: white;
}
.user-level {
background: rgba(255, 255, 255, 0.2);
padding: 8px 16px;
border-radius: 20px;
font-size: 14px;
}
.content {
padding: 30px;
}
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.status-card {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 20px;
text-align: center;
}
.status-card .label {
font-size: 14px;
color: #6c757d;
margin-bottom: 8px;
}
.status-card .value {
font-size: 18px;
font-weight: bold;
font-family: 'Courier New', monospace;
color: #495057;
}
.buttons-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-bottom: 30px;
}
.btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 20px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-blue {
background: #007bff;
color: white;
}
.btn-blue:hover:not(:disabled) {
background: #0056b3;
}
.btn-green {
background: #28a745;
color: white;
}
.btn-green:hover:not(:disabled) {
background: #1e7e34;
}
.btn-orange {
background: #fd7e14;
color: white;
}
.btn-orange:hover:not(:disabled) {
background: #e55a00;
}
.btn-gray {
background: #6c757d;
color: white;
}
.btn-gray:hover:not(:disabled) {
background: #545b62;
}
.message {
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
font-weight: 500;
}
.message.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.message.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.message.warning {
background: #fff3cd;
color: #856404;
border: 1px solid #ffeaa7;
}
.help-section {
background: #e3f2fd;
border: 1px solid #bbdefb;
border-radius: 8px;
padding: 20px;
margin-top: 20px;
}
.help-section h3 {
color: #1565c0;
margin-bottom: 10px;
}
.help-section ul {
color: #1976d2;
line-height: 1.6;
}
.help-section li {
margin-bottom: 5px;
}
.loading {
opacity: 0.7;
pointer-events: none;
}
.no-permission {
background: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
text-align: center;
}
.icon {
width: 16px;
height: 16px;
fill: currentColor;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>
<svg class="shield-icon" viewBox="0 0 24 24">
<path d="M12,1L3,5V11C3,16.55 6.84,21.74 12,23C17.16,21.74 21,16.55 21,11V5L12,1M12,7C13.4,7 14.8,8.6 14.8,10V11H16V21H8V11H9.2V10C9.2,8.6 10.6,7 12,7M12,8.2C11.2,8.2 10.4,8.7 10.4,10V11H13.6V10C13.6,8.7 12.8,8.2 12,8.2Z"/>
</svg>
Rate Limit 관리
</h1>
<div class="user-level" id="userLevel">권한 레벨: -</div>
</div>
<div class="content">
<!-- 권한 없음 메시지 -->
<div class="no-permission" id="noPermission" style="display: none;">
<svg class="icon" viewBox="0 0 24 24" style="width: 24px; height: 24px; margin-bottom: 10px;">
<path d="M12,2C13.1,2 14,2.9 14,4C14,5.1 13.1,6 12,6C10.9,6 10,5.1 10,4C10,2.9 10.9,2 12,2M21,9V7L15,1H5C3.89,1 3,1.89 3,3V21A2,2 0 0,0 5,23H19A2,2 0 0,0 21,21V9M19,9H14V4H5V21H19V9Z"/>
</svg>
<h3>접근 권한 부족</h3>
<p>Rate Limit 관리 기능은 권한 레벨 4 이상의 사용자만 사용할 수 있습니다.</p>
</div>
<!-- 현재 상태 -->
<div id="statusSection">
<h2 style="margin-bottom: 20px; display: flex; align-items: center; gap: 8px;">
<svg class="icon" viewBox="0 0 24 24">
<path d="M12,2C13.1,2 14,2.9 14,4C14,5.1 13.1,6 12,6C10.9,6 10,5.1 10,4C10,2.9 10.9,2 12,2M21,9V7L15,1H5C3.89,1 3,1.89 3,3V21A2,2 0 0,0 5,23H19A2,2 0 0,0 21,21V9M19,9H14V4H5V21H19V9Z"/>
</svg>
현재 상태
</h2>
<div class="status-grid">
<div class="status-card">
<div class="label">클라이언트 IP</div>
<div class="value" id="clientIP">로딩 중...</div>
</div>
<div class="status-card">
<div class="label">API 제한</div>
<div class="value" id="apiLimit">로딩 중...</div>
</div>
<div class="status-card">
<div class="label">로그인 제한</div>
<div class="value" id="loginLimit">로딩 중...</div>
</div>
<div class="status-card">
<div class="label">시간 윈도우</div>
<div class="value">15분</div>
</div>
</div>
</div>
<!-- 컨트롤 버튼들 -->
<div id="controlSection">
<div class="buttons-grid">
<button class="btn btn-blue" onclick="resetRateLimit()">
<svg class="icon" viewBox="0 0 24 24">
<path d="M12,2C13.1,2 14,2.9 14,4C14,5.1 13.1,6 12,6C10.9,6 10,5.1 10,4C10,2.9 10.9,2 12,2M21,9V7L15,1H5C3.89,1 3,1.89 3,3V21A2,2 0 0,0 5,23H19A2,2 0 0,0 21,21V9M19,9H14V4H5V21H19V9Z"/>
</svg>
내 IP 제한 초기화
</button>
<button class="btn btn-green" onclick="bypassRateLimit(3600000)">
<svg class="icon" viewBox="0 0 24 24">
<path d="M12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22C6.47,22 2,17.5 2,12A10,10 0 0,1 12,2Z"/>
</svg>
1시간 제한 해제
</button>
<button class="btn btn-orange" onclick="bypassRateLimit(86400000)">
<svg class="icon" viewBox="0 0 24 24">
<path d="M12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22C6.47,22 2,17.5 2,12A10,10 0 0,1 12,2Z"/>
</svg>
24시간 제한 해제
</button>
<button class="btn btn-gray" onclick="checkStatus()">
<svg class="icon" viewBox="0 0 24 24">
<path d="M17.65,6.35C16.2,4.9 14.21,4 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20C15.73,20 18.84,17.45 19.73,14H17.65C16.83,16.33 14.61,18 12,18A6,6 0 0,1 6,12A6,6 0 0,1 12,6C13.66,6 15.14,6.69 16.22,7.78L13,11H20V4L17.65,6.35Z"/>
</svg>
상태 새로고침
</button>
</div>
</div>
<!-- 메시지 표시 영역 -->
<div id="messageArea"></div>
<!-- 도움말 -->
<div class="help-section">
<h3>💡 사용 가이드</h3>
<ul>
<li><strong>초기화</strong>: 현재 IP의 요청 카운터를 0으로 리셋</li>
<li><strong>제한 해제</strong>: 지정된 시간 동안 Rate Limit 완전 비활성화</li>
<li><strong>권한 요구사항</strong>: 레벨 4-5 사용자만 접근 가능</li>
<li><strong>자동 해제</strong>: 임시 해제는 설정된 시간 후 자동으로 복구됨</li>
</ul>
</div>
</div>
</div>
<script>
let userLevel = 0;
let loading = false;
// 토큰 가져오기
function getToken() {
return localStorage.getItem('token') || sessionStorage.getItem('token');
}
// 로딩 상태 설정
function setLoading(isLoading) {
loading = isLoading;
const container = document.querySelector('.container');
if (isLoading) {
container.classList.add('loading');
} else {
container.classList.remove('loading');
}
}
// 메시지 표시
function showMessage(message, type = 'info') {
const messageArea = document.getElementById('messageArea');
const messageDiv = document.createElement('div');
messageDiv.className = `message ${type}`;
messageDiv.textContent = message;
messageArea.innerHTML = '';
messageArea.appendChild(messageDiv);
// 5초 후 자동 제거
setTimeout(() => {
if (messageDiv.parentNode) {
messageDiv.parentNode.removeChild(messageDiv);
}
}, 5000);
}
// 사용자 권한 확인
async function checkUserPermission() {
try {
const token = getToken();
if (!token) {
showMessage('로그인이 필요합니다.', 'error');
return false;
}
const response = await fetch('/api/auth/me', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
const userData = await response.json();
userLevel = userData.access_level || 0;
document.getElementById('userLevel').textContent = `권한 레벨: ${userLevel}`;
if (userLevel < 4) {
document.getElementById('noPermission').style.display = 'block';
document.getElementById('statusSection').style.display = 'none';
document.getElementById('controlSection').style.display = 'none';
return false;
}
return true;
} else {
showMessage('사용자 정보 확인 실패', 'error');
return false;
}
} catch (error) {
showMessage('네트워크 오류: ' + error.message, 'error');
return false;
}
}
// 현재 상태 조회
async function checkStatus() {
if (loading || userLevel < 4) return;
setLoading(true);
try {
const token = getToken();
const response = await fetch('/api/admin/rate-limit/status', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
const data = await response.json();
document.getElementById('clientIP').textContent = data.clientIP;
document.getElementById('apiLimit').textContent = `${data.rateLimitInfo.apiLimit}회/15분`;
document.getElementById('loginLimit').textContent = `${data.rateLimitInfo.loginLimit}회/15분`;
} else {
const errorData = await response.json();
showMessage('상태 조회 실패: ' + (errorData.error || response.statusText), 'error');
}
} catch (error) {
showMessage('네트워크 오류: ' + error.message, 'error');
}
setLoading(false);
}
// Rate Limit 초기화
async function resetRateLimit(targetIP = null) {
if (loading || userLevel < 4) return;
setLoading(true);
try {
const token = getToken();
const response = await fetch('/api/admin/rate-limit/reset', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ targetIP })
});
const data = await response.json();
if (response.ok) {
showMessage('✅ ' + data.message, 'success');
checkStatus(); // 상태 새로고침
} else {
showMessage('❌ ' + data.error, 'error');
}
} catch (error) {
showMessage('❌ 초기화 실패: ' + error.message, 'error');
}
setLoading(false);
}
// Rate Limit 임시 비활성화
async function bypassRateLimit(duration = 3600000) {
if (loading || userLevel < 4) return;
setLoading(true);
try {
const token = getToken();
const response = await fetch('/api/admin/rate-limit/bypass', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ duration })
});
const data = await response.json();
if (response.ok) {
const hours = duration / 3600000;
showMessage(`🔓 ${hours}시간 동안 Rate Limit가 해제되었습니다.`, 'success');
checkStatus();
} else {
showMessage('❌ ' + data.error, 'error');
}
} catch (error) {
showMessage('❌ Bypass 설정 실패: ' + error.message, 'error');
}
setLoading(false);
}
// 페이지 로드 시 초기화
async function init() {
const hasPermission = await checkUserPermission();
if (hasPermission) {
await checkStatus();
}
}
// 페이지 로드 시 실행
document.addEventListener('DOMContentLoaded', init);
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,87 @@
<!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/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">
<div class="page-header">
<h1>📊 출근부 조회</h1>
<p class="subtitle">월별 출근 현황 및 잔업 시간을 확인할 수 있습니다.</p>
</div>
<div id="pdfContent">
<!-- 조회 컨트롤 -->
<div class="control-panel card">
<div class="control-group">
<label for="year">연도</label>
<select id="year" class="form-control"></select>
</div>
<div class="control-group">
<label for="month"></label>
<select id="month" class="form-control"></select>
</div>
<div class="control-actions">
<button id="loadAttendance" class="btn btn-primary">
<span class="icon">📊</span> 조회하기
</button>
<button id="downloadPdf" class="btn btn-secondary">
<span class="icon">📄</span> PDF 저장
</button>
</div>
</div>
<!-- 출근부 테이블 -->
<div class="table-container card">
<div id="attendanceTableContainer">
<div class="empty-state">
<p>조회할 연도와 월을 선택하세요.</p>
</div>
</div>
</div>
</div>
<!-- 범례 -->
<div class="legend card">
<h3>범례</h3>
<div class="legend-items">
<span class="legend-item">
<span class="color-box overtime-cell"></span> 잔업
</span>
<span class="legend-item">
<span class="color-box leave"></span> 연차/반차
</span>
<span class="legend-item">
<span class="color-box paid-leave"></span> 유급
</span>
<span class="legend-item">
<span class="color-box holiday"></span> 휴무
</span>
</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/attendance.js"></script>
<!-- PDF 생성 라이브러리 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,100 @@
<!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/daily-report-viewer.css">
</head>
<body>
<div class="container">
<header class="page-header">
<h1>📊 일일 작업보고서 조회</h1>
<p class="subtitle">날짜를 선택하여 해당일의 작업 현황을 확인하세요</p>
</header>
<div class="date-selector">
<div class="date-input-group">
<label for="reportDate">📅 조회 날짜:</label>
<input type="date" id="reportDate" class="date-input">
<button id="searchBtn" class="search-btn">조회</button>
<button id="todayBtn" class="today-btn">오늘</button>
</div>
</div>
<div id="loadingSpinner" class="loading-spinner" style="display: none;">
<div class="spinner"></div>
<p>데이터를 불러오는 중...</p>
</div>
<div id="errorMessage" class="error-message" style="display: none;">
<div class="error-content">
<span class="error-icon">⚠️</span>
<span class="error-text"></span>
</div>
</div>
<div id="noDataMessage" class="no-data-message" style="display: none;">
<div class="no-data-content">
<span class="no-data-icon">📭</span>
<h3>해당 날짜의 작업보고서가 없습니다</h3>
<p>다른 날짜를 선택해 주세요.</p>
</div>
</div>
<div id="reportSummary" class="report-summary" style="display: none;">
<div class="summary-cards">
<div class="summary-card">
<div class="card-header">
<span class="card-icon">👥</span>
<span class="card-title">작업자 수</span>
</div>
<div class="card-value" id="totalWorkers">0</div>
</div>
<div class="summary-card">
<div class="card-header">
<span class="card-icon"></span>
<span class="card-title">총 작업시간</span>
</div>
<div class="card-value" id="totalHours">0시간</div>
</div>
<div class="summary-card">
<div class="card-header">
<span class="card-icon">📝</span>
<span class="card-title">작업 항목</span>
</div>
<div class="card-value" id="totalEntries">0개</div>
</div>
<div class="summary-card error-card">
<div class="card-header">
<span class="card-icon">⚠️</span>
<span class="card-title">에러 항목</span>
</div>
<div class="card-value" id="errorCount">0개</div>
</div>
</div>
</div>
<div id="workersReport" class="workers-report" style="display: none;">
<h2 class="section-title">👥 작업자별 상세 현황</h2>
<div id="workersList" class="workers-list">
<!-- 작업자별 데이터가 여기에 표시됩니다 -->
</div>
</div>
<div id="exportSection" class="export-section" style="display: none;">
<h3>📤 데이터 내보내기</h3>
<div class="export-buttons">
<button id="exportExcelBtn" class="export-btn excel-btn">
📊 Excel로 내보내기
</button>
<button id="printBtn" class="export-btn print-btn">
🖨️ 인쇄
</button>
</div>
</div>
</div>
<script src="/js/daily-report-viewer.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,47 @@
<!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/factory.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="container">
<h2>공장 정보 등록</h2>
<form id="uploadForm" enctype="multipart/form-data">
<label>공장명</label>
<input type="text" name="factory_name" placeholder="예: 울산 제1공장" required>
<label>주소</label>
<input type="text" name="address" placeholder="예: 울산광역시 남구 산업로 123" required>
<label>설명</label>
<textarea name="description" placeholder="공장에 대한 간단한 설명을 입력하세요." required></textarea>
<label>공장 지도 이미지</label>
<input type="file" name="map_image" accept="image/*" required>
<div id="file-preview" style="margin: 10px 0; text-align: center;"></div>
<button type="submit">등록하기</button>
</form>
</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/factory-upload.js"></script>
</body>
</html>

View File

@@ -0,0 +1,59 @@
<!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/factory.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="container">
<!-- 로딩 상태 -->
<div id="loading" class="loading">
공장 정보를 불러오는 중...
</div>
<!-- 실제 컨텐츠 (초기에는 숨김) -->
<div id="content" style="display: none;">
<h2 id="factoryName">공장 이름</h2>
<div class="info-section">
<div class="info-label">📍 주소</div>
<p id="factoryAddress" style="margin: 0;">주소</p>
</div>
<img id="factoryImage" alt="공장 지도" style="max-width: 100%; margin: 20px 0;">
<div class="info-section">
<div class="info-label">📝 설명</div>
<p id="factoryDescription" style="margin: 0;">설명</p>
</div>
<div style="margin-top: 30px; text-align: center;">
<button onclick="history.back()" style="padding: 10px 20px;">뒤로가기</button>
</div>
</div>
<!-- 에러 메시지 (필요시 표시) -->
<div id="error" class="error-state" style="display: none;">
공장 정보를 불러올 수 없습니다.
</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/factory-view.js"></script>
</body>
</html>

View File

@@ -0,0 +1,215 @@
<!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 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>

View File

@@ -0,0 +1,304 @@
<!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>

View File

@@ -0,0 +1,723 @@
<!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>

View File

@@ -0,0 +1,733 @@
<!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>

View File

@@ -0,0 +1,34 @@
<!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">
<!-- ✅ 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>
<main id="admin-sections">
<div class="loading">페이지를 불러오는 중...</div>
</main>
</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>
<!-- ✅ admin.js는 다른 모듈들이 로딩된 후 실행되도록 순서 조정 -->
<script type="module" src="/js/admin.js"></script>
</body>
</html>

View File

@@ -0,0 +1,564 @@
<!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="icon" type="image/png" href="/img/favicon.png">
<script src="/js/auth-check.js" defer></script>
</head>
<body>
<div class="main-layout-no-sidebar">
<!-- 네비게이션만 로드 -->
<div id="navbar-container"></div>
<!-- 사이드바 없이 전체 화면 사용 -->
<div class="full-content-wrapper">
<div id="content-container">
<header class="dashboard-header">
<div class="header-content">
<div class="header-left">
<h1>📊 작업 현황판</h1>
<p id="welcome-message">실시간 팀 현황 및 작업 모니터링</p>
</div>
<div class="header-right">
<div class="status-badge">
<span class="status-dot active"></span>
<span>활성 상태</span>
</div>
</div>
</div>
</header>
<main id="dashboard-sections">
<!-- 빠른 액션 영역 -->
<section class="quick-actions-section">
<h2>⚡ 빠른 메뉴</h2>
<div class="workflow-cards">
<div class="workflow-card" onclick="location.href='/pages/common/daily-work-report.html'">
<div class="card-icon">👥</div>
<div class="card-content">
<h3>팀 작업 관리</h3>
<p>팀원 작업 현황 확인 및 관리</p>
</div>
<div class="card-arrow"></div>
</div>
<div class="workflow-card" onclick="location.href='/pages/common/management-dashboard.html'">
<div class="card-icon">📊</div>
<div class="card-content">
<h3>관리 대시보드</h3>
<p>종합 관리 및 모니터링 시스템</p>
</div>
<div class="card-arrow"></div>
</div>
<div class="workflow-card-empty"></div>
</div>
</section>
<!-- 대시보드 그리드 -->
<div class="dashboard-grid">
<!-- 팀 현황 -->
<section class="dashboard-card">
<div class="card-header">
<h2>👥 팀 현황</h2>
<div class="refresh-btn" onclick="refreshTeamStatus()">🔄</div>
</div>
<div class="team-stats">
<div class="stat-item">
<div class="stat-number" id="team-total">8</div>
<div class="stat-label">총 팀원</div>
</div>
<div class="stat-item">
<div class="stat-number present" id="team-present">7</div>
<div class="stat-label">출근</div>
</div>
<div class="stat-item">
<div class="stat-number absent" id="team-absent">1</div>
<div class="stat-label">결근</div>
</div>
</div>
<div class="team-list" id="team-list">
<div class="team-member present">
<span class="member-name">김작업</span>
<span class="member-status">출근</span>
</div>
<div class="team-member present">
<span class="member-name">이현장</span>
<span class="member-status">출근</span>
</div>
<div class="team-member absent">
<span class="member-name">박휴가</span>
<span class="member-status">휴가</span>
</div>
</div>
</section>
<!-- 공지사항 -->
<section class="dashboard-card">
<div class="card-header">
<h2>📢 공지사항</h2>
</div>
<div class="notice-content">
<div class="notice-item">
<div class="notice-title">안전 교육 실시 안내</div>
<div class="notice-date">2025.06.10</div>
</div>
<div class="notice-item">
<div class="notice-title">하절기 근무시간 변경</div>
<div class="notice-date">2025.06.05</div>
</div>
</div>
</section>
<!-- 근무 일지 (준비중) -->
<section class="dashboard-card coming-soon-card">
<div class="card-header">
<h2>📅 근무 일지</h2>
<div class="coming-soon-badge">준비중</div>
</div>
<div class="coming-soon-content">
<div class="coming-soon-icon">🚧</div>
<p>근무 일지 기능을 준비중입니다.</p>
</div>
</section>
<!-- 통계 (준비중) -->
<section class="dashboard-card coming-soon-card">
<div class="card-header">
<h2>📈 통계</h2>
<div class="coming-soon-badge">준비중</div>
</div>
<div class="coming-soon-content">
<div class="coming-soon-icon">📊</div>
<p>통계 기능을 준비중입니다.</p>
</div>
</section>
</div>
</main>
</div>
</div>
</div>
<!-- 사이드바 로딩 없이 네비게이션만 로드 -->
<script type="module" src="/js/load-navbar.js"></script>
<style>
/* 사이드바 없는 레이아웃 */
.main-layout-no-sidebar {
display: flex;
flex-direction: column;
min-height: 100vh;
font-family: 'Malgun Gothic', sans-serif;
}
.full-content-wrapper {
flex: 1;
padding: 20px;
background: #f5f7fa;
}
#content-container {
max-width: 1400px;
margin: 0 auto;
}
/* 현황판 전용 스타일 */
.dashboard-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 32px;
border-radius: 16px;
margin-bottom: 32px;
box-shadow: 0 8px 32px rgba(0,0,0,0.12);
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
}
.header-left h1 {
margin: 0 0 12px 0;
font-size: 2.2rem;
font-weight: 700;
}
.header-left p {
margin: 0;
opacity: 0.9;
font-size: 1.1rem;
}
.status-badge {
display: flex;
align-items: center;
gap: 8px;
background: rgba(255,255,255,0.2);
padding: 12px 20px;
border-radius: 25px;
backdrop-filter: blur(10px);
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #4CAF50;
animation: pulse 2s infinite;
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
/* 빠른 메뉴 영역 */
.quick-actions-section {
margin-bottom: 40px;
}
.quick-actions-section h2 {
margin-bottom: 20px;
color: #333;
font-size: 1.4rem;
font-weight: 600;
}
.workflow-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
max-width: 1000px;
}
.workflow-card {
background: white;
border-radius: 16px;
padding: 24px;
display: flex;
align-items: center;
gap: 20px;
cursor: pointer;
transition: all 0.3s ease;
border: 2px solid transparent;
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
}
.workflow-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 32px rgba(0,0,0,0.15);
border-color: #667eea;
}
.card-icon {
font-size: 2.5rem;
flex-shrink: 0;
}
.card-content {
flex: 1;
}
.card-content h3 {
margin: 0 0 8px 0;
font-size: 1.2rem;
font-weight: 600;
color: #333;
}
.card-content p {
margin: 0;
color: #666;
font-size: 1rem;
}
.card-arrow {
font-size: 1.8rem;
color: #667eea;
transition: transform 0.3s ease;
}
.workflow-card:hover .card-arrow {
transform: translateX(6px);
}
.workflow-card-empty {
background: transparent;
border: 3px dashed #e0e0e0;
border-radius: 16px;
padding: 24px;
min-height: 100px;
opacity: 0.3;
}
/* 대시보드 그리드 */
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 24px;
}
.dashboard-card {
background: white;
border-radius: 16px;
padding: 28px;
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
transition: all 0.3s ease;
}
.dashboard-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.card-header h2 {
margin: 0;
font-size: 1.3rem;
font-weight: 600;
color: #333;
}
.refresh-btn {
cursor: pointer;
opacity: 0.6;
transition: opacity 0.3s ease;
font-size: 1.2rem;
}
.refresh-btn:hover {
opacity: 1;
}
/* 통계 스타일 */
.team-stats {
display: flex;
gap: 24px;
margin-bottom: 24px;
}
.stat-item {
text-align: center;
flex: 1;
padding: 16px;
background: #f8f9fa;
border-radius: 12px;
}
.stat-number {
font-size: 2.2rem;
font-weight: 700;
color: #333;
display: block;
margin-bottom: 4px;
}
.stat-number.present {
color: #4CAF50;
}
.stat-number.absent {
color: #f44336;
}
.stat-label {
font-size: 0.9rem;
color: #666;
font-weight: 500;
}
/* 팀 멤버 리스트 */
.team-list {
space-y: 10px;
}
.team-member {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-radius: 8px;
background: #f8f9fa;
margin-bottom: 10px;
transition: background 0.3s ease;
}
.team-member:hover {
background: #e9ecef;
}
.team-member.present {
border-left: 5px solid #4CAF50;
}
.team-member.absent {
border-left: 5px solid #f44336;
}
.member-name {
font-weight: 600;
color: #333;
}
.member-status {
font-size: 0.9rem;
padding: 4px 8px;
border-radius: 12px;
font-weight: 500;
}
.team-member.present .member-status {
background: #e8f5e8;
color: #4CAF50;
}
.team-member.absent .member-status {
background: #ffebee;
color: #f44336;
}
/* 준비중 카드 스타일 */
.coming-soon-card {
position: relative;
opacity: 0.7;
}
.coming-soon-badge {
background: #ff9800;
color: white;
font-size: 0.75rem;
padding: 6px 12px;
border-radius: 16px;
font-weight: 600;
}
.coming-soon-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 50px 20px;
color: #666;
}
.coming-soon-icon {
font-size: 3.5rem;
margin-bottom: 20px;
opacity: 0.5;
}
.coming-soon-content p {
margin: 0;
font-size: 1rem;
}
/* 공지사항 스타일 */
.notice-content {
space-y: 16px;
}
.notice-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-left: 5px solid #2196f3;
background: #f8f9fa;
border-radius: 8px;
margin-bottom: 16px;
transition: background 0.3s ease;
}
.notice-item:hover {
background: #e9ecef;
}
.notice-title {
font-weight: 600;
color: #333;
font-size: 1rem;
}
.notice-date {
font-size: 0.85rem;
color: #666;
font-weight: 500;
}
/* 반응형 */
@media (max-width: 1024px) {
.full-content-wrapper {
padding: 16px;
}
.dashboard-header {
padding: 24px;
}
.header-left h1 {
font-size: 1.8rem;
}
}
@media (max-width: 768px) {
.workflow-cards {
grid-template-columns: 1fr;
}
.workflow-card-empty {
display: none;
}
.dashboard-grid {
grid-template-columns: 1fr;
}
.header-content {
flex-direction: column;
gap: 20px;
text-align: center;
}
.team-stats {
flex-direction: column;
gap: 12px;
}
}
</style>
<script>
// 디버깅용 콘솔 로그
console.log('📊 그룹장 대시보드 로딩됨');
console.log('👤 현재 사용자:', JSON.parse(localStorage.getItem('user') || '{}'));
// 팀 현황 새로고침
function refreshTeamStatus() {
console.log('팀 현황 새로고침');
// 실제로는 API 호출
}
// 환영 메시지 개인화
document.addEventListener('DOMContentLoaded', function() {
const user = JSON.parse(localStorage.getItem('user') || '{}');
if (user && user.name) {
const welcomeMsg = document.getElementById('welcome-message');
if (welcomeMsg) {
welcomeMsg.textContent = `${user.name}님의 실시간 팀 현황 및 작업 모니터링`;
}
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,70 @@
<!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/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>

View File

@@ -0,0 +1,76 @@
<!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/daily-issue.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">
<form id="issueForm">
<div class="form-group">
<label for="dateSelect">날짜</label>
<input type="date" id="dateSelect" class="form-control" required />
</div>
<div class="form-group">
<label for="projectSelect">프로젝트</label>
<select id="projectSelect" class="form-control" required>
<option value="">-- 프로젝트 선택 --</option>
</select>
</div>
<div class="form-group">
<label>작업자 선택</label>
<div id="workerList" class="multi-select-box">
날짜를 먼저 선택하세요.
</div>
</div>
<div class="form-group">
<label for="issueTypeSelect">이슈 유형</label>
<select id="issueTypeSelect" class="form-control" required>
<option value="">-- 이슈 유형 선택 --</option>
</select>
</div>
<div class="form-group">
<label>시간 범위</label>
<div class="time-range">
<select id="timeStart" class="form-control" required></select>
<span style="margin: 0 10px;">~</span>
<select id="timeEnd" class="form-control" required></select>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="history.back()">취소</button>
<button type="submit" id="submitBtn" class="btn btn-primary">등록</button>
</div>
</form>
</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/daily-issue.js"></script>
</body>
</html>

View File

@@ -0,0 +1,391 @@
<!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">
<script src="/js/auth-check.js" defer></script>
<style>
/* 페이지 전용 스타일 */
.password-page {
max-width: 600px;
margin: 0 auto;
padding: 40px 20px;
}
.page-title {
text-align: center;
margin-bottom: 40px;
}
.page-title h1 {
font-size: 2rem;
color: #333;
margin-bottom: 12px;
}
.page-title p {
color: #666;
font-size: 1.1rem;
}
/* 카드 스타일 */
.password-card {
background: white;
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
overflow: hidden;
}
.card-header {
background: linear-gradient(135deg, #ff9800 0%, #f57c00 100%);
color: white;
padding: 24px 32px;
}
.card-header h2 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 12px;
}
.card-body {
padding: 32px;
}
/* 알림 박스 */
.info-box {
background: #e3f2fd;
border: 1px solid #90caf9;
border-radius: 8px;
padding: 16px;
margin-bottom: 28px;
}
.info-box h4 {
margin: 0 0 12px 0;
color: #1565c0;
font-size: 1rem;
display: flex;
align-items: center;
gap: 8px;
}
.info-box ul {
margin: 0;
padding-left: 24px;
color: #0d47a1;
}
.info-box li {
margin-bottom: 6px;
}
/* 폼 스타일 */
.password-form {
display: flex;
flex-direction: column;
gap: 24px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 10px;
}
.form-group label {
font-weight: 600;
color: #333;
font-size: 0.95rem;
display: flex;
align-items: center;
gap: 8px;
}
.input-wrapper {
position: relative;
}
.form-control {
width: 100%;
padding: 14px 48px 14px 16px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 1rem;
transition: all 0.3s ease;
font-family: inherit;
}
.form-control:focus {
outline: none;
border-color: #ff9800;
box-shadow: 0 0 0 3px rgba(255, 152, 0, 0.1);
}
.password-toggle {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
padding: 8px;
font-size: 1.2rem;
opacity: 0.6;
transition: opacity 0.2s;
}
.password-toggle:hover {
opacity: 1;
}
/* 버튼 */
.form-actions {
display: flex;
gap: 12px;
margin-top: 12px;
}
.btn {
padding: 14px 28px;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-family: inherit;
}
.btn-primary {
background: #ff9800;
color: white;
flex: 1;
}
.btn-primary:hover {
background: #f57c00;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(255, 152, 0, 0.3);
}
.btn-primary:disabled {
background: #ccc;
cursor: not-allowed;
transform: none;
}
.btn-secondary {
background: #f5f5f5;
color: #666;
border: 1px solid #e0e0e0;
}
.btn-secondary:hover {
background: #e0e0e0;
}
/* 메시지 */
.message-box {
padding: 16px;
border-radius: 8px;
margin-bottom: 20px;
animation: slideDown 0.3s ease-out;
}
.message-box.error {
background: #ffebee;
border: 1px solid #ffcdd2;
color: #c62828;
}
.message-box.success {
background: #e8f5e9;
border: 1px solid #c8e6c9;
color: #2e7d32;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 하단 링크 */
.back-link {
text-align: center;
margin-top: 32px;
padding-top: 32px;
border-top: 1px solid #e0e0e0;
}
.back-link a {
color: #1976d2;
text-decoration: none;
font-size: 0.95rem;
display: inline-flex;
align-items: center;
gap: 6px;
transition: color 0.2s;
}
.back-link a:hover {
color: #1565c0;
text-decoration: underline;
}
/* 반응형 */
@media (max-width: 768px) {
.password-page {
padding: 20px 16px;
}
.card-body {
padding: 24px;
}
.form-actions {
flex-direction: column;
}
.btn {
width: 100%;
}
}
</style>
</head>
<body>
<div class="main-layout-no-sidebar">
<div id="navbar-container"></div>
<div class="password-page">
<div class="page-title">
<h1>🔐 비밀번호 변경</h1>
<p>계정 보안을 위해 정기적으로 비밀번호를 변경해주세요</p>
</div>
<div class="password-card">
<div class="card-header">
<h2>
<span>🔑</span>
<span>새 비밀번호 설정</span>
</h2>
</div>
<div class="card-body">
<!-- 메시지 영역 -->
<div id="message-area"></div>
<!-- 안내 정보 -->
<div class="info-box">
<h4>
<span></span>
<span>비밀번호 요구사항</span>
</h4>
<ul>
<li>최소 6자 이상 입력해주세요</li>
<li>영문 대/소문자, 숫자, 특수문자를 조합하면 더 안전합니다</li>
<li>개인정보나 쉬운 단어는 피해주세요</li>
<li>이전 비밀번호와 다르게 설정해주세요</li>
</ul>
</div>
<!-- 비밀번호 변경 폼 -->
<form id="changePasswordForm" class="password-form">
<div class="form-group">
<label for="currentPassword">
<span>🔓</span>
<span>현재 비밀번호</span>
</label>
<div class="input-wrapper">
<input
type="password"
id="currentPassword"
class="form-control"
placeholder="현재 비밀번호를 입력하세요"
required
autocomplete="current-password"
/>
<button type="button" class="password-toggle" data-target="currentPassword">👁️</button>
</div>
</div>
<div class="form-group">
<label for="newPassword">
<span>🔐</span>
<span>새 비밀번호</span>
</label>
<div class="input-wrapper">
<input
type="password"
id="newPassword"
class="form-control"
placeholder="새 비밀번호를 입력하세요"
required
autocomplete="new-password"
/>
<button type="button" class="password-toggle" data-target="newPassword">👁️</button>
</div>
<div id="passwordStrength"></div>
</div>
<div class="form-group">
<label for="confirmPassword">
<span></span>
<span>새 비밀번호 확인</span>
</label>
<div class="input-wrapper">
<input
type="password"
id="confirmPassword"
class="form-control"
placeholder="새 비밀번호를 다시 입력하세요"
required
autocomplete="new-password"
/>
<button type="button" class="password-toggle" data-target="confirmPassword">👁️</button>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" id="submitBtn">
<span>🔑</span>
<span>비밀번호 변경</span>
</button>
<button type="button" class="btn btn-secondary" id="resetBtn">
초기화
</button>
</div>
</form>
<div class="back-link">
<a href="javascript:history.back()">
<span></span>
<span>이전 페이지로 돌아가기</span>
</a>
</div>
</div>
</div>
</div>
</div>
<script type="module" src="/js/load-navbar.js"></script>
<script type="module" src="/js/change-password.js"></script>
</body>
</html>

View File

@@ -0,0 +1,317 @@
<!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">
<script src="/js/auth-check.js" defer></script>
<style>
.profile-page {
max-width: 800px;
margin: 0 auto;
padding: 40px 20px;
}
.profile-header {
text-align: center;
margin-bottom: 40px;
}
.profile-avatar {
width: 120px;
height: 120px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
color: white;
margin: 0 auto 20px;
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
}
.profile-name {
font-size: 2rem;
font-weight: 700;
color: #333;
margin-bottom: 8px;
}
.profile-role {
font-size: 1.2rem;
color: #666;
font-weight: 500;
}
.profile-cards {
display: grid;
gap: 24px;
}
.profile-card {
background: white;
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
padding: 28px;
}
.card-title {
font-size: 1.3rem;
font-weight: 600;
color: #333;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 10px;
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
}
.info-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.info-label {
font-size: 0.85rem;
color: #666;
font-weight: 500;
}
.info-value {
font-size: 1.05rem;
color: #333;
font-weight: 600;
}
.action-buttons {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 24px;
}
.action-btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 8px;
text-decoration: none;
font-family: inherit;
}
.btn-primary {
background: #1976d2;
color: white;
}
.btn-primary:hover {
background: #1565c0;
transform: translateY(-1px);
}
.btn-secondary {
background: #f5f5f5;
color: #333;
border: 1px solid #e0e0e0;
}
.btn-secondary:hover {
background: #e0e0e0;
}
.btn-warning {
background: #ff9800;
color: white;
}
.btn-warning:hover {
background: #f57c00;
transform: translateY(-1px);
}
/* 통계 카드 */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px;
margin-top: 16px;
}
.stat-box {
background: #f5f5f5;
border-radius: 8px;
padding: 20px;
text-align: center;
}
.stat-number {
font-size: 2rem;
font-weight: 700;
color: #1976d2;
display: block;
margin-bottom: 4px;
}
.stat-label {
font-size: 0.9rem;
color: #666;
}
@media (max-width: 768px) {
.profile-page {
padding: 20px 16px;
}
.profile-avatar {
width: 100px;
height: 100px;
font-size: 2.5rem;
}
.profile-name {
font-size: 1.6rem;
}
.info-grid {
grid-template-columns: 1fr;
}
.action-buttons {
flex-direction: column;
}
.action-btn {
width: 100%;
justify-content: center;
}
}
</style>
</head>
<body>
<div class="main-layout-no-sidebar">
<div id="navbar-container"></div>
<div class="profile-page">
<div class="profile-header">
<div class="profile-avatar" id="profileAvatar">👤</div>
<h1 class="profile-name" id="profileName">사용자</h1>
<p class="profile-role" id="profileRole">역할</p>
</div>
<div class="profile-cards">
<!-- 기본 정보 -->
<div class="profile-card">
<h2 class="card-title">
<span>📋</span>
<span>기본 정보</span>
</h2>
<div class="info-grid">
<div class="info-item">
<span class="info-label">사용자 ID</span>
<span class="info-value" id="userId">-</span>
</div>
<div class="info-item">
<span class="info-label">사용자명</span>
<span class="info-value" id="username">-</span>
</div>
<div class="info-item">
<span class="info-label">이름</span>
<span class="info-value" id="fullName">-</span>
</div>
<div class="info-item">
<span class="info-label">권한 레벨</span>
<span class="info-value" id="accessLevel">-</span>
</div>
<div class="info-item">
<span class="info-label">작업자 ID</span>
<span class="info-value" id="workerId">-</span>
</div>
<div class="info-item">
<span class="info-label">가입일</span>
<span class="info-value" id="createdAt">-</span>
</div>
</div>
</div>
<!-- 활동 정보 -->
<div class="profile-card">
<h2 class="card-title">
<span>📊</span>
<span>활동 정보</span>
</h2>
<div class="info-grid">
<div class="info-item">
<span class="info-label">마지막 로그인</span>
<span class="info-value" id="lastLogin">-</span>
</div>
<div class="info-item">
<span class="info-label">이메일</span>
<span class="info-value" id="email">-</span>
</div>
</div>
<!-- 간단한 통계 (준비중) -->
<div class="stats-grid" style="margin-top: 24px; opacity: 0.5;">
<div class="stat-box">
<span class="stat-number">-</span>
<span class="stat-label">작업 보고서</span>
</div>
<div class="stat-box">
<span class="stat-number">-</span>
<span class="stat-label">이번 달 활동</span>
</div>
<div class="stat-box">
<span class="stat-number">-</span>
<span class="stat-label">팀 기여도</span>
</div>
</div>
</div>
<!-- 빠른 작업 -->
<div class="profile-card">
<h2 class="card-title">
<span></span>
<span>빠른 작업</span>
</h2>
<div class="action-buttons">
<a href="/pages/profile/change-password.html" class="action-btn btn-warning">
<span>🔐</span>
<span>비밀번호 변경</span>
</a>
<button class="action-btn btn-secondary" disabled>
<span>✏️</span>
<span>프로필 수정 (준비중)</span>
</button>
<button class="action-btn btn-secondary" disabled>
<span>⚙️</span>
<span>설정 (준비중)</span>
</button>
<a href="javascript:history.back()" class="action-btn btn-secondary">
<span></span>
<span>돌아가기</span>
</a>
</div>
</div>
</div>
</div>
</div>
<script type="module" src="/js/load-navbar.js"></script>
<script type="module" src="/js/my-profile.js"></script>
</body>
</html>

View File

@@ -0,0 +1,65 @@
<!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>

View File

@@ -0,0 +1,61 @@
<!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>