- 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>
3304 lines
128 KiB
HTML
3304 lines
128 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="ko">
|
|
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>대기자 관리</title>
|
|
<link rel="stylesheet" href="/static/css/common.css">
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Nanum+Gothic:wght@400;700;800&display=swap" rel="stylesheet">
|
|
<style>
|
|
:root {
|
|
--manager-font-family: 'Nanum Gothic', sans-serif;
|
|
--manager-font-size: 15px;
|
|
}
|
|
|
|
/* Override container max-width for this page to allow wider layout */
|
|
.container {
|
|
max-width: 95% !important;
|
|
}
|
|
|
|
/* Custom Modal Styles to match .modal but avoid conflicts */
|
|
.custom-modal {
|
|
display: none;
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background: rgba(0, 0, 0, 0.5);
|
|
z-index: 9999;
|
|
/* High z-index */
|
|
align-items: center;
|
|
justify-content: center;
|
|
opacity: 0;
|
|
/* transition: opacity 0.3s ease; REMOVED for stability */
|
|
}
|
|
|
|
.custom-modal.active {
|
|
display: flex;
|
|
opacity: 1;
|
|
}
|
|
|
|
/* Force content visibility, overriding common.css opacity:0 animation */
|
|
.custom-modal .modal-content {
|
|
opacity: 1 !important;
|
|
animation: none !important;
|
|
transform: none !important;
|
|
}
|
|
|
|
.manage-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.batch-section {
|
|
background: #fff;
|
|
padding: 12px 20px;
|
|
border-radius: 10px;
|
|
margin-bottom: 15px;
|
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
|
|
}
|
|
|
|
.batch-info {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.batch-info .info-text {
|
|
font-size: 24px;
|
|
/* Increased from 18px */
|
|
font-weight: 600;
|
|
color: #2c3e50;
|
|
}
|
|
|
|
.class-tabs {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-bottom: 20px;
|
|
overflow-x: auto;
|
|
padding: 10px 5px;
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 90;
|
|
background: #f5f7fa;
|
|
}
|
|
|
|
.class-tab {
|
|
padding: 16px 32px;
|
|
/* Adjusted padding */
|
|
background: #fff;
|
|
border: 2px solid #ecf0f1;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
transition: all 0.3s;
|
|
white-space: nowrap;
|
|
font-weight: 600;
|
|
font-size: 22px;
|
|
/* Increased from 18px to 22px */
|
|
}
|
|
|
|
.class-tab:hover {
|
|
border-color: #3498db;
|
|
}
|
|
|
|
.class-tab.active {
|
|
background: #3498db;
|
|
color: #fff;
|
|
border-color: #3498db;
|
|
}
|
|
|
|
.class-tab .count {
|
|
display: inline-block;
|
|
margin-left: 8px;
|
|
padding: 2px 8px;
|
|
background: rgba(0, 0, 0, 0.1);
|
|
border-radius: 10px;
|
|
font-size: 18px;
|
|
/* Increased from 15px to 18px */
|
|
}
|
|
|
|
/* 마감된 교시 드롭다운 버튼 스타일 */
|
|
.closed-classes-dropdown {
|
|
position: relative;
|
|
display: inline-block;
|
|
}
|
|
|
|
.closed-dropdown-btn {
|
|
padding: 12px 24px;
|
|
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
|
|
color: #fff;
|
|
border: 2px solid #c0392b;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
transition: all 0.3s;
|
|
white-space: nowrap;
|
|
font-weight: 600;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
box-shadow: 0 2px 8px rgba(231, 76, 60, 0.3);
|
|
}
|
|
|
|
.closed-dropdown-btn:hover {
|
|
background: linear-gradient(135deg, #c0392b 0%, #a93226 100%);
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 4px 12px rgba(231, 76, 60, 0.4);
|
|
}
|
|
|
|
.closed-dropdown-btn .arrow {
|
|
transition: transform 0.3s;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.closed-dropdown-btn.active .arrow {
|
|
transform: rotate(180deg);
|
|
}
|
|
|
|
.closed-dropdown-menu {
|
|
display: none;
|
|
position: absolute;
|
|
top: 100%;
|
|
left: 0;
|
|
margin-top: 8px;
|
|
background: #fff;
|
|
border: 2px solid #e74c3c;
|
|
border-radius: 8px;
|
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
|
min-width: 250px;
|
|
z-index: 1000;
|
|
max-height: 400px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.closed-dropdown-menu.show {
|
|
display: block;
|
|
animation: dropdownFadeIn 0.3s ease-out;
|
|
}
|
|
|
|
@keyframes dropdownFadeIn {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(-10px);
|
|
}
|
|
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
}
|
|
|
|
.closed-class-item {
|
|
padding: 12px 16px;
|
|
border-bottom: 1px solid #ecf0f1;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.closed-class-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.closed-class-item:hover {
|
|
background: #fef5f5;
|
|
}
|
|
|
|
.closed-class-item.active {
|
|
background: #e74c3c;
|
|
color: #fff;
|
|
}
|
|
|
|
.closed-class-item .class-info {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
|
|
.closed-class-item .class-name {
|
|
font-weight: 600;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.closed-class-item .class-count {
|
|
font-size: 12px;
|
|
color: #7f8c8d;
|
|
}
|
|
|
|
.closed-class-item.active .class-count {
|
|
color: rgba(255, 255, 255, 0.8);
|
|
}
|
|
|
|
.closed-class-item .badge-closed {
|
|
padding: 4px 12px;
|
|
background: #e74c3c;
|
|
color: #fff;
|
|
border-radius: 12px;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.closed-class-item.active .badge-closed {
|
|
background: rgba(255, 255, 255, 0.3);
|
|
}
|
|
|
|
/* 스크롤바 스타일 */
|
|
.closed-dropdown-menu::-webkit-scrollbar {
|
|
width: 6px;
|
|
}
|
|
|
|
.closed-dropdown-menu::-webkit-scrollbar-track {
|
|
background: #f1f1f1;
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.closed-dropdown-menu::-webkit-scrollbar-thumb {
|
|
background: #e74c3c;
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.closed-dropdown-menu::-webkit-scrollbar-thumb:hover {
|
|
background: #c0392b;
|
|
}
|
|
|
|
.waiting-table {
|
|
width: 100%;
|
|
background: #fff;
|
|
border-radius: 10px;
|
|
overflow: hidden;
|
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
|
|
position: relative;
|
|
}
|
|
|
|
/* 마감된 교시의 리스트 스타일 - 비활성화 상태로 표시 */
|
|
.waiting-table.closed {
|
|
background: #f5f5f5;
|
|
opacity: 0.8;
|
|
}
|
|
|
|
.waiting-table.closed::before {
|
|
content: '🔒 마감된 교시입니다';
|
|
display: block;
|
|
padding: 15px;
|
|
background: #e74c3c;
|
|
color: #fff;
|
|
text-align: center;
|
|
font-weight: 600;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.waiting-table.closed .waiting-item {
|
|
pointer-events: none;
|
|
opacity: 0.6;
|
|
}
|
|
|
|
/* 하단 상태바 스타일 */
|
|
.footer-status-bar {
|
|
position: fixed;
|
|
bottom: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
background: #fff;
|
|
border-top: 1px solid #ddd;
|
|
padding: 8px 20px;
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
align-items: center;
|
|
font-size: 13px;
|
|
color: #666;
|
|
z-index: 1000;
|
|
box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.05);
|
|
}
|
|
|
|
.footer-status-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
|
|
.status-indicator {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
background-color: #ccc;
|
|
transition: background-color 0.3s;
|
|
}
|
|
|
|
.status-indicator.connected {
|
|
background-color: #2ecc71;
|
|
box-shadow: 0 0 5px rgba(46, 204, 113, 0.5);
|
|
}
|
|
|
|
.status-indicator.disconnected {
|
|
background-color: #e74c3c;
|
|
}
|
|
|
|
.status-indicator.connecting {
|
|
background-color: #f1c40f;
|
|
animation: blink 1s infinite;
|
|
}
|
|
|
|
@keyframes blink {
|
|
50% {
|
|
opacity: 0.5;
|
|
}
|
|
}
|
|
|
|
.waiting-table.closed .waiting-item button {
|
|
cursor: not-allowed;
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.waiting-item {
|
|
display: grid;
|
|
/* grid-template-columns: 60px minmax(100px, 1fr) minmax(180px, 1.5fr) 90px 90px auto; */
|
|
/* Fully responsive grid for large fonts */
|
|
/* Number | Name (min 140px) | Phone (min 200px) | Class (auto) | Order (auto) | Actions (auto) */
|
|
/* grid-template-columns: 80px 1fr 1.5fr auto auto auto; OLD */
|
|
grid-template-columns: 50px minmax(100px, 1fr) minmax(180px, 2fr) auto auto auto;
|
|
/* Changed to fr units to fill width: Number Name Phone Class Order Actions */
|
|
align-items: center;
|
|
padding: 15px 10px;
|
|
border-bottom: 2px solid #ecf0f1;
|
|
transition: all 0.3s;
|
|
cursor: grab;
|
|
background: #fff;
|
|
position: relative;
|
|
/* Added */
|
|
column-gap: 10px;
|
|
/* Removed justify-content: center to fill width */
|
|
}
|
|
|
|
.waiting-item:hover {
|
|
background: #f8f9fa;
|
|
}
|
|
|
|
.waiting-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.waiting-item.dragging {
|
|
opacity: 0.5;
|
|
background: #e3f2fd;
|
|
cursor: grabbing;
|
|
}
|
|
|
|
.waiting-item.drag-over {
|
|
border-top: 3px solid #3498db;
|
|
}
|
|
|
|
.item-number {
|
|
font-size: 22px;
|
|
font-weight: 700;
|
|
color: #3498db;
|
|
text-align: center;
|
|
}
|
|
|
|
.item-info {
|
|
display: contents;
|
|
}
|
|
|
|
.item-name {
|
|
font-size: calc(var(--manager-font-size) + 4px);
|
|
font-weight: 700;
|
|
color: #2c3e50;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
font-family: var(--manager-font-family);
|
|
}
|
|
|
|
.item-phone {
|
|
font-size: calc(var(--manager-font-size) + 3px);
|
|
font-weight: bold;
|
|
color: #5f6c6d;
|
|
letter-spacing: 0.5px;
|
|
font-family: var(--manager-font-family);
|
|
font-variant-numeric: tabular-nums;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.item-class {
|
|
font-size: calc(var(--manager-font-size) + 6px);
|
|
/* Increased from +2px to +6px */
|
|
color: #3498db;
|
|
font-weight: 600;
|
|
text-align: center;
|
|
font-family: var(--manager-font-family);
|
|
margin-left: 15px;
|
|
/* Added spacing */
|
|
}
|
|
|
|
.item-order {
|
|
font-size: calc(var(--manager-font-size) + 2px);
|
|
color: #e74c3c;
|
|
font-weight: 700;
|
|
text-align: center;
|
|
font-family: var(--manager-font-family);
|
|
margin-right: 20px;
|
|
/* Added spacing before action buttons */
|
|
}
|
|
|
|
.item-order-controls {
|
|
display: flex;
|
|
gap: 5px;
|
|
align-items: center;
|
|
}
|
|
|
|
.btn-icon {
|
|
width: 50px;
|
|
/* Increased from 40px */
|
|
height: 50px;
|
|
/* Increased from 40px */
|
|
padding: 0;
|
|
border: none;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
font-size: 20px;
|
|
/* Increased from 16px */
|
|
transition: all 0.2s;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.btn-icon:hover:not(:disabled) {
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
.btn-icon:disabled {
|
|
opacity: 0.3;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.btn-up {
|
|
background: #95a5a6;
|
|
color: #fff;
|
|
}
|
|
|
|
.btn-down {
|
|
background: #95a5a6;
|
|
color: #fff;
|
|
}
|
|
|
|
.btn-left {
|
|
background: #f39c12;
|
|
color: #fff;
|
|
}
|
|
|
|
.btn-right {
|
|
background: #f39c12;
|
|
color: #fff;
|
|
}
|
|
|
|
.item-actions {
|
|
display: flex;
|
|
gap: 15px;
|
|
/* Increased from 5px to 15px */
|
|
/* flex-wrap: wrap; */
|
|
flex-wrap: nowrap;
|
|
/* Prevent wrapping */
|
|
align-items: center;
|
|
}
|
|
|
|
.item-actions .btn {
|
|
/* padding: 8px 14px; Removed relative padding */
|
|
width: auto;
|
|
/* Dynamic width */
|
|
min-width: 60px;
|
|
/* Minimum width */
|
|
padding: 0 16px;
|
|
/* Horizontal padding */
|
|
height: 50px;
|
|
/* Fixed height */
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: var(--manager-font-size);
|
|
font-family: var(--manager-font-family);
|
|
font-weight: 600;
|
|
white-space: nowrap;
|
|
/* Prevent text wrapping */
|
|
}
|
|
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 60px 20px;
|
|
color: #7f8c8d;
|
|
}
|
|
|
|
.empty-state .icon {
|
|
font-size: 64px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.quick-register-input {
|
|
width: 280px;
|
|
padding: 6px 15px;
|
|
height: 50px;
|
|
font-size: 24px;
|
|
font-weight: bold;
|
|
border: 2px solid #ddd;
|
|
border-radius: 8px;
|
|
letter-spacing: 2px;
|
|
}
|
|
|
|
/* Tablet Layout (768px - 1023px) */
|
|
@media (min-width: 768px) and (max-width: 1023px) {
|
|
.container {
|
|
max-width: 95%;
|
|
padding: 10px;
|
|
}
|
|
|
|
.waiting-item {
|
|
/* Number, Name, Phone, Order, Actions */
|
|
/* Class name hidden to save space */
|
|
grid-template-columns: 60px 120px 1fr 80px auto;
|
|
gap: 5px;
|
|
padding: 12px 10px;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.item-class {
|
|
display: none;
|
|
}
|
|
|
|
.item-actions .btn {
|
|
padding: 8px 12px;
|
|
}
|
|
|
|
.btn-icon {
|
|
width: 36px;
|
|
height: 36px;
|
|
}
|
|
}
|
|
|
|
/* Mobile Layout (< 768px) */
|
|
@media (max-width: 767px) {
|
|
.container {
|
|
padding: 10px;
|
|
}
|
|
|
|
.manage-header {
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
gap: 10px;
|
|
}
|
|
|
|
.manage-header h1 {
|
|
font-size: 22px;
|
|
}
|
|
|
|
.quick-register-input {
|
|
width: 100% !important;
|
|
height: 40px !important;
|
|
font-size: 14px !important;
|
|
padding: 6px 10px !important;
|
|
}
|
|
|
|
.batch-info {
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
gap: 10px;
|
|
}
|
|
|
|
.quick-register {
|
|
width: 100%;
|
|
margin-left: 0 !important;
|
|
margin-right: 0 !important;
|
|
flex-wrap: wrap;
|
|
/* Allow wrapping if needed */
|
|
}
|
|
|
|
.batch-info .btn {
|
|
width: 100%;
|
|
padding: 12px;
|
|
}
|
|
|
|
.waiting-table {
|
|
background: transparent;
|
|
box-shadow: none;
|
|
}
|
|
|
|
.waiting-item {
|
|
display: grid;
|
|
grid-template-columns: 50px 1fr auto;
|
|
grid-template-areas:
|
|
"number name order"
|
|
"number phone class"
|
|
"actions actions actions";
|
|
gap: 8px 10px;
|
|
background: #fff;
|
|
margin-bottom: 12px;
|
|
border-radius: 12px;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
|
padding: 15px;
|
|
border: 1px solid #eee;
|
|
}
|
|
|
|
.waiting-item:last-child {
|
|
border-bottom: 1px solid #eee;
|
|
}
|
|
|
|
.item-number {
|
|
grid-area: number;
|
|
font-size: 24px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: #f8f9fa;
|
|
border-radius: 8px;
|
|
height: 100%;
|
|
color: #3498db;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.item-name {
|
|
grid-area: name;
|
|
font-size: 16px;
|
|
font-weight: 700;
|
|
align-self: end;
|
|
}
|
|
|
|
.item-phone {
|
|
grid-area: phone;
|
|
font-size: 13px;
|
|
color: #888;
|
|
align-self: start;
|
|
}
|
|
|
|
.item-order {
|
|
grid-area: order;
|
|
text-align: right;
|
|
font-size: 14px;
|
|
color: #e74c3c;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.item-class {
|
|
grid-area: class;
|
|
text-align: right;
|
|
font-size: 12px;
|
|
color: #999;
|
|
}
|
|
|
|
.item-actions {
|
|
grid-area: actions;
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
justify-content: space-between;
|
|
margin-top: 10px;
|
|
padding-top: 10px;
|
|
border-top: 1px solid #f5f5f5;
|
|
gap: 8px;
|
|
}
|
|
|
|
.item-actions .btn {
|
|
flex: 1;
|
|
padding: 10px 0;
|
|
font-size: 14px;
|
|
min-width: 60px;
|
|
}
|
|
|
|
.btn-icon {
|
|
width: 44px;
|
|
height: 44px;
|
|
flex: none;
|
|
}
|
|
}
|
|
|
|
/* 애니메이션 효과 */
|
|
@keyframes slideIn {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateX(-20px);
|
|
}
|
|
|
|
to {
|
|
opacity: 1;
|
|
transform: translateX(0);
|
|
}
|
|
}
|
|
|
|
@keyframes slideOut {
|
|
from {
|
|
opacity: 1;
|
|
transform: translateX(0);
|
|
}
|
|
|
|
to {
|
|
opacity: 0;
|
|
transform: translateX(20px);
|
|
}
|
|
}
|
|
|
|
@keyframes fadeIn {
|
|
from {
|
|
opacity: 0;
|
|
}
|
|
|
|
to {
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
@keyframes fadeOut {
|
|
from {
|
|
opacity: 1;
|
|
}
|
|
|
|
to {
|
|
opacity: 0;
|
|
}
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0% {
|
|
box-shadow: 0 0 0 0 rgba(52, 152, 219, 0.7);
|
|
}
|
|
|
|
50% {
|
|
box-shadow: 0 0 0 10px rgba(52, 152, 219, 0);
|
|
}
|
|
|
|
100% {
|
|
box-shadow: 0 0 0 0 rgba(52, 152, 219, 0);
|
|
}
|
|
}
|
|
|
|
@keyframes shake {
|
|
|
|
0%,
|
|
100% {
|
|
transform: translateX(0);
|
|
}
|
|
|
|
10%,
|
|
30%,
|
|
50%,
|
|
70%,
|
|
90% {
|
|
transform: translateX(-5px);
|
|
}
|
|
|
|
20%,
|
|
40%,
|
|
60%,
|
|
80% {
|
|
transform: translateX(5px);
|
|
}
|
|
}
|
|
|
|
.waiting-item.slide-in {
|
|
animation: slideIn 0.4s ease-out;
|
|
}
|
|
|
|
.waiting-item.slide-out {
|
|
animation: slideOut 0.4s ease-out forwards;
|
|
}
|
|
|
|
.waiting-item.fade-in {
|
|
animation: fadeIn 0.3s ease-out;
|
|
}
|
|
|
|
.waiting-item.fade-out {
|
|
animation: fadeOut 0.3s ease-out forwards;
|
|
}
|
|
|
|
.waiting-item.highlight {
|
|
background: #e3f2fd !important;
|
|
animation: pulse 1s ease-out;
|
|
}
|
|
|
|
.waiting-item.shake {
|
|
animation: shake 0.5s ease-out;
|
|
}
|
|
|
|
.waiting-item.updating {
|
|
opacity: 0.6;
|
|
pointer-events: none;
|
|
}
|
|
|
|
/* 부드러운 전환 */
|
|
.smooth-update {
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
/* 출석 조회 모달 */
|
|
.attendance-modal {
|
|
display: none;
|
|
position: fixed;
|
|
z-index: 10000;
|
|
left: 0;
|
|
top: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background-color: rgba(0, 0, 0, 0.5);
|
|
animation: fadeIn 0.3s;
|
|
}
|
|
|
|
.attendance-modal.active {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.attendance-modal-content {
|
|
background-color: white;
|
|
border-radius: 12px;
|
|
width: 95%;
|
|
max-width: 1400px;
|
|
height: 90vh;
|
|
position: relative;
|
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.attendance-modal-header {
|
|
padding: 20px 30px;
|
|
border-bottom: 2px solid #ecf0f1;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.attendance-modal-header h2 {
|
|
margin: 0;
|
|
color: #2c3e50;
|
|
font-size: 24px;
|
|
}
|
|
|
|
.attendance-modal-close {
|
|
font-size: 32px;
|
|
font-weight: bold;
|
|
color: #95a5a6;
|
|
cursor: pointer;
|
|
background: none;
|
|
border: none;
|
|
padding: 0;
|
|
width: 40px;
|
|
height: 40px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border-radius: 50%;
|
|
transition: all 0.3s;
|
|
}
|
|
|
|
.attendance-modal-close:hover {
|
|
background-color: #ecf0f1;
|
|
color: #2c3e50;
|
|
}
|
|
|
|
.attendance-modal-body {
|
|
flex: 1;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.attendance-modal-body iframe {
|
|
width: 100%;
|
|
height: 100%;
|
|
border: none;
|
|
}
|
|
|
|
/* 상세 연결 상태 표시기 */
|
|
.connection-status {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 6px 12px;
|
|
background: #f8f9fa;
|
|
border-radius: 20px;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
color: #7f8c8d;
|
|
border: 1px solid #ecf0f1;
|
|
transition: all 0.3s;
|
|
}
|
|
|
|
.status-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
background-color: #bdc3c7;
|
|
/* 연결 끊김/대기 */
|
|
box-shadow: 0 0 0 2px rgba(189, 195, 199, 0.2);
|
|
transition: all 0.3s;
|
|
}
|
|
|
|
.connection-status.connected {
|
|
background: #e8f8f5;
|
|
color: #27ae60;
|
|
border-color: #a3e4d7;
|
|
}
|
|
|
|
.connection-status.connected .status-dot {
|
|
background-color: #2ecc71;
|
|
/* 연결됨 */
|
|
box-shadow: 0 0 0 2px rgba(46, 204, 113, 0.2);
|
|
animation: pulse-green 2s infinite;
|
|
}
|
|
|
|
.connection-status.disconnected {
|
|
background: #fdf2e9;
|
|
color: #e67e22;
|
|
border-color: #fad7a0;
|
|
}
|
|
|
|
.connection-status.disconnected .status-dot {
|
|
background-color: #e67e22;
|
|
box-shadow: 0 0 0 2px rgba(230, 126, 34, 0.2);
|
|
}
|
|
|
|
@keyframes pulse-green {
|
|
0% {
|
|
box-shadow: 0 0 0 0 rgba(46, 204, 113, 0.4);
|
|
}
|
|
|
|
70% {
|
|
box-shadow: 0 0 0 4px rgba(46, 204, 113, 0);
|
|
}
|
|
|
|
100% {
|
|
box-shadow: 0 0 0 0 rgba(46, 204, 113, 0);
|
|
}
|
|
}
|
|
|
|
/* Store Font Settings Application */
|
|
.info-text,
|
|
.register-btn,
|
|
#batchBtn,
|
|
.class-tab,
|
|
.quick-register-input {
|
|
font-family: var(--manager-font-family) !important;
|
|
font-size: var(--manager-font-size) !important;
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
<input type="text" id="waitingInput" style="position: absolute; left: -9999px; opacity: 0; pointer-events: none;"
|
|
autocomplete="off">
|
|
<!-- Quick Register Modal (Moved to Top) -->
|
|
<div id="quickRegisterModal" class="custom-modal">
|
|
<div class="modal-content" style="max-width: 400px;">
|
|
<div class="modal-header">
|
|
<h3>회원 이름 등록</h3>
|
|
</div>
|
|
<div style="padding: 20px;">
|
|
<p style="margin-bottom: 20px; font-size: 16px;">핸드폰번호: <span id="quickRegPhoneDisplay"
|
|
style="font-weight: bold; color: #2980b9;"></span></p>
|
|
<input type="hidden" id="quickRegPhone">
|
|
<div class="form-group" style="margin-bottom: 15px;">
|
|
<label style="display: block; margin-bottom: 8px; font-weight: 500;">이름</label>
|
|
<input type="text" id="quickRegName" class="form-control"
|
|
style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px;"
|
|
placeholder="이름 입력" required>
|
|
</div>
|
|
<div class="form-group" style="margin-bottom: 20px;">
|
|
<label style="display: block; margin-bottom: 8px; font-weight: 500;">바코드 (선택)</label>
|
|
<input type="text" id="quickRegBarcode" class="form-control"
|
|
style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px;"
|
|
placeholder="바코드">
|
|
</div>
|
|
<div class="modal-footer"
|
|
style="display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px;">
|
|
<button type="button" class="btn btn-secondary" onclick="closeQuickRegisterModal()">취소</button>
|
|
<button class="btn btn-primary" onclick="saveQuickRegister()">저장</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="container">
|
|
<div class="manage-header">
|
|
<div>
|
|
<h1 id="storeNameHeader" style="margin: 0; display: flex; align-items: center; gap: 10px;">
|
|
<span id="storeNameTitle">매장명</span> 대기자 관리 <span
|
|
style="font-size: 12px; color: #aaa;">(v.Fixed)</span>
|
|
<span id="headerBusinessDate"
|
|
style="font-size: 22px; font-weight: bold; color: #2c3e50; background: #ecf0f1; padding: 8px 18px; border-radius: 20px;">
|
|
<!-- 날짜 표시 영역 -->
|
|
</span>
|
|
</h1>
|
|
<p class="subtitle">대기자 출석, 취소, 순서 변경 관리</p>
|
|
</div>
|
|
<a href="/" class="btn btn-secondary">← 메인으로</a>
|
|
</div>
|
|
|
|
<div class="batch-section">
|
|
<div class="batch-info">
|
|
<span id="batchInfo" class="info-text empty-message">대기자가 없습니다</span>
|
|
|
|
<!-- Quick Register Section -->
|
|
<div class="quick-register"
|
|
style="display: flex; align-items: center; gap: 8px; margin-left: 20px; margin-right: auto;">
|
|
<input type="text" id="quickRegisterInput" class="form-control quick-register-input"
|
|
placeholder="이름/핸드폰번호(8자리)/바코드" onkeyup="if(event.key==='Enter') handleQuickRegister()"
|
|
autocomplete="off">
|
|
<button class="btn btn-primary register-btn" onclick="handleQuickRegister()"
|
|
style="height: 48px; padding: 0 20px; white-space: nowrap; border-radius: 8px; font-weight: 600;">대기
|
|
등록</button>
|
|
<!-- Loading Indicator for Quick Register -->
|
|
<div id="quickRegisterLoading" style="display: none; color: #3498db;">
|
|
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
|
</div>
|
|
</div>
|
|
<button id="batchBtn" class="btn btn-success" style="display: none;" onclick="processBatchAttendance()">
|
|
1교시 마감 (0명)
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="class-tabs" id="classTabs">
|
|
<!-- 동적으로 생성 -->
|
|
</div>
|
|
|
|
<div class="waiting-table" id="waitingTable">
|
|
<div class="loading">
|
|
<div class="spinner"></div>
|
|
<p>로딩 중...</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 하단 상태바 -->
|
|
<div class="footer-status-bar">
|
|
<div class="footer-status-item">
|
|
<div id="footerConnectionStatus" class="status-indicator connecting"></div>
|
|
<span id="footerConnectionText">실시간 연결 중...</span>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// Helper function to get headers with store ID
|
|
function getHeaders() {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const storeId = params.get('store_id');
|
|
const headers = {
|
|
'Content-Type': 'application/json'
|
|
};
|
|
if (storeId) {
|
|
headers['X-Store-ID'] = storeId;
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
let classes = [];
|
|
let currentClassId = null;
|
|
let batchClassId = null;
|
|
let eventSource = null;
|
|
let closedClasses = new Set(); // 마감된 교시 추적
|
|
let currentStoreId = null; // 전역 변수로 storeId 저장
|
|
|
|
// Debounce Utility
|
|
function debounce(func, wait) {
|
|
let timeout;
|
|
return function executedFunction(...args) {
|
|
const later = () => {
|
|
clearTimeout(timeout);
|
|
func(...args);
|
|
};
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(later, wait);
|
|
};
|
|
}
|
|
|
|
// Debounced Functions
|
|
const debouncedUpdateClassCounts = debounce(() => updateClassCounts(), 300);
|
|
const debouncedLoadBatchInfo = debounce(() => loadBatchInfo(), 300);
|
|
|
|
// 초기화
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
console.log('[INIT] DOMContentLoaded fired');
|
|
await applyStoreFonts(); // Apply fonts early
|
|
|
|
// URL 파라미터에서 store_id 또는 store 확인
|
|
const params = new URLSearchParams(window.location.search);
|
|
let storeId = params.get('store_id');
|
|
const storeCode = params.get('store');
|
|
|
|
try {
|
|
// store_id가 없고 store(code)만 있는 경우 ID 조회
|
|
if (!storeId && storeCode) {
|
|
const codeResponse = await fetch(`/api/stores/code/${storeCode}`, {
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
|
|
if (codeResponse.ok) {
|
|
const storeData = await codeResponse.json();
|
|
storeId = storeData.id;
|
|
|
|
// URL에 store_id가 없으면 추가하여 히스토리 업데이트
|
|
const newUrl = new URL(window.location);
|
|
newUrl.searchParams.set('store_id', storeId);
|
|
window.history.replaceState({}, '', newUrl);
|
|
} else {
|
|
throw new Error('매장 코드로 매장 정보를 찾을 수 없습니다.');
|
|
}
|
|
}
|
|
|
|
if (!storeId) {
|
|
alert('매장 정보가 없습니다.');
|
|
window.location.href = '/';
|
|
return;
|
|
}
|
|
|
|
currentStoreId = storeId; // 전역 변수에 storeId 저장
|
|
console.log('[INIT] Store ID set:', currentStoreId);
|
|
|
|
// 매장 정보 조회 및 표시
|
|
const response = await fetch(`/api/stores/${storeId}`, { headers: getHeaders() });
|
|
if (response.ok) {
|
|
const storeData = await response.json();
|
|
document.getElementById('storeNameTitle').textContent = storeData.name;
|
|
console.log('[INIT] Store name set:', storeData.name);
|
|
} else {
|
|
console.error("[INIT] 매장 정보 조회 실패:", response.status, response.statusText);
|
|
alert('매장 정보를 불러오는데 실패했습니다.');
|
|
window.location.href = '/';
|
|
return;
|
|
}
|
|
|
|
// Load initial data
|
|
// loadBusinessDate is called inside loadClasses, so we skip explicit call here to avoid duplication
|
|
// console.log('[INIT] Loading business date...');
|
|
// await loadBusinessDate();
|
|
console.log('[INIT] Loading classes...');
|
|
await loadClasses();
|
|
|
|
// Auto-focus quick registration input after page loads
|
|
setTimeout(() => {
|
|
const quickInput = document.getElementById('quickRegisterInput');
|
|
if (quickInput) quickInput.focus();
|
|
}, 500);
|
|
|
|
// SSE 연결 설정
|
|
console.log('[INIT] Initializing SSE...');
|
|
initSSE();
|
|
loadBatchInfo(); // 배치 정보도 초기 로드
|
|
console.log('[INIT] Initialization complete!');
|
|
|
|
} catch (e) {
|
|
console.error("[INIT] 매장 초기화 실패", e);
|
|
alert('매장 정보를 불러오는 중 오류가 발생했습니다: ' + e.message);
|
|
window.location.href = '/';
|
|
}
|
|
});
|
|
|
|
// 요일 매핑
|
|
const WEEKDAY_MAP = {
|
|
0: "mon", // Monday
|
|
1: "tue", // Tuesday
|
|
2: "wed", // Wednesday
|
|
3: "thu", // Thursday
|
|
4: "fri", // Friday
|
|
5: "sat", // Saturday
|
|
6: "sun" // Sunday
|
|
};
|
|
|
|
// 영업일 기준 요일 구하기
|
|
function getBusinessDayWeekday(dateString) {
|
|
if (!dateString) return new Date().getDay(); // fallback
|
|
const date = new Date(dateString);
|
|
// JS getDay(): 0=Sun, 1=Mon, ..., 6=Sat
|
|
// Python weekday(): 0=Mon, ..., 6=Sun
|
|
// We need to match Python's weekday for WEEKDAY_MAP keys if keys correspond to Python's 0-6
|
|
// But wait, the previous WEEKDAY_MAP in JS (seen in snippet 650's `manage.html` was not fully visible but typically JS uses 0-6).
|
|
// Let's check python map: 0:mon, 1:tue... 6:sun.
|
|
// JS getDay: 0=Sun. 1=Mon.
|
|
// So JS 1 -> Mon (0), JS 2 -> Tue (1), ... JS 0 -> Sun (6).
|
|
const jsDay = date.getDay();
|
|
const pythonDay = jsDay === 0 ? 6 : jsDay - 1;
|
|
return pythonDay;
|
|
}
|
|
|
|
// 오늘 요일에 맞는 클래스만 필터링
|
|
function filterClassesByToday(classes) {
|
|
// business_date가 있으면 그것을 사용, 없으면 현재 시간
|
|
const dateText = document.getElementById('headerBusinessDate').textContent;
|
|
let currentDay;
|
|
if (dateText) {
|
|
currentDay = getBusinessDayWeekday(dateText);
|
|
} else {
|
|
const jsDay = new Date().getDay();
|
|
currentDay = jsDay === 0 ? 6 : jsDay - 1;
|
|
}
|
|
|
|
const weekdayKey = WEEKDAY_MAP[currentDay];
|
|
|
|
return classes.filter(cls => {
|
|
try {
|
|
const schedule = JSON.parse(cls.weekday_schedule);
|
|
return schedule[weekdayKey] === true;
|
|
} catch (e) {
|
|
return true; // 파싱 실패 시 기본적으로 표시
|
|
}
|
|
});
|
|
}
|
|
|
|
// 연결 상태 업데이트 (UI)
|
|
function updateConnectionStatus(status) {
|
|
const indicator = document.getElementById('footerConnectionStatus');
|
|
const text = document.getElementById('footerConnectionText');
|
|
|
|
// 기존 클래스 제거
|
|
indicator.classList.remove('connected', 'disconnected', 'connecting');
|
|
|
|
if (status === 'connected') {
|
|
indicator.classList.add('connected');
|
|
text.textContent = '실시간 시스템 연결됨';
|
|
text.style.color = '#2ecc71';
|
|
} else if (status === 'disconnected') {
|
|
indicator.classList.add('disconnected');
|
|
text.textContent = '연결 끊김 (자동 재연결 중...)';
|
|
text.style.color = '#e74c3c';
|
|
} else {
|
|
indicator.classList.add('connecting');
|
|
text.textContent = '시스템 연결 중...';
|
|
text.style.color = '#f39c12';
|
|
}
|
|
}
|
|
|
|
// SSE 연결 초기화
|
|
function initSSE() {
|
|
if (eventSource) {
|
|
eventSource.close();
|
|
}
|
|
|
|
const storeId = localStorage.getItem('selected_store_id') || 'default';
|
|
console.log(`SSE 연결 시도: Store ID = ${storeId}`);
|
|
eventSource = new EventSource(`/api/sse/stream?store_id=${storeId}`);
|
|
|
|
eventSource.onopen = () => {
|
|
console.log('SSE 연결됨');
|
|
updateConnectionStatus('connected');
|
|
};
|
|
|
|
eventSource.onmessage = (event) => {
|
|
try {
|
|
const message = JSON.parse(event.data);
|
|
// Heartbeat 처리
|
|
if (message.event === 'ping') {
|
|
return;
|
|
}
|
|
handleSSEMessage(message);
|
|
} catch (error) {
|
|
console.error('SSE 메시지 파싱 오류:', error);
|
|
}
|
|
};
|
|
|
|
eventSource.onerror = (error) => {
|
|
console.error('SSE 오류:', error);
|
|
updateConnectionStatus('disconnected');
|
|
eventSource.close(); // 명시적 종료
|
|
|
|
// 3초 후 재연결 시도
|
|
setTimeout(() => {
|
|
console.log('SSE 재연결 시도...');
|
|
initSSE();
|
|
}, 3000);
|
|
};
|
|
}
|
|
|
|
// SSE 메시지 처리
|
|
function handleSSEMessage(message) {
|
|
console.log('SSE 메시지 수신:', message);
|
|
|
|
switch (message.event) {
|
|
case 'connected':
|
|
console.log('SSE 연결 확인됨');
|
|
break;
|
|
|
|
case 'new_user':
|
|
// 새로운 대기자 등록 - 부드럽게 추가 (새로고침 없음)
|
|
// 새로운 대기자 등록 - 부드럽게 추가 (새로고침 없음)
|
|
debouncedUpdateClassCounts();
|
|
addNewWaitingItem(message.data);
|
|
debouncedLoadBatchInfo();
|
|
break;
|
|
|
|
case 'status_changed':
|
|
// 상태 변경 (출석/취소) - 해당 항목만 제거 (새로고침 없음)
|
|
removeWaitingItem(message.data.waiting_id);
|
|
// 상태 변경 (출석/취소) - 해당 항목만 제거 (새로고침 없음)
|
|
removeWaitingItem(message.data.waiting_id);
|
|
debouncedUpdateClassCounts();
|
|
debouncedLoadBatchInfo();
|
|
break;
|
|
|
|
case 'user_called':
|
|
// 호출 - 시각적 피드백 (하이라이트 애니메이션)
|
|
highlightWaitingItem(message.data.waiting_id);
|
|
console.log('대기자 호출:', message.data.waiting_number);
|
|
break;
|
|
|
|
case 'order_changed':
|
|
// 순서 변경 - 부드럽게 재배치 (새로고침 없음)
|
|
updateWaitingOrder();
|
|
break;
|
|
|
|
case 'class_moved':
|
|
// 클래스 이동 - 카운트 업데이트 + 부드러운 재배치
|
|
debouncedUpdateClassCounts();
|
|
updateWaitingOrder();
|
|
break;
|
|
|
|
case 'member_updated':
|
|
console.log('[SSE] 회원 정보 업데이트:', message.data);
|
|
// 현재 보고 있는 클래스 목록 새로고침하여 이름 변경 반영
|
|
if (currentClassId) {
|
|
selectClass(currentClassId);
|
|
}
|
|
break;
|
|
|
|
case 'class_closed':
|
|
// 교시 마감 - 마감 상태 추적 + 탭 업데이트
|
|
closedClasses.add(message.data.class_id); // 마감된 교시 추가
|
|
|
|
// 현재 선택된 클래스가 방금 마감된 클래스라면 다음 미마감 교시로 자동 전환
|
|
if (currentClassId === message.data.class_id) {
|
|
// 클래스 목록 다시 가져오기
|
|
updateClassCounts().then(() => {
|
|
// 미마감 교시 찾기
|
|
const openClasses = classes.filter(cls => !closedClasses.has(cls.id));
|
|
|
|
if (openClasses.length > 0) {
|
|
// 다음 미마감 교시로 자동 전환
|
|
selectClass(openClasses[0].id);
|
|
showNotificationModal('교시 마감', `${message.data.class_name}이(가) 마감되었습니다.\n다음 교시(${openClasses[0].class_name})로 이동합니다.`);
|
|
} else {
|
|
// 모든 교시가 마감된 경우
|
|
currentClassId = null;
|
|
document.getElementById('waitingTable').innerHTML = `
|
|
<div class="empty-state fade-in">
|
|
<div class="icon">🎉</div>
|
|
<p>모든 교시가 마감되었습니다</p>
|
|
</div>
|
|
`;
|
|
showNotificationModal('교시 마감', `${message.data.class_name}이(가) 마감되었습니다.\n모든 교시가 마감되었습니다.`);
|
|
}
|
|
});
|
|
} else {
|
|
// 다른 교시를 보고 있으면 카운트만 업데이트
|
|
debouncedUpdateClassCounts();
|
|
}
|
|
|
|
loadBatchInfo();
|
|
break;
|
|
|
|
case 'class_reopened':
|
|
// 교시 마감 해제 - 마감 상태에서 제거 + 탭 업데이트
|
|
closedClasses.delete(message.data.class_id); // 마감된 교시에서 제거
|
|
debouncedUpdateClassCounts();
|
|
debouncedLoadBatchInfo();
|
|
// 현재 선택된 클래스가 마감 해제된 클래스라면 목록도 업데이트
|
|
if (currentClassId === message.data.class_id) {
|
|
// 테이블의 closed 클래스 제거
|
|
const table = document.getElementById('waitingTable');
|
|
table.classList.remove('closed');
|
|
updateWaitingOrder();
|
|
}
|
|
alert(`${message.data.class_name}의 마감이 해제되었습니다.`);
|
|
break;
|
|
|
|
case 'empty_seat_inserted':
|
|
// 빈 좌석 삽입 - 부드럽게 재배치 (새로고침 없음)
|
|
updateWaitingOrder();
|
|
break;
|
|
|
|
default:
|
|
console.log('알 수 없는 이벤트:', message.event);
|
|
}
|
|
}
|
|
|
|
// 클래스별 카운트 업데이트 (완료된 교시도 유지)
|
|
async function updateClassCounts() {
|
|
try {
|
|
const response = await fetch('/api/waiting/list/by-class', { headers: getHeaders() });
|
|
const data = await response.json();
|
|
|
|
// 메모리에 저장된 클래스 정보가 없으면 fetch (최초 1회 또는 강제 갱신)
|
|
if (classes.length === 0) {
|
|
const allActiveClasses = await fetch('/api/classes/', { headers: getHeaders() }).then(r => r.json());
|
|
// 전역 변수에 저장 (캐싱) - 필터링 전 원본은 따로 저장하지 않고 바로 처리하지만,
|
|
// 여기서는 classes 변수가 이미 있는데 이게 필터링된 결과임.
|
|
// 최적화를 위해 fetch는 최소화.
|
|
}
|
|
|
|
// 현재 선택된 클래스 ID 저장
|
|
const previousClassId = currentClassId;
|
|
|
|
// 대기자가 있는 클래스 + 마감된 교시 모두 유지
|
|
const classesWithWaiting = data.map(cls => ({
|
|
id: cls.class_id,
|
|
class_name: cls.class_name,
|
|
class_number: cls.class_number,
|
|
start_time: cls.start_time,
|
|
end_time: cls.end_time,
|
|
max_capacity: cls.max_capacity,
|
|
current_count: cls.current_count
|
|
}));
|
|
|
|
// 마감된 교시 로직은 유지하되, /api/classes/ 호출을 최적화해야 함.
|
|
// 하지만 현재 구조상 /api/classes/를 매번 부르고 있음.
|
|
// 이를 개선하기 위해 전역 변수 'allClassesCache' 도입 필요, 혹은 여기서는 일단 debounce만으로도 효과가 큼.
|
|
// 우선 debounce 적용에 집중하고, fetch 최적화는 별도로 진행하거나 간단히 캐싱.
|
|
|
|
|
|
// 마감된 교시 중 대기자가 없는 클래스 추가 (마감 탭 유지)
|
|
const allActiveClasses = await fetch('/api/classes/', { headers: getHeaders() }).then(r => r.json());
|
|
// 오늘 요일에 운영되는 클래스만 필터링
|
|
const todayClasses = filterClassesByToday(allActiveClasses);
|
|
todayClasses.forEach(cls => {
|
|
if (closedClasses.has(cls.id) && !classesWithWaiting.find(c => c.id === cls.id)) {
|
|
classesWithWaiting.push({
|
|
id: cls.id,
|
|
class_name: cls.class_name,
|
|
class_number: cls.class_number,
|
|
start_time: cls.start_time,
|
|
end_time: cls.end_time,
|
|
max_capacity: cls.max_capacity,
|
|
current_count: 0
|
|
});
|
|
}
|
|
});
|
|
|
|
// 교시 번호순으로 정렬
|
|
classesWithWaiting.sort((a, b) => a.class_number - b.class_number);
|
|
|
|
// 탭 구성이 변경되었는지 확인
|
|
const tabsChanged = classes.length !== classesWithWaiting.length ||
|
|
classes.some((cls, idx) => cls.id !== classesWithWaiting[idx]?.id);
|
|
|
|
if (tabsChanged) {
|
|
// 탭 구성이 변경됨 - 다시 렌더링
|
|
classes = classesWithWaiting;
|
|
renderClassTabs();
|
|
|
|
// 이전에 선택했던 클래스가 여전히 존재하면 선택 유지
|
|
const stillExists = classes.find(c => c.id === previousClassId);
|
|
if (stillExists) {
|
|
selectClass(previousClassId);
|
|
} else if (classes.length > 0) {
|
|
// 이전 클래스가 사라졌으면 첫 번째 클래스 선택
|
|
selectClass(classes[0].id);
|
|
} else {
|
|
// 모든 대기가 완료된 경우
|
|
currentClassId = null;
|
|
document.getElementById('waitingTable').innerHTML = `
|
|
<div class="empty-state fade-in">
|
|
<div class="icon">🎉</div>
|
|
<p>모든 대기가 완료되었습니다</p>
|
|
</div>
|
|
`;
|
|
}
|
|
} else {
|
|
// 탭 구성은 그대로, 카운트만 업데이트
|
|
classes = classesWithWaiting;
|
|
renderClassTabs();
|
|
}
|
|
} catch (error) {
|
|
console.error('클래스 카운트 업데이트 실패:', error);
|
|
}
|
|
}
|
|
|
|
// 새로운 대기자 추가 (애니메이션과 함께)
|
|
async function addNewWaitingItem(data) {
|
|
try {
|
|
// 현재 선택된 클래스가 아니면 무시
|
|
if (currentClassId !== data.class_id) {
|
|
return;
|
|
}
|
|
|
|
const table = document.getElementById('waitingTable');
|
|
|
|
// 빈 상태 메시지 제거
|
|
const emptyState = table.querySelector('.empty-state');
|
|
if (emptyState) {
|
|
emptyState.remove();
|
|
}
|
|
|
|
// 새 항목의 위치 찾기 (class_order 기준)
|
|
const response = await fetch(`/api/waiting/${data.waiting_id}`, { headers: getHeaders() });
|
|
const newItem = await response.json();
|
|
|
|
const newItemEl = createWaitingItem(newItem);
|
|
newItemEl.classList.add('slide-in');
|
|
|
|
// 올바른 위치에 삽입
|
|
const existingItems = Array.from(table.querySelectorAll('.waiting-item'));
|
|
let inserted = false;
|
|
|
|
for (let i = 0; i < existingItems.length; i++) {
|
|
const item = existingItems[i];
|
|
const itemOrder = parseInt(item.querySelector('.item-number').dataset.order || 999);
|
|
|
|
if (newItem.class_order < itemOrder) {
|
|
table.insertBefore(newItemEl, item);
|
|
inserted = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!inserted) {
|
|
table.appendChild(newItemEl);
|
|
}
|
|
|
|
// 애니메이션 클래스 제거
|
|
setTimeout(() => {
|
|
newItemEl.classList.remove('slide-in');
|
|
}, 400);
|
|
|
|
} catch (error) {
|
|
console.error('대기자 추가 실패:', error);
|
|
// 실패 시 전체 리프레시
|
|
await refreshWaitingList();
|
|
}
|
|
}
|
|
|
|
// 대기자 항목 제거 (애니메이션과 함께)
|
|
function removeWaitingItem(waitingId) {
|
|
const items = document.querySelectorAll('.waiting-item');
|
|
items.forEach(item => {
|
|
if (item.dataset.waitingId == waitingId) {
|
|
// 애니메이션 클래스 추가
|
|
item.classList.add('slide-out');
|
|
|
|
setTimeout(() => {
|
|
item.remove();
|
|
checkEmptyState();
|
|
}, 400);
|
|
}
|
|
});
|
|
}
|
|
|
|
// 대기자 항목 하이라이트 (호출 시)
|
|
function highlightWaitingItem(waitingId) {
|
|
const items = document.querySelectorAll('.waiting-item');
|
|
items.forEach(item => {
|
|
if (item.dataset.waitingId == waitingId) {
|
|
// highlight 클래스 추가
|
|
item.classList.add('highlight');
|
|
|
|
setTimeout(() => {
|
|
item.classList.remove('highlight');
|
|
}, 1500);
|
|
}
|
|
});
|
|
}
|
|
|
|
// 순서 변경 시 부드럽게 재배치 (전체 리로드 대신)
|
|
async function updateWaitingOrder() {
|
|
try {
|
|
const table = document.getElementById('waitingTable');
|
|
const currentScroll = window.scrollY;
|
|
|
|
// 현재 항목들 가져오기 (마감된 교시는 그대로 대기 목록 표시)
|
|
const isClosed = closedClasses.has(currentClassId);
|
|
const status = 'waiting'; // 마감된 교시도 waiting 상태 유지
|
|
const response = await fetch(`/api/waiting/list?status=${status}&class_id=${currentClassId}`, { headers: getHeaders() });
|
|
const waitingList = await response.json();
|
|
|
|
if (waitingList.length === 0) {
|
|
checkEmptyState();
|
|
return;
|
|
}
|
|
|
|
// 새로운 순서대로 재배치 (항상 새로 생성하여 드래그 이벤트 보장)
|
|
table.innerHTML = '';
|
|
waitingList.forEach(item => {
|
|
// 항상 새로 생성하여 드래그 앤 드롭 이벤트 리스너 보장
|
|
const newItem = createWaitingItem(item);
|
|
newItem.classList.add('fade-in');
|
|
table.appendChild(newItem);
|
|
|
|
setTimeout(() => {
|
|
newItem.classList.remove('fade-in');
|
|
}, 300);
|
|
});
|
|
|
|
// 스크롤 위치 복원
|
|
window.scrollTo(0, currentScroll);
|
|
|
|
} catch (error) {
|
|
console.error('순서 업데이트 실패:', error);
|
|
}
|
|
}
|
|
|
|
// 대기자 리스트 새로고침 (스크롤 위치 유지)
|
|
async function refreshWaitingList() {
|
|
const table = document.getElementById('waitingTable');
|
|
const scrollPos = window.scrollY;
|
|
|
|
try {
|
|
const isClosed = closedClasses.has(currentClassId);
|
|
const status = 'waiting'; // 마감된 교시도 waiting 상태 유지
|
|
const response = await fetch(`/api/waiting/list?status=${status}&class_id=${currentClassId}`, { headers: getHeaders() });
|
|
const waitingList = await response.json();
|
|
|
|
if (waitingList.length === 0) {
|
|
const emptyMessage = isClosed ? '마감된 교시에 대기자가 없습니다' : '대기자가 없습니다';
|
|
table.innerHTML = `
|
|
<div class="empty-state">
|
|
<div class="icon">📭</div>
|
|
<p>${emptyMessage}</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
table.innerHTML = '';
|
|
waitingList.forEach(item => {
|
|
const itemEl = createWaitingItem(item);
|
|
table.appendChild(itemEl);
|
|
});
|
|
|
|
// 스크롤 위치 복원
|
|
window.scrollTo(0, scrollPos);
|
|
|
|
} catch (error) {
|
|
console.error('대기자 조회 실패:', error);
|
|
}
|
|
}
|
|
|
|
// 빈 상태 체크
|
|
function checkEmptyState() {
|
|
const table = document.getElementById('waitingTable');
|
|
if (table.children.length === 0) {
|
|
table.innerHTML = `
|
|
<div class="empty-state">
|
|
<div class="icon">📭</div>
|
|
<p>대기자가 없습니다</p>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
async function loadBusinessDate() {
|
|
console.log('[DEBUG] loadBusinessDate called');
|
|
try {
|
|
const response = await fetch('/api/daily/check-status', { headers: getHeaders() });
|
|
const data = await response.json();
|
|
console.log('[DEBUG] API response:', data);
|
|
|
|
if (data && data.business_date) {
|
|
// 날짜를 한국어 형식으로 변환
|
|
const date = new Date(data.business_date);
|
|
const year = date.getFullYear();
|
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
const day = String(date.getDate()).padStart(2, '0');
|
|
const formattedDate = `📅 ${year}년 ${month}월 ${day}일`;
|
|
|
|
console.log('[DEBUG] Formatted date:', formattedDate);
|
|
const element = document.getElementById('headerBusinessDate');
|
|
console.log('[DEBUG] Element found:', element);
|
|
|
|
if (element) {
|
|
element.textContent = formattedDate;
|
|
console.log('[DEBUG] Date set successfully');
|
|
} else {
|
|
console.error('[DEBUG] headerBusinessDate element not found!');
|
|
}
|
|
} else {
|
|
console.log('[DEBUG] No business_date in response');
|
|
document.getElementById('headerBusinessDate').textContent = '';
|
|
}
|
|
} catch (error) {
|
|
console.error('[DEBUG] 영업일 정보 조회 실패:', error);
|
|
}
|
|
}
|
|
|
|
async function loadClasses() {
|
|
try {
|
|
// 개점일 표시
|
|
await loadBusinessDate();
|
|
|
|
// 마감된 교시 목록 조회
|
|
const closedResponse = await fetch('/api/board/closed-classes', { headers: getHeaders() });
|
|
const closedData = await closedResponse.json();
|
|
closedClasses = new Set(closedData.closed_class_ids);
|
|
|
|
// 대기자가 있는 클래스만 조회
|
|
const response = await fetch('/api/waiting/list/by-class', { headers: getHeaders() });
|
|
const classData = await response.json();
|
|
|
|
// classes 배열 업데이트
|
|
classes = classData.map(cls => ({
|
|
id: cls.class_id,
|
|
class_name: cls.class_name,
|
|
class_number: cls.class_number,
|
|
start_time: cls.start_time,
|
|
end_time: cls.end_time,
|
|
max_capacity: cls.max_capacity,
|
|
current_count: cls.current_count
|
|
}));
|
|
|
|
renderClassTabs();
|
|
|
|
if (classes.length > 0) {
|
|
// 마감된 교시가 있으면 그 다음 미마감 교시를 선택
|
|
const closedClassList = classes.filter(cls => closedClasses.has(cls.id));
|
|
if (closedClassList.length > 0) {
|
|
// 가장 높은 번호의 마감 교시 찾기
|
|
const highestClosedClass = closedClassList[closedClassList.length - 1];
|
|
|
|
// 해당 마감 교시 다음의 미마감 교시 찾기
|
|
const nextOpenClass = classes.find(cls =>
|
|
cls.class_number > highestClosedClass.class_number &&
|
|
!closedClasses.has(cls.id)
|
|
);
|
|
|
|
if (nextOpenClass) {
|
|
// 다음 미마감 교시가 있으면 선택 (예: 7교시 마감 시 8교시 선택)
|
|
selectClass(nextOpenClass.id);
|
|
} else {
|
|
// 다음 미마감 교시가 없으면 첫 번째 미마감 교시 선택
|
|
const firstOpenClass = classes.find(cls => !closedClasses.has(cls.id));
|
|
if (firstOpenClass) {
|
|
selectClass(firstOpenClass.id);
|
|
} else {
|
|
// 모든 교시가 마감된 경우, 가장 높은 번호의 마감 교시 선택
|
|
selectClass(highestClosedClass.id);
|
|
}
|
|
}
|
|
} else {
|
|
// 마감된 교시가 없으면 첫 번째 교시 선택
|
|
selectClass(classes[0].id);
|
|
}
|
|
} else {
|
|
// 대기자가 없는 경우
|
|
document.getElementById('waitingTable').innerHTML = `
|
|
<div class="empty-state fade-in">
|
|
<div class="icon">🎉</div>
|
|
<p>모든 대기가 완료되었습니다</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// 다음 교시 마감 대상 조회
|
|
await loadBatchInfo();
|
|
|
|
} catch (error) {
|
|
console.error('클래스 조회 실패:', error);
|
|
}
|
|
}
|
|
|
|
function renderClassTabs() {
|
|
const tabsContainer = document.getElementById('classTabs');
|
|
tabsContainer.innerHTML = '';
|
|
|
|
// 미마감 교시만 표시 (마감된 교시는 숨김)
|
|
const openClasses = classes.filter(cls => !closedClasses.has(cls.id));
|
|
|
|
// 미마감 교시 탭만 렌더링
|
|
openClasses.forEach(cls => {
|
|
const tab = document.createElement('div');
|
|
tab.className = 'class-tab';
|
|
tab.onclick = () => selectClass(cls.id);
|
|
|
|
tab.innerHTML = `
|
|
${cls.class_name}
|
|
<span class="count">${cls.current_count || 0}명</span>
|
|
`;
|
|
tabsContainer.appendChild(tab);
|
|
});
|
|
|
|
// 탭 선택 상태 업데이트
|
|
updateTabSelection();
|
|
}
|
|
|
|
// 드롭다운 토글 함수
|
|
function toggleClosedDropdown() {
|
|
const btn = document.getElementById('closedDropdownBtn');
|
|
const menu = document.getElementById('closedDropdownMenu');
|
|
|
|
if (menu.classList.contains('show')) {
|
|
menu.classList.remove('show');
|
|
btn.classList.remove('active');
|
|
} else {
|
|
menu.classList.add('show');
|
|
btn.classList.add('active');
|
|
}
|
|
}
|
|
|
|
// 외부 클릭 시 드롭다운 닫기
|
|
document.addEventListener('click', (e) => {
|
|
const dropdown = document.querySelector('.closed-classes-dropdown');
|
|
if (dropdown && !dropdown.contains(e.target)) {
|
|
|
|
const menu = document.getElementById('closedDropdownMenu');
|
|
const btn = document.getElementById('closedDropdownBtn');
|
|
if (menu && menu.classList.contains('show')) {
|
|
console.log('[DEBUG] Global click closed dropdown');
|
|
menu.classList.remove('show');
|
|
}
|
|
if (btn) btn.classList.remove('active');
|
|
}
|
|
});
|
|
|
|
// 탭 선택 상태 업데이트
|
|
function updateTabSelection() {
|
|
// 미마감 교시 탭만 업데이트
|
|
const openClasses = classes.filter(cls => !closedClasses.has(cls.id));
|
|
document.querySelectorAll('.class-tab').forEach((tab, idx) => {
|
|
if (openClasses[idx]?.id === currentClassId) {
|
|
tab.classList.add('active');
|
|
} else {
|
|
tab.classList.remove('active');
|
|
}
|
|
});
|
|
}
|
|
|
|
function selectClass(classId) {
|
|
currentClassId = classId;
|
|
|
|
// 탭 선택 상태 업데이트 (미마감 및 마감 교시 모두 처리)
|
|
updateTabSelection();
|
|
|
|
// 마감된 교시인지 확인하고 테이블에 closed 클래스 추가/제거
|
|
const table = document.getElementById('waitingTable');
|
|
if (closedClasses.has(classId)) {
|
|
table.classList.add('closed');
|
|
} else {
|
|
table.classList.remove('closed');
|
|
}
|
|
|
|
loadWaitingList();
|
|
loadBatchInfo(); // 교시 선택 시 배치 정보 업데이트 (마감된 교시면 해제 버튼 표시)
|
|
}
|
|
|
|
async function loadWaitingList() {
|
|
const table = document.getElementById('waitingTable');
|
|
|
|
try {
|
|
// 마감된 교시도 대기 목록을 그대로 보여줌 (비활성화 상태로)
|
|
const isClosed = closedClasses.has(currentClassId);
|
|
const status = 'waiting';
|
|
const response = await fetch(`/api/waiting/list?status=${status}&class_id=${currentClassId}`, { headers: getHeaders() });
|
|
const waitingList = await response.json();
|
|
|
|
// 부드러운 전환을 위해 기존 항목 유지하면서 업데이트
|
|
if (waitingList.length === 0) {
|
|
// 빈 상태로 전환
|
|
if (!table.querySelector('.empty-state')) {
|
|
const emptyMessage = isClosed ? '마감된 교시에 대기자가 없습니다' : '대기자가 없습니다';
|
|
table.innerHTML = `
|
|
<div class="empty-state fade-in">
|
|
<div class="icon">📭</div>
|
|
<p>${emptyMessage}</p>
|
|
</div>
|
|
`;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 빈 상태 메시지 제거
|
|
const emptyState = table.querySelector('.empty-state');
|
|
if (emptyState) {
|
|
emptyState.remove();
|
|
}
|
|
|
|
// 기존 항목들을 Map으로 저장
|
|
const existingItems = new Map();
|
|
table.querySelectorAll('.waiting-item').forEach(item => {
|
|
const id = item.dataset.waitingId;
|
|
existingItems.set(id, item);
|
|
});
|
|
|
|
// 새로운 목록으로 재구성
|
|
const newItems = new Set();
|
|
waitingList.forEach(item => {
|
|
newItems.add(String(item.id));
|
|
});
|
|
|
|
// 제거해야 할 항목들 (페이드아웃)
|
|
existingItems.forEach((item, id) => {
|
|
if (!newItems.has(id)) {
|
|
item.classList.add('fade-out');
|
|
setTimeout(() => item.remove(), 300);
|
|
}
|
|
});
|
|
|
|
// 새로운 순서대로 재배치
|
|
table.innerHTML = '';
|
|
waitingList.forEach(item => {
|
|
const existingItem = existingItems.get(String(item.id));
|
|
|
|
if (existingItem) {
|
|
// 기존 항목 재사용하되, 내용은 업데이트 (이름 변경 등 반영)
|
|
// createWaitingItem이 반환하는 새 노드의 내용을 복사하거나
|
|
// createWaitingItem을 사용하여 새 노드로 교체 (드래그 이벤트 등은 createWaitingItem 내부에서 다시 걸림)
|
|
|
|
// Note: createWaitingItem adds event listeners. If we just innerHTML copy, we might lose root listeners if they were waiting-item specific?
|
|
// createWaitingItem attaches listeners to the DIV itself.
|
|
// So simplest is to REPLACE the element to ensure fresh render.
|
|
|
|
const newItem = createWaitingItem(item);
|
|
// 애니메이션 효과 없이 즉시 교체 (깜빡임 줄임)
|
|
table.appendChild(newItem);
|
|
} else {
|
|
// 새 항목 생성 (애니메이션)
|
|
const newItem = createWaitingItem(item);
|
|
newItem.classList.add('fade-in');
|
|
table.appendChild(newItem);
|
|
|
|
setTimeout(() => {
|
|
newItem.classList.remove('fade-in');
|
|
}, 300);
|
|
}
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('대기자 조회 실패:', error);
|
|
table.innerHTML = '<div class="empty-state"><p>데이터 로딩 실패</p></div>';
|
|
}
|
|
}
|
|
|
|
function formatPhoneNumber(phone) {
|
|
if (!phone) return '';
|
|
// 숫자만 남기기
|
|
const numbers = phone.replace(/[^0-9]/g, '');
|
|
// 010-XXXX-XXXX 형식으로 변환
|
|
if (numbers.length === 11) {
|
|
return numbers.replace(/(\d{3})(\d{4})(\d{4})/, '$1-$2-$3');
|
|
}
|
|
return phone;
|
|
}
|
|
|
|
function createWaitingItem(item) {
|
|
const div = document.createElement('div');
|
|
div.className = 'waiting-item';
|
|
div.dataset.waitingId = item.id; // 항목 식별을 위한 data attribute
|
|
|
|
const displayName = item.name || item.phone.slice(-4);
|
|
// Use loose equality to match ID types (string vs number)
|
|
const currentClass = classes.find(c => c.id == item.class_id);
|
|
const className = currentClass ? currentClass.class_name : (item.class_id + '교시');
|
|
const classIndex = classes.findIndex(c => c.id == item.class_id);
|
|
|
|
// 드래그 앤 드롭 속성 추가
|
|
div.draggable = true;
|
|
div.addEventListener('dragstart', handleDragStart);
|
|
div.addEventListener('dragend', handleDragEnd);
|
|
div.addEventListener('dragover', handleDragOver);
|
|
div.addEventListener('drop', handleDrop);
|
|
div.addEventListener('dragleave', handleDragLeave);
|
|
|
|
// 빈 좌석인 경우 다른 스타일 적용
|
|
if (item.is_empty_seat) {
|
|
div.style.background = '#f8f9fa';
|
|
div.style.opacity = '0.7';
|
|
div.innerHTML = `
|
|
<div class="item-number" data-order="${item.class_order}">-</div>
|
|
<div class="item-name">빈 좌석</div>
|
|
<div class="item-phone">-</div>
|
|
<div class="item-class">${currentClass?.class_name}</div>
|
|
<div class="item-order">${item.class_order}번째</div>
|
|
<div class="item-actions">
|
|
<button class="btn btn-danger btn-sm" onclick="updateStatus(${item.id}, 'cancelled')">
|
|
제거
|
|
</button>
|
|
</div>
|
|
`;
|
|
} else {
|
|
const displayName = item.name || item.phone.slice(-4);
|
|
const currentClass = classes.find(c => c.id === item.class_id);
|
|
const formattedPhone = formatPhoneNumber(item.phone);
|
|
|
|
// 이전/다음 교시 이동 가능 여부 확인
|
|
const hasNextClass = classIndex < classes.length - 1;
|
|
// 이전 교시가 있고, 마감되지 않았는지 확인
|
|
const prevClass = classIndex > 0 ? classes[classIndex - 1] : null;
|
|
const leftArrowDisabled = !prevClass || closedClasses.has(prevClass.id);
|
|
|
|
// 이름에 따옴표가 있을 경우를 대비해 이스케이프 처리
|
|
const safeDisplayName = displayName ? displayName.replace(/'/g, "\\'") : '';
|
|
const safePhone = item.phone || '';
|
|
|
|
div.innerHTML = `
|
|
<div class="item-number" data-order="${item.class_order}">${item.waiting_number}</div>
|
|
<div class="item-name">${displayName}</div>
|
|
<div class="item-phone">${formattedPhone}</div>
|
|
<div class="item-class">${className}</div>
|
|
<div class="item-order">${item.class_order}번째</div>
|
|
<div class="item-actions">
|
|
<button class="btn btn-sm btn-secondary btn-icon"
|
|
${leftArrowDisabled ? 'disabled' : ''}
|
|
style="margin-right: 5px;"
|
|
onclick="event.preventDefault(); event.stopPropagation(); moveToClass(${item.id}, ${classIndex - 1})"
|
|
onmousedown="event.stopPropagation();"
|
|
title="이전 교시로 이동">
|
|
←
|
|
</button>
|
|
<button class="btn btn-sm btn-secondary btn-icon"
|
|
${hasNextClass ? '' : 'disabled'}
|
|
style="margin-right: 5px;"
|
|
onclick="console.log('[DEBUG] Right Arrow Clicked for Item ${item.id}'); event.preventDefault(); event.stopPropagation(); moveToClass(${item.id}, ${classIndex + 1})"
|
|
onmousedown="console.log('[DEBUG] MouseDown on Right Arrow'); event.stopPropagation();"
|
|
title="다음 교시로 이동">
|
|
→
|
|
</button>
|
|
<button class="btn btn-warning btn-sm" onclick="event.preventDefault(); event.stopPropagation(); callWaiting(${item.id})" onmousedown="event.stopPropagation();">
|
|
호출
|
|
</button>
|
|
<button class="btn btn-info btn-sm" onclick="event.preventDefault(); event.stopPropagation(); insertEmptySeat(${item.id})" onmousedown="event.stopPropagation();">
|
|
빈좌석
|
|
</button>
|
|
<button class="btn btn-success btn-sm" onclick="event.preventDefault(); event.stopPropagation(); updateStatus(${item.id}, 'attended')" onmousedown="event.stopPropagation();">
|
|
출석
|
|
</button>
|
|
<button class="btn btn-danger btn-sm" onclick="event.preventDefault(); event.stopPropagation(); updateStatus(${item.id}, 'cancelled')" onmousedown="event.stopPropagation();">
|
|
취소
|
|
</button>
|
|
<button class="btn btn-primary btn-sm" onclick="event.preventDefault(); event.stopPropagation(); lookupAttendance('${safePhone}', '${safeDisplayName}')" onmousedown="event.stopPropagation();" title="출석 조회">
|
|
<span style="font-size: 1.2em; font-weight: bold;">${item.last_month_attendance_count || 0}회</span>
|
|
</button>
|
|
${(!item.name || (item.phone && item.name == item.phone.slice(-4))) ? `
|
|
<button class="btn btn-secondary btn-sm" onclick="event.preventDefault(); event.stopPropagation(); openQuickRegisterModal('${item.phone}')" onmousedown="event.stopPropagation();" title="명찰 발급">
|
|
명찰
|
|
</button>
|
|
` : ''}
|
|
</div>
|
|
`;
|
|
}
|
|
return div;
|
|
}
|
|
|
|
// --- Quick Register Modal Functions ---
|
|
function openQuickRegisterModal(phone, waitingId) {
|
|
document.getElementById('quickRegPhoneDisplay').textContent = phone;
|
|
document.getElementById('quickRegPhone').value = phone;
|
|
document.getElementById('quickRegName').value = '';
|
|
document.getElementById('quickRegBarcode').value = '';
|
|
|
|
const modal = document.getElementById('quickRegisterModal');
|
|
console.log('[DEBUG] Opening Quick Register Modal');
|
|
// Explicitly set display first
|
|
modal.style.display = 'flex';
|
|
modal.style.opacity = '1'; // Force visible
|
|
modal.style.visibility = 'visible'; // Force visible
|
|
modal.style.zIndex = '2000'; // Force on top
|
|
|
|
// Force reflow
|
|
void modal.offsetWidth;
|
|
// Then add active class for opacity transition
|
|
modal.classList.add('active');
|
|
|
|
// Focus name input safely
|
|
setTimeout(() => {
|
|
const nameInput = document.getElementById('quickRegName');
|
|
if (nameInput) nameInput.focus();
|
|
}, 100);
|
|
}
|
|
|
|
function closeQuickRegisterModal() {
|
|
console.trace('[DEBUG] closeQuickRegisterModal called');
|
|
const modal = document.getElementById('quickRegisterModal');
|
|
modal.classList.remove('active');
|
|
// Wait for transition to finish before hiding
|
|
setTimeout(() => {
|
|
modal.style.display = 'none';
|
|
}, 300);
|
|
}
|
|
|
|
async function saveQuickRegister() {
|
|
const phone = document.getElementById('quickRegPhone').value;
|
|
const name = document.getElementById('quickRegName').value.trim();
|
|
const barcode = document.getElementById('quickRegBarcode').value.trim() || null;
|
|
|
|
if (!name) {
|
|
alert('이름을 입력해주세요.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// 1. Try to create member
|
|
const response = await fetch('/api/members/', {
|
|
method: 'POST',
|
|
headers: { ...getHeaders(), 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name, phone, barcode })
|
|
});
|
|
|
|
if (response.ok) {
|
|
// alert('회원명이 등록되었습니다.'); // 팝업 제거
|
|
closeQuickRegisterModal();
|
|
// SSE will automatically reload the lists
|
|
} else {
|
|
const error = await response.json();
|
|
|
|
// 2. If phone exists, automatically update name
|
|
if (response.status === 400 && error.detail && error.detail.includes('이미 등록')) {
|
|
// 팝업 없이 바로 업데이트 진행
|
|
updateExistingMemberName(phone, name, barcode);
|
|
} else {
|
|
alert(error.detail || '등록 실패');
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Registration failed:', err);
|
|
alert('오류가 발생했습니다.');
|
|
}
|
|
}
|
|
|
|
async function updateExistingMemberName(phone, name, barcode) {
|
|
try {
|
|
// Get Member ID by Phone
|
|
const lookupRes = await fetch(`/api/members/phone/${phone}`, { headers: getHeaders() });
|
|
if (!lookupRes.ok) {
|
|
alert('기존 회원 정보를 찾을 수 없습니다.');
|
|
return;
|
|
}
|
|
const member = await lookupRes.json();
|
|
|
|
// Update Name
|
|
const updateRes = await fetch(`/api/members/${member.id}`, {
|
|
method: 'PUT',
|
|
headers: { ...getHeaders(), 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name, phone, barcode: barcode || member.barcode })
|
|
});
|
|
|
|
if (updateRes.ok) {
|
|
// alert('회원명이 변경되었습니다.'); // 팝업 제거
|
|
closeQuickRegisterModal();
|
|
} else {
|
|
const err = await updateRes.json();
|
|
alert(err.detail || '수정 실패');
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
alert('수정 중 오류 발생');
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
async function updateStatus(waitingId, status) {
|
|
const statusText = status === 'attended' ? '출석' : '취소';
|
|
|
|
showConfirmModal(statusText, `${statusText} 처리하시겠습니까?`, async function () {
|
|
// 낙관적 업데이트: 즉시 UI에 반영
|
|
const item = document.querySelector(`[data-waiting-id="${waitingId}"]`);
|
|
if (item) {
|
|
item.classList.add('updating');
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/board/${waitingId}/status`, {
|
|
method: 'PUT',
|
|
headers: getHeaders({ 'Content-Type': 'application/json' }),
|
|
body: JSON.stringify({ status: status })
|
|
});
|
|
|
|
if (response.ok) {
|
|
const result = await response.json();
|
|
// SSE 이벤트를 통해 자동으로 삭제됨 (새로고침 없음)
|
|
console.log(`${statusText} 처리 완료, SSE 이벤트 대기 중...`);
|
|
} else {
|
|
const error = await response.json();
|
|
if (item) {
|
|
item.classList.remove('updating');
|
|
item.classList.add('shake');
|
|
setTimeout(() => item.classList.remove('shake'), 500);
|
|
}
|
|
showNotificationModal('오류', error.detail || `${statusText} 처리에 실패했습니다.`);
|
|
}
|
|
} catch (error) {
|
|
console.error(`${statusText} 처리 실패:`, error);
|
|
if (item) {
|
|
item.classList.remove('updating');
|
|
item.classList.add('shake');
|
|
setTimeout(() => item.classList.remove('shake'), 500);
|
|
}
|
|
showNotificationModal('오류', `${statusText} 처리 중 오류가 발생했습니다.`);
|
|
}
|
|
});
|
|
}
|
|
|
|
async function callWaiting(waitingId, waitingNumber) {
|
|
showConfirmModal('호출', `${waitingNumber}번 고객님을 호출하시겠습니까?`, async function () {
|
|
// 낙관적 업데이트: 즉시 하이라이트
|
|
const item = document.querySelector(`[data-waiting-id="${waitingId}"]`);
|
|
if (item) {
|
|
item.classList.add('highlight');
|
|
setTimeout(() => item.classList.remove('highlight'), 1500);
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/board/${waitingId}/call`, {
|
|
method: 'POST',
|
|
headers: getHeaders()
|
|
});
|
|
|
|
if (response.ok) {
|
|
const result = await response.json();
|
|
console.log('호출 완료:', result.message);
|
|
// SSE 이벤트를 통해 상태 업데이트됨
|
|
} else {
|
|
const error = await response.json();
|
|
showNotificationModal('오류', error.detail || '호출에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error('호출 실패:', error);
|
|
showNotificationModal('오류', '호출 중 오류가 발생했습니다.');
|
|
}
|
|
});
|
|
}
|
|
|
|
async function moveToClass(waitingId, targetClassIndex) {
|
|
if (targetClassIndex < 0 || targetClassIndex >= classes.length) {
|
|
showNotificationModal('알림', '이동할 수 있는 교시가 없습니다.');
|
|
return;
|
|
}
|
|
|
|
const targetClass = classes[targetClassIndex];
|
|
|
|
showConfirmModal('교시 이동', `${targetClass.class_name}로 이동하시겠습니까?`, async function () {
|
|
try {
|
|
const response = await fetch(`/api/board/${waitingId}/move-class`, {
|
|
method: 'PUT',
|
|
headers: getHeaders({ 'Content-Type': 'application/json' }),
|
|
body: JSON.stringify({
|
|
target_class_id: targetClass.id
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
const result = await response.json();
|
|
console.log('교시 이동 완료:', result.message);
|
|
// SSE 이벤트를 통해 자동으로 업데이트됨
|
|
} else {
|
|
const error = await response.json();
|
|
showNotificationModal('오류', error.detail || '교시 이동에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error('교시 이동 실패:', error);
|
|
showNotificationModal('오류', '교시 이동 중 오류가 발생했습니다.');
|
|
}
|
|
});
|
|
}
|
|
|
|
// ... (lines skipped) ...
|
|
|
|
function showConfirmModal(title, message, callback) {
|
|
console.log('[DEBUG] showConfirmModal called:', 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-secondary" style="flex: 1; padding: 12px; font-size: 16px; background-color: #95a5a6;" id="confirmCancelBtn">취소</button>
|
|
<button class="btn btn-primary" style="flex: 1; padding: 12px; font-size: 16px;" id="confirmModalBtn">확인</button>
|
|
`;
|
|
|
|
// 콜백 설정
|
|
const confirmAction = function () {
|
|
closeNotificationModal(); // 모달 닫기
|
|
if (callback) callback(); // 콜백 실행
|
|
};
|
|
|
|
const confirmBtn = document.getElementById('confirmModalBtn');
|
|
confirmBtn.onclick = confirmAction;
|
|
|
|
// 취소 버튼 처리
|
|
const cancelBtn = document.getElementById('confirmCancelBtn');
|
|
if (cancelBtn) {
|
|
cancelBtn.onclick = () => {
|
|
closeNotificationModal();
|
|
};
|
|
}
|
|
|
|
const modal = document.getElementById('notificationModal');
|
|
modal.classList.add('show');
|
|
|
|
// 입력창 포커스 해제 (Enter 키 중복 입력 방지)
|
|
const input = document.getElementById('quickRegisterInput');
|
|
if (input) input.blur();
|
|
}
|
|
|
|
async function changeOrder(waitingId, direction) {
|
|
// 낙관적 업데이트: 즉시 updating 표시
|
|
const item = document.querySelector(`[data-waiting-id="${waitingId}"]`);
|
|
if (item) {
|
|
item.classList.add('updating');
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/board/${waitingId}/order`, {
|
|
method: 'PUT',
|
|
headers: getHeaders({ 'Content-Type': 'application/json' }),
|
|
body: JSON.stringify({ direction })
|
|
});
|
|
|
|
if (response.ok) {
|
|
// SSE 이벤트를 통해 자동으로 순서 업데이트됨 (새로고침 없음)
|
|
console.log('순서 변경 완료, SSE 이벤트 대기 중...');
|
|
} else {
|
|
const error = await response.json();
|
|
if (item) {
|
|
item.classList.remove('updating');
|
|
item.classList.add('shake');
|
|
setTimeout(() => item.classList.remove('shake'), 500);
|
|
}
|
|
showNotificationModal('오류', error.detail || '순서 변경에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error('순서 변경 실패:', error);
|
|
showNotificationModal('오류', '순서 변경 중 오류가 발생했습니다.');
|
|
}
|
|
}
|
|
|
|
async function moveClass(waitingId, targetClassId) {
|
|
const targetClass = classes.find(c => c.id === targetClassId);
|
|
|
|
showConfirmModal('교시 이동', `${targetClass.class_name}(으)로 이동하시겠습니까?`, async function () {
|
|
try {
|
|
const response = await fetch(`/api/board/${waitingId}/move-class`, {
|
|
method: 'PUT',
|
|
headers: getHeaders({ 'Content-Type': 'application/json' }),
|
|
body: JSON.stringify({ target_class_id: targetClassId })
|
|
});
|
|
|
|
if (response.ok) {
|
|
await loadClasses();
|
|
await loadWaitingList();
|
|
} else {
|
|
const error = await response.json();
|
|
showNotificationModal('오류', error.detail || '이동에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error('클래스 이동 실패:', error);
|
|
showNotificationModal('오류', '이동 중 오류가 발생했습니다.');
|
|
}
|
|
});
|
|
}
|
|
|
|
// 드래그 앤 드롭 핸들러
|
|
let draggedItem = null;
|
|
|
|
// 드래그 이벤트 리스너 부착 헬퍼 함수
|
|
function attachDragListeners(element) {
|
|
element.draggable = true;
|
|
element.addEventListener('dragstart', handleDragStart);
|
|
element.addEventListener('dragend', handleDragEnd);
|
|
element.addEventListener('dragover', handleDragOver);
|
|
element.addEventListener('drop', handleDrop);
|
|
element.addEventListener('dragleave', handleDragLeave);
|
|
}
|
|
|
|
function handleDragStart(e) {
|
|
draggedItem = this;
|
|
this.classList.add('dragging');
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
e.dataTransfer.setData('text/html', this.innerHTML);
|
|
}
|
|
|
|
function handleDragEnd(e) {
|
|
this.classList.remove('dragging');
|
|
|
|
// 모든 drag-over 클래스 제거
|
|
document.querySelectorAll('.drag-over').forEach(item => {
|
|
item.classList.remove('drag-over');
|
|
});
|
|
}
|
|
|
|
function handleDragOver(e) {
|
|
if (e.preventDefault) {
|
|
e.preventDefault();
|
|
}
|
|
e.dataTransfer.dropEffect = 'move';
|
|
|
|
// 드래그 중인 아이템 위에 있을 때 시각적 피드백
|
|
if (this !== draggedItem) {
|
|
this.classList.add('drag-over');
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function handleDragLeave(e) {
|
|
this.classList.remove('drag-over');
|
|
}
|
|
|
|
async function handleDrop(e) {
|
|
if (e.stopPropagation) {
|
|
e.stopPropagation();
|
|
}
|
|
|
|
if (draggedItem !== this) {
|
|
const draggedId = parseInt(draggedItem.dataset.waitingId);
|
|
const targetId = parseInt(this.dataset.waitingId);
|
|
|
|
// 즉시 UI 업데이트 (Optimistic Update) - 삽입 방식
|
|
const table = document.getElementById('waitingTable');
|
|
const targetElement = this;
|
|
const draggedElement = draggedItem;
|
|
|
|
// 드래그 요소를 타겟 요소 위치에 삽입
|
|
// insertBefore를 사용하면 드래그 요소가 타겟 앞에 삽입됨
|
|
table.insertBefore(draggedElement, targetElement);
|
|
|
|
// 이벤트 리스너 재부착 (DOM 이동 시 유지되지만 안전하게 재부착)
|
|
attachDragListeners(draggedElement);
|
|
|
|
// API 호출하여 순서 변경
|
|
try {
|
|
const response = await fetch(`/api/board/${draggedId}/swap/${targetId}`, {
|
|
method: 'PUT',
|
|
headers: getHeaders()
|
|
});
|
|
|
|
if (response.ok) {
|
|
console.log('✅ 드래그 앤 드롭으로 순서 삽입 완료, SSE 이벤트 대기 중...');
|
|
// SSE 이벤트를 통해 최종 동기화됨
|
|
} else {
|
|
const error = await response.json();
|
|
showNotificationModal('오류', error.detail || '순서 변경에 실패했습니다.');
|
|
// 실패 시 UI 원상복구
|
|
await updateWaitingOrder();
|
|
}
|
|
} catch (error) {
|
|
console.error('❌ 순서 변경 실패:', error);
|
|
showNotificationModal('오류', '순서 변경 중 오류가 발생했습니다.');
|
|
// 실패 시 UI 원상복구
|
|
await updateWaitingOrder();
|
|
}
|
|
}
|
|
|
|
this.classList.remove('drag-over');
|
|
return false;
|
|
}
|
|
|
|
async function loadBatchInfo() {
|
|
try {
|
|
const batchInfo = document.getElementById('batchInfo');
|
|
const batchBtn = document.getElementById('batchBtn');
|
|
|
|
// 현재 선택된 클래스가 마감된 교시인지 확인
|
|
if (currentClassId && closedClasses.has(currentClassId)) {
|
|
const currentClass = classes.find(c => c.id === currentClassId);
|
|
batchInfo.textContent = `${currentClass.class_name}은(는) 마감된 교시입니다`;
|
|
batchBtn.style.display = 'none'; // 버튼 숨김 (마감 해제 기능 제거)
|
|
return;
|
|
}
|
|
|
|
// 마감되지 않은 교시라면 다음 마감 대상 표시
|
|
const response = await fetch('/api/board/next-batch-class', { headers: getHeaders() });
|
|
const result = await response.json();
|
|
|
|
if (result.class_id) {
|
|
batchClassId = result.class_id;
|
|
batchInfo.textContent = `${result.class_name} ${result.waiting_count}명 대기 중`;
|
|
batchBtn.textContent = `${result.class_name} 마감`;
|
|
batchBtn.disabled = false;
|
|
batchBtn.classList.remove('btn-warning');
|
|
batchBtn.classList.add('btn-success');
|
|
batchBtn.onclick = batchAttendance;
|
|
batchBtn.style.display = 'inline-block'; // 버튼 표시
|
|
} else {
|
|
batchClassId = null;
|
|
batchInfo.textContent = '대기자가 없습니다';
|
|
batchBtn.textContent = '교시 마감';
|
|
// 대기자가 없어도 버튼을 숨기지 않고 비활성화 상태로 표시할지, 아니면 숨길지 결정
|
|
// 요구사항: "대기자가 2명인데... 1교시 마감 버튼이 사라짐"
|
|
// 여기서는 대기자가 없으면 비활성화
|
|
batchBtn.disabled = true;
|
|
batchBtn.classList.remove('btn-warning');
|
|
batchBtn.classList.add('btn-success');
|
|
batchBtn.onclick = batchAttendance;
|
|
batchBtn.style.display = 'inline-block'; // 버튼 표시
|
|
}
|
|
} catch (error) {
|
|
console.error('교시 마감 정보 조회 실패:', error);
|
|
}
|
|
}
|
|
|
|
async function batchAttendance() {
|
|
if (!batchClassId) return;
|
|
|
|
const batchClass = classes.find(c => c.id === batchClassId);
|
|
|
|
showConfirmModal('교시 마감', `${batchClass.class_name}을(를) 마감하시겠습니까?\n마감 후 해당 교시에는 더 이상 대기자를 등록할 수 없습니다.`, async function () {
|
|
try {
|
|
const response = await fetch('/api/board/batch-attendance', {
|
|
method: 'POST',
|
|
headers: getHeaders({ 'Content-Type': 'application/json' }),
|
|
body: JSON.stringify({ class_id: batchClassId })
|
|
});
|
|
|
|
if (response.ok) {
|
|
const result = await response.json();
|
|
// SSE 이벤트를 통해 자동으로 업데이트되고 알림도 표시됨
|
|
console.log('교시 마감 완료:', result.message);
|
|
} else {
|
|
const error = await response.json();
|
|
showNotificationModal('오류', error.detail || '교시 마감에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error('교시 마감 실패:', error);
|
|
showNotificationModal('오류', '교시 마감 중 오류가 발생했습니다.');
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
|
|
// 개점일 조회 함수 제거됨 (UI에서 삭제)
|
|
// loadBusinessDate 함수는 라인 1485에 정의되어 있음 (중복 제거됨)
|
|
|
|
async function insertEmptySeat(waitingId) {
|
|
showConfirmModal('빈 좌석 삽입', '이 대기자 뒤에 빈 좌석을 삽입하시겠습니까?', async function () {
|
|
try {
|
|
const response = await fetch('/api/board/insert-empty-seat', {
|
|
method: 'POST',
|
|
headers: getHeaders({ 'Content-Type': 'application/json' }),
|
|
body: JSON.stringify({
|
|
waiting_id: waitingId
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
const result = await response.json();
|
|
console.log('빈 좌석 삽입 완료:', result.message);
|
|
// SSE 이벤트를 통해 자동으로 업데이트됨
|
|
} else {
|
|
const error = await response.json();
|
|
showNotificationModal('오류', error.detail || '빈 좌석 삽입에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error('빈 좌석 삽입 실패:', error);
|
|
showNotificationModal('오류', '빈 좌석 삽입 중 오류가 발생했습니다.');
|
|
}
|
|
});
|
|
}
|
|
|
|
// 페이지 로드 시 매장 컨텍스트 확인
|
|
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);
|
|
const header = document.getElementById('storeNameHeader');
|
|
if (header) header.textContent = context.name;
|
|
console.log(`매장 컨텍스트 적용: ${context.name} (ID: ${context.id})`);
|
|
}
|
|
// 사용한 컨텍스트 정리
|
|
localStorage.removeItem('store_management_context');
|
|
} catch (e) {
|
|
console.error('매장 컨텍스트 파싱 실패:', e);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
const header = document.getElementById('storeNameHeader');
|
|
if (header) header.textContent = store.name;
|
|
console.log(`URL 매장 파라미터 적용: ${store.name} (코드: ${storeParam})`);
|
|
} else {
|
|
console.error('매장 코드를 찾을 수 없습니다:', storeParam);
|
|
}
|
|
} catch (e) {
|
|
console.error('매장 정보 조회 실패:', e);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 초기 로드
|
|
async function applyStoreFonts() {
|
|
try {
|
|
// Fetch settings
|
|
const response = await fetch('/api/store/', { headers: getHeaders() });
|
|
if (!response.ok) return;
|
|
const settings = await response.json();
|
|
|
|
// Manager Font Settings
|
|
if (settings.manager_font_family) {
|
|
loadFont(settings.manager_font_family);
|
|
document.documentElement.style.setProperty('--manager-font-family', settings.manager_font_family || 'Nanum Gothic');
|
|
}
|
|
document.documentElement.style.setProperty('--manager-font-size', settings.manager_font_size || '15px');
|
|
|
|
// 레이아웃 너비 설정 적용
|
|
const container = document.querySelector('.container');
|
|
if (container && settings.waiting_manager_max_width) {
|
|
container.style.setProperty('max-width', `${settings.waiting_manager_max_width}px`, 'important');
|
|
container.style.margin = '0 auto'; // 중앙 정렬
|
|
} else if (container) {
|
|
container.style.setProperty('max-width', '95%', 'important'); // 기본값
|
|
}
|
|
|
|
} catch (e) { console.error('Font settings error:', e); }
|
|
}
|
|
|
|
function loadFont(fontName) {
|
|
if (!fontName) return;
|
|
// System fonts (no download needed)
|
|
if (['Malgun Gothic', 'Arial', 'AppleSDGothicNeo'].includes(fontName)) return;
|
|
|
|
// Check if already loaded
|
|
if (document.querySelector(`link[data-font="${fontName}"]`)) return;
|
|
|
|
const link = document.createElement('link');
|
|
link.rel = 'stylesheet';
|
|
link.dataset.font = fontName;
|
|
|
|
if (fontName === 'Spoqa Han Sans Neo') {
|
|
link.href = 'https://spoqa.github.io/spoqa-han-sans/css/SpoqaHanSansNeo.css';
|
|
} else {
|
|
// Default to Google Fonts
|
|
link.href = `https://fonts.googleapis.com/css2?family=${fontName.replace(/ /g, '+')}:wght@400;700;800&display=swap`;
|
|
}
|
|
|
|
document.head.appendChild(link);
|
|
}
|
|
|
|
async function init() {
|
|
await applyStoreFonts();
|
|
// 저장된 매장 이름이 있으면 먼저 표시
|
|
const storeName = localStorage.getItem('selected_store_name');
|
|
if (storeName) {
|
|
const header = document.getElementById('storeNameHeader');
|
|
if (header) header.textContent = storeName;
|
|
}
|
|
|
|
await checkUrlStoreParam();
|
|
checkStoreContext();
|
|
initSSE();
|
|
loadClasses();
|
|
}
|
|
|
|
// init(); // Redundant call disabled (handled by DOMContentLoaded)
|
|
|
|
// 페이지 종료 시 SSE 연결 닫기
|
|
window.addEventListener('beforeunload', () => {
|
|
if (eventSource) {
|
|
eventSource.close();
|
|
}
|
|
});
|
|
|
|
// 출석 조회 모달 함수
|
|
function lookupAttendance(phone, name) {
|
|
console.log('lookupAttendance called', phone, name);
|
|
|
|
if (!phone) {
|
|
alert('전화번호 정보가 없습니다.');
|
|
return;
|
|
}
|
|
|
|
const modal = document.getElementById('attendanceModal');
|
|
const iframe = document.getElementById('attendanceFrame');
|
|
|
|
if (!modal || !iframe) {
|
|
console.error('Modal elements not found');
|
|
return;
|
|
}
|
|
|
|
// attendance 페이지를 iframe으로 로드하고 개인별 출석 탭 활성화
|
|
// URL 파라미터로 전화번호와 자동 조회 플래그, 최소 뷰 모드 전달
|
|
const url = `/attendance?tab=individual&phone=${encodeURIComponent(phone)}&auto=true&name=${encodeURIComponent(name || '')}&view=minimal`;
|
|
console.log('Loading iframe:', url);
|
|
|
|
iframe.src = url;
|
|
modal.classList.add('active');
|
|
|
|
// ESC 키로 모달 닫기
|
|
document.addEventListener('keydown', handleEscKey);
|
|
}
|
|
|
|
function closeAttendanceModal() {
|
|
const modal = document.getElementById('attendanceModal');
|
|
const iframe = document.getElementById('attendanceFrame');
|
|
|
|
modal.classList.remove('active');
|
|
iframe.src = ''; // iframe 초기화
|
|
|
|
// ESC 키 이벤트 리스너 제거
|
|
document.removeEventListener('keydown', handleEscKey);
|
|
}
|
|
|
|
function handleEscKey(event) {
|
|
if (event.key === 'Escape') {
|
|
closeAttendanceModal();
|
|
}
|
|
}
|
|
|
|
// 모달 외부 클릭 시 닫기
|
|
document.getElementById('attendanceModal')?.addEventListener('click', function (event) {
|
|
if (event.target === this) {
|
|
closeAttendanceModal();
|
|
}
|
|
});
|
|
</script>
|
|
<!-- 출석 조회 모달 -->
|
|
<div id="attendanceModal" class="attendance-modal">
|
|
<div class="attendance-modal-content">
|
|
<div class="attendance-modal-header">
|
|
<h2>출석 조회</h2>
|
|
<button class="attendance-modal-close" onclick="closeAttendanceModal()">×</button>
|
|
</div>
|
|
<iframe id="attendanceFrame" src=""></iframe>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
<!-- 검색 결과 선택 모달 -->
|
|
<div id="searchResultModal" class="modal">
|
|
<div class="modal-content" style="max-width: 500px;">
|
|
<h3>회원 선택</h3>
|
|
<p style="color: #7f8c8d; font-size: 14px; margin-bottom: 15px;">
|
|
여러 명의 회원이 검색되었습니다.<br>대기 등록할 회원을 선택해주세요.
|
|
</p>
|
|
<div id="searchResultsList" class="search-results-list"
|
|
style="max-height: 300px; overflow-y: auto; border: 1px solid #eee; border-radius: 8px;">
|
|
<!-- JS Generated Items -->
|
|
</div>
|
|
<div style="margin-top: 20px; text-align: right;">
|
|
<button class="btn btn-secondary"
|
|
onclick="document.getElementById('searchResultModal').classList.remove('show');"
|
|
style="padding: 10px 20px;">취소</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- 알림 모달 -->
|
|
<div id="notificationModal" class="modal">
|
|
<div class="modal-content" style="text-align: center; max-width: 400px;">
|
|
<div style="font-size: 48px; margin-bottom: 20px;">📢</div>
|
|
<h2 id="notificationTitle" style="font-size: 32px; font-weight: 800; margin-bottom: 20px; color: #333;">알림
|
|
</h2>
|
|
<p id="notificationMessage"
|
|
style="font-size: 24px; font-weight: 500; color: #333; margin-bottom: 30px; line-height: 1.5;">
|
|
</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);
|
|
/* Extended to standard semi-transparent */
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.modal.show {
|
|
display: flex !important;
|
|
}
|
|
|
|
.modal-content {
|
|
background-color: #fefefe;
|
|
padding: 30px;
|
|
border-radius: 12px;
|
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
|
/* animation: modalSlideIn 0.3s ease-out; Removed for stability */
|
|
opacity: 1 !important;
|
|
/* User requested 100% opacity */
|
|
max-width: 400px;
|
|
width: 90%;
|
|
}
|
|
|
|
@keyframes modalSlideIn {
|
|
from {
|
|
transform: translateY(-20px);
|
|
opacity: 0;
|
|
}
|
|
|
|
to {
|
|
transform: translateY(0);
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
.attendance-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;
|
|
}
|
|
|
|
.attendance-modal.active {
|
|
display: flex;
|
|
}
|
|
|
|
.attendance-modal-content {
|
|
background-color: #fefefe;
|
|
border-radius: 12px;
|
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
|
width: 90%;
|
|
max-width: 1000px;
|
|
height: 90%;
|
|
max-height: 900px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
position: relative;
|
|
/* animation: modalSlideIn 0.3s ease-out; Removed for stability */
|
|
}
|
|
|
|
.attendance-modal-header {
|
|
padding: 20px;
|
|
border-bottom: 1px solid #eee;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
background: #fff;
|
|
}
|
|
|
|
.attendance-modal-close {
|
|
background: none;
|
|
border: none;
|
|
font-size: 28px;
|
|
cursor: pointer;
|
|
color: #aaa;
|
|
}
|
|
|
|
.attendance-modal-close:hover {
|
|
color: #333;
|
|
}
|
|
|
|
#attendanceFrame {
|
|
flex: 1;
|
|
width: 100%;
|
|
height: 100%;
|
|
border: none;
|
|
}
|
|
</style>
|
|
|
|
<script>
|
|
let isRegistering = false; // 글로벌 등록 진행 상태 플래그
|
|
|
|
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;" id="notificationConfirmBtn">확인</button>`;
|
|
|
|
const confirmBtn = document.getElementById('notificationConfirmBtn');
|
|
const closeAction = function () {
|
|
closeNotificationModal();
|
|
};
|
|
confirmBtn.onclick = closeAction;
|
|
|
|
// Enter 키 이벤트 리스너 추가 (알림창 닫기용)
|
|
const enterKeyHandler = function (e) {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
closeAction();
|
|
document.removeEventListener('keydown', enterKeyHandler, { capture: true });
|
|
}
|
|
};
|
|
document.addEventListener('keydown', enterKeyHandler, { capture: true });
|
|
|
|
const modal = document.getElementById('notificationModal');
|
|
modal.classList.add('show');
|
|
|
|
// 입력창 포커스 해제
|
|
const input = document.getElementById('quickRegisterInput');
|
|
if (input) input.blur();
|
|
}
|
|
|
|
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;" id="confirmCancelBtn">취소</button>
|
|
<button class="btn btn-primary" style="flex: 1; padding: 12px; font-size: 16px;" id="confirmModalBtn">확인</button>
|
|
`;
|
|
|
|
// 콜백 설정
|
|
const confirmAction = function () {
|
|
closeNotificationModal(); // 모달 닫기
|
|
if (callback) callback(); // 콜백 실행
|
|
};
|
|
|
|
const confirmBtn = document.getElementById('confirmModalBtn');
|
|
confirmBtn.onclick = confirmAction;
|
|
|
|
// 취소 버튼 처리
|
|
const cancelBtn = document.getElementById('confirmCancelBtn');
|
|
if (cancelBtn) {
|
|
cancelBtn.onclick = () => {
|
|
closeNotificationModal();
|
|
};
|
|
}
|
|
|
|
const modal = document.getElementById('notificationModal');
|
|
modal.classList.add('show');
|
|
|
|
// 입력창 포커스 해제 (Enter 키 중복 입력 방지)
|
|
const input = document.getElementById('quickRegisterInput');
|
|
if (input) input.blur();
|
|
}
|
|
|
|
function closeNotificationModal() {
|
|
const modal = document.getElementById('notificationModal');
|
|
modal.classList.remove('show');
|
|
|
|
// 모달 닫힌 후 입력창에 포커스 복귀 (주석 처리: 시스템 멈춤 현상 원인 의심)
|
|
// setTimeout(() => {
|
|
// const input = document.getElementById('quickRegisterInput');
|
|
// // 모달이 닫힌 후에도 다른 모달이 떠있지 않을 때만 포커스
|
|
// if (input && !document.querySelector('.modal.show')) {
|
|
// input.focus();
|
|
// }
|
|
// }, 100);
|
|
}
|
|
|
|
// Quick Register Function
|
|
async function handleQuickRegister() {
|
|
const input = document.getElementById('quickRegisterInput');
|
|
const loading = document.getElementById('quickRegisterLoading');
|
|
const keyword = input.value.trim();
|
|
|
|
// 이미 등록 진행 중이거나 모달이 떠있으면 무시 (Enter 키 중복/스팸 방지)
|
|
if (isRegistering || document.querySelector('.modal.show')) return;
|
|
|
|
if (!keyword) {
|
|
showNotificationModal('알림', '검색어를 입력해주세요 (예: 홍길동, 1234, 바코드)');
|
|
return;
|
|
}
|
|
|
|
// Check context
|
|
if (!currentClassId) {
|
|
showNotificationModal('알림', '현재 선택된 교시가 없습니다. 먼저 교시를 선택해주세요.');
|
|
return;
|
|
}
|
|
if (closedClasses.has(currentClassId)) {
|
|
showNotificationModal('알림', '마감된 교시에는 대기 등록을 할 수 없습니다.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
isRegistering = true; // 등록 시작
|
|
if (loading) loading.style.display = 'block';
|
|
input.disabled = true;
|
|
|
|
// 검색어 정규화: 전화번호 형식(숫자, 하이픈)인 경우 하이픈 제거
|
|
let searchKeyword = keyword;
|
|
if (/^[0-9-]+$/.test(keyword)) {
|
|
searchKeyword = keyword.replace(/-/g, '');
|
|
}
|
|
|
|
// Fix: use correct endpoint /api/members/?search=...
|
|
const searchResponse = await fetch(`/api/members/?search=${encodeURIComponent(searchKeyword)}`, {
|
|
headers: getHeaders()
|
|
});
|
|
|
|
if (!searchResponse.ok) throw new Error('회원 검색 중 오류가 발생했습니다.');
|
|
const members = await searchResponse.json();
|
|
|
|
if (members.length === 0) {
|
|
// Check if keyword is a phone number
|
|
let phone = keyword.replace(/[^0-9]/g, '');
|
|
if (phone.length === 8) phone = '010' + phone;
|
|
|
|
// Barcode Logic: If input > 10 digits and not a standard 010 phone number
|
|
// (Assuming 010 phone numbers are handled by guest registration if not found)
|
|
if (keyword.length > 10 && !(phone.length === 11 && phone.startsWith('010'))) {
|
|
showNotificationModal('알림', '등록되지 않은 바코드입니다: ' + keyword);
|
|
// Optional: Could open register modal with barcode here if desired
|
|
} else if (phone.length === 11 && phone.startsWith('010')) {
|
|
// Guest registration (Phone number not found -> New Guest)
|
|
proceedWithRegistration({ name: '', phone: phone });
|
|
} else {
|
|
showNotificationModal('알림', '일치하는 회원을 찾을 수 없습니다.');
|
|
// 여기서는 포커스를 주지 않음 (finally에서 처리)
|
|
}
|
|
} else if (members.length === 1) {
|
|
proceedWithRegistration(members[0]);
|
|
} else {
|
|
// Multiple matches -> Show Selection Modal
|
|
showSearchResultModal(members);
|
|
}
|
|
|
|
} catch (err) {
|
|
console.error(err);
|
|
showNotificationModal('오류', '오류가 발생했습니다: ' + err.message);
|
|
} finally {
|
|
isRegistering = false; // 등록 종료
|
|
if (loading) loading.style.display = 'none';
|
|
input.disabled = false;
|
|
|
|
// 모달이 떠있지 않을 때만 포커스 복귀
|
|
if (!document.querySelector('.modal.show')) {
|
|
if (!document.querySelector('.modal.show') && !document.getElementById('quickRegisterModal').classList.contains('show')) {
|
|
input.focus();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Phone Formatting Logic
|
|
// Phone Formatting Logic
|
|
document.getElementById('waitingInput').addEventListener('input', function (e) {
|
|
const input = e.target;
|
|
const originalValue = input.value;
|
|
|
|
// Allow name input / command input without changes if not purely numeric/hyphen
|
|
const isNumeric = /^\d+(-\d*)*$/.test(originalValue);
|
|
if (!isNumeric) return;
|
|
|
|
// 1. Capture cursor position & count digits before it
|
|
const cursorPosition = input.selectionStart;
|
|
let digitsBeforeCursor = 0;
|
|
for (let i = 0; i < cursorPosition; i++) {
|
|
if (/\d/.test(originalValue[i])) digitsBeforeCursor++;
|
|
}
|
|
|
|
// Remove non-digits for processing
|
|
let digits = originalValue.replace(/[^0-9]/g, '');
|
|
|
|
// [Smart Formatting Logic]
|
|
// Rule:
|
|
// 1. If digits > 8 (e.g. Barcode, Full Phone): Show RAW numbers.
|
|
// 2. If digits <= 8: Format as ####-####.
|
|
|
|
if (digits.length > 8) {
|
|
// Formatting REMOVED for long numbers
|
|
if (digits.length > 20) digits = digits.slice(0, 20);
|
|
input.value = digits;
|
|
} else {
|
|
// Formatting APPLIED for short numbers (Phone parts)
|
|
let formatted = digits;
|
|
if (digits.length > 4) {
|
|
// split 4-4
|
|
formatted = digits.slice(0, 4) + '-' + digits.slice(4);
|
|
}
|
|
input.value = formatted;
|
|
}
|
|
|
|
// 2. Restore cursor position
|
|
// Find the position in the new value where the same number of digits have appeared
|
|
let newCursorPosition = 0;
|
|
let digitsSeen = 0;
|
|
const newValue = input.value;
|
|
|
|
for (let i = 0; i < newValue.length; i++) {
|
|
if (digitsSeen === digitsBeforeCursor) break;
|
|
if (/\d/.test(newValue[i])) digitsSeen++;
|
|
newCursorPosition++;
|
|
}
|
|
|
|
// If the cursor ended up landing exactly after the last digit which matches our count,
|
|
// we are good.
|
|
input.setSelectionRange(newCursorPosition, newCursorPosition);
|
|
});
|
|
|
|
// Focus prevention listener removed.
|
|
|
|
// Initial Focus
|
|
document.getElementById('waitingInput').focus();
|
|
|
|
function proceedWithRegistration(member) {
|
|
// Predict overflow class (Sequential Logic - Start from beginning)
|
|
let predictedClass = null;
|
|
|
|
// 순차적 로직: 1교시부터 차례대로 빈 자리 찾기 (현재 탭 무시)
|
|
for (let i = 0; i < classes.length; i++) {
|
|
const cls = classes[i];
|
|
// Skip if closed
|
|
if (closedClasses.has(cls.id)) continue;
|
|
|
|
// Check capacity using total_count (Waiting + Attended) if available, fallback to current_count logic
|
|
// Backend provides 'total_count' which matches the real capacity check.
|
|
const count = (cls.total_count !== undefined) ? cls.total_count : (cls.current_count || 0);
|
|
|
|
if (count < cls.max_capacity) {
|
|
predictedClass = cls;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Fallback: If no prediction found (all full?), backend will handle it or fail.
|
|
// We just show a safe default (e.g. current tab or first active) for the message.
|
|
if (!predictedClass) {
|
|
// Try to find first non-closed class even if full
|
|
predictedClass = classes.find(c => !closedClasses.has(c.id));
|
|
}
|
|
if (!predictedClass) {
|
|
// All closed?
|
|
predictedClass = classes.find(c => c.id == currentClassId);
|
|
}
|
|
|
|
const className = predictedClass ? predictedClass.class_name : '자동 배정';
|
|
// If overflow occurred, count is based on the new class
|
|
const nextOrder = predictedClass ? ((predictedClass.current_count || 0) + 1) + '번째' : '';
|
|
|
|
// 이름 유무에 따른 메시지 포맷팅
|
|
let userDisplayHtml = '';
|
|
|
|
if (member.name) {
|
|
// 이름이 있는 경우: 기존 방식 (이름 + 뒷자리 4개)
|
|
userDisplayHtml = `<span style="font-weight:bold; color:#2c3e50;">${member.name}</span> <span style="color:#7f8c8d;">(${member.phone.slice(-4)})</span>님을`;
|
|
} else {
|
|
// 이름이 없는 경우: 핸드폰 번호 전체 포맷팅 + 크게 표시
|
|
const formattedPhone = formatPhoneNumber(member.phone);
|
|
userDisplayHtml = `<span style="font-weight:800; color:#2c3e50; font-size: 1.4em;">${formattedPhone}</span>님을`;
|
|
}
|
|
|
|
showConfirmModal(
|
|
'대기 등록 확인',
|
|
`${userDisplayHtml}<br><span style="font-weight:bold; color:#3498db;">${className} ${nextOrder} 대기자</span>로 등록하시겠습니까?`,
|
|
async function () {
|
|
try {
|
|
const regResponse = await fetch('/api/waiting/', {
|
|
method: 'POST',
|
|
headers: getHeaders({ 'Content-Type': 'application/json' }),
|
|
body: JSON.stringify({
|
|
phone: member.phone,
|
|
person_count: 1,
|
|
class_id: null, // 순차 배정을 위해 null 전송
|
|
name: member.name || '' // Add name if available
|
|
})
|
|
});
|
|
|
|
if (regResponse.ok) {
|
|
const result = await regResponse.json();
|
|
document.getElementById('quickRegisterInput').value = '';
|
|
|
|
// Don't refresh or switch tabs - let SSE handle the update smoothly
|
|
// Just refocus the input for next registration
|
|
setTimeout(() => {
|
|
const input = document.getElementById('quickRegisterInput');
|
|
if (input) input.focus();
|
|
}, 100);
|
|
} else {
|
|
const error = await regResponse.json();
|
|
showNotificationModal('등록 실패', error.detail || '대기 등록 실패');
|
|
}
|
|
} catch (e) {
|
|
showNotificationModal('오류', '등록 중 오류가 발생했습니다.');
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
function showSearchResultModal(members) {
|
|
const listContainer = document.getElementById('searchResultsList');
|
|
listContainer.innerHTML = '';
|
|
|
|
// Increase modal width for this specific modal
|
|
const modalContent = document.querySelector('#searchResultModal .modal-content');
|
|
if (modalContent) modalContent.style.maxWidth = '700px';
|
|
|
|
members.forEach(member => {
|
|
const item = document.createElement('div');
|
|
item.style.cssText = 'padding: 20px; border-bottom: 1px solid #f0f0f0; cursor: pointer; display: flex; justify-content: space-between; align-items: center; transition: background 0.2s;';
|
|
item.onmouseover = () => item.style.background = '#f8f9fa';
|
|
item.onmouseout = () => item.style.background = 'white';
|
|
|
|
// Format phone
|
|
const phone = member.phone.replace(/(\d{3})(\d{4})(\d{4})/, '$1-$2-$3');
|
|
const last4 = member.phone.slice(-4);
|
|
|
|
item.onclick = () => {
|
|
document.getElementById('searchResultModal').classList.remove('show');
|
|
proceedWithRegistration(member);
|
|
};
|
|
|
|
item.innerHTML = `
|
|
<div>
|
|
<div style="font-weight: bold; font-size: 24px; color: #2c3e50; margin-bottom: 5px;">${member.name}</div>
|
|
<div style="font-size: 18px; color: #7f8c8d;">${phone}</div>
|
|
</div>
|
|
<div style="text-align: right;">
|
|
<span style="background: #e1f5fe; color: #3498db; padding: 8px 16px; border-radius: 8px; font-weight: 800; font-size: 32px;">${last4}</span>
|
|
</div>
|
|
`;
|
|
listContainer.appendChild(item);
|
|
});
|
|
|
|
document.getElementById('searchResultModal').classList.add('show');
|
|
}
|
|
}
|
|
</script>
|
|
<!-- Quick Register Modal -->
|
|
<!-- Quick Register Modal Moved to Top -->
|
|
<!-- FORCED VISIBILITY FIX (RESTORED) -->
|
|
<style>
|
|
/* Force Modal Visibility Override - Essential for User Environment */
|
|
#notificationModal.show,
|
|
#notificationModal.active,
|
|
.modal.show,
|
|
.attendance-modal.active {
|
|
display: flex !important;
|
|
opacity: 1 !important;
|
|
visibility: visible !important;
|
|
background-color: transparent !important;
|
|
/* Transparent overlay as requested */
|
|
z-index: 9999 !important;
|
|
}
|
|
|
|
#notificationModal .modal-content,
|
|
.modal .modal-content,
|
|
.attendance-modal .attendance-modal-content {
|
|
display: block !important;
|
|
opacity: 1 !important;
|
|
visibility: visible !important;
|
|
transform: none !important;
|
|
animation: none !important;
|
|
background-color: white !important;
|
|
box-shadow: 0 0 50px rgba(0, 0, 0, 0.5) !important;
|
|
}
|
|
|
|
/* Ensure text is visible */
|
|
#notificationModal *,
|
|
.modal-content * {
|
|
opacity: 1 !important;
|
|
visibility: visible !important;
|
|
}
|
|
</style>
|
|
</body>
|
|
|
|
</html> |