Files
waiting-system/templates/attendance.html.backup
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

1744 lines
77 KiB
Plaintext

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>출석 및 대기 조회</title>
<link rel="stylesheet" href="/static/css/common.css">
<style>
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
}
.tabs-container {
display: flex;
gap: 10px;
margin-bottom: 30px;
border-bottom: 2px solid #ecf0f1;
}
.tab-btn {
padding: 15px 30px;
background: transparent;
border: none;
border-bottom: 3px solid transparent;
cursor: pointer;
font-size: 16px;
font-weight: 600;
color: #7f8c8d;
transition: all 0.3s;
}
.tab-btn.active {
color: #3498db;
border-bottom-color: #3498db;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
animation: fadeIn 0.3s;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.filter-container {
display: flex;
gap: 10px;
margin-bottom: 20px;
align-items: center;
background: #fff;
padding: 15px;
border-radius: 10px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: #fff;
padding: 20px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
text-align: center;
}
.stat-value {
font-size: 32px;
font-weight: 700;
color: #2c3e50;
margin: 10px 0;
}
.stat-label {
color: #7f8c8d;
font-size: 14px;
}
.data-table {
width: 100%;
border-collapse: collapse;
background: #fff;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.data-table th,
.data-table td {
padding: 15px;
text-align: left;
border-bottom: 1px solid #ecf0f1;
}
.data-table th {
background: #f8f9fa;
font-weight: 600;
color: #2c3e50;
}
.search-box {
display: flex;
gap: 10px;
flex: 1;
}
.rank-badge {
display: inline-flex;
width: 30px;
height: 30px;
align-items: center;
justify-content: center;
border-radius: 50%;
font-weight: bold;
color: white;
}
.rank-1 {
background: #f1c40f;
}
.rank-2 {
background: #95a5a6;
}
.rank-3 {
background: #e67e22;
}
.rank-other {
background: #ecf0f1;
color: #7f8c8d;
}
/* 최소 뷰 모드 (팝업용) */
body.minimal-view {
padding: 10px;
background: white;
}
body.minimal-view .header,
body.minimal-view .tabs-container {
display: none !important;
}
body.minimal-view .container {
max-width: 100%;
padding: 0;
margin: 0;
}
body.minimal-view .filter-container {
display: none;
}
body.minimal-view .tab-content {
padding: 10px;
}
/* Loading Overlay */
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
flex-direction: column;
}
/* Custom Date Picker Styles */
.date-picker-wrapper {
position: relative;
display: inline-block;
width: 150px;
}
.date-input-hidden {
position: absolute;
opacity: 0;
width: 100%;
height: 100%;
top: 0;
left: 0;
z-index: 1;
cursor: pointer;
}
.date-display {
background-color: #fff !important;
cursor: pointer;
text-align: center;
padding-right: 30px;
/* 아이콘 공간 확보 */
}
.calendar-icon {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
color: #7f8c8d;
pointer-events: none;
/* 클릭 이벤트가 input으로 전달되도록 */
z-index: 0;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div>
<h1 id="storeName" style="display: flex; align-items: center; gap: 10px;">
대기 및 출석 현황
<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="tabs-container">
<button class="tab-btn active" onclick="switchTab('waiting_status')">대기현황</button>
<button class="tab-btn" onclick="switchTab('status')">출석현황</button>
<button class="tab-btn" onclick="switchTab('individual')">개인별 출석</button>
<button class="tab-btn" onclick="switchTab('new_members')">신규회원</button>
<button class="tab-btn" onclick="switchTab('ranking')">출석순위</button>
</div>
<!-- 0. 대기현황 탭 -->
<div id="waiting_statusTab" class="tab-content active">
<div class="filter-container">
<select id="waitingPeriod" class="form-control" style="width: 150px;"
onchange="handlePeriodChange('waiting')">
<option value="daily">개점일</option>
<option value="weekly">주간 (이번주)</option>
<option value="monthly">월간 (이번달)</option>
<option value="yearly">연간 (올해)</option>
<option value="custom">기간별</option>
</select>
<!-- 날짜 선택 영역 -->
<div id="waitingDateContainer" style="display: flex; align-items: center; gap: 5px;">
<div class="date-picker-wrapper">
<input type="text" id="waitingDateDisplay" class="form-control date-display" readonly>
<svg class="calendar-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<input type="date" id="waitingDate" class="date-input-hidden"
onchange="updateDateDisplay(this, 'waitingDateDisplay')">
</div>
<span class="date-separator" style="display: none;">~</span>
<div class="date-picker-wrapper date-end" style="display: none;">
<input type="text" id="waitingEndDateDisplay" class="form-control date-display" readonly>
<svg class="calendar-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<input type="date" id="waitingEndDate" class="date-input-hidden"
onchange="updateDateDisplay(this, 'waitingEndDateDisplay')">
</div>
</div>
<button class="btn btn-primary" onclick="loadWaitingStatus()">조회</button>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">총 대기</div>
<div class="stat-value" id="totalWaiting">0명</div>
</div>
<div class="stat-card">
<div class="stat-label">기존 회원 대기</div>
<div class="stat-value" style="color: #3498db;" id="existingMemberWaiting">0명</div>
</div>
<div class="stat-card">
<div class="stat-label">신규 회원 대기</div>
<div class="stat-value" style="color: #2ecc71;" id="newMemberWaiting">0명</div>
</div>
</div>
<div class="stats-grid" style="margin-top: 15px;">
<div class="stat-card">
<div class="stat-label">현 대기</div>
<div class="stat-value" id="currentTotalWaiting">0명</div>
</div>
<div class="stat-card">
<div class="stat-label">현 기존회원 대기</div>
<div class="stat-value" style="color: #3498db;" id="currentExistingWaiting">0명</div>
</div>
<div class="stat-card">
<div class="stat-label">현 신규회원 대기</div>
<div class="stat-value" style="color: #2ecc71;" id="currentNewWaiting">0명</div>
</div>
</div>
<!-- 실시간 업데이트 상태 표시 -->
<div
style="margin-top: 15px; padding: 10px; background: #f8f9fa; border-radius: 8px; display: flex; justify-content: space-between; align-items: center;">
<div style="display: flex; align-items: center; gap: 10px;">
<div style="width: 10px; height: 10px; border-radius: 50%; background: #95a5a6;"
id="autoRefreshIndicator"></div>
<span style="font-size: 14px; color: #7f8c8d;">
이벤트 연결: <span id="autoRefreshStatus" style="font-weight: bold; color: #95a5a6;">연결 중...</span>
</span>
</div>
<span style="font-size: 12px; color: #95a5a6;" id="lastUpdateTime">이벤트 기반 실시간 업데이트</span>
</div>
</div>
<!-- 1. 출석현황 탭 -->
<div id="statusTab" class="tab-content">
<div class="filter-container">
<select id="statusPeriod" class="form-control" style="width: 150px;"
onchange="handlePeriodChange('status')">
<option value="daily">개점일</option>
<option value="weekly">주간 (이번주)</option>
<option value="monthly">월간 (이번달)</option>
<option value="yearly">연간 (올해)</option>
<option value="custom">기간별</option>
</select>
<div id="statusDateContainer" style="display: flex; align-items: center; gap: 5px;">
<div class="date-picker-wrapper">
<input type="text" id="statusDateDisplay" class="form-control date-display" readonly>
<svg class="calendar-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<input type="date" id="statusDate" class="date-input-hidden"
onchange="updateDateDisplay(this, 'statusDateDisplay')">
</div>
<span class="date-separator" style="display: none;">~</span>
<div class="date-picker-wrapper date-end" style="display: none;">
<input type="text" id="statusEndDateDisplay" class="form-control date-display" readonly>
<svg class="calendar-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<input type="date" id="statusEndDate" class="date-input-hidden"
onchange="updateDateDisplay(this, 'statusEndDateDisplay')">
</div>
</div>
<button class="btn btn-primary" onclick="loadStatus()">조회</button>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">총 출석</div>
<div class="stat-value" id="totalAttendance">0명</div>
</div>
<div class="stat-card">
<div class="stat-label">기존 회원 출석</div>
<div class="stat-value" style="color: #3498db;" id="existingMemberAttendance">0명</div>
</div>
<div class="stat-card">
<div class="stat-label">신규 회원 출석</div>
<div class="stat-value" style="color: #2ecc71;" id="newMemberAttendance">0명</div>
</div>
</div>
<!-- 실시간 업데이트 상태 표시 (출석현황) -->
<div
style="margin-top: 15px; padding: 10px; background: #f8f9fa; border-radius: 8px; display: flex; justify-content: space-between; align-items: center;">
<div style="display: flex; align-items: center; gap: 10px;">
<div style="width: 10px; height: 10px; border-radius: 50%; background: #95a5a6;"
id="statusTabIndicator"></div>
<span style="font-size: 14px; color: #7f8c8d;">
이벤트 연결: <span id="statusTabStatus" style="font-weight: bold; color: #95a5a6;">연결 중...</span>
</span>
</div>
<span style="font-size: 12px; color: #95a5a6;">출석 이벤트 발생 시 자동 업데이트</span>
</div>
</div>
<!-- 2. 개인별 출석 탭 -->
<div id="individualTab" class="tab-content">
<div class="filter-container">
<div class="search-box">
<input type="text" id="memberSearch" class="form-control" placeholder="이름 또는 전화번호 뒷자리 입력">
<button class="btn btn-primary" onclick="searchMember()">검색</button>
</div>
</div>
<div id="individualResult">
<div class="empty-state">
<p>회원을 검색해주세요.</p>
</div>
</div>
<!-- 회원 선택 후 표시되는 영역 -->
<div id="memberAttendanceDetail" style="display: none;">
<div class="filter-container" style="margin-top: 20px;">
<select id="individualPeriod" class="form-control" style="width: 150px;"
onchange="handlePeriodChange('individual')">
<option value="daily">개점일</option>
<option value="weekly">주간 (이번주)</option>
<option value="monthly" selected>월간 (이번달)</option>
<option value="yearly">연간 (올해)</option>
<option value="custom">기간별</option>
</select>
<div id="individualDateContainer" style="display: flex; align-items: center; gap: 5px;">
<div class="date-picker-wrapper">
<input type="text" id="individualDateDisplay" class="form-control date-display" readonly>
<svg class="calendar-icon" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<input type="date" id="individualDate" class="date-input-hidden"
onchange="updateDateDisplay(this, 'individualDateDisplay')">
</div>
<span class="date-separator" style="display: none;">~</span>
<div class="date-picker-wrapper date-end" style="display: none;">
<input type="text" id="individualEndDateDisplay" class="form-control date-display" readonly>
<svg class="calendar-icon" xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<input type="date" id="individualEndDate" class="date-input-hidden"
onchange="updateDateDisplay(this, 'individualEndDateDisplay')">
</div>
</div>
<button class="btn btn-primary" onclick="loadMemberDetailWithPeriod()">조회</button>
</div>
<div class="stats-grid" style="margin-top: 15px;">
<div class="stat-card">
<div class="stat-label">회원 정보</div>
<div class="stat-value" id="memberInfo" style="font-size: 16px;">-</div>
</div>
<div class="stat-card">
<div class="stat-label">기간 내 출석 횟수</div>
<div class="stat-value" id="memberAttendanceCount">0회</div>
</div>
<div class="stat-card">
<div class="stat-label">조회 기간</div>
<div class="stat-value" id="memberPeriodInfo" style="font-size: 14px;">-</div>
</div>
</div>
<!-- 출석 캘린더 -->
<div style="margin-top: 20px;">
<h3 style="margin-bottom: 15px;">출석 캘린더</h3>
<div id="attendanceCalendar"
style="background: #fff; padding: 20px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.05);">
</div>
</div>
<!-- 최근 출석 내역 -->
<div style="margin-top: 20px;">
<h3 style="margin-bottom: 15px;">최근 출석 내역 (최대 20개)</h3>
<table class="data-table">
<thead>
<tr>
<th>출석일시</th>
<th>교시</th>
</tr>
</thead>
<tbody id="memberAttendanceHistory">
<!-- JS로 채움 -->
</tbody>
</table>
</div>
</div>
</div>
<!-- 3. 신규회원 탭 -->
<div id="new_membersTab" class="tab-content">
<div class="filter-container">
<select id="newMemberPeriod" class="form-control" style="width: 150px;"
onchange="handlePeriodChange('newMember')">
<option value="daily">개점일</option>
<option value="weekly">주간 (이번주)</option>
<option value="monthly">월간 (이번달)</option>
<option value="yearly">연간 (올해)</option>
<option value="custom">기간별</option>
</select>
<div id="newMemberDateContainer" style="display: flex; align-items: center; gap: 5px;">
<div class="date-picker-wrapper">
<input type="text" id="newMemberDateDisplay" class="form-control date-display" readonly>
<svg class="calendar-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<input type="date" id="newMemberDate" class="date-input-hidden"
onchange="updateDateDisplay(this, 'newMemberDateDisplay')">
</div>
<span class="date-separator" style="display: none;">~</span>
<div class="date-picker-wrapper date-end" style="display: none;">
<input type="text" id="newMemberEndDateDisplay" class="form-control date-display" readonly>
<svg class="calendar-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<input type="date" id="newMemberEndDate" class="date-input-hidden"
onchange="updateDateDisplay(this, 'newMemberEndDateDisplay')">
</div>
</div>
<button class="btn btn-primary" onclick="loadNewMembers()">조회</button>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">신규 가입 회원</div>
<div class="stat-value" style="color: #2ecc71;" id="newMemberCount">0명</div>
</div>
<div class="stat-card">
<div class="stat-label">총 출석 횟수</div>
<div class="stat-value" style="color: #3498db;" id="newMemberTotalAttendance">0회</div>
</div>
<div class="stat-card">
<div class="stat-label">평균 출석 횟수</div>
<div class="stat-value" style="color: #9b59b6;" id="newMemberAvgAttendance">0회</div>
</div>
</div>
<table class="data-table">
<thead>
<tr>
<th width="80">순위</th>
<th>이름</th>
<th>전화번호</th>
<th>출석 횟수</th>
<th>가입일</th>
<th>최초 출석일</th>
<th>최근 출석일</th>
</tr>
</thead>
<tbody id="newMemberList">
<!-- JS로 채움 -->
</tbody>
</tbody>
</table>
<!-- 무한 스크롤 감지용 Sentinel (신규회원) -->
<div id="newMemberSentinel" style="height: 20px; text-align: center; margin-top: 10px;">
<span id="newMemberLoader" style="display: none; color: #7f8c8d;">불러오는 중...</span>
</div>
</div>
<!-- 4. 출석순위 탭 -->
<div id="rankingTab" class="tab-content">
<div class="filter-container">
<select id="rankingPeriod" class="form-control" style="width: 150px;"
onchange="handlePeriodChange('ranking')">
<option value="daily">개점일</option>
<option value="weekly">주간 (이번주)</option>
<option value="monthly" selected>월간 (이번달)</option>
<option value="yearly">연간 (올해)</option>
<option value="custom">기간별</option>
</select>
<div id="rankingDateContainer" style="display: flex; align-items: center; gap: 5px;">
<div class="date-picker-wrapper">
<input type="text" id="rankingDateDisplay" class="form-control date-display" readonly>
<svg class="calendar-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<input type="date" id="rankingDate" class="date-input-hidden"
onchange="updateDateDisplay(this, 'rankingDateDisplay')">
</div>
<span class="date-separator" style="display: none;">~</span>
<div class="date-picker-wrapper date-end" style="display: none;">
<input type="text" id="rankingEndDateDisplay" class="form-control date-display" readonly>
<svg class="calendar-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<input type="date" id="rankingEndDate" class="date-input-hidden"
onchange="updateDateDisplay(this, 'rankingEndDateDisplay')">
</div>
</div>
<input type="number" id="minAttendance" class="form-control" placeholder="최소 출석 횟수 (예: 10)"
style="width: 200px;">
<button class="btn btn-primary" onclick="loadRanking()">조회</button>
</div>
<table class="data-table">
<thead>
<tr>
<th width="80">순위</th>
<th>이름</th>
<th>전화번호</th>
<th>출석 횟수</th>
<th>최근 출석일</th>
</tr>
</thead>
<tbody id="rankingList">
<!-- JS로 채움 -->
</tbody>
</table>
<!-- 무한 스크롤 감지용 Sentinel -->
<div id="rankingSentinel" style="height: 20px; text-align: center; margin-top: 10px;">
<span id="rankingLoader" style="display: none; color: #7f8c8d;">불러오는 중...</span>
</div>
</div>
</div>
<script>
// 공통 헤더 설정
function getHeaders() {
const headers = {};
const storeId = localStorage.getItem('selected_store_id');
const token = localStorage.getItem('access_token');
if (storeId) headers['X-Store-Id'] = storeId;
if (token) headers['Authorization'] = `Bearer ${token}`;
return headers;
}
// 공통 헤더 설정
function getHeaders() {
const headers = {};
const storeId = localStorage.getItem('selected_store_id');
const token = localStorage.getItem('access_token');
if (storeId) headers['X-Store-Id'] = storeId;
if (token) headers['Authorization'] = `Bearer ${token}`;
return headers;
}
// 전역 변수 (순위 더보기용)
let currentRankingOffset = 0;
const RANKING_LIMIT = 20;
let isRankingLoading = false;
let rankingObserver = null;
// 전역 변수 (신규회원 더보기용)
let currentNewMemberOffset = 0;
let isNewMemberLoading = false;
let newMemberObserver = null;
// SSE(Server-Sent Events) 관련 변수
let eventSource = null;
let sseConnected = false;
let currentTab = 'waiting_status'; // 현재 활성화된 탭 추적
// SSE 연결 초기화
function initSSE() {
if (eventSource) {
eventSource.close();
}
const storeId = localStorage.getItem('selected_store_id');
if (!storeId) {
console.error('Store ID가 없습니다');
return;
}
eventSource = new EventSource(`/api/sse/stream?store_id=${storeId}`);
eventSource.onopen = () => {
console.log('SSE 연결됨');
sseConnected = true;
updateSSEStatus(true);
};
eventSource.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
handleSSEMessage(message);
} catch (error) {
console.error('SSE 메시지 파싱 오류:', error);
}
};
eventSource.onerror = (error) => {
console.error('SSE 오류:', error);
sseConnected = false;
updateSSEStatus(false);
// 3초 후 재연결 시도
setTimeout(() => {
console.log('SSE 재연결 시도...');
initSSE();
}, 3000);
};
}
// SSE 연결 종료
function closeSSE() {
if (eventSource) {
eventSource.close();
eventSource = null;
sseConnected = false;
updateSSEStatus(false);
}
}
// SSE 메시지 처리
function handleSSEMessage(message) {
console.log('SSE 메시지 수신:', message);
switch (message.event) {
case 'connected':
console.log('SSE 연결 확인됨');
break;
case 'new_user':
// 새 대기자 등록 시: 대기현황만 업데이트
console.log('신규 대기자 등록 이벤트');
if (currentTab === 'waiting_status') {
loadWaitingStatus();
}
break;
case 'status_changed':
// 상태 변경 시: 해당 탭이 활성화되어 있으면 업데이트
console.log('상태 변경 이벤트 (출석/취소)');
if (currentTab === 'waiting_status') {
loadWaitingStatus();
}
if (currentTab === 'status') {
loadStatus();
}
break;
case 'batch_attendance':
// 일괄 출석 처리 시: 해당 탭이 활성화되어 있으면 업데이트
console.log('일괄 출석 처리 이벤트');
if (currentTab === 'waiting_status') {
loadWaitingStatus();
}
if (currentTab === 'status') {
loadStatus();
}
break;
case 'class_closed':
case 'class_reopened':
case 'order_changed':
case 'class_moved':
// 기타 대기 관련 이벤트: 대기현황만 업데이트
console.log('대기 관리 이벤트:', message.event);
if (currentTab === 'waiting_status') {
loadWaitingStatus();
}
break;
default:
console.log('처리하지 않는 이벤트:', message.event);
}
}
// SSE 상태 UI 업데이트
function updateSSEStatus(connected) {
// 대기현황 탭 상태 업데이트
const statusElement = document.getElementById('autoRefreshStatus');
const indicatorElement = document.getElementById('autoRefreshIndicator');
// 출석현황 탭 상태 업데이트
const statusTabElement = document.getElementById('statusTabStatus');
const statusTabIndicator = document.getElementById('statusTabIndicator');
if (connected) {
// 대기현황 탭
if (statusElement) {
statusElement.textContent = '실시간 연결';
statusElement.style.color = '#2ecc71';
}
if (indicatorElement) {
indicatorElement.style.background = '#2ecc71';
indicatorElement.style.animation = 'pulse 2s infinite';
}
// 출석현황 탭
if (statusTabElement) {
statusTabElement.textContent = '실시간 연결';
statusTabElement.style.color = '#2ecc71';
}
if (statusTabIndicator) {
statusTabIndicator.style.background = '#2ecc71';
statusTabIndicator.style.animation = 'pulse 2s infinite';
}
} else {
// 대기현황 탭
if (statusElement) {
statusElement.textContent = '연결 끊김';
statusElement.style.color = '#e74c3c';
}
if (indicatorElement) {
indicatorElement.style.background = '#e74c3c';
indicatorElement.style.animation = 'none';
}
// 출석현황 탭
if (statusTabElement) {
statusTabElement.textContent = '연결 끊김';
statusTabElement.style.color = '#e74c3c';
}
if (statusTabIndicator) {
statusTabIndicator.style.background = '#e74c3c';
statusTabIndicator.style.animation = 'none';
}
}
}
// 깜빡이는 애니메이션 CSS 추가
const style = document.createElement('style');
style.textContent = `
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
`;
document.head.appendChild(style);
// 탭 전환
function switchTab(tabId) {
// 현재 탭 저장
currentTab = tabId;
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
// 버튼 활성화 (인덱스 기반이 아니라 클릭된 요소 찾기 위해 event.target 대신 쿼리로 매칭)
const buttons = document.querySelectorAll('.tab-btn');
if (tabId === 'waiting_status') buttons[0].classList.add('active');
if (tabId === 'status') buttons[1].classList.add('active');
if (tabId === 'individual') buttons[2].classList.add('active');
if (tabId === 'new_members') buttons[3].classList.add('active');
if (tabId === 'ranking') buttons[4].classList.add('active');
document.getElementById(tabId + 'Tab').classList.add('active');
// SSE 연결 제어: 모든 탭에서 SSE 연결 유지
// SSE는 전체 매장의 이벤트를 수신하므로 연결 유지
if (!eventSource) {
initSSE();
}
// 탭별 데이터 로드
if (tabId === 'waiting_status') {
loadWaitingStatus();
} else if (tabId === 'status') {
loadStatus();
} else if (tabId === 'new_members') {
loadNewMembers();
} else if (tabId === 'ranking') {
loadRanking();
}
}
// 날짜 관련 유틸리티 함수
function formatDateToDisplay(dateString, period = 'daily') {
if (!dateString) return '';
const d = new Date(dateString);
if (period === 'weekly') {
// 해당 날짜가 포함된 주(월~일) 계산
const day = d.getDay(); // 0:Sun, 1:Mon, ...
const diff = d.getDate() - day + (day === 0 ? -6 : 1); // Monday
const monday = new Date(d);
monday.setDate(diff);
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
const start = `${monday.getFullYear()}. ${String(monday.getMonth() + 1).padStart(2, '0')}. ${String(monday.getDate()).padStart(2, '0')}`;
const end = `${sunday.getFullYear()}. ${String(sunday.getMonth() + 1).padStart(2, '0')}. ${String(sunday.getDate()).padStart(2, '0')}`;
return `${start} ~ ${end}`;
} else if (period === 'monthly') {
return `${d.getFullYear()}. ${String(d.getMonth() + 1).padStart(2, '0')}`;
} else if (period === 'yearly') {
return `${d.getFullYear()}`;
}
// Default (daily, custom, etc.)
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}. ${month}. ${day}`;
}
function updateDateDisplay(input, displayId) {
const display = document.getElementById(displayId);
if (!display) return;
// 어떤 탭의 어떤 기간 설정인지 확인
// displayId 형식: {prefix}DateDisplay (예: waitingDateDisplay)
const prefix = displayId.replace('DateDisplay', '').replace('EndDateDisplay', '');
// EndDateDisplay인 경우는 custom 기간의 종료일이므로 항상 daily 포맷
if (displayId.includes('EndDate')) {
display.value = formatDateToDisplay(input.value, 'daily');
return;
}
const periodSelect = document.getElementById(prefix + 'Period');
const period = periodSelect ? periodSelect.value : 'daily';
display.value = formatDateToDisplay(input.value, period);
// 주간 선택 시 입력창 너비 조정 필요할 수 있음
if (period === 'weekly') {
display.parentElement.style.width = '240px';
} else {
display.parentElement.style.width = '150px';
}
}
function handlePeriodChange(prefix) {
const periodSelect = document.getElementById(prefix + 'Period');
const container = document.getElementById(prefix + 'DateContainer');
const isCustom = periodSelect.value === 'custom';
const separator = periodSelect.parentElement.querySelector('.date-separator');
const endDateWrapper = periodSelect.parentElement.querySelector('.date-end');
if (separator) separator.style.display = isCustom ? 'inline' : 'none';
if (endDateWrapper) endDateWrapper.style.display = isCustom ? 'inline-block' : 'none';
// 기간 변경 시 날짜 표시 업데이트
const dateInput = document.getElementById(prefix + 'Date');
if (dateInput) {
updateDateDisplay(dateInput, prefix + 'DateDisplay');
}
}
// 날짜 초기화 (API에서 영업일 가져오기)
async function initializeDates() {
try {
// API에서 영업일 가져오기
const response = await fetch('/api/daily/check-status', { headers: getHeaders() });
const data = await response.json();
let businessDate;
if (data && data.business_date) {
businessDate = data.business_date; // YYYY-MM-DD 형식
} else {
// API 실패 시 현재 날짜 사용
const today = new Date();
businessDate = today.toISOString().split('T')[0];
}
// 모든 날짜 입력 필드에 영업일 설정
const dateInputs = ['waitingDate', 'statusDate', 'individualDate', 'newMemberDate', 'rankingDate'];
dateInputs.forEach(id => {
const input = document.getElementById(id);
if (input) {
input.value = businessDate;
// 표시용 필드도 업데이트
const displayId = id + 'Display';
updateDateDisplay(input, displayId);
}
});
// 종료일도 영업일로 초기화 (custom 기간이 아닐 경우)
const endDateInputs = ['waitingEndDate', 'statusEndDate', 'individualEndDate', 'newMemberEndDate', 'rankingEndDate'];
endDateInputs.forEach(id => {
const input = document.getElementById(id);
if (input) {
input.value = businessDate;
const displayId = id + 'Display';
updateDateDisplay(input, displayId);
}
});
} catch (error) {
console.error('영업일 초기화 실패:', error);
// 오류 시 현재 날짜 사용
const today = new Date();
const todayStr = today.toISOString().split('T')[0];
const dateInputs = ['waitingDate', 'statusDate', 'individualDate', 'newMemberDate', 'rankingDate'];
dateInputs.forEach(id => {
const input = document.getElementById(id);
if (input) {
input.value = todayStr;
const displayId = id + 'Display';
updateDateDisplay(input, displayId);
}
});
const endDateInputs = ['waitingEndDate', 'statusEndDate', 'individualEndDate', 'newMemberEndDate', 'rankingEndDate'];
endDateInputs.forEach(id => {
const input = document.getElementById(id);
if (input) {
input.value = todayStr;
const displayId = id + 'Display';
updateDateDisplay(input, displayId);
}
});
}
// URL 파라미터 처리 (대기자 관리에서 조회 버튼 클릭 시)
const urlParams = new URLSearchParams(window.location.search);
const autoTab = urlParams.get('tab');
const autoPhone = urlParams.get('phone');
const autoLookup = urlParams.get('auto');
const autoName = urlParams.get('name');
const viewMode = urlParams.get('view');
// 최소 뷰 모드 적용
if (viewMode === 'minimal') {
document.body.classList.add('minimal-view');
}
if (autoTab === 'individual' && autoPhone && autoLookup === 'true') {
// 개인별 출석 탭으로 전환
switchTab('individual');
// 로딩 표시
const loadingDiv = document.createElement('div');
loadingDiv.className = 'loading-overlay';
loadingDiv.innerHTML = '<div class="spinner"></div><p style="margin-top:10px; font-weight:bold;">데이터 조회 중...</p>';
document.body.appendChild(loadingDiv);
// 전화번호 자동 입력 및 조회
setTimeout(() => {
const phoneInput = document.getElementById('memberSearch');
if (phoneInput) {
// 하이픈 제거 후 입력
const cleanPhone = autoPhone.replace(/[^0-9]/g, '');
phoneInput.value = cleanPhone;
// 자동 조회 실행 (자동 선택 활성화)
searchMember(true).finally(() => {
loadingDiv.remove();
});
} else {
loadingDiv.remove();
}
}, 500);
} else {
// 기본 탭 활성화
switchTab('waiting_status');
}
// 초기 탭 활성화 (URL 파라미터가 없는 경우)
if (!autoTab) {
switchTab('waiting_status');
}
// 0. 대기현황 조회
async function loadWaitingStatus() {
const period = document.getElementById('waitingPeriod').value;
const date = document.getElementById('waitingDate').value;
let url = `/api/attendance/waiting-status?period=${period}&date=${date}`;
if (period === 'custom') {
const endDate = document.getElementById('waitingEndDate').value;
if (!endDate) {
alert('종료일을 선택해주세요.');
return;
}
if (date > endDate) {
alert('시작일이 종료일보다 늦을 수 없습니다.');
return;
}
url += `&start_date=${date}&end_date=${endDate}`;
}
try {
const response = await fetch(url, { headers: getHeaders() });
const data = await response.json();
document.getElementById('totalWaiting').textContent = `${data.total}명`;
document.getElementById('existingMemberWaiting').textContent = `${data.existing}명`;
document.getElementById('newMemberWaiting').textContent = `${data.new}명`;
document.getElementById('currentTotalWaiting').textContent = `${data.current_total}명`;
document.getElementById('currentExistingWaiting').textContent = `${data.current_existing}명`;
document.getElementById('currentNewWaiting').textContent = `${data.current_new}명`;
// 마지막 업데이트 시간 표시
const now = new Date();
const timeString = now.toLocaleTimeString('ko-KR', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
document.getElementById('lastUpdateTime').textContent = `마지막 업데이트: ${timeString}`;
} catch (e) {
console.error('대기현황 조회 실패', e);
alert('데이터 조회 중 오류가 발생했습니다.');
}
}
// 1. 출석현황 조회
async function loadStatus() {
const period = document.getElementById('statusPeriod').value;
const date = document.getElementById('statusDate').value;
let url = `/api/attendance/status?period=${period}&date=${date}`;
if (period === 'custom') {
const endDate = document.getElementById('statusEndDate').value;
if (!endDate) {
alert('종료일을 선택해주세요.');
return;
}
if (date > endDate) {
alert('시작일이 종료일보다 늦을 수 없습니다.');
return;
}
url += `&start_date=${date}&end_date=${endDate}`;
}
try {
const response = await fetch(url, { headers: getHeaders() });
const data = await response.json();
document.getElementById('totalAttendance').textContent = `${data.total}명`;
document.getElementById('existingMemberAttendance').textContent = `${data.existing}명`;
document.getElementById('newMemberAttendance').textContent = `${data.new}명`;
} catch (e) {
console.error('출석현황 조회 실패', e);
}
}
// 2. 개인별 출석 조회
async function searchMember(autoSelect = false) {
let query = document.getElementById('memberSearch').value;
if (!query) return alert('검색어를 입력해주세요');
// 숫자만 있는 경우 하이픈 제거 (검색 유연성)
query = query.replace(/[^0-9a-zA-Z가-힣]/g, '');
try {
const response = await fetch(`/api/attendance/individual/search?query=${query}`, { headers: getHeaders() });
const members = await response.json();
const resultDiv = document.getElementById('individualResult');
// 검색 결과 영역 항상 표시
resultDiv.style.display = 'block';
// 상세 정보 영역 숨기기
document.getElementById('memberAttendanceDetail').style.display = 'none';
if (members.length === 0) {
resultDiv.innerHTML = '<div class="empty-state"><p>검색 결과가 없습니다.</p></div>';
return;
}
// 자동 선택 로직
if (autoSelect) {
// 1. 전화번호가 정확히 일치하는 회원이 있으면 자동 선택 (하이픈 제거 후 비교)
const exactMatch = members.find(m => m.phone.replace(/[^0-9]/g, '') === query.replace(/[^0-9]/g, ''));
if (exactMatch) {
loadMemberDetail(exactMatch.id);
return;
}
// 2. 검색 결과가 1명이면 자동 선택
if (members.length === 1) {
loadMemberDetail(members[0].id);
return;
}
}
let htmlContent = '<div class="member-list" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px; margin-bottom: 20px;">';
members.forEach(member => {
// 핸드폰 번호 포맷팅 (010-0000-0000)
let formattedPhone = member.phone;
if (member.phone.length === 11) {
formattedPhone = member.phone.replace(/(\d{3})(\d{4})(\d{4})/, '$1-$2-$3');
}
htmlContent += `
<div class="member-card" onclick="loadMemberDetail(${member.id})" style="padding: 15px; border: 1px solid #ecf0f1; border-radius: 8px; cursor: pointer; transition: all 0.2s; background: #fff;">
<div style="font-weight: bold; font-size: 16px; margin-bottom: 5px;">${member.name}</div>
<div style="color: #2980b9; font-size: 18px; font-weight: bold; letter-spacing: 0.5px;">${formattedPhone}</div>
</div>
`;
});
htmlContent += '</div>';
resultDiv.innerHTML = htmlContent;
} catch (e) {
console.error('회원 검색 실패', e);
}
}
let currentMemberId = null;
async function loadMemberDetail(memberId) {
currentMemberId = memberId;
// 검색 결과 숨기기
document.getElementById('individualResult').style.display = 'none';
// 상세 영역 표시
const detailSection = document.getElementById('memberAttendanceDetail');
detailSection.style.display = 'block';
// 날짜 초기화 (오늘 날짜 기준)
const today = new Date();
const dateInput = document.getElementById('individualDate');
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
dateInput.value = `${year}-${month}-${day}`;
// 기본 기간을 'monthly'로 강제 설정
const periodSelect = document.getElementById('individualPeriod');
if (periodSelect) {
periodSelect.value = 'monthly';
handlePeriodChange('individual'); // UI 업데이트 (DatePicker 표시 여부 등)
}
// 데이터 로드 (monthly 강제 적용)
await loadMemberDetailWithPeriod('monthly');
}
async function loadMemberDetailWithPeriod(overridePeriod = null) {
if (!currentMemberId) return;
const periodSelect = document.getElementById('individualPeriod');
let period = periodSelect ? periodSelect.value : 'monthly';
// 오버라이드 값이 있으면 DOM 값보다 우선 사용
if (overridePeriod) {
if (periodSelect) periodSelect.value = overridePeriod;
period = overridePeriod;
handlePeriodChange('individual'); // UI 업데이트
}
const date = document.getElementById('individualDate').value;
let url = `/api/attendance/individual/${currentMemberId}?period=${period}&date=${date}`;
if (period === 'custom') {
const endDate = document.getElementById('individualEndDate').value;
url += `&start_date=${date}&end_date=${endDate}`;
}
try {
const response = await fetch(url, { headers: getHeaders() });
if (!response.ok) {
const errorData = await response.json();
console.error('API Error:', errorData);
throw new Error(errorData.detail || '서버 오류');
}
const data = await response.json();
// 회원 정보 표시
// 핸드폰 번호 포맷팅
let formattedPhone = data.member.phone;
if (data.member.phone.length === 11) {
formattedPhone = data.member.phone.replace(/(\d{3})(\d{4})(\d{4})/, '$1-$2-$3');
}
document.getElementById('memberInfo').innerHTML = `<span style="font-size: 24px; font-weight: bold; color: #2c3e50;">${data.member.name}</span> <span style="font-size: 20px; font-weight: bold; color: #2980b9; margin-left: 8px;">(${formattedPhone})</span>`;
// 출석 횟수도 크게
document.getElementById('memberAttendanceCount').style.fontSize = "32px";
document.getElementById('memberAttendanceCount').style.fontWeight = "bold";
document.getElementById('memberAttendanceCount').style.color = "#2c3e50";
document.getElementById('memberAttendanceCount').textContent = `${data.total_count}회`;
// 조회 기간 크게
document.getElementById('memberPeriodInfo').innerHTML = `<span style="font-size: 20px; font-weight: bold; color: #2c3e50;">${data.period.start} ~ ${data.period.end}</span>`;
// 출석 내역 테이블
const historyBody = document.getElementById('memberAttendanceHistory');
if (data.history.length === 0) {
historyBody.innerHTML = '<tr><td colspan="2">출석 이력이 없습니다.</td></tr>';
} else {
historyBody.innerHTML = data.history.map(h => `
<tr>
<td>${h.date}</td>
<td>${h.class_name}</td>
</tr>
`).join('');
}
// 캘린더 렌더링
renderAttendanceCalendar(data.period.start, data.period.end, data.calendar_dates);
} catch (e) {
console.error('상세 조회 실패:', e);
alert(`출석 정보를 불러오는데 실패했습니다.\n오류: ${e.message}`);
}
}
function renderAttendanceCalendar(startDate, endDate, attendanceDates) {
const calendarDiv = document.getElementById('attendanceCalendar');
const start = new Date(startDate);
const end = new Date(endDate);
// 출석 날짜를 Set으로 변환 (빠른 검색)
const attendanceSet = new Set(attendanceDates);
// 월별로 그룹화
const months = [];
let current = new Date(start);
// 1일로 설정하여 해당 월의 처음부터 체크
current.setDate(1);
while (current <= end) {
const year = current.getFullYear();
const month = current.getMonth();
const monthKey = `${year}-${month}`;
// 중복 방지 및 범위 체크: 해당 월이 표시 범위와 겹치는지
const monthEnd = new Date(year, month + 1, 0);
const monthStart = new Date(year, month, 1);
if (monthEnd >= start && monthStart <= end) {
if (!months.find(m => m.key === monthKey)) {
months.push({
key: monthKey,
year: year,
month: month,
monthName: current.toLocaleDateString('ko-KR', { year: 'numeric', month: 'long' })
});
}
}
current.setMonth(current.getMonth() + 1);
}
// 캘린더 HTML 생성
let html = '<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px;">';
months.forEach(monthInfo => {
const firstDay = new Date(monthInfo.year, monthInfo.month, 1);
const lastDay = new Date(monthInfo.year, monthInfo.month + 1, 0);
const startDay = firstDay.getDay(); // 0 = Sunday
html += `
<div style="border: 1px solid #e0e0e0; border-radius: 8px; padding: 15px; background: #fafafa;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<button class="btn btn-sm btn-outline-secondary" onclick="changeAttendanceMonth(-1)" style="border: none; background: none; font-size: 18px; cursor: pointer;">
</button>
<h4 style="text-align: center; margin: 0; color: #2c3e50; font-size: 18px;">${monthInfo.monthName}</h4>
<button class="btn btn-sm btn-outline-secondary" onclick="changeAttendanceMonth(1)" style="border: none; background: none; font-size: 18px; cursor: pointer;">
</button>
</div>
<div style="display: grid; grid-template-columns: repeat(7, 1fr); gap: 5px; text-align: center;">
<div style="font-weight: bold; color: #e74c3c;">일</div>
<div style="font-weight: bold;">월</div>
<div style="font-weight: bold;">화</div>
<div style="font-weight: bold;">수</div>
<div style="font-weight: bold;">목</div>
<div style="font-weight: bold;">금</div>
<div style="font-weight: bold; color: #3498db;">토</div>
`;
// 빈 칸 추가 (월 시작 전)
for (let i = 0; i < startDay; i++) {
html += '<div></div>';
}
// 날짜 추가
for (let day = 1; day <= lastDay.getDate(); day++) {
const dateStr = `${monthInfo.year}-${String(monthInfo.month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const isAttended = attendanceSet.has(dateStr);
const isInRange = new Date(dateStr) >= start && new Date(dateStr) <= end;
// 출석한 날은 녹색 배경, 흰색 글씨, 굵게
const bgColor = isAttended ? '#2ecc71' : (isInRange ? '#ecf0f1' : '#f8f9fa');
const textColor = isAttended ? '#fff' : '#2c3e50';
const fontWeight = isAttended ? 'bold' : 'normal';
html += `
<div style="
padding: 8px;
background: ${bgColor};
color: ${textColor};
font-weight: ${fontWeight};
border-radius: 4px;
${isAttended ? 'box-shadow: 0 2px 4px rgba(46, 204, 113, 0.3); transform: scale(1.05);' : ''}
transition: all 0.2s;
">
${day}
</div>
`;
}
html += '</div></div>';
});
html += '</div>';
calendarDiv.innerHTML = html;
}
// 캘린더 월 이동 함수
function changeAttendanceMonth(offset) {
const dateInput = document.getElementById('individualDate');
const currentDate = new Date(dateInput.value);
// 월 변경
currentDate.setMonth(currentDate.getMonth() + offset);
// 변경된 날짜 설정
// toISOString()은 UTC 기준이므로 로컬 시간대 반영 필요
const year = currentDate.getFullYear();
const month = String(currentDate.getMonth() + 1).padStart(2, '0');
const day = String(currentDate.getDate()).padStart(2, '0');
dateInput.value = `${year}-${month}-${day}`;
// 'daily'나 'custom'이 아닌 경우 날짜 변경 시 조회 트리거
// 'monthly'인 경우 날짜만 변경하고 조회 호출
loadMemberDetailWithPeriod();
}
// 3. 신규회원 조회
async function loadNewMembers(isLoadMore = false) {
const period = document.getElementById('newMemberPeriod').value;
let date = document.getElementById('newMemberDate').value;
// 날짜가 비어있으면 오늘로 설정
if (!date) {
date = new Date().toISOString().split('T')[0];
document.getElementById('newMemberDate').value = date;
}
if (isNewMemberLoading) return;
isNewMemberLoading = true;
const loader = document.getElementById('newMemberLoader');
if (isLoadMore && loader) loader.style.display = 'inline-block';
if (!isLoadMore) {
currentNewMemberOffset = 0;
}
let url = `/api/attendance/new-members?period=${period}&date=${date}&skip=${currentNewMemberOffset}&limit=${RANKING_LIMIT}`;
if (period === 'custom') {
const endDate = document.getElementById('newMemberEndDate').value;
if (date > endDate) {
alert('시작일이 종료일보다 늦을 수 없습니다.');
isNewMemberLoading = false;
return;
}
url += `&start_date=${date}&end_date=${endDate}`;
}
try {
const response = await fetch(url, { headers: getHeaders() });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (!isLoadMore) {
document.getElementById('newMemberCount').textContent = `${data.count}명`;
document.getElementById('newMemberTotalAttendance').textContent = `${data.total_attendance}회`;
document.getElementById('newMemberAvgAttendance').textContent = `${data.avg_attendance}회`;
}
const tbody = document.getElementById('newMemberList');
const members = data.new_members || []; // 백엔드 키 확인 (new_members)
if (members.length === 0) {
if (!isLoadMore) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center; padding:40px; color:#95a5a6;">해당 기간에 가입한 신규회원이 없습니다.</td></tr>';
}
if (newMemberObserver) newMemberObserver.disconnect();
if (loader) loader.style.display = 'none';
isNewMemberLoading = false;
return;
}
const html = members.map((m, index) => {
const realIndex = currentNewMemberOffset + index;
let rankClass = 'rank-other';
if (realIndex === 0) rankClass = 'rank-1';
if (realIndex === 1) rankClass = 'rank-2';
if (realIndex === 2) rankClass = 'rank-3';
return `
<tr>
<td><span class="rank-badge ${rankClass}">${realIndex + 1}</span></td>
<td>${m.name}</td>
<td>${m.phone}</td>
<td><strong>${m.attendance_count}회</strong></td>
<td>${m.joined_at}</td>
<td>${m.first_attendance || '-'}</td>
<td>${m.last_attendance || '-'}</td>
</tr>
`;
}).join('');
if (isLoadMore) {
tbody.insertAdjacentHTML('beforeend', html);
} else {
tbody.innerHTML = html;
setupNewMemberObserver();
}
currentNewMemberOffset += RANKING_LIMIT;
if (members.length < RANKING_LIMIT) {
if (newMemberObserver) newMemberObserver.disconnect();
}
} catch (e) {
console.error('신규회원 조회 실패:', e);
const tbody = document.getElementById('newMemberList');
if (!isLoadMore) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center; padding:40px; color:#e74c3c;">데이터 로딩 실패. 다시 시도해주세요.</td></tr>';
}
} finally {
isNewMemberLoading = false;
if (loader) loader.style.display = 'none';
}
}
// 신규회원 무한 스크롤 Observer 설정
function setupNewMemberObserver() {
if (newMemberObserver) newMemberObserver.disconnect();
const sentinel = document.getElementById('newMemberSentinel');
newMemberObserver = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !isNewMemberLoading) {
loadNewMembers(true);
}
}, { threshold: 0.1 });
newMemberObserver.observe(sentinel);
}
// 4. 출석순위 조회
async function loadRanking(isLoadMore = false) {
const period = document.getElementById('rankingPeriod').value;
const minCount = document.getElementById('minAttendance').value || 0;
const date = document.getElementById('rankingDate').value;
if (isRankingLoading) return;
isRankingLoading = true;
const loader = document.getElementById('rankingLoader');
if (isLoadMore && loader) loader.style.display = 'inline-block';
if (!isLoadMore) {
currentRankingOffset = 0;
// 초기화 시 스크롤 최상단으로? (필요시)
}
let url = `/api/attendance/ranking?period=${period}&min_count=${minCount}&date=${date}&skip=${currentRankingOffset}&limit=${RANKING_LIMIT}`;
if (period === 'custom') {
const endDate = document.getElementById('rankingEndDate').value;
if (!endDate) {
alert('종료일을 선택해주세요.');
isRankingLoading = false;
return;
}
if (date > endDate) {
alert('시작일이 종료일보다 늦을 수 없습니다.');
isRankingLoading = false;
return;
}
url += `&start_date=${date}&end_date=${endDate}`;
}
try {
const response = await fetch(url, { headers: getHeaders() });
const data = await response.json();
const tbody = document.getElementById('rankingList');
// 데이터가 없으면
if (data.length === 0) {
if (!isLoadMore) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center; padding:20px;">데이터가 없습니다.</td></tr>';
}
// 더 이상 데이터 없으면 Observer 해제
if (rankingObserver) {
rankingObserver.disconnect();
}
if (loader) loader.style.display = 'none';
isRankingLoading = false;
return;
}
const html = data.map((m, index) => {
const realIndex = currentRankingOffset + index;
let rankClass = 'rank-other';
if (realIndex === 0) rankClass = 'rank-1';
if (realIndex === 1) rankClass = 'rank-2';
if (realIndex === 2) rankClass = 'rank-3';
return `
<tr>
<td><span class="rank-badge ${rankClass}">${realIndex + 1}</span></td>
<td>${m.name}</td>
<td>${m.phone}</td>
<td><strong>${m.attendance_count}회</strong></td>
<td>${m.last_attendance}</td>
</tr>
`;
}).join('');
if (isLoadMore) {
tbody.insertAdjacentHTML('beforeend', html);
} else {
tbody.innerHTML = html;
// 첫 로드 후 Observer 연결
setupRankingObserver();
}
// 다음 오프셋 준비
currentRankingOffset += RANKING_LIMIT;
// 데이터가 Limit보다 적게 오면 끝난 것
if (data.length < RANKING_LIMIT) {
if (rankingObserver) rankingObserver.disconnect();
}
} catch (e) {
console.error('순위 조회 실패', e);
if (!isLoadMore) {
document.getElementById('rankingList').innerHTML = '<tr><td colspan="5" style="text-align:center; color:red;">로딩 실패</td></tr>';
}
} finally {
isRankingLoading = false;
if (loader) loader.style.display = 'none';
}
}
// 무한 스크롤 Observer 설정
function setupRankingObserver() {
if (rankingObserver) rankingObserver.disconnect();
const sentinel = document.getElementById('rankingSentinel');
rankingObserver = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !isRankingLoading) {
loadRanking(true);
}
}, { threshold: 0.1 });
rankingObserver.observe(sentinel);
}
// 날짜 필드 초기화
function initializeDates() {
const today = new Date().toISOString().split('T')[0];
document.getElementById('waitingDate').value = today;
document.getElementById('statusDate').value = today;
document.getElementById('newMemberDate').value = today;
}
// 초기 로드
// initializeDates(); // 위에서 이미 실행함
loadWaitingStatus(); // 기본 탭인 대기현황 로드
initSSE(); // SSE 연결 시작 (이벤트 기반 실시간 업데이트)
// 영업일 조회 함수
async function loadBusinessDate() {
try {
const response = await fetch('/api/daily/check-status', { headers: getHeaders() });
const data = await response.json();
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}일`;
const element = document.getElementById('headerBusinessDate');
if (element) {
element.textContent = formattedDate;
}
} else {
const element = document.getElementById('headerBusinessDate');
if (element) {
element.textContent = '';
}
}
} catch (error) {
console.error('영업일 정보 조회 실패:', error);
}
}
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', async function () {
// 영업일 표시 및 날짜 초기화
await loadBusinessDate();
await initializeDates();
window.addEventListener('beforeunload', () => {
closeSSE();
});
});
</script>
</body>
</html>