- 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>
586 lines
24 KiB
HTML
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> |