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>
This commit is contained in:
2025-12-14 00:29:39 +09:00
parent dd1322625e
commit f699a29a85
120 changed files with 35602 additions and 0 deletions

3862
templates/admin.html Normal file

File diff suppressed because it is too large Load Diff

2245
templates/attendance.html Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

586
templates/index.html Normal file
View File

@@ -0,0 +1,586 @@
<!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>

240
templates/log_viewer.html Normal file
View File

@@ -0,0 +1,240 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>시스템 로그 분석기</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style>
:root {
--bg-color: #1e1e1e;
--text-color: #d4d4d4;
--panel-bg: #252526;
--border-color: #3e3e42;
--info-color: #3794ff;
--error-color: #f14c4c;
--warn-color: #cca700;
--debug-color: #6a9955;
}
body {
font-family: 'Consolas', 'Monaco', monospace;
background-color: var(--bg-color);
color: var(--text-color);
margin: 0;
padding: 0;
height: 100vh;
display: flex;
flex-direction: column;
}
header {
background-color: #333333;
padding: 10px 20px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border-color);
}
h1 {
margin: 0;
font-size: 18px;
font-weight: 500;
}
.controls {
display: flex;
gap: 10px;
}
input,
select,
button {
background-color: #3c3c3c;
border: 1px solid var(--border-color);
color: white;
padding: 6px 12px;
border-radius: 4px;
font-family: inherit;
}
button {
cursor: pointer;
}
button:hover {
background-color: #4c4c4c;
}
button.primary {
background-color: #0e639c;
border-color: #0e639c;
}
button.primary:hover {
background-color: #1177bb;
}
#log-container {
flex: 1;
overflow-y: auto;
padding: 10px;
}
.log-entry {
display: flex;
padding: 4px 8px;
border-bottom: 1px solid #2d2d2d;
font-size: 13px;
}
.log-entry:hover {
background-color: #2a2d2e;
}
.log-time {
color: #858585;
min-width: 160px;
}
.log-level {
min-width: 80px;
font-weight: bold;
}
.log-level.INFO {
color: var(--info-color);
}
.log-level.ERROR {
color: var(--error-color);
}
.log-level.WARNING {
color: var(--warn-color);
}
.log-level.DEBUG {
color: var(--debug-color);
}
.log-module {
color: #c586c0;
min-width: 150px;
}
.log-message {
flex: 1;
white-space: pre-wrap;
word-break: break-all;
}
.loading {
text-align: center;
padding: 20px;
color: #858585;
}
</style>
</head>
<body>
<header>
<h1><i class="fas fa-terminal"></i> System Log Analyzer</h1>
<div class="controls">
<select id="levelFilter">
<option value="">All Levels</option>
<option value="INFO">INFO</option>
<option value="WARNING">WARNING</option>
<option value="ERROR">ERROR</option>
<option value="DEBUG">DEBUG</option>
</select>
<input type="text" id="keywordFilter" placeholder="Search keyword..." style="width: 200px;">
<button class="primary" onclick="loadLogs()">Refresh</button>
<label style="display: flex; align-items: center; gap: 5px; font-size: 12px;">
<input type="checkbox" id="autoRefresh"> Auto Poll (5s)
</label>
</div>
</header>
<div id="log-container">
<!-- Logs will be injected here -->
</div>
<script>
let autoRefreshInterval = null;
async function loadLogs() {
const container = document.getElementById('log-container');
const level = document.getElementById('levelFilter').value;
const keyword = document.getElementById('keywordFilter').value;
// Only show loading if empty
if (container.children.length === 0) {
container.innerHTML = '<div class="loading">Loading logs...</div>';
}
try {
let url = `/logs/api?limit=200`;
if (level) url += `&level=${level}`;
if (keyword) url += `&keyword=${encodeURIComponent(keyword)}`;
const response = await fetch(url);
const data = await response.json();
renderLogs(data.logs);
} catch (error) {
container.innerHTML = `<div class="loading" style="color:var(--error-color)">Error loading logs: ${error.message}</div>`;
}
}
function renderLogs(logs) {
const container = document.getElementById('log-container');
container.innerHTML = logs.map(log => {
// Formatting timestamp
const date = new Date(log.timestamp);
const timeStr = date.toLocaleTimeString('ko-KR', { hour12: false }) + '.' + date.getMilliseconds().toString().padStart(3, '0');
return `
<div class="log-entry">
<div class="log-time" title="${log.timestamp}">${timeStr}</div>
<div class="log-level ${log.level}">${log.level}</div>
<div class="log-module">${log.module}:${log.line}</div>
<div class="log-message">${escapeHtml(log.message)}</div>
</div>
`;
}).join('');
}
function escapeHtml(text) {
if (!text) return '';
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// Auto Refresh Logic
document.getElementById('autoRefresh').addEventListener('change', (e) => {
if (e.target.checked) {
loadLogs(); // Load immediately
autoRefreshInterval = setInterval(loadLogs, 5000);
} else {
clearInterval(autoRefreshInterval);
}
});
// Trigger search on enter
document.getElementById('keywordFilter').addEventListener('keypress', (e) => {
if (e.key === 'Enter') loadLogs();
});
// Initial Load
loadLogs();
</script>
</body>
</html>

384
templates/login.html Normal file
View File

@@ -0,0 +1,384 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WaitFlow Login</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: #f3f4f6;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #1f2937;
}
.login-wrapper {
width: 100%;
max-width: 520px;
/* Increased from 420px */
padding: 20px;
}
.brand-section {
text-align: center;
margin-bottom: 40px;
}
.brand-title {
font-size: 42px;
/* Increased from 36px */
font-weight: 800;
color: #4f46e5;
letter-spacing: -1px;
margin-bottom: 8px;
}
.brand-subtitle {
font-size: 18px;
/* Increased from 16px */
color: #6b7280;
font-weight: 500;
}
.login-card {
background: white;
padding: 50px;
/* Increased from 40px */
border-radius: 16px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.form-group {
margin-bottom: 24px;
}
.form-label {
display: block;
font-size: 16px;
/* Increased from 14px */
font-weight: 500;
color: #374151;
margin-bottom: 8px;
}
.form-input {
width: 100%;
padding: 16px 20px;
/* Increased from 12px 16px */
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 16px;
/* Increased from 15px */
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.form-input:focus {
border-color: #4f46e5;
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
}
.btn-login {
width: 100%;
padding: 16px;
/* Increased from 14px */
background-color: #4f46e5;
color: white;
font-size: 18px;
/* Increased from 16px */
font-weight: 600;
border: none;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.2s;
}
.btn-login:hover {
background-color: #4338ca;
}
.btn-login:disabled {
background-color: #a5b4fc;
cursor: not-allowed;
}
.alert {
padding: 14px;
border-radius: 8px;
margin-bottom: 24px;
font-size: 14px;
display: none;
line-height: 1.5;
}
.alert.error {
background-color: #fef2f2;
color: #991b1b;
border: 1px solid #fecaca;
}
.alert.success {
background-color: #ecfdf5;
color: #065f46;
border: 1px solid #a7f3d0;
}
.alert.info {
background-color: #eff6ff;
color: #1e40af;
border: 1px solid #bfdbfe;
}
.credentials-info {
margin-top: 30px;
text-align: center;
font-size: 13px;
color: #9ca3af;
}
.credentials-details {
margin-top: 10px;
padding: 15px;
background-color: #f9fafb;
border-radius: 8px;
text-align: left;
border: 1px solid #e5e7eb;
display: none;
/* Hidden by default for simple look, toggled via JS if needed or just remove */
}
/* Footer */
.footer-copy {
margin-top: 40px;
font-size: 12px;
color: #9ca3af;
}
</style>
</head>
<body>
<div class="login-wrapper">
<div class="brand-section">
<h1 class="brand-title">WaitFlow</h1>
<p class="brand-subtitle">Smart Waiting Management System</p>
</div>
<div class="login-card">
<div id="alert" class="alert"></div>
<form id="loginForm">
<div class="form-group">
<label for="username" class="form-label">아이디</label>
<input type="text" id="username" name="username" class="form-input" required autocomplete="username"
placeholder="아이디를 입력하세요">
<div style="margin-top: 10px; display: flex; align-items: center;">
<input type="checkbox" id="saveId"
style="width: auto; margin-right: 8px; transform: scale(1.2);">
<label for="saveId" style="font-size: 15px; color: #6b7280; cursor: pointer;">아이디 저장</label>
</div>
</div>
<div class="form-group">
<label for="password" class="form-label">비밀번호</label>
<input type="password" id="password" name="password" class="form-input" required
autocomplete="current-password" placeholder="비밀번호를 입력하세요">
<div style="margin-top: 10px; display: flex; align-items: center;">
<input type="checkbox" id="savePw"
style="width: auto; margin-right: 8px; transform: scale(1.2);">
<label for="savePw" style="font-size: 15px; color: #6b7280; cursor: pointer;">비밀번호 저장</label>
</div>
</div>
<button type="submit" class="btn-login" id="loginBtn">로그인</button>
</form>
<div class="footer-copy">
&copy; 2025 WaitFlow. All rights reserved.
</div>
</div>
<script>
const loginForm = document.getElementById('loginForm');
const loginBtn = document.getElementById('loginBtn');
const alertBox = document.getElementById('alert');
function showAlert(message, type = 'info') {
alertBox.textContent = message;
alertBox.className = `alert ${type}`;
alertBox.style.display = 'block';
if (type === 'success') {
setTimeout(() => {
alertBox.style.display = 'none';
}, 3000);
}
}
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
const usernameInput = document.getElementById('username');
const passwordInput = document.getElementById('password');
const username = usernameInput.value;
const password = passwordInput.value;
const saveId = document.getElementById('saveId').checked;
const savePw = document.getElementById('savePw').checked;
if (!username || !password) {
showAlert('사용자명과 비밀번호를 입력해주세요.', 'error');
return;
}
loginBtn.disabled = true;
loginBtn.textContent = '로그인 중...';
alertBox.style.display = 'none';
try {
const formData = new URLSearchParams();
formData.append('username', username);
formData.append('password', password);
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formData
});
const data = await response.json();
if (response.ok) {
// 토큰 저장 (세션용)
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('username', username);
// 아이디/비밀번호 저장 (옵션)
if (saveId) {
localStorage.setItem('saved_username', username);
localStorage.setItem('remember_id', 'true');
} else {
localStorage.removeItem('saved_username');
localStorage.removeItem('remember_id');
}
if (savePw) {
localStorage.setItem('saved_password', password);
localStorage.setItem('remember_pw', 'true');
} else {
localStorage.removeItem('saved_password');
localStorage.removeItem('remember_pw');
}
showAlert('로그인 성공! 페이지를 이동합니다...', 'success');
// 사용자 정보 가져오기 및 리다이렉트 (기존 로직 유지)
setTimeout(async () => {
try {
const userResponse = await fetch('/api/auth/me', {
headers: {
'Authorization': `Bearer ${data.access_token}`
}
});
if (userResponse.ok) {
const currentUser = await userResponse.json();
if (currentUser) {
localStorage.setItem('user_role', currentUser.role);
// 역할에 따라 다른 페이지로 이동
if (currentUser.role === 'system_admin') {
window.location.href = '/superadmin';
} else if (currentUser.role === 'franchise_admin' || currentUser.role === 'franchise_manager') {
if (currentUser.franchise_id) {
window.location.href = `/admin?franchise_id=${currentUser.franchise_id}`;
} else {
window.location.href = '/admin';
}
} else if (currentUser.role === 'store_admin') {
if (currentUser.store_id) {
try {
const storeResponse = await fetch(`/api/stores/${currentUser.store_id}`, {
headers: {
'Authorization': `Bearer ${data.access_token}`
}
});
if (storeResponse.ok) {
const store = await storeResponse.json();
if (store && store.code) {
window.location.href = `/?store=${store.code}`;
} else {
window.location.href = '/';
}
} else {
window.location.href = '/';
}
} catch (e) {
console.error('매장 정보 조회 실패:', e);
window.location.href = '/';
}
} else {
window.location.href = '/';
}
}
}
} else {
window.location.href = '/';
}
} catch (error) {
window.location.href = '/';
}
}, 1000);
} else {
showAlert(data.detail || '로그인에 실패했습니다.', 'error');
loginBtn.disabled = false;
loginBtn.textContent = '로그인';
}
} catch (error) {
console.error('Login error:', error);
showAlert('서버 연결에 실패했습니다.', 'error');
loginBtn.disabled = false;
loginBtn.textContent = '로그인';
}
});
// 페이지 로드 시 초기화 및 저장된 정보 로드
window.addEventListener('load', () => {
// 1. 기존 세션 정보 삭제 (자동 로그인 방지)
localStorage.removeItem('access_token');
localStorage.removeItem('username');
localStorage.removeItem('user_role');
// 2. 저장된 아이디/비밀번호 불러오기
const savedUsername = localStorage.getItem('saved_username');
const rememberId = localStorage.getItem('remember_id');
const savedPassword = localStorage.getItem('saved_password');
const rememberPw = localStorage.getItem('remember_pw');
if (rememberId === 'true' && savedUsername) {
document.getElementById('username').value = savedUsername;
document.getElementById('saveId').checked = true;
}
if (rememberPw === 'true' && savedPassword) {
document.getElementById('password').value = savedPassword;
document.getElementById('savePw').checked = true;
}
});
</script>
</body>
</html>

