Files
waiting-system/templates/index.html
Jun-dev f699a29a85 Add waiting system application files
- Add main application files (main.py, models.py, schemas.py, etc.)
- Add routers for all features (waiting, attendance, members, etc.)
- Add HTML templates for admin and user interfaces
- Add migration scripts and utility files
- Add Docker configuration
- Add documentation files
- Add .gitignore to exclude database and cache files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-14 00:29:39 +09:00

586 lines
24 KiB
HTML

<!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="/static/css/common.css">
<style>
.menu-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
margin-top: 30px;
}
.menu-item {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 40px;
border-radius: 15px;
color: #fff;
text-decoration: none;
transition: all 0.3s;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
}
.menu-item:hover {
transform: translateY(-5px);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
}
.menu-item.board {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
.menu-item.reception {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
.menu-item.mobile {
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
}
.menu-item.manage {
background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
}
.menu-item.members {
background: linear-gradient(135deg, #30cfd0 0%, #330867 100%);
}
.menu-item.settings {
background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%);
}
.menu-item.attendance {
background: linear-gradient(135deg, #89f7fe 0%, #66a6ff 100%);
}
.menu-item h2 {
font-size: 24px;
margin-bottom: 10px;
}
.menu-item p {
font-size: 14px;
opacity: 0.9;
}
.status-bar {
background: #fff;
padding: 20px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
margin-bottom: 30px;
display: flex;
justify-content: space-between;
align-items: center;
}
.status-info {
display: flex;
gap: 30px;
}
.status-item {
display: flex;
flex-direction: column;
}
.status-item label {
font-size: 12px;
color: #7f8c8d;
margin-bottom: 5px;
}
.status-item span {
font-size: 18px;
font-weight: 600;
color: #2c3e50;
}
.action-buttons {
display: flex;
gap: 10px;
}
@media (max-width: 768px) {
.menu-grid {
grid-template-columns: 1fr;
}
.status-bar {
flex-direction: column;
gap: 20px;
}
.status-info {
width: 100%;
flex-wrap: wrap;
}
.action-buttons {
width: 100%;
flex-direction: column;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header" style="display: flex; justify-content: space-between; align-items: center;">
<div>
<h1 id="storeName">대기 시스템</h1>
<p class="subtitle" id="storeSubtitle">매장 대기 관리 시스템</p>
</div>
<button class="btn btn-secondary" onclick="logout(event)" style="height: fit-content;">로그아웃</button>
</div>
<div class="status-bar" id="statusBar">
<div class="status-info">
<div class="status-item">
<label>영업 상태</label>
<span id="businessStatus">확인 중...</span>
</div>
<div class="status-item">
<label>영업일</label>
<span id="businessDate">-</span>
</div>
<div class="status-item">
<label>현재 대기</label>
<span id="waitingCount">0명</span>
</div>
</div>
<div class="action-buttons">
<button type="button" class="btn btn-success" id="openBtn" style="display:none;">개점하기</button>
<button type="button" class="btn btn-danger" id="closeBtn" style="display:none;">일마감</button>
</div>
</div>
<div class="menu-grid">
<a href="/manage" class="menu-item manage" onclick="handleManageClick(event)">
<h2>대기자 관리</h2>
<p>대기자 출석, 취소, 순서 변경 등을 관리합니다</p>
</a>
<a href="/board" class="menu-item board" target="_blank">
<h2>대기현황판</h2>
<p>실시간 대기 현황을 표시합니다</p>
</a>
<a href="/reception" class="menu-item reception" target="_blank">
<h2>대기접수 (데스크)</h2>
<p>데스크에서 대기자를 접수합니다</p>
</a>
<a href="/mobile" class="menu-item mobile" target="_blank">
<h2>대기접수 (모바일)</h2>
<p>모바일에서 대기자를 접수합니다</p>
</a>
<a href="/attendance" class="menu-item attendance">
<h2>출석 및 대기 조회</h2>
<p>회원 출석 및 대기 현황을 조회합니다</p>
</a>
<a href="/members" class="menu-item members">
<h2>회원 관리</h2>
<p>회원 등록, 조회, 수정을 관리합니다</p>
</a>
<a href="/settings" class="menu-item settings">
<h2>매장 설정</h2>
<p>매장 정보 및 클래스를 관리합니다</p>
</a>
</div>
<!-- 알림 모달 -->
<div id="notificationModal" class="modal">
<div class="modal-content" onclick="event.stopPropagation()"
style="text-align: center; max-width: 400px; padding: 30px; border-radius: 12px; background: white; box-shadow: 0 4px 20px rgba(0,0,0,0.15);">
<div style="font-size: 48px; margin-bottom: 20px;">📢</div>
<h2 id="notificationTitle" style="font-size: 20px; font-weight: 600; margin-bottom: 10px; color: #333;">
알림</h2>
<p id="notificationMessage"
style="font-size: 20px; color: #333; margin-bottom: 30px; line-height: 1.6; font-weight: 500; word-break: keep-all;">
</p>
<div id="modalButtons" style="display: flex; gap: 10px; justify-content: center;">
<button class="btn btn-primary" style="flex: 1; padding: 12px; font-size: 16px;"
onclick="closeNotificationModal()">확인</button>
</div>
</div>
</div>
<style>
/* 모달 스타일 */
.modal {
display: none;
position: fixed;
z-index: 2000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
align-items: center;
justify-content: center;
}
.modal.show {
display: flex !important;
opacity: 1 !important;
}
.modal-content {
animation: modalSlideIn 0.3s ease-out forwards;
opacity: 1;
width: 90%;
margin: 0 auto;
}
@keyframes modalSlideIn {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
</style>
<script>
let businessStatus = null;
// Helper function to get headers with store ID
function getHeaders(additionalHeaders = {}) {
const headers = { ...additionalHeaders };
const storeId = localStorage.getItem('selected_store_id');
if (storeId) {
headers['X-Store-Id'] = storeId;
}
return headers;
}
async function checkBusinessStatus() {
try {
const response = await fetch('/api/daily/check-status', {
headers: getHeaders()
});
businessStatus = await response.json();
const statusSpan = document.getElementById('businessStatus');
const dateSpan = document.getElementById('businessDate');
const openBtn = document.getElementById('openBtn');
const closeBtn = document.getElementById('closeBtn');
if (businessStatus.is_open) {
statusSpan.textContent = '영업 중';
statusSpan.style.color = '#27ae60';
dateSpan.textContent = businessStatus.business_date;
closeBtn.style.display = 'block';
openBtn.style.display = 'none';
} else {
statusSpan.textContent = '영업 종료';
statusSpan.style.color = '#e74c3c';
dateSpan.textContent = '-';
openBtn.style.display = 'block';
closeBtn.style.display = 'none';
}
// 현재 대기 수 조회
await loadWaitingCount();
} catch (error) {
console.error('영업 상태 조회 실패:', error);
}
}
async function loadWaitingCount() {
try {
const response = await fetch('/api/waiting/list?status=waiting', {
headers: getHeaders()
});
const data = await response.json();
document.getElementById('waitingCount').textContent = `${data.length}`;
} catch (error) {
console.error('대기자 수 조회 실패:', error);
}
}
// URL 파라미터에서 매장 정보 가져오기
async function checkUrlStoreParam() {
const urlParams = new URLSearchParams(window.location.search);
const storeParam = urlParams.get('store');
if (storeParam) {
try {
// 매장 코드로 매장 정보 조회
const response = await fetch(`/api/stores/code/${storeParam}`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
}
});
if (response.ok) {
const store = await response.json();
localStorage.setItem('selected_store_id', store.id);
localStorage.setItem('selected_store_name', store.name);
localStorage.setItem('selected_store_code', store.code);
console.log(`URL 매장 파라미터 적용: ${store.name} (코드: ${storeParam})`);
updateDashboardLinks(store.code);
// URL 파라미터 유지 (매장별 고유 URL 지원)
// window.history.replaceState({}, '', '/');
} else {
console.error('매장 코드를 찾을 수 없습니다:', storeParam);
alert(`매장 코드 '${storeParam}'를 찾을 수 없습니다.`);
}
} catch (e) {
console.error('매장 정보 조회 실패:', e);
}
}
}
// 매장 컨텍스트 확인 (admin 페이지에서 넘어온 경우)
function checkStoreContext() {
const storeContext = localStorage.getItem('store_management_context');
if (storeContext) {
try {
const context = JSON.parse(storeContext);
// 5분 이내의 컨텍스트만 유효
if (context.timestamp && (Date.now() - context.timestamp < 5 * 60 * 1000)) {
localStorage.setItem('selected_store_id', context.id);
localStorage.setItem('selected_store_name', context.name);
if (context.code) {
localStorage.setItem('selected_store_code', context.code);
updateDashboardLinks(context.code);
}
console.log(`매장 컨텍스트 적용: ${context.name} (ID: ${context.id})`);
}
// 사용 후 제거
localStorage.removeItem('store_management_context');
} catch (e) {
console.error('매장 컨텍스트 파싱 실패:', e);
}
}
}
function updateDashboardLinks(storeCode) {
if (!storeCode) return;
const links = [
{ selector: '.menu-item.board', path: '/board' },
{ selector: '.menu-item.reception', path: '/reception' },
{ selector: '.menu-item.mobile', path: '/mobile' },
{ selector: '.menu-item.manage', path: '/manage' },
{ selector: '.menu-item.members', path: '/members' },
{ selector: '.menu-item.settings', path: '/settings' },
{ selector: '.menu-item.attendance', path: '/attendance' }
];
links.forEach(link => {
const element = document.querySelector(link.selector);
if (element) {
element.href = `${link.path}?store=${storeCode}`;
}
});
}
async function loadStoreInfo() {
try {
const response = await fetch('/api/store/', {
headers: getHeaders()
});
const store = await response.json();
document.getElementById('storeName').textContent = store.store_name;
} catch (error) {
console.error('매장 정보 조회 실패:', error);
}
}
let modalCallback = null;
function showNotificationModal(title, message) {
document.getElementById('notificationTitle').textContent = title;
document.getElementById('notificationMessage').innerHTML = message.replace(/\n/g, '<br>');
// 버튼 설정 (알림용)
const btnContainer = document.getElementById('modalButtons');
btnContainer.innerHTML = `<button class="btn btn-primary" style="flex: 1; padding: 12px; font-size: 16px;" onclick="closeNotificationModal()">확인</button>`;
modalCallback = null;
document.getElementById('notificationModal').classList.add('show');
}
function showConfirmModal(title, message, callback) {
document.getElementById('notificationTitle').textContent = title;
document.getElementById('notificationMessage').innerHTML = message.replace(/\n/g, '<br>');
// 버튼 설정 (확인/취소용)
const btnContainer = document.getElementById('modalButtons');
btnContainer.innerHTML = `
<button class="btn btn-secondary" style="flex: 1; padding: 12px; font-size: 16px; background-color: #95a5a6;" onclick="closeNotificationModal()">취소</button>
<button class="btn btn-primary" style="flex: 1; padding: 12px; font-size: 16px;" id="confirmModalBtn">확인</button>
`;
// 콜백 설정
document.getElementById('confirmModalBtn').onclick = function () {
closeNotificationModal();
if (callback) callback();
};
document.getElementById('notificationModal').classList.add('show');
}
function closeNotificationModal() {
document.getElementById('notificationModal').classList.remove('show');
// Reset title font size
document.getElementById('notificationTitle').style.fontSize = '20px';
}
async function openBusiness(event) {
if (event) event.stopPropagation();
try {
// 서버에서 예상 개점 날짜 가져오기
const dateResponse = await fetch('/api/daily/predict-date', {
headers: getHeaders()
});
const dateData = await dateResponse.json();
const businessDate = dateData.business_date;
showConfirmModal(
'영업 개점',
`<span style="font-size: 26px; font-weight: bold; display: block; margin-bottom: 15px;">영업을 개점하시겠습니까?</span><div style="font-size: 28px; font-weight: bold; color: #2c3e50; background: #ecf0f1; padding: 15px; border-radius: 10px; text-align: center;">📅 영업 개점일<br>${businessDate}</div>`,
async function () {
try {
const response = await fetch('/api/daily/open', {
method: 'POST',
headers: getHeaders()
});
if (response.ok) {
showNotificationModal('성공', '영업이 개점되었습니다.');
checkBusinessStatus();
} else {
const error = await response.json();
showNotificationModal('알림', error.detail || '개점에 실패했습니다.');
document.getElementById('notificationTitle').style.fontSize = '24px'; // 오류 시 타이틀 크기 복구
}
} catch (error) {
console.error('개점 실패:', error);
showNotificationModal('오류', '개점 중 오류가 발생했습니다.');
document.getElementById('notificationTitle').style.fontSize = '24px';
}
}
);
// 개점 모달의 경우 타이틀을 아주 크게 설정
document.getElementById('notificationTitle').style.fontSize = '40px';
} catch (error) {
console.error('개점 예정일 조회 실패:', error);
showNotificationModal('오류', '서버 통신 중 오류가 발생했습니다.');
}
}
async function closeBusiness(event) {
if (event) event.stopPropagation();
showConfirmModal('일마감', '일마감을 진행하시겠습니까?\n마감 후에는 다시 개점해야 합니다.', async function () {
try {
const response = await fetch('/api/daily/close', {
method: 'POST',
headers: getHeaders()
});
if (response.ok) {
const result = await response.json();
showNotificationModal('마감 완료', `일마감이 완료되었습니다.<br><br>총 대기: ${result.total_waiting}명<br>출석: ${result.total_attended}명<br>취소: ${result.total_cancelled}`);
checkBusinessStatus();
} else {
const error = await response.json();
showNotificationModal('오류', error.detail || '마감에 실패했습니다.');
}
} catch (error) {
console.error('마감 실패:', error);
showNotificationModal('오류', '마감 중 오류가 발생했습니다.');
}
});
}
function handleManageClick(event) {
// businessStatus가 로드되지 않았거나 영업 중이 아니면 차단
if (!businessStatus || !businessStatus.is_open) {
event.preventDefault(); // 페이지 이동 막기
showNotificationModal('알림', '영업을 개점해주세요.');
}
// 영업 중이면 href="/manage"로 정상 이동
}
document.getElementById('openBtn').addEventListener('click', openBusiness);
document.getElementById('closeBtn').addEventListener('click', closeBusiness);
// 초기 로드
async function init() {
// 매장 이름 즉시 표시
const storeName = localStorage.getItem('selected_store_name');
if (storeName) {
document.getElementById('storeName').textContent = storeName;
document.getElementById('storeSubtitle').textContent = '매장 대기 관리 시스템';
}
// 저장된 매장 코드가 있으면 링크 업데이트
const storeCode = localStorage.getItem('selected_store_code');
if (storeCode) {
updateDashboardLinks(storeCode);
}
await checkUrlStoreParam(); // URL 파라미터 먼저 확인
checkStoreContext(); // 매장 컨텍스트 확인
// 매장 이름 다시 업데이트 (URL 파라미터나 컨텍스트에서 변경되었을 수 있음)
const updatedStoreName = localStorage.getItem('selected_store_name');
if (updatedStoreName) {
document.getElementById('storeName').textContent = updatedStoreName;
}
checkBusinessStatus();
// updateWaitingCount -> loadWaitingCount 이름 불일치 수정
loadWaitingCount();
}
init();
// SSE 연결로 실시간 업데이트 (폴링 제거)
const storeId = localStorage.getItem('selected_store_id');
if (storeId) {
window.eventSource = new EventSource(`/api/sse/stream?store_id=${storeId}`);
window.eventSource.onopen = () => {
console.log('[SSE] Dashboard connected');
};
// 새로운 대기자 등록 시 카운트 업데이트
window.eventSource.addEventListener('new_user', () => {
console.log('[SSE] New user registered, updating count');
loadWaitingCount();
});
// 상태 변경 시 카운트 업데이트
window.eventSource.addEventListener('status_change', () => {
console.log('[SSE] Status changed, updating count');
loadWaitingCount();
});
window.eventSource.onerror = (error) => {
console.error('[SSE] Connection error:', error);
};
}
</script>
<script src="/static/js/logout.js"></script>
</body>
</html>