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

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>