- 일일 공수 입력 기능 - 부적합 사항 등록 (이미지 선택사항) - 날짜별 부적합 조회 (시간순 나열) - 목록 관리 (인라인 편집, 작업시간 확인 버튼) - 보고서 생성 (총 공수/부적합 시간 분리) - JWT 인증 및 권한 관리 - Docker 기반 배포 환경 구성
418 lines
15 KiB
HTML
418 lines
15 KiB
HTML
<!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>
|