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:
3862
templates/admin.html
Normal file
3862
templates/admin.html
Normal file
File diff suppressed because it is too large
Load Diff
2245
templates/attendance.html
Normal file
2245
templates/attendance.html
Normal file
File diff suppressed because it is too large
Load Diff
1744
templates/attendance.html.backup
Normal file
1744
templates/attendance.html.backup
Normal file
File diff suppressed because it is too large
Load Diff
586
templates/index.html
Normal file
586
templates/index.html
Normal 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
240
templates/log_viewer.html
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// 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
384
templates/login.html
Normal 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">
|
||||
© 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
3304
templates/manage.html
Normal file
File diff suppressed because it is too large
Load Diff
563
templates/members.html
Normal file
563
templates/members.html
Normal 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
645
templates/mobile.html
Normal 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
1562
templates/reception.html
Normal file
File diff suppressed because it is too large
Load Diff
400
templates/reception_login.html
Normal file
400
templates/reception_login.html
Normal 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
1533
templates/settings.html
Normal file
File diff suppressed because it is too large
Load Diff
1790
templates/superadmin.html
Normal file
1790
templates/superadmin.html
Normal file
File diff suppressed because it is too large
Load Diff
1160
templates/waiting_board.html
Normal file
1160
templates/waiting_board.html
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user