Files
waiting-system/templates/mobile.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

645 lines
21 KiB
HTML

<!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>