feat: 작업보고서 시스템 완성
- 일일 공수 입력 기능 - 부적합 사항 등록 (이미지 선택사항) - 날짜별 부적합 조회 (시간순 나열) - 목록 관리 (인라인 편집, 작업시간 확인 버튼) - 보고서 생성 (총 공수/부적합 시간 분리) - JWT 인증 및 권한 관리 - Docker 기반 배포 환경 구성
This commit is contained in:
417
frontend/chart.html
Normal file
417
frontend/chart.html
Normal file
@@ -0,0 +1,417 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>M-Project - 차트</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--sky-50: #f0f9ff;
|
||||
--sky-100: #e0f2fe;
|
||||
--sky-200: #bae6fd;
|
||||
--sky-300: #7dd3fc;
|
||||
--sky-400: #38bdf8;
|
||||
--sky-500: #0ea5e9;
|
||||
--gray-100: #f3f4f6;
|
||||
--gray-200: #e5e7eb;
|
||||
}
|
||||
|
||||
body {
|
||||
background: linear-gradient(to bottom, #ffffff, #f0f9ff);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.glass {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
border-left: 4px solid var(--sky-400);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.gallery-item {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.gallery-item:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal.show {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 헤더 -->
|
||||
<header class="glass sticky top-0 z-50 shadow-sm">
|
||||
<div class="container mx-auto px-4 py-3 flex justify-between items-center">
|
||||
<h1 class="text-xl font-bold text-gray-800">
|
||||
<i class="fas fa-chart-bar text-sky-500 mr-2"></i>차트
|
||||
</h1>
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="index.html" class="text-sky-600 hover:text-sky-700">
|
||||
<i class="fas fa-camera text-xl"></i>
|
||||
</a>
|
||||
<span class="text-gray-600 text-sm" id="userName"></span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="container mx-auto px-4 py-6 max-w-6xl">
|
||||
<!-- 통계 카드 -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<div class="stat-card p-4 rounded-xl shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">전체 사진</p>
|
||||
<p class="text-2xl font-bold text-gray-800" id="totalCount">0</p>
|
||||
</div>
|
||||
<i class="fas fa-images text-3xl text-sky-400"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card p-4 rounded-xl shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">오늘</p>
|
||||
<p class="text-2xl font-bold text-gray-800" id="todayCount">0</p>
|
||||
</div>
|
||||
<i class="fas fa-calendar-day text-3xl text-sky-400"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card p-4 rounded-xl shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">이번 주</p>
|
||||
<p class="text-2xl font-bold text-gray-800" id="weekCount">0</p>
|
||||
</div>
|
||||
<i class="fas fa-calendar-week text-3xl text-sky-400"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card p-4 rounded-xl shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">가장 많은</p>
|
||||
<p class="text-lg font-bold text-gray-800" id="topCategory">-</p>
|
||||
</div>
|
||||
<i class="fas fa-trophy text-3xl text-sky-400"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 차트 영역 -->
|
||||
<div class="grid md:grid-cols-2 gap-6 mb-8">
|
||||
<!-- 카테고리별 차트 -->
|
||||
<div class="glass p-6 rounded-xl shadow-lg">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">카테고리별 분포</h3>
|
||||
<canvas id="categoryChart" width="400" height="300"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- 일별 추이 차트 -->
|
||||
<div class="glass p-6 rounded-xl shadow-lg">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">최근 7일 추이</h3>
|
||||
<canvas id="trendChart" width="400" height="300"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 갤러리 -->
|
||||
<div class="glass p-6 rounded-xl shadow-lg">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-800">전체 갤러리</h3>
|
||||
<select id="categoryFilter" class="px-4 py-2 rounded-lg border border-gray-300 focus:border-sky-400 focus:outline-none">
|
||||
<option value="all">전체 카테고리</option>
|
||||
<option value="food">음식</option>
|
||||
<option value="place">장소</option>
|
||||
<option value="people">사람</option>
|
||||
<option value="object">물건</option>
|
||||
<option value="event">행사</option>
|
||||
<option value="etc">기타</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="gallery" class="grid grid-cols-3 md:grid-cols-6 gap-3">
|
||||
<!-- 갤러리 아이템들이 여기에 표시됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 이미지 모달 -->
|
||||
<div id="imageModal" class="modal" onclick="closeModal()">
|
||||
<div class="bg-white rounded-xl p-4 max-w-2xl mx-4" onclick="event.stopPropagation()">
|
||||
<img id="modalImage" class="w-full rounded-lg mb-4">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<p class="font-semibold text-lg" id="modalDescription"></p>
|
||||
<p class="text-sm text-gray-600 mt-1" id="modalInfo"></p>
|
||||
</div>
|
||||
<button onclick="closeModal()" class="text-gray-500 hover:text-gray-700">
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let allData = [];
|
||||
let currentUser = null;
|
||||
|
||||
// 초기화
|
||||
window.onload = () => {
|
||||
// 로그인 체크
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
currentUser = params.get('user');
|
||||
|
||||
if (!currentUser) {
|
||||
// 로컬스토리지에서 최근 사용자 확인
|
||||
const recentUser = localStorage.getItem('m-project-recent-user');
|
||||
if (recentUser) {
|
||||
currentUser = recentUser;
|
||||
} else {
|
||||
window.location.href = 'index.html';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('userName').textContent = currentUser;
|
||||
loadData();
|
||||
};
|
||||
|
||||
// 데이터 로드
|
||||
function loadData() {
|
||||
const savedData = JSON.parse(localStorage.getItem('m-project-data') || '[]');
|
||||
allData = savedData.filter(item => item.user === currentUser);
|
||||
|
||||
updateStats();
|
||||
createCharts();
|
||||
displayGallery();
|
||||
}
|
||||
|
||||
// 통계 업데이트
|
||||
function updateStats() {
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
document.getElementById('totalCount').textContent = allData.length;
|
||||
|
||||
// 오늘 카운트
|
||||
const todayCount = allData.filter(item => {
|
||||
const itemDate = new Date(item.timestamp);
|
||||
return itemDate >= today;
|
||||
}).length;
|
||||
document.getElementById('todayCount').textContent = todayCount;
|
||||
|
||||
// 이번 주 카운트
|
||||
const weekCount = allData.filter(item => {
|
||||
const itemDate = new Date(item.timestamp);
|
||||
return itemDate >= weekAgo;
|
||||
}).length;
|
||||
document.getElementById('weekCount').textContent = weekCount;
|
||||
|
||||
// 가장 많은 카테고리
|
||||
const categoryCounts = {};
|
||||
allData.forEach(item => {
|
||||
categoryCounts[item.category] = (categoryCounts[item.category] || 0) + 1;
|
||||
});
|
||||
|
||||
const categoryNames = {
|
||||
food: '음식',
|
||||
place: '장소',
|
||||
people: '사람',
|
||||
object: '물건',
|
||||
event: '행사',
|
||||
etc: '기타'
|
||||
};
|
||||
|
||||
if (Object.keys(categoryCounts).length > 0) {
|
||||
const topCat = Object.entries(categoryCounts)
|
||||
.sort((a, b) => b[1] - a[1])[0][0];
|
||||
document.getElementById('topCategory').textContent = categoryNames[topCat];
|
||||
}
|
||||
}
|
||||
|
||||
// 차트 생성
|
||||
function createCharts() {
|
||||
// 카테고리별 차트
|
||||
const categoryCounts = {};
|
||||
allData.forEach(item => {
|
||||
categoryCounts[item.category] = (categoryCounts[item.category] || 0) + 1;
|
||||
});
|
||||
|
||||
const categoryNames = {
|
||||
food: '음식',
|
||||
place: '장소',
|
||||
people: '사람',
|
||||
object: '물건',
|
||||
event: '행사',
|
||||
etc: '기타'
|
||||
};
|
||||
|
||||
const categoryCtx = document.getElementById('categoryChart').getContext('2d');
|
||||
new Chart(categoryCtx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: Object.keys(categoryCounts).map(key => categoryNames[key]),
|
||||
datasets: [{
|
||||
data: Object.values(categoryCounts),
|
||||
backgroundColor: [
|
||||
'#0ea5e9',
|
||||
'#38bdf8',
|
||||
'#7dd3fc',
|
||||
'#bae6fd',
|
||||
'#e0f2fe',
|
||||
'#f0f9ff'
|
||||
]
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 일별 추이 차트
|
||||
const today = new Date();
|
||||
const dates = [];
|
||||
const counts = [];
|
||||
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const date = new Date(today);
|
||||
date.setDate(date.getDate() - i);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
|
||||
const nextDate = new Date(date);
|
||||
nextDate.setDate(nextDate.getDate() + 1);
|
||||
|
||||
const count = allData.filter(item => {
|
||||
const itemDate = new Date(item.timestamp);
|
||||
return itemDate >= date && itemDate < nextDate;
|
||||
}).length;
|
||||
|
||||
dates.push(date.toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' }));
|
||||
counts.push(count);
|
||||
}
|
||||
|
||||
const trendCtx = document.getElementById('trendChart').getContext('2d');
|
||||
new Chart(trendCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: dates,
|
||||
datasets: [{
|
||||
label: '업로드 수',
|
||||
data: counts,
|
||||
borderColor: '#0ea5e9',
|
||||
backgroundColor: 'rgba(14, 165, 233, 0.1)',
|
||||
tension: 0.4
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
stepSize: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 갤러리 표시
|
||||
function displayGallery(filter = 'all') {
|
||||
const gallery = document.getElementById('gallery');
|
||||
gallery.innerHTML = '';
|
||||
|
||||
const filteredData = filter === 'all'
|
||||
? allData
|
||||
: allData.filter(item => item.category === filter);
|
||||
|
||||
// 최신순으로 정렬
|
||||
filteredData.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
|
||||
|
||||
filteredData.forEach(item => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'gallery-item';
|
||||
div.innerHTML = `<img src="${item.image}" class="w-full h-24 object-cover rounded-lg shadow-sm">`;
|
||||
div.onclick = () => showModal(item);
|
||||
gallery.appendChild(div);
|
||||
});
|
||||
|
||||
if (filteredData.length === 0) {
|
||||
gallery.innerHTML = '<p class="col-span-full text-center text-gray-500 py-8">표시할 이미지가 없습니다.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// 필터 변경
|
||||
document.getElementById('categoryFilter').addEventListener('change', (e) => {
|
||||
displayGallery(e.target.value);
|
||||
});
|
||||
|
||||
// 모달 표시
|
||||
function showModal(item) {
|
||||
const categoryNames = {
|
||||
food: '음식',
|
||||
place: '장소',
|
||||
people: '사람',
|
||||
object: '물건',
|
||||
event: '행사',
|
||||
etc: '기타'
|
||||
};
|
||||
|
||||
document.getElementById('modalImage').src = item.image;
|
||||
document.getElementById('modalDescription').textContent = item.description;
|
||||
document.getElementById('modalInfo').textContent =
|
||||
`${categoryNames[item.category]} • ${new Date(item.timestamp).toLocaleString('ko-KR')}`;
|
||||
document.getElementById('imageModal').classList.add('show');
|
||||
}
|
||||
|
||||
// 모달 닫기
|
||||
function closeModal() {
|
||||
document.getElementById('imageModal').classList.remove('show');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user