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:
352
static/css/common.css
Normal file
352
static/css/common.css
Normal file
@@ -0,0 +1,352 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: #f5f7fa;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
margin-bottom: 30px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 28px;
|
||||
color: #2c3e50;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.header .subtitle {
|
||||
font-size: 14px;
|
||||
color: #7f8c8d;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
padding: 30px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3498db;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2980b9;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #27ae60;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: #229954;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #e74c3c;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: #f39c12;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background: #e67e22;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #95a5a6;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #7f8c8d;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: 16px 32px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 2px solid #ecf0f1;
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: #3498db;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 15px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.alert-danger {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
border: 1px solid #bee5eb;
|
||||
}
|
||||
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.modal.active {
|
||||
display: flex;
|
||||
animation: fadeIn 0.3s ease forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: #fff;
|
||||
padding: 30px;
|
||||
border-radius: 10px;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
transform: scale(0.9);
|
||||
opacity: 0;
|
||||
animation: modalSlideIn 0.3s ease forwards;
|
||||
}
|
||||
|
||||
@keyframes modalSlideIn {
|
||||
from {
|
||||
transform: scale(0.9) translateY(-20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: scale(1) translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 2px solid #ecf0f1;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-size: 24px;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
margin-top: 20px;
|
||||
padding-top: 15px;
|
||||
border-top: 2px solid #ecf0f1;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.modal-footer .btn {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #ecf0f1;
|
||||
}
|
||||
|
||||
.table th {
|
||||
background: #f8f9fa;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.table tr:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-waiting {
|
||||
background: #3498db;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.badge-attended {
|
||||
background: #27ae60;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.badge-cancelled {
|
||||
background: #e74c3c;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #3498db;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
background: #fff;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.nav-menu a {
|
||||
display: inline-block;
|
||||
padding: 10px 20px;
|
||||
margin: 5px;
|
||||
color: #2c3e50;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.nav-menu a:hover,
|
||||
.nav-menu a.active {
|
||||
background: #3498db;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.table {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
114
static/css/keypad-styles.css
Normal file
114
static/css/keypad-styles.css
Normal file
@@ -0,0 +1,114 @@
|
||||
/* 키패드 스타일 - Modern (기본) */
|
||||
.keypad-style-modern .key {
|
||||
background: linear-gradient(145deg, #ffffff, #f8f9fa);
|
||||
border: 2px solid #e8ecef;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12),
|
||||
0 3px 6px rgba(0, 0, 0, 0.08),
|
||||
inset 0 -2px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.keypad-style-modern .key:hover {
|
||||
background: linear-gradient(145deg, #3498db, #2980b9);
|
||||
border-color: #2980b9;
|
||||
transform: translateY(-4px) scale(1.03);
|
||||
box-shadow: 0 10px 24px rgba(52, 152, 219, 0.35),
|
||||
0 6px 12px rgba(52, 152, 219, 0.25);
|
||||
}
|
||||
|
||||
/* 키패드 스타일 - Bold (진한 경계선) */
|
||||
.keypad-style-bold .key {
|
||||
background: #ffffff;
|
||||
border: 4px solid #34495e;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.keypad-style-bold .key:hover {
|
||||
background: #3498db;
|
||||
border-color: #2c3e50;
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 8px 16px rgba(52, 152, 219, 0.4);
|
||||
}
|
||||
|
||||
/* 키패드 스타일 - Dark (검정 배경, 흰색 글자) */
|
||||
.keypad-style-dark .key {
|
||||
background: linear-gradient(145deg, #2c3e50, #34495e);
|
||||
border: 2px solid #1a252f;
|
||||
border-radius: 18px;
|
||||
color: #ffffff !important;
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3),
|
||||
0 3px 6px rgba(0, 0, 0, 0.2),
|
||||
inset 0 -2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.keypad-style-dark .key:hover {
|
||||
background: linear-gradient(145deg, #3498db, #2980b9);
|
||||
border-color: #2980b9;
|
||||
transform: translateY(-4px) scale(1.03);
|
||||
box-shadow: 0 10px 24px rgba(52, 152, 219, 0.5),
|
||||
0 6px 12px rgba(52, 152, 219, 0.3);
|
||||
}
|
||||
|
||||
/* 키패드 스타일 - Colorful (다채로운 그라데이션) */
|
||||
.keypad-style-colorful .key {
|
||||
background: linear-gradient(145deg, #667eea, #764ba2);
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
color: #ffffff !important;
|
||||
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4),
|
||||
0 4px 10px rgba(118, 75, 162, 0.3);
|
||||
}
|
||||
|
||||
.keypad-style-colorful .key:hover {
|
||||
background: linear-gradient(145deg, #f093fb, #f5576c);
|
||||
transform: translateY(-5px) scale(1.05);
|
||||
box-shadow: 0 12px 28px rgba(240, 147, 251, 0.5),
|
||||
0 6px 14px rgba(245, 87, 108, 0.4);
|
||||
}
|
||||
|
||||
/* 백스페이스 버튼 스타일 오버라이드 */
|
||||
.keypad-style-modern .key.backspace,
|
||||
.keypad-style-bold .key.backspace,
|
||||
.keypad-style-dark .key.backspace,
|
||||
.keypad-style-colorful .key.backspace {
|
||||
background: linear-gradient(145deg, #e74c3c, #c0392b);
|
||||
border-color: #c0392b;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* 폰트 크기 - Small */
|
||||
.keypad-font-small .key {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.keypad-font-small .phone-number {
|
||||
font-size: 52px;
|
||||
}
|
||||
|
||||
/* 폰트 크기 - Medium */
|
||||
.keypad-font-medium .key {
|
||||
font-size: 38px;
|
||||
}
|
||||
|
||||
.keypad-font-medium .phone-number {
|
||||
font-size: 60px;
|
||||
}
|
||||
|
||||
/* 폰트 크기 - Large (기본) */
|
||||
.keypad-font-large .key {
|
||||
font-size: 44px;
|
||||
}
|
||||
|
||||
.keypad-font-large .phone-number {
|
||||
font-size: 68px;
|
||||
}
|
||||
|
||||
/* 폰트 크기 - XLarge */
|
||||
.keypad-font-xlarge .key {
|
||||
font-size: 52px;
|
||||
}
|
||||
|
||||
.keypad-font-xlarge .phone-number {
|
||||
font-size: 76px;
|
||||
}
|
||||
189
static/js/logout.js
Normal file
189
static/js/logout.js
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Centralized Logout Logic
|
||||
* Handles modal injection, display, and API interactions for logging out.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
// Inject Logout Modal HTML and Styles if they don't exist
|
||||
function injectLogoutModal() {
|
||||
if (document.getElementById('common-logout-modal')) return;
|
||||
|
||||
const modalHtml = `
|
||||
<div id="common-logout-modal" class="common-modal-overlay">
|
||||
<div class="common-modal-content">
|
||||
<h2 class="common-modal-title">로그아웃</h2>
|
||||
<p class="common-modal-message">정말 로그아웃 하시겠습니까?</p>
|
||||
<div class="common-modal-actions">
|
||||
<button id="common-logout-cancel" class="common-btn common-btn-secondary">취소</button>
|
||||
<button id="common-logout-confirm" class="common-btn common-btn-primary">로그아웃</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const styleHtml = `
|
||||
<style>
|
||||
.common-modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 99999;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(2px);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.common-modal-overlay.active {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
}
|
||||
.common-modal-content {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 16px;
|
||||
width: 90%;
|
||||
max-width: 400px;
|
||||
text-align: center;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
|
||||
transform: scale(0.95);
|
||||
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
.common-modal-overlay.active .common-modal-content {
|
||||
transform: scale(1);
|
||||
}
|
||||
.common-modal-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #2c3e50;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.common-modal-message {
|
||||
font-size: 16px;
|
||||
color: #7f8c8d;
|
||||
margin-bottom: 25px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.common-modal-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
.common-btn {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
flex: 1;
|
||||
}
|
||||
.common-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
.common-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
.common-btn-primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
.common-btn-secondary {
|
||||
background: #f1f3f5;
|
||||
color: #495057;
|
||||
}
|
||||
.common-btn-secondary:hover {
|
||||
background: #e9ecef;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
document.head.insertAdjacentHTML('beforeend', styleHtml);
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
|
||||
// Bind Events
|
||||
document.getElementById('common-logout-cancel').addEventListener('click', closeLogoutModal);
|
||||
document.getElementById('common-logout-confirm').addEventListener('click', executeLogout);
|
||||
|
||||
// Close on overlay click
|
||||
document.getElementById('common-logout-modal').addEventListener('click', function (e) {
|
||||
if (e.target === this) closeLogoutModal();
|
||||
});
|
||||
}
|
||||
|
||||
// Expose global functions
|
||||
window.showLogoutModal = function () {
|
||||
injectLogoutModal(); // Ensure it exists
|
||||
const modal = document.getElementById('common-logout-modal');
|
||||
// Small timeout to allow display:flex to apply before opacity transition
|
||||
modal.style.display = 'flex';
|
||||
requestAnimationFrame(() => {
|
||||
modal.classList.add('active');
|
||||
});
|
||||
};
|
||||
|
||||
window.closeLogoutModal = function () {
|
||||
const modal = document.getElementById('common-logout-modal');
|
||||
if (modal) {
|
||||
modal.classList.remove('active');
|
||||
setTimeout(() => {
|
||||
modal.style.display = 'none';
|
||||
}, 300); // Wait for transition
|
||||
}
|
||||
};
|
||||
|
||||
// Alias for backward compatibility with existing buttons
|
||||
window.logout = function (event) {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
window.showLogoutModal();
|
||||
};
|
||||
|
||||
window.executeLogout = async function () {
|
||||
try {
|
||||
// Close SSE connection if exists
|
||||
if (window.eventSource) {
|
||||
window.eventSource.close();
|
||||
}
|
||||
|
||||
// Call API
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
} finally {
|
||||
// Clear all possible Storage items
|
||||
const keysToRemove = [
|
||||
'access_token',
|
||||
'refresh_token',
|
||||
'selected_store_id',
|
||||
'selected_store_name',
|
||||
'selected_store_code',
|
||||
'username',
|
||||
'user_role',
|
||||
'superadmin_franchise_context',
|
||||
'store_management_context'
|
||||
];
|
||||
|
||||
keysToRemove.forEach(key => localStorage.removeItem(key));
|
||||
|
||||
// Redirect
|
||||
window.location.replace('/login');
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize on load
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', injectLogoutModal);
|
||||
} else {
|
||||
injectLogoutModal();
|
||||
}
|
||||
|
||||
})();
|
||||
21
static/js/screen-monitor.js
Normal file
21
static/js/screen-monitor.js
Normal file
@@ -0,0 +1,21 @@
|
||||
// 화면 크기 및 방향 모니터링 (태블릿 최적화 디버깅)
|
||||
function logScreenInfo() {
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
const orientation = width > height ? 'landscape' : 'portrait';
|
||||
const deviceType = width < 600 ? 'mobile' :
|
||||
width < 768 ? 'small-tablet' :
|
||||
width < 1024 ? 'tablet' : 'large-tablet/desktop';
|
||||
|
||||
console.log(`📱 Screen Info: ${width}x${height} (${orientation}) - ${deviceType}`);
|
||||
}
|
||||
|
||||
// 초기 로드 시 화면 정보 출력
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
logScreenInfo();
|
||||
});
|
||||
|
||||
// 화면 크기 변경 시 정보 출력 (회전 등)
|
||||
window.addEventListener('resize', () => {
|
||||
logScreenInfo();
|
||||
});
|
||||
Reference in New Issue
Block a user