3304
templates/manage.html Normal file

File diff suppressed because it is too large Load Diff

563
templates/members.html Normal file
View File

@@ -0,0 +1,563 @@
<!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>
.members-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
}
.search-section {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.search-section input {
flex: 1;
padding: 12px;
border: 2px solid #ecf0f1;
border-radius: 6px;
font-size: 16px;
}
.actions-bar {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.members-table {
overflow-x: auto;
}
.member-item {
display: grid;
grid-template-columns: 60px 1fr 150px 180px 150px;
gap: 15px;
padding: 20px;
background: #fff;
border-bottom: 1px solid #ecf0f1;
align-items: center;
transition: background 0.2s;
}
.member-item:hover {
background: #f8f9fa;
}
.member-item:first-child {
border-top-left-radius: 10px;
border-top-right-radius: 10px;
}
.member-item:last-child {
border-bottom: none;
border-bottom-left-radius: 10px;
border-bottom-right-radius: 10px;
}
.member-number {
font-weight: 600;
color: #7f8c8d;
text-align: center;
}
.member-info .name {
font-size: 18px;
font-weight: 600;
color: #2c3e50;
margin-bottom: 5px;
}
.member-info .date {
font-size: 12px;
color: #7f8c8d;
}
.member-phone {
font-size: 20px;
font-weight: 700;
color: #2980b9;
letter-spacing: 0.5px;
}
.member-actions {
display: flex;
gap: 8px;
}
@media (max-width: 768px) {
.member-item {
grid-template-columns: 1fr;
gap: 10px;
}
.members-header {
flex-direction: column;
gap: 15px;
align-items: flex-start;
}
.actions-bar {
flex-wrap: wrap;
}
}
</style>
</head>
<body>
<div class="container">
<div class="members-header">
<div>
<h1>회원 관리</h1>
<p class="subtitle">회원 등록, 조회, 수정</p>
</div>
<a href="/" class="btn btn-secondary">← 메인으로</a>
</div>
<div class="card">
<div class="search-section">
<input type="text" id="searchInput" placeholder="이름 또는 핸드폰 뒷자리 4자리 검색..."
onkeyup="handleSearchKeyup(event)">
<button class="btn btn-primary" onclick="searchMembers()">검색</button>
</div>
<div class="actions-bar">
<button class="btn btn-success" onclick="openAddModal()">회원 등록</button>
<button class="btn btn-warning" onclick="openExcelModal()">엑셀 일괄등록</button>
</div>
<div class="members-table" id="membersTable">
<div class="loading">
<div class="spinner"></div>
<p>로딩 중...</p>
</div>
</div>
</div>
</div>
<!-- 회원 등록/수정 모달 -->
<div id="memberModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 id="modalTitle">회원 등록</h2>
</div>
<form id="memberForm" onsubmit="saveMember(event)">
<div class="form-group">
<label>이름</label>
<input type="text" id="memberName" class="form-control" required>
</div>
<div class="form-group">
<label>핸드폰번호</label>
<div style="display: flex; align-items: center; gap: 8px;">
<div
style="padding: 12px; background: #ecf0f1; border-radius: 6px; font-weight: 600; color: #7f8c8d;">
010-</div>
<input type="tel" id="memberPhone" class="form-control" placeholder="0000-0000" maxlength="9"
required style="flex: 1;">
</div>
</div>
<div class="form-group">
<label>바코드 (선택)</label>
<input type="text" id="memberBarcode" class="form-control" placeholder="바코드 스캔 또는 입력">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal('memberModal')">취소</button>
<button type="submit" class="btn btn-primary">저장</button>
</div>
</form>
</div>
</div>
<!-- 엑셀 업로드 모달 -->
<div id="excelModal" class="modal">
<div class="modal-content" style="max-width: 700px;">
<div class="modal-header">
<h2>엑셀 일괄등록</h2>
</div>
<div>
<div class="alert alert-info">
<strong>엑셀 파일 형식:</strong><br>
1열: 이름, 2열: 핸드폰번호 (010-0000-0000 또는 01000000000)<br>
첫 번째 행은 헤더로 간주되어 스킵됩니다.
</div>
<div class="form-group">
<label>엑셀 파일 선택</label>
<input type="file" id="excelFile" class="form-control" accept=".xlsx,.xls">
</div>
<button class="btn btn-primary" onclick="uploadExcel()">검수하기</button>
<div id="excelResult" style="display:none; margin-top:20px;">
<h3>검수 결과</h3>
<div id="excelSummary" class="alert alert-info"></div>
<div id="invalidList" style="display:none;">
<h4 style="color:#e74c3c; margin-bottom:10px;">오류 목록</h4>
<div
style="max-height:200px; overflow-y:auto; border:1px solid #ecf0f1; border-radius:6px; padding:10px; background:#f8f9fa;">
<table class="table" style="font-size:12px;">
<thead>
<tr>
<th></th>
<th>이름</th>
<th>핸드폰</th>
<th>오류</th>
</tr>
</thead>
<tbody id="invalidTableBody"></tbody>
</table>
</div>
</div>
<button class="btn btn-success" id="confirmExcelBtn" style="margin-top:15px;"
onclick="confirmExcelUpload()">
최종 등록
</button>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('excelModal')">닫기</button>
</div>
</div>
</div>
<script>
let members = [];
let currentMemberId = null;
let validMembers = [];
// 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 loadMembers() {
const table = document.getElementById('membersTable');
// 초기 로드 시 안내 메시지만 표시
table.innerHTML = `
<div class="empty-state">
<div class="icon">🔍</div>
<p>이름 또는 핸드폰번호로 회원을 검색하세요</p>
</div>
`;
}
function renderMembers(data) {
const table = document.getElementById('membersTable');
if (data.length === 0) {
table.innerHTML = '<div class="empty-state"><div class="icon">👥</div><p>등록된 회원이 없습니다</p></div>';
return;
}
table.innerHTML = '';
data.forEach((member, idx) => {
const item = document.createElement('div');
item.className = 'member-item';
const date = new Date(member.created_at);
const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
// 핸드폰 번호 포맷팅 (010-0000-0000)
let formattedPhone = member.phone;
if (member.phone.length === 11) {
formattedPhone = member.phone.replace(/(\d{3})(\d{4})(\d{4})/, '$1-$2-$3');
}
item.innerHTML = `
<div class="member-number">${idx + 1}</div>
<div class="member-info">
<div class="name">${member.name}</div>
<div class="date">등록일: ${dateStr}</div>
</div>
<div style="font-family: monospace; color: #7f8c8d;">${member.barcode || '-'}</div>
<div class="member-phone">${formattedPhone}</div>
<div class="member-actions">
<button class="btn btn-sm btn-primary" onclick="openEditModal(${member.id})">수정</button>
<button class="btn btn-sm btn-danger" onclick="deleteMember(${member.id})">삭제</button>
</div>
`;
table.appendChild(item);
});
}
async function searchMembers() {
const searchText = document.getElementById('searchInput').value.trim();
if (!searchText) {
alert('검색어를 입력해주세요.');
return;
}
const table = document.getElementById('membersTable');
table.innerHTML = '<div class="loading"><div class="spinner"></div><p>검색 중...</p></div>';
try {
const response = await fetch(`/api/members/?search=${encodeURIComponent(searchText)}&limit=1000`, {
headers: getHeaders()
});
const data = await response.json();
// 검색 결과를 members 배열에 저장 (수정 시 사용)
members = data;
if (data.length === 0) {
table.innerHTML = `
<div class="empty-state">
<div class="icon">🔍</div>
<p>검색 결과가 없습니다</p>
</div>
`;
} else {
renderMembers(data);
}
} catch (error) {
console.error('검색 실패:', error);
table.innerHTML = '<div class="empty-state"><p>검색 중 오류가 발생했습니다</p></div>';
}
}
function handleSearchKeyup(event) {
if (event.key === 'Enter') {
searchMembers();
}
}
function openAddModal() {
currentMemberId = null;
document.getElementById('modalTitle').textContent = '회원 등록';
document.getElementById('memberName').value = '';
document.getElementById('memberPhone').value = '';
document.getElementById('memberBarcode').value = '';
document.getElementById('memberModal').classList.add('active');
}
function openEditModal(memberId) {
const member = members.find(m => m.id === memberId);
if (!member) return;
currentMemberId = memberId;
document.getElementById('modalTitle').textContent = '회원 수정';
document.getElementById('memberName').value = member.name;
// 010을 제외한 나머지 부분만 표시 (010XXXXXXXX -> XXXX-XXXX)
const phoneWithoutPrefix = member.phone.substring(3);
const formatted = phoneWithoutPrefix.length === 8
? phoneWithoutPrefix.substring(0, 4) + '-' + phoneWithoutPrefix.substring(4)
: phoneWithoutPrefix;
document.getElementById('memberPhone').value = formatted;
document.getElementById('memberBarcode').value = member.barcode || '';
document.getElementById('memberModal').classList.add('active');
}
async function saveMember(event) {
event.preventDefault();
const name = document.getElementById('memberName').value.trim();
const phoneInput = document.getElementById('memberPhone').value.trim().replace(/-/g, '');
const barcode = document.getElementById('memberBarcode').value.trim() || null;
if (!name || !phoneInput) {
alert('모든 항목을 입력해주세요.');
return;
}
// 8자리 숫자인지 확인
if (!/^\d{8}$/.test(phoneInput)) {
alert('핸드폰번호를 정확히 입력해주세요. (8자리 숫자)');
return;
}
// 010을 앞에 붙여서 완전한 번호 생성
const phone = '010' + phoneInput;
try {
let response;
if (currentMemberId) {
// 수정
response = await fetch(`/api/members/${currentMemberId}`, {
method: 'PUT',
headers: getHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ name, phone, barcode })
});
} else {
// 등록
response = await fetch('/api/members/', {
method: 'POST',
headers: getHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ name, phone, barcode })
});
}
if (response.ok) {
alert('저장되었습니다.');
closeModal('memberModal');
// 검색어가 있으면 다시 검색, 없으면 초기 화면
const searchText = document.getElementById('searchInput').value.trim();
if (searchText) {
searchMembers();
} else {
loadMembers();
}
} else {
const error = await response.json();
alert(error.detail || '저장에 실패했습니다.');
}
} catch (error) {
console.error('저장 실패:', error);
alert('저장 중 오류가 발생했습니다.');
}
}
async function deleteMember(memberId) {
const member = members.find(m => m.id === memberId);
if (!confirm(`${member.name} 회원을 삭제하시겠습니까?`)) return;
try {
const response = await fetch(`/api/members/${memberId}`, {
method: 'DELETE'
});
if (response.ok) {
alert('삭제되었습니다.');
// 검색어가 있으면 다시 검색, 없으면 초기 화면
const searchText = document.getElementById('searchInput').value.trim();
if (searchText) {
searchMembers();
} else {
loadMembers();
}
} else {
const error = await response.json();
alert(error.detail || '삭제에 실패했습니다.');
}
} catch (error) {
console.error('삭제 실패:', error);
alert('삭제 중 오류가 발생했습니다.');
}
}
function openExcelModal() {
document.getElementById('excelFile').value = '';
document.getElementById('excelResult').style.display = 'none';
document.getElementById('excelModal').classList.add('active');
}
async function uploadExcel() {
const fileInput = document.getElementById('excelFile');
if (!fileInput.files.length) {
alert('파일을 선택해주세요.');
return;
}
const formData = new FormData();
formData.append('file', fileInput.files[0]);
try {
const response = await fetch('/api/members/upload-excel', {
method: 'POST',
headers: getHeaders(),
body: formData
});
if (response.ok) {
const result = await response.json();
showExcelResult(result);
} else {
const error = await response.json();
alert(error.detail || '파일 처리에 실패했습니다.');
}
} catch (error) {
console.error('업로드 실패:', error);
alert('업로드 중 오류가 발생했습니다.');
}
}
function showExcelResult(result) {
validMembers = result.valid_members;
document.getElementById('excelSummary').innerHTML = `
총 <strong>${result.total_count}</strong>개 항목 중
<strong style="color:#27ae60;">${result.valid_count}개 유효</strong>,
<strong style="color:#e74c3c;">${result.invalid_count}개 오류</strong>
`;
if (result.invalid_count > 0) {
const tbody = document.getElementById('invalidTableBody');
tbody.innerHTML = '';
result.invalid_members.forEach(item => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${item.row}</td>
<td>${item.name}</td>
<td>${item.phone}</td>
<td style="color:#e74c3c;">${item.errors.join(', ')}</td>
`;
tbody.appendChild(tr);
});
document.getElementById('invalidList').style.display = 'block';
}
document.getElementById('confirmExcelBtn').disabled = result.valid_count === 0;
document.getElementById('excelResult').style.display = 'block';
}
async function confirmExcelUpload() {
if (!confirm(`${validMembers.length}명을 등록하시겠습니까?`)) return;
try {
const response = await fetch('/api/members/bulk', {
method: 'POST',
headers: getHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ members: validMembers })
});
if (response.ok) {
const result = await response.json();
alert(result.message);
closeModal('excelModal');
// 엑셀 등록 후 초기 화면
loadMembers();
} else {
const error = await response.json();
alert(error.detail || '등록에 실패했습니다.');
}
} catch (error) {
console.error('등록 실패:', error);
alert('등록 중 오류가 발생했습니다.');
}
}
function closeModal(modalId) {
document.getElementById(modalId).classList.remove('active');
}
// 전화번호 입력 포맷팅 (0000-0000)
document.getElementById('memberPhone').addEventListener('input', function (e) {
let value = e.target.value.replace(/[^0-9]/g, '');
if (value.length > 4) {
value = value.slice(0, 4) + '-' + value.slice(4, 8);
}
e.target.value = value;
});
// 초기 로드
loadMembers();
</script>
</body>
</html>

645
templates/mobile.html Normal file
View File

@@ -0,0 +1,645 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>대기접수 - 모바일</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body,
html {
height: 100%;
overflow: hidden;
/* 스크롤 방지 */
}
body {
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
}
.mobile-container {
padding: 2vh 20px;
max-width: 500px;
margin: 0 auto;
display: flex;
flex-direction: column;
height: 100%;
}
.header {
text-align: center;
padding: 1vh 0 2vh 0;
flex-shrink: 0;
}
.header h1 {
font-size: 3.5vh;
margin-bottom: 0.5vh;
}
.header .date {
font-size: 1.8vh;
opacity: 0.9;
}
.tabs {
display: flex;
background: rgba(255, 255, 255, 0.2);
border-radius: 12px;
padding: 4px;
margin-bottom: 2vh;
flex-shrink: 0;
}
.tab {
flex: 1;
padding: 1.5vh;
text-align: center;
border-radius: 10px;
cursor: pointer;
transition: all 0.3s;
font-weight: 600;
font-size: 2vh;
}
.tab.active {
background: #fff;
color: #667eea;
}
.tab-content {
display: none;
flex: 1;
overflow-y: auto;
}
.tab-content.active {
display: flex;
flex-direction: column;
animation: fadeIn 0.3s;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card {
background: rgba(255, 255, 255, 0.95);
border-radius: 20px;
padding: 2.5vh 20px;
color: #2c3e50;
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
/* 위아래 분산 */
max-height: 100%;
}
.phone-input-section {
margin-bottom: 2vh;
}
.phone-input-section label {
display: block;
font-size: 2vh;
font-weight: 600;
margin-bottom: 1vh;
color: #2c3e50;
}
.phone-display {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 1.5vh;
}
.phone-prefix {
font-size: 2.5vh;
font-weight: 600;
color: #7f8c8d;
padding: 1.5vh;
background: #ecf0f1;
border-radius: 10px;
}
.phone-number {
flex: 1;
font-size: 3vh;
font-weight: 600;
padding: 1.5vh;
text-align: center;
background: #fff;
border: 2px solid #667eea;
border-radius: 10px;
letter-spacing: 1px;
}
.keypad {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1vh;
margin-bottom: 2vh;
flex: 1;
/* 남은 공간 차지 */
}
.key {
padding: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 3vh;
font-weight: 600;
background: #fff;
border: 1px solid #ecf0f1;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s;
color: #2c3e50;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.key:active {
background: #667eea;
color: #fff;
transform: scale(0.95);
}
.key.zero {
grid-column: 2;
}
.key.special {
background: #e74c3c;
color: #fff;
border-color: #e74c3c;
}
.submit-btn {
width: 100%;
padding: 2.2vh;
font-size: 2.5vh;
font-weight: 700;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
border: none;
border-radius: 12px;
cursor: pointer;
transition: all 0.3s;
flex-shrink: 0;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.submit-btn:active:not(:disabled) {
transform: scale(0.98);
}
.submit-btn:disabled {
background: #95a5a6;
cursor: not-allowed;
box-shadow: none;
}
/* Compact Mode for smaller screens or keyboards */
.mobile-container.compact-mode .header {
padding: 0;
margin-bottom: 1vh;
}
.mobile-container.compact-mode .header h1 {
font-size: 2.5vh;
}
.mobile-container.compact-mode .header .date {
display: none;
}
.mobile-container.compact-mode .tabs {
margin-bottom: 1vh;
padding: 2px;
}
.mobile-container.compact-mode .card {
padding: 1.5vh;
}
.mobile-container.compact-mode .key {
font-size: 2.5vh;
}
/* Search Section specific */
.search-section input {
width: 100%;
padding: 2vh;
font-size: 2.5vh;
border: 2px solid #ecf0f1;
border-radius: 10px;
margin-bottom: 2vh;
}
.search-btn {
width: 100%;
padding: 2vh;
font-size: 2.5vh;
font-weight: 600;
background: #3498db;
color: #fff;
border: none;
border-radius: 10px;
cursor: pointer;
}
.result-box {
background: #f8f9fa;
padding: 3vh;
border-radius: 12px;
text-align: center;
margin-top: 2vh;
flex: 0 0 auto;
}
.result-box .number {
font-size: 5vh;
font-weight: 700;
color: #667eea;
margin-bottom: 1.5vh;
}
.result-box .info {
font-size: 2.5vh;
margin-bottom: 1vh;
color: #2c3e50;
}
.result-box .detail {
font-size: 1.8vh;
color: #7f8c8d;
}
.empty-state {
text-align: center;
padding: 5vh 20px;
color: #7f8c8d;
}
.empty-state .icon {
font-size: 6vh;
margin-bottom: 2vh;
}
</style>
</head>
<body>
<div class="mobile-container">
<div class="header">
<h1 id="storeName">대기 시스템</h1>
<div class="date" id="currentDate"></div>
</div>
<div class="tabs">
<div class="tab active" onclick="switchTab('register')">대기 접수</div>
<div class="tab" onclick="switchTab('search')">대기 조회</div>
</div>
<!-- 대기 접수 탭 -->
<div id="registerTab" class="tab-content active">
<div class="card">
<!-- waitingStatus 제거됨 -->
<div class="phone-input-section">
<label>핸드폰번호 입력</label>
<div class="phone-display">
<div class="phone-prefix">010-</div>
<div class="phone-number" id="phoneDisplay">____-____</div>
</div>
</div>
<div class="keypad">
<button class="key" onclick="inputNumber('1')">1</button>
<button class="key" onclick="inputNumber('2')">2</button>
<button class="key" onclick="inputNumber('3')">3</button>
<button class="key" onclick="inputNumber('4')">4</button>
<button class="key" onclick="inputNumber('5')">5</button>
<button class="key" onclick="inputNumber('6')">6</button>
<button class="key" onclick="inputNumber('7')">7</button>
<button class="key" onclick="inputNumber('8')">8</button>
<button class="key" onclick="inputNumber('9')">9</button>
<button class="key special" onclick="backspace()"></button>
<button class="key zero" onclick="inputNumber('0')">0</button>
<button class="key" onclick="clearInput()">C</button>
</div>
<button class="submit-btn" id="submitBtn" onclick="submitReception()">
대기 접수
</button>
</div>
<div id="registerResult" style="display:none;">
<div class="card result-box">
<div class="number" id="resultNumber"></div>
<div class="info" id="resultClass"></div>
<div class="detail" id="resultDetail"></div>
</div>
</div>
</div>
<!-- 대기 조회 탭 -->
<div id="searchTab" class="tab-content">
<div class="card">
<div class="search-section">
<label style="display:block; margin-bottom:10px; font-weight:600;">핸드폰번호 조회</label>
<div class="phone-display" style="margin-bottom:15px;">
<div class="phone-prefix">010-</div>
<input type="tel" id="searchPhone" placeholder="1234-5678" maxlength="9"
style="flex:1; padding:12px; font-size:18px; border:2px solid #ecf0f1; border-radius:10px; text-align:center;">
</div>
<button class="search-btn" onclick="searchWaiting()">조회하기</button>
</div>
<div id="searchResult" style="display:none;">
<div class="result-box">
<div class="number" id="searchResultNumber"></div>
<div class="info" id="searchResultClass"></div>
<div class="detail" id="searchResultDetail"></div>
</div>
</div>
<div id="searchEmpty" class="empty-state" style="display:none;">
<div class="icon">🔍</div>
<p>대기 내역이 없습니다</p>
</div>
</div>
</div>
</div>
<script>
// 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;
}
let phoneNumber = '';
let storeSettings = null;
async function loadStoreInfo() {
try {
const response = await fetch('/api/store/', { headers: getHeaders() });
storeSettings = await response.json();
document.getElementById('storeName').textContent = storeSettings.store_name;
// waitingStatus 로드 제거 (사용자 요청)
} catch (error) {
console.error('매장 정보 조회 실패:', error);
}
}
async function updateDate() {
try {
const response = await fetch('/api/daily/check-status', { headers: getHeaders() });
const status = await response.json();
if (status && status.business_date) {
const dateObj = new Date(status.business_date);
const year = dateObj.getFullYear();
const month = dateObj.getMonth() + 1;
const day = dateObj.getDate();
document.getElementById('currentDate').textContent = `${year}${month}${day}`;
} else {
const now = new Date();
document.getElementById('currentDate').textContent = `${now.getFullYear()}${now.getMonth() + 1}${now.getDate()}`;
}
} catch (error) {
console.error('영업일 조회 실패:', error);
const now = new Date();
document.getElementById('currentDate').textContent = `${now.getFullYear()}${now.getMonth() + 1}${now.getDate()}`;
}
}
function switchTab(tab) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
if (tab === 'register') {
document.querySelectorAll('.tab')[0].classList.add('active');
document.getElementById('registerTab').classList.add('active');
} else {
document.querySelectorAll('.tab')[1].classList.add('active');
document.getElementById('searchTab').classList.add('active');
}
}
function inputNumber(num) {
if (phoneNumber.length < 8) {
phoneNumber += num;
updateDisplay();
}
}
function backspace() {
if (phoneNumber.length > 0) {
phoneNumber = phoneNumber.slice(0, -1);
updateDisplay();
}
}
function clearInput() {
phoneNumber = '';
updateDisplay();
document.getElementById('registerResult').style.display = 'none';
}
function updateDisplay() {
const display = document.getElementById('phoneDisplay');
const submitBtn = document.getElementById('submitBtn');
if (phoneNumber.length === 0) {
display.textContent = '____-____';
submitBtn.disabled = true;
} else if (phoneNumber.length <= 4) {
const part1 = phoneNumber.padEnd(4, '_');
display.textContent = `${part1}-____`;
submitBtn.disabled = true;
} else {
const part1 = phoneNumber.substring(0, 4);
const part2 = phoneNumber.substring(4).padEnd(4, '_');
display.textContent = `${part1}-${part2}`;
submitBtn.disabled = phoneNumber.length !== 8;
}
}
async function submitReception() {
if (phoneNumber.length !== 8) {
alert('핸드폰번호 8자리를 입력해주세요.');
return;
}
const fullPhone = '010' + phoneNumber;
const submitBtn = document.getElementById('submitBtn');
submitBtn.disabled = true;
submitBtn.textContent = '접수 중...';
try {
const response = await fetch('/api/waiting/register', {
method: 'POST',
headers: getHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
phone: fullPhone
})
});
if (response.ok) {
const result = await response.json();
showRegisterResult(result);
} else {
const error = await response.json();
alert(error.detail || '접수에 실패했습니다.');
}
} catch (error) {
console.error('접수 실패:', error);
alert('접수 중 오류가 발생했습니다.');
} finally {
submitBtn.disabled = false;
submitBtn.textContent = '대기 접수';
}
}
function showRegisterResult(result) {
// 조회 탭으로 전환
switchTab('search');
// 조회 결과 표시
const phoneInput = document.getElementById('phoneDisplay').textContent.replace('010-', '').replace(/-/g, '').replace(/_/g, '');
document.getElementById('searchPhone').value = phoneInput.substring(0, 4) + (phoneInput.length > 4 ? '-' + phoneInput.substring(4, 8) : '');
// 대기 정보 표시
document.getElementById('searchResultNumber').textContent = `${result.waiting_number}`;
document.getElementById('searchResultClass').textContent = result.class_name;
document.getElementById('searchResultDetail').textContent = `${result.class_order}번째 대기`;
document.getElementById('searchResult').style.display = 'block';
document.getElementById('searchEmpty').style.display = 'none';
// 접수 폼 초기화
phoneNumber = '';
updateDisplay();
document.getElementById('registerResult').style.display = 'none';
}
async function searchWaiting() {
let searchPhone = document.getElementById('searchPhone').value.replace(/-/g, '');
// 숫자만 남기기
searchPhone = searchPhone.replace(/[^0-9]/g, '');
if (searchPhone.length !== 8) {
alert('핸드폰번호 8자리를 입력해주세요.');
return;
}
const fullPhone = '010' + searchPhone;
try {
const response = await fetch(`/api/waiting/check/${fullPhone}`, { headers: getHeaders() });
const result = await response.json();
if (result.found) {
document.getElementById('searchResultNumber').textContent = `${result.waiting_number}`;
document.getElementById('searchResultClass').textContent = result.class_name;
document.getElementById('searchResultDetail').textContent = `앞에 ${result.ahead_count}명 대기 중`;
document.getElementById('searchResult').style.display = 'block';
document.getElementById('searchEmpty').style.display = 'none';
} else {
document.getElementById('searchResult').style.display = 'none';
document.getElementById('searchEmpty').style.display = 'block';
}
} catch (error) {
console.error('조회 실패:', error);
alert('조회 중 오류가 발생했습니다.');
}
}
// 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);
console.log(`URL 매장 파라미터 적용: ${store.name} (코드: ${storeParam})`);
} else {
console.error('매장 코드를 찾을 수 없습니다:', storeParam);
}
} catch (e) {
console.error('매장 정보 조회 실패:', e);
}
}
}
// 화면 높이에 따른 자동 레이아웃 조정
function adjustLayout() {
const height = window.innerHeight;
const container = document.querySelector('.mobile-container');
// 작은 화면 (예: 키보드가 올라오거나 작은 폰)
if (height < 600) {
container.classList.add('compact-mode');
} else {
container.classList.remove('compact-mode');
}
}
// 초기 로드
async function init() {
// 레이아웃 조정 리스너 등록
window.addEventListener('resize', adjustLayout);
adjustLayout();
await checkUrlStoreParam();
loadStoreInfo();
updateDate();
updateDisplay();
}
init();
// 조회 탭 전화번호 포맷팅
document.getElementById('searchPhone').addEventListener('input', function (e) {
let value = e.target.value.replace(/[^0-9]/g, '');
if (value.length > 4) {
value = value.slice(0, 4) + '-' + value.slice(4, 8);
}
e.target.value = value;
});
</script>
</body>
</html>

1562
templates/reception.html Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,400 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>대기접수 로그인</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.login-container {
background: white;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
max-width: 650px;
width: 100%;
padding: 60px 50px;
}
.logo {
text-align: center;
margin-bottom: 50px;
}
.logo h1 {
font-size: 48px;
font-weight: 700;
color: #2c3e50;
margin-bottom: 10px;
}
.logo p {
font-size: 24px;
color: #7f8c8d;
font-weight: 500;
}
.form-group {
margin-bottom: 30px;
}
.form-group label {
display: block;
font-size: 22px;
font-weight: 600;
color: #2c3e50;
margin-bottom: 12px;
}
.form-group input[type="text"],
.form-group input[type="password"] {
width: 100%;
height: 70px;
padding: 0 25px;
font-size: 26px;
border: 3px solid #ecf0f1;
border-radius: 12px;
transition: all 0.3s;
font-weight: 500;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.1);
}
.remember-me {
display: flex;
align-items: center;
margin-bottom: 30px;
cursor: pointer;
user-select: none;
}
.remember-me input[type="checkbox"] {
width: 28px;
height: 28px;
margin-right: 12px;
cursor: pointer;
accent-color: #667eea;
}
.remember-me label {
font-size: 20px;
color: #2c3e50;
cursor: pointer;
font-weight: 500;
}
.login-btn {
width: 100%;
height: 80px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-size: 32px;
font-weight: 700;
border: none;
border-radius: 12px;
cursor: pointer;
transition: all 0.3s;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
.login-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
}
.login-btn:active {
transform: translateY(0);
}
.login-btn:disabled {
background: #95a5a6;
cursor: not-allowed;
box-shadow: none;
transform: none;
}
.error-message {
background: #e74c3c;
color: white;
padding: 20px;
border-radius: 10px;
margin-bottom: 30px;
font-size: 20px;
text-align: center;
display: none;
}
.error-message.show {
display: block;
animation: shake 0.5s;
}
@keyframes shake {
0%,
100% {
transform: translateX(0);
}
25% {
transform: translateX(-10px);
}
75% {
transform: translateX(10px);
}
}
.spinner {
display: none;
width: 30px;
height: 30px;
border: 4px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.login-btn.loading .btn-text {
display: none;
}
.login-btn.loading .spinner {
display: block;
}
.info-text {
text-align: center;
color: #7f8c8d;
font-size: 18px;
margin-top: 30px;
}
</style>
</head>
<body>
<div class="login-container">
<div class="logo">
<h1>🎯 대기접수</h1>
<p>로그인</p>
</div>
<div class="error-message" id="errorMessage"></div>
<form id="loginForm" onsubmit="handleLogin(event)">
<div class="form-group">
<label for="username">사용자명</label>
<input type="text" id="username" name="username" placeholder="사용자명을 입력하세요" autocomplete="username"
required autofocus>
</div>
<div class="form-group">
<label for="password">비밀번호</label>
<input type="password" id="password" name="password" placeholder="비밀번호를 입력하세요"
autocomplete="current-password" required>
</div>
<div class="remember-me">
<input type="checkbox" id="rememberMe" name="rememberMe">
<label for="rememberMe">로그인 정보 저장</label>
</div>
<button type="submit" class="login-btn" id="loginBtn">
<span class="btn-text">로그인</span>
<div class="spinner"></div>
</button>
</form>
<p class="info-text">로그인 후 대기접수 화면으로 이동합니다</p>
</div>
<script>
// Base64 인코딩/디코딩 함수 (간단한 난독화용)
function encodePassword(password) {
return btoa(encodeURIComponent(password));
}
function decodePassword(encoded) {
try {
return decodeURIComponent(atob(encoded));
} catch (e) {
return '';
}
}
// 페이지 로드 시 저장된 로그인 정보 불러오기
window.addEventListener('DOMContentLoaded', function () {
const rememberMe = localStorage.getItem('reception_remember_me') === 'true';
const savedUsername = localStorage.getItem('reception_username');
const savedPassword = localStorage.getItem('reception_password');
if (rememberMe && savedUsername) {
document.getElementById('username').value = savedUsername;
document.getElementById('rememberMe').checked = true;
if (savedPassword) {
document.getElementById('password').value = decodePassword(savedPassword);
}
}
});
// 로그인 정보 저장 체크박스 변경 이벤트
document.addEventListener('DOMContentLoaded', function () {
const rememberMeCheckbox = document.getElementById('rememberMe');
rememberMeCheckbox.addEventListener('change', function () {
if (!this.checked) {
// 체크 해제 시 저장된 정보 삭제
localStorage.removeItem('reception_remember_me');
localStorage.removeItem('reception_username');
localStorage.removeItem('reception_password');
}
});
});
async function handleLogin(event) {
event.preventDefault();
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
const rememberMe = document.getElementById('rememberMe').checked;
const loginBtn = document.getElementById('loginBtn');
const errorMessage = document.getElementById('errorMessage');
if (!username || !password) {
showError('사용자명과 비밀번호를 입력해주세요');
return;
}
// 로딩 상태
loginBtn.disabled = true;
loginBtn.classList.add('loading');
errorMessage.classList.remove('show');
try {
// 로그인 API 호출
const formData = new URLSearchParams();
formData.append('username', username);
formData.append('password', password);
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formData
});
if (!response.ok) {
if (response.status === 401) {
throw new Error('사용자명 또는 비밀번호가 올바르지 않습니다');
}
throw new Error('로그인에 실패했습니다');
}
const data = await response.json();
// 로그인 정보 저장 (체크박스가 선택된 경우)
if (rememberMe) {
localStorage.setItem('reception_remember_me', 'true');
localStorage.setItem('reception_username', username);
localStorage.setItem('reception_password', encodePassword(password));
} else {
// 체크하지 않은 경우 저장된 정보 삭제
localStorage.removeItem('reception_remember_me');
localStorage.removeItem('reception_username');
localStorage.removeItem('reception_password');
}
// 토큰 저장
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('token_type', data.token_type);
// 사용자 정보 조회하여 매장 정보 저장
const userResponse = await fetch('/api/auth/me', {
headers: {
'Authorization': `Bearer ${data.access_token}`
}
});
if (userResponse.ok) {
const user = await userResponse.json();
// 매장 관리자인 경우 매장 정보 저장
if (user.store_id) {
localStorage.setItem('selected_store_id', user.store_id);
// 매장 정보 조회
const storeResponse = await fetch(`/api/stores/${user.store_id}`, {
headers: {
'Authorization': `Bearer ${data.access_token}`
}
});
if (storeResponse.ok) {
const store = await storeResponse.json();
localStorage.setItem('selected_store_name', store.name);
localStorage.setItem('selected_store_code', store.code);
}
}
}
// 대기접수 화면으로 리다이렉트
window.location.href = '/reception';
} catch (error) {
console.error('Login error:', error);
showError(error.message || '로그인 중 오류가 발생했습니다');
loginBtn.disabled = false;
loginBtn.classList.remove('loading');
}
}
function showError(message) {
const errorMessage = document.getElementById('errorMessage');
errorMessage.textContent = message;
errorMessage.classList.add('show');
setTimeout(() => {
errorMessage.classList.remove('show');
}, 3000);
}
// Enter 키 지원
document.getElementById('password').addEventListener('keypress', function (e) {
if (e.key === 'Enter') {
handleLogin(e);
}
});
</script>
</body>
</html>

1533
templates/settings.html Normal file

File diff suppressed because it is too large Load Diff

1790
templates/superadmin.html Normal file

File diff suppressed because it is too large Load Diff

1160
templates/waiting_board.html Normal file

File diff suppressed because it is too large Load Diff