Files
waiting-system/templates/manage.html
Jun-dev f699a29a85 Add waiting system application files
- Add main application files (main.py, models.py, schemas.py, etc.)
- Add routers for all features (waiting, attendance, members, etc.)
- Add HTML templates for admin and user interfaces
- Add migration scripts and utility files
- Add Docker configuration
- Add documentation files
- Add .gitignore to exclude database and cache files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-14 00:29:39 +09:00

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()">&times;</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>