- 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>
2245 lines
102 KiB
HTML
2245 lines
102 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">
|
||
<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>
|
||
<div style="display: flex; align-items: baseline; flex-wrap: wrap; gap: 15px;">
|
||
<h1 id="storeName" style="margin: 0;">대기 및 출석 현황</h1>
|
||
<div id="businessDateDisplay" style="font-size: 24px; font-weight: 800; color: #d35400;"></div>
|
||
</div>
|
||
<p class="subtitle" style="margin-top: 10px;">회원 출석 현황 및 통계</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>
|
||
<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 id="detailAttendanceModal" class="modal">
|
||
<div class="modal-content" style="max-width: 900px; width: 95%; max-height: 90vh; overflow-y: auto;">
|
||
<div class="modal-header"
|
||
style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||
<span style="font-size: 24px; font-weight: bold;">상세 출석 현황</span>
|
||
<span style="cursor: pointer; font-size: 28px;" onclick="closeDetailModal()">×</span>
|
||
</div>
|
||
|
||
<div class="filter-container" style="border-radius: 10px; margin-top: 0; background-color: #f8f9fa;">
|
||
<select id="detailPeriodType" class="form-control" style="width: 150px;"
|
||
onchange="handleDetailPeriodChange()">
|
||
<option value="daily">개점일 (오늘)</option>
|
||
<option value="weekly">주간 (이번주)</option>
|
||
<option value="monthly" selected>월간 (이번달)</option>
|
||
<option value="yearly">연간 (올해)</option>
|
||
<option value="custom">기간별 (직접선택)</option>
|
||
</select>
|
||
|
||
<div id="detailDateContainer" class="date-picker-wrapper">
|
||
<input type="text" id="detailBaseDateDisplay" 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="detailBaseDate" class="date-input-hidden"
|
||
onchange="updateDateDisplay(this, 'detailBaseDateDisplay')">
|
||
</div>
|
||
<div id="detailCustomDateRange" style="display: none; align-items: center; gap: 5px;">
|
||
<div class="date-picker-wrapper">
|
||
<input type="date" id="detailStartDate" class="form-control">
|
||
</div>
|
||
<span>~</span>
|
||
<div class="date-picker-wrapper">
|
||
<input type="date" id="detailEndDate" class="form-control">
|
||
</div>
|
||
</div>
|
||
<button class="btn btn-primary" onclick="loadMemberDetailStats()">조회</button>
|
||
</div>
|
||
|
||
<!-- 요약 정보 카드 -->
|
||
<div class="stats-grid" style="margin-bottom: 20px;">
|
||
<div class="stat-card">
|
||
<div class="stat-label">회원 정보</div>
|
||
<div class="stat-value" id="detailMemberInfo" style="font-size: 20px;">-</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">기간 내 출석 횟수</div>
|
||
<div class="stat-value" id="detailAttendanceCount">-</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">조회 기간</div>
|
||
<div class="stat-value" id="detailPeriodDate" style="font-size: 16px;">-</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 캘린더 -->
|
||
<div class="calendar-container"
|
||
style="border: 1px solid #e0e0e0; border-radius: 10px; overflow: hidden; margin-bottom: 30px;">
|
||
<div class="calendar-header"
|
||
style="display: flex; justify-content: space-between; align-items: center; padding: 15px 20px; background: #f8f9fa; border-bottom: 1px solid #e0e0e0;">
|
||
<button class="calendar-nav-btn" onclick="changeDetailMonth(-1)"
|
||
style="border: 1px solid #ddd; background: white; padding: 5px 10px; border-radius: 5px; cursor: pointer;">❮
|
||
이전달</button>
|
||
<h4 id="detailCalendarTitle" style="margin: 0; font-size: 18px;">2024년 12월</h4>
|
||
<button class="calendar-nav-btn" onclick="changeDetailMonth(1)"
|
||
style="border: 1px solid #ddd; background: white; padding: 5px 10px; border-radius: 5px; cursor: pointer;">다음달
|
||
❯</button>
|
||
</div>
|
||
<div class="calendar-grid"
|
||
style="display: grid; grid-template-columns: repeat(7, 1fr); text-align: center;">
|
||
<div class="calendar-day-header sunday"
|
||
style="padding: 10px; background: #fff; border-bottom: 1px solid #eee; color: #e74c3c;">일
|
||
</div>
|
||
<div class="calendar-day-header"
|
||
style="padding: 10px; background: #fff; border-bottom: 1px solid #eee;">월</div>
|
||
<div class="calendar-day-header"
|
||
style="padding: 10px; background: #fff; border-bottom: 1px solid #eee;">화</div>
|
||
<div class="calendar-day-header"
|
||
style="padding: 10px; background: #fff; border-bottom: 1px solid #eee;">수</div>
|
||
<div class="calendar-day-header"
|
||
style="padding: 10px; background: #fff; border-bottom: 1px solid #eee;">목</div>
|
||
<div class="calendar-day-header"
|
||
style="padding: 10px; background: #fff; border-bottom: 1px solid #eee;">금</div>
|
||
<div class="calendar-day-header saturday"
|
||
style="padding: 10px; background: #fff; border-bottom: 1px solid #eee; color: #3498db;">토
|
||
</div>
|
||
</div>
|
||
<div id="detailCalendarBody" class="calendar-grid"
|
||
style="display: grid; grid-template-columns: repeat(7, 1fr);">
|
||
<!-- JS로 채움 -->
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 최근 출석 내역 리스트 -->
|
||
<div>
|
||
<h3 style="font-size: 18px; margin-bottom: 15px; color: #333;">최근 출석 내역 (최대 20개)</h3>
|
||
<div class="data-table-container">
|
||
<table class="data-table">
|
||
<thead>
|
||
<tr>
|
||
<th>출석일시</th>
|
||
<th>교시</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="detailHistoryList">
|
||
<!-- JS로 채움 -->
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<style>
|
||
/* 모달 스타일 추가 */
|
||
.modal {
|
||
display: none;
|
||
position: fixed;
|
||
z-index: 2000;
|
||
left: 0;
|
||
top: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: rgba(0, 0, 0, 0.5);
|
||
justify-content: center;
|
||
align-items: center;
|
||
/* common.css의 opacity: 0 오버라이드 */
|
||
opacity: 1 !important;
|
||
visibility: visible !important;
|
||
}
|
||
|
||
.modal-content {
|
||
background: white;
|
||
padding: 30px;
|
||
border-radius: 10px;
|
||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||
/* common.css의 opacity: 0 && transform 오버라이드 */
|
||
opacity: 1 !important;
|
||
transform: none !important;
|
||
}
|
||
|
||
.calendar-day {
|
||
padding: 15px 10px;
|
||
border-right: 1px solid #eee;
|
||
border-bottom: 1px solid #eee;
|
||
background: white;
|
||
min-height: 80px;
|
||
position: relative;
|
||
}
|
||
|
||
.calendar-day:nth-child(7n) {
|
||
border-right: none;
|
||
}
|
||
|
||
.calendar-day.empty {
|
||
background: #fcfcfc;
|
||
}
|
||
|
||
.calendar-day.sunday .day-number {
|
||
color: #e74c3c;
|
||
}
|
||
|
||
.calendar-day.saturday .day-number {
|
||
color: #3498db;
|
||
}
|
||
|
||
.calendar-day.today {
|
||
background-color: #f0f7ff;
|
||
}
|
||
|
||
.day-number {
|
||
display: block;
|
||
margin-bottom: 5px;
|
||
font-weight: 500;
|
||
}
|
||
</style>
|
||
</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');
|
||
}
|
||
}
|
||
|
||
// 날짜 초기화 (오늘)
|
||
const today = new Date().toISOString().split('T')[0];
|
||
const initialIds = ['waiting', 'status', 'individual', 'newMember', 'ranking'];
|
||
|
||
initialIds.forEach(id => {
|
||
const dateInput = document.getElementById(id + 'Date');
|
||
const endDateInput = document.getElementById(id + 'EndDate');
|
||
if (dateInput) {
|
||
dateInput.value = today;
|
||
updateDateDisplay(dateInput, id + 'DateDisplay');
|
||
}
|
||
if (endDateInput) {
|
||
endDateInput.value = today;
|
||
updateDateDisplay(endDateInput, id + 'EndDateDisplay');
|
||
}
|
||
});
|
||
|
||
|
||
// URL 파라미터에서 매장 정보 가져오기
|
||
async function checkUrlStoreParam() {
|
||
const urlParams = new URLSearchParams(window.location.search);
|
||
const storeParam = urlParams.get('store');
|
||
|
||
if (storeParam) {
|
||
try {
|
||
// 매장 코드로 매장 정보 조회
|
||
const response = await fetch(`/api/stores/code/${storeParam}`, {
|
||
headers: {
|
||
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
|
||
}
|
||
});
|
||
if (response.ok) {
|
||
const store = await response.json();
|
||
localStorage.setItem('selected_store_id', store.id);
|
||
localStorage.setItem('selected_store_name', store.name);
|
||
localStorage.setItem('selected_franchise_id', store.franchise_id);
|
||
|
||
// 상단 제목 업데이트
|
||
const header = document.getElementById('storeName');
|
||
if (header) header.innerHTML = `대기 및 출석 현황 <span style="font-size: 18px; color: #7f8c8d;">(${store.name})</span>`;
|
||
|
||
console.log(`URL 매장 파라미터 적용: ${store.name} (코드: ${storeParam})`);
|
||
|
||
// SSE 재연결 (매장 ID 변경 시)
|
||
if (eventSource) {
|
||
eventSource.close();
|
||
initSSE();
|
||
}
|
||
|
||
// 초기 데이터 로드 (매장 ID 설정 후)
|
||
loadBusinessDate();
|
||
|
||
// URL 파라미터로 탭이 지정된 경우 해당 탭 로드, 아니면 기본 탭 로드
|
||
// --- 상세 출석 현황 모달 관련 JS ---
|
||
let currentDetailMemberId = null;
|
||
|
||
function openDetailModal(memberId, name, phone) {
|
||
currentDetailMemberId = memberId;
|
||
document.getElementById('detailMemberInfo').innerHTML = `<span style="font-weight:bold; color:#2c3e50;">${name}</span> <span style="font-size: 16px; color: #666;">(${phone})</span>`;
|
||
|
||
// 초기화: 이번달 기준
|
||
document.getElementById('detailPeriodType').value = 'monthly';
|
||
const today = new Date().toISOString().split('T')[0];
|
||
document.getElementById('detailBaseDate').value = today;
|
||
document.getElementById('detailStartDate').value = today;
|
||
document.getElementById('detailEndDate').value = today;
|
||
|
||
handleDetailPeriodChange(); // UI 업데이트
|
||
|
||
// 모달 표시
|
||
document.getElementById('detailAttendanceModal').style.display = 'flex';
|
||
|
||
// 데이터 로드
|
||
loadMemberDetailStats();
|
||
}
|
||
|
||
function closeDetailModal() {
|
||
document.getElementById('detailAttendanceModal').style.display = 'none';
|
||
}
|
||
|
||
function handleDetailPeriodChange() {
|
||
const type = document.getElementById('detailPeriodType').value;
|
||
const customDiv = document.getElementById('detailCustomDateRange');
|
||
const baseDateDiv = document.getElementById('detailDateContainer');
|
||
|
||
if (type === 'custom') {
|
||
customDiv.style.display = 'flex';
|
||
baseDateDiv.style.display = 'none';
|
||
} else {
|
||
customDiv.style.display = 'none';
|
||
baseDateDiv.style.display = 'inline-block';
|
||
|
||
// 날짜 표시 업데이트 추가
|
||
const dateInput = document.getElementById('detailBaseDate');
|
||
updateDateDisplay(dateInput, 'detailBaseDateDisplay');
|
||
}
|
||
}
|
||
|
||
async function loadMemberDetailStats() {
|
||
if (!currentDetailMemberId) return;
|
||
|
||
const period = document.getElementById('detailPeriodType').value;
|
||
const date = document.getElementById('detailBaseDate').value;
|
||
|
||
// API 호출 URL 생성 (Individual Tab과 동일한 API 사용)
|
||
let url = `/api/attendance/individual/${currentDetailMemberId}?period=${period}&date=${date}`;
|
||
|
||
if (period === 'custom') {
|
||
const startDate = document.getElementById('detailStartDate').value;
|
||
const endDate = document.getElementById('detailEndDate').value;
|
||
if (!startDate || !endDate) return alert("기간을 선택해주세요.");
|
||
url += `&start_date=${startDate}&end_date=${endDate}`;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(url, { headers: getHeaders() });
|
||
if (!response.ok) throw new Error("데이터 로딩 실패");
|
||
|
||
const data = await response.json();
|
||
// data format: { member, period, total_count, calendar_dates, history }
|
||
|
||
// UI 업데이트
|
||
document.getElementById('detailPeriodDate').textContent = `${data.period.start} ~ ${data.period.end}`;
|
||
document.getElementById('detailAttendanceCount').textContent = `${data.total_count}회`;
|
||
|
||
// 리스트 업데이트
|
||
const listBody = document.getElementById('detailHistoryList');
|
||
listBody.innerHTML = '';
|
||
if (data.history.length === 0) {
|
||
listBody.innerHTML = '<tr><td colspan="2" style="text-align: center;">출석 기록이 없습니다.</td></tr>';
|
||
} else {
|
||
data.history.slice(0, 50).forEach(item => {
|
||
const row = `<tr>
|
||
<td>${item.date} ${item.time || ''}</td>
|
||
<td>${item.class_name || '-'}</td>
|
||
</tr>`;
|
||
listBody.innerHTML += row;
|
||
});
|
||
}
|
||
|
||
// 캘린더 렌더링 분기
|
||
if (period === 'yearly') {
|
||
renderYearlyCalendar(data.period.start, data.period.end, data.calendar_dates);
|
||
} else {
|
||
renderDetailCalendar(data.period.start, data.period.end, data.calendar_dates);
|
||
}
|
||
|
||
} catch (e) {
|
||
console.error(e);
|
||
alert('데이터 로딩에 실패했습니다.');
|
||
}
|
||
}
|
||
|
||
function changeDetailMonth(offset) {
|
||
const dateInput = document.getElementById('detailBaseDate');
|
||
const currentDate = new Date(dateInput.value);
|
||
currentDate.setMonth(currentDate.getMonth() + offset);
|
||
|
||
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}`;
|
||
|
||
// 데이터 로드
|
||
loadMemberDetailStats();
|
||
}
|
||
|
||
// 연간 캘린더 렌더링 함수 추가
|
||
function renderYearlyCalendar(startDate, endDate, attendanceDates) {
|
||
const calendarTitle = document.getElementById('detailCalendarTitle');
|
||
const calendarBody = document.getElementById('detailCalendarBody');
|
||
|
||
if (!calendarTitle || !calendarBody) return;
|
||
|
||
const start = new Date(startDate);
|
||
const year = start.getFullYear();
|
||
|
||
// Update Title
|
||
calendarTitle.textContent = `${year}년 전체 현황`;
|
||
|
||
const attendanceSet = new Set(attendanceDates);
|
||
|
||
// 12개월 그리드 생성
|
||
let html = '<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 20px;">';
|
||
|
||
for (let m = 0; m < 12; m++) {
|
||
const currentMonthStart = new Date(year, m, 1);
|
||
const currentMonthEnd = new Date(year, m + 1, 0);
|
||
const monthName = `${m + 1}월`;
|
||
const startDayOfWeek = currentMonthStart.getDay();
|
||
|
||
html += `
|
||
<div style="border: 1px solid #eee; border-radius: 8px; padding: 10px; background: #fafafa;">
|
||
<h5 style="text-align: center; margin: 0 0 10px 0; color: #2c3e50;">${monthName}</h5>
|
||
<div style="display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; text-align: center; font-size: 11px;">
|
||
<div style="color: #e74c3c;">일</div><div>월</div><div>화</div><div>수</div><div>목</div><div>금</div><div style="color: #3498db;">토</div>
|
||
`;
|
||
|
||
// Empty cells
|
||
for (let i = 0; i < startDayOfWeek; i++) {
|
||
html += '<div></div>';
|
||
}
|
||
|
||
// Days
|
||
for (let day = 1; day <= currentMonthEnd.getDate(); day++) {
|
||
const dateStr = `${year}-${String(m + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||
const isAttended = attendanceSet.has(dateStr);
|
||
|
||
const bg = isAttended ? '#2ecc71' : 'transparent';
|
||
const color = isAttended ? 'white' : '#333';
|
||
const weight = isAttended ? 'bold' : 'normal';
|
||
|
||
html += `<div style="padding: 4px; border-radius: 3px; background: ${bg}; color: ${color}; font-weight: ${weight};">${day}</div>`;
|
||
}
|
||
|
||
html += '</div></div>';
|
||
}
|
||
|
||
html += '</div>';
|
||
calendarBody.innerHTML = html;
|
||
|
||
// 그리드 레이아웃을 위해 calendarBody의 스타일을 잠시 변경 (기존 grid 컬럼 수 무시)
|
||
calendarBody.style.display = 'block';
|
||
}
|
||
|
||
function renderDetailCalendar(startDate, endDate, attendanceDates) {
|
||
const calendarTitle = document.getElementById('detailCalendarTitle');
|
||
const calendarBody = document.getElementById('detailCalendarBody');
|
||
|
||
if (!calendarTitle || !calendarBody) return;
|
||
|
||
// grid 레이아웃 복구 (연간 뷰에서 block으로 변경되었을 수 있음)
|
||
calendarBody.style.display = 'grid';
|
||
|
||
const start = new Date(startDate);
|
||
// Ensure we are rendering the month of the start date
|
||
const year = start.getFullYear();
|
||
const month = start.getMonth(); // 0-based
|
||
|
||
// Update Title
|
||
calendarTitle.textContent = `${year}년 ${month + 1}월`;
|
||
|
||
const attendanceSet = new Set(attendanceDates);
|
||
|
||
// Calculate days
|
||
const firstDay = new Date(year, month, 1);
|
||
const lastDay = new Date(year, month + 1, 0);
|
||
const startDayOfWeek = firstDay.getDay(); // 0: Sun, 1: Mon...
|
||
|
||
let html = '';
|
||
|
||
// Empty cells for days before the 1st
|
||
for (let i = 0; i < startDayOfWeek; i++) {
|
||
html += '<div class="calendar-day empty"></div>';
|
||
}
|
||
|
||
// Days of the month
|
||
for (let day = 1; day <= lastDay.getDate(); day++) {
|
||
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||
const isAttended = attendanceSet.has(dateStr);
|
||
|
||
// Check if it's Sunday (0) or Saturday (6) for styling
|
||
const currentDayOfWeek = new Date(year, month, day).getDay();
|
||
let dateClass = '';
|
||
if (currentDayOfWeek === 0) dateClass = 'sunday';
|
||
else if (currentDayOfWeek === 6) dateClass = 'saturday';
|
||
|
||
const cellStyle = `
|
||
padding: 10px;
|
||
height: 80px;
|
||
border-right: 1px solid #eee;
|
||
border-bottom: 1px solid #eee;
|
||
text-align: left;
|
||
position: relative;
|
||
background: ${isAttended ? '#e8f8f5' : '#fff'};
|
||
`;
|
||
|
||
const dayNumberStyle = `
|
||
font-weight: bold;
|
||
color: ${currentDayOfWeek === 0 ? '#e74c3c' : (currentDayOfWeek === 6 ? '#3498db' : '#333')};
|
||
`;
|
||
|
||
const badge = isAttended ?
|
||
`<div style="
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
background: #2ecc71;
|
||
color: white;
|
||
padding: 4px 8px;
|
||
border-radius: 12px;
|
||
font-size: 12px;
|
||
font-weight: bold;
|
||
">출석</div>` : '';
|
||
|
||
html += `
|
||
<div style="${cellStyle}">
|
||
<div style="${dayNumberStyle}">${day}</div>
|
||
${badge}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Fill remaining cells to complete the last row (optional, but good for grid)
|
||
const totalCells = startDayOfWeek + lastDay.getDate();
|
||
const remainingCells = 7 - (totalCells % 7);
|
||
if (remainingCells < 7) {
|
||
for (let i = 0; i < remainingCells; i++) {
|
||
html += '<div class="calendar-day empty" style="border-right: 1px solid #eee; border-bottom: 1px solid #eee;"></div>';
|
||
}
|
||
}
|
||
|
||
calendarBody.innerHTML = html;
|
||
}
|
||
|
||
// window 객체에 등록
|
||
window.openDetailModal = openDetailModal;
|
||
window.closeDetailModal = closeDetailModal;
|
||
window.handleDetailPeriodChange = handleDetailPeriodChange;
|
||
window.loadMemberDetailStats = loadMemberDetailStats;
|
||
window.changeDetailMonth = changeDetailMonth;
|
||
window.renderYearlyCalendar = renderYearlyCalendar;
|
||
if (!urlParams.get('tab')) {
|
||
loadWaitingStatus();
|
||
}
|
||
} else {
|
||
console.error('매장 코드를 찾을 수 없습니다:', storeParam);
|
||
}
|
||
} catch (e) {
|
||
console.error('매장 정보 조회 실패:', e);
|
||
}
|
||
} else {
|
||
// 매장 파라미터가 없으면 로컬 스토리지의 매장 이름 표시
|
||
const storeName = localStorage.getItem('selected_store_name');
|
||
if (storeName) {
|
||
const header = document.getElementById('storeName');
|
||
if (header) header.innerHTML = `대기 및 출석 현황 <span style="font-size: 18px; color: #7f8c8d;">(${storeName})</span>`;
|
||
}
|
||
loadBusinessDate(); // 로컬 스토리지 기반으로 조회
|
||
}
|
||
}
|
||
|
||
async function loadBusinessDate() {
|
||
try {
|
||
const response = await fetch('/api/daily/check-status', { headers: getHeaders() });
|
||
if (response.ok) {
|
||
const status = await response.json();
|
||
const dateDisplay = document.getElementById('businessDateDisplay');
|
||
|
||
if (dateDisplay && status.business_date) {
|
||
const dateObj = new Date(status.business_date);
|
||
const year = dateObj.getFullYear();
|
||
const month = dateObj.getMonth() + 1;
|
||
const day = dateObj.getDate();
|
||
dateDisplay.textContent = `${year}년 ${month}월 ${day}일`;
|
||
} else if (dateDisplay) {
|
||
const now = new Date();
|
||
dateDisplay.textContent = `${now.getFullYear()}년 ${now.getMonth() + 1}월 ${now.getDate()}일`;
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('영업일 조회 실패:', e);
|
||
}
|
||
}
|
||
|
||
// 페이지 로드 시 매장 파라미터 확인 실행
|
||
checkUrlStoreParam();
|
||
|
||
// 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 loadRanking(isLoadMore = false) {
|
||
if (isRankingLoading) return;
|
||
isRankingLoading = true;
|
||
document.getElementById('rankingLoader').style.display = 'block';
|
||
|
||
if (!isLoadMore) {
|
||
currentRankingOffset = 0;
|
||
document.getElementById('rankingList').innerHTML = '';
|
||
}
|
||
|
||
const storeId = localStorage.getItem('selected_store_id');
|
||
const franchiseId = localStorage.getItem('selected_franchise_id');
|
||
const period = document.getElementById('rankingPeriod').value;
|
||
const startDate = document.getElementById('rankingDate').value;
|
||
const endDate = document.getElementById('rankingEndDate').value;
|
||
const minAttendance = document.getElementById('minAttendance').value;
|
||
|
||
try {
|
||
let url = `/api/attendance/ranking`;
|
||
const params = new URLSearchParams();
|
||
|
||
// 스토어 파라미터를 추가할 필요 없음 (토큰 인증으로 처리됨)
|
||
// 하지만 checkUrlStoreParam에서 토큰을 설정했는지 확인해야 함.
|
||
// attendance.html은 localStorage의 access_token을 사용하여 getHeaders()를 호출함.
|
||
|
||
params.append('period', period);
|
||
|
||
// params mapping according to routers/attendance.py
|
||
if (period === 'daily') {
|
||
// routers/attendance.py uses 'date' for daily target
|
||
params.append('date', startDate);
|
||
} else if (period === 'monthly') {
|
||
// routers/attendance.py expects 'date' for monthly target (e.g. 2025-12-01)
|
||
// startDate input is "YYYY-MM" or "YYYY-MM-DD"?
|
||
// attendance.html handles this.
|
||
// Let's pass 'date' as startDate which is usually correct for monthly logic there
|
||
params.append('date', startDate);
|
||
} else {
|
||
// custom, weekly, etc.
|
||
params.append('start_date', startDate);
|
||
params.append('end_date', endDate);
|
||
}
|
||
|
||
if (minAttendance) params.append('min_count', minAttendance);
|
||
params.append('limit', RANKING_LIMIT);
|
||
params.append('skip', currentRankingOffset); // API uses 'skip', not 'offset'
|
||
|
||
url += '?' + params.toString();
|
||
|
||
const response = await fetch(url, { headers: getHeaders() });
|
||
const data = await response.json();
|
||
|
||
const tbody = document.getElementById('rankingList');
|
||
|
||
if (!Array.isArray(data)) {
|
||
console.error('Data is not an array:', data);
|
||
if (!isLoadMore) {
|
||
tbody.innerHTML = '<tr><td colspan="6" style="text-align: center; color: #e74c3c;">데이터 로드 중 오류가 발생했습니다.</td></tr>';
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (data.length === 0 && !isLoadMore) {
|
||
tbody.innerHTML = '<tr><td colspan="6" style="text-align: center; color: #999;">데이터가 없습니다.</td></tr>';
|
||
} else {
|
||
data.forEach((item, index) => {
|
||
const row = document.createElement('tr');
|
||
const rank = currentRankingOffset + index + 1;
|
||
let rankClass = 'rank-other';
|
||
if (rank === 1) rankClass = 'rank-1';
|
||
else if (rank === 2) rankClass = 'rank-2';
|
||
else if (rank === 3) rankClass = 'rank-3';
|
||
|
||
row.innerHTML = `
|
||
<td><span class="rank-badge ${rankClass}">${rank}</span></td>
|
||
<td>${item.name}</td>
|
||
<td>${item.phone}</td>
|
||
<td><span style="font-weight: bold; color: #2c3e50;">${item.attendance_count}회</span></td>
|
||
<td>${item.last_attendance}</td>
|
||
<td>
|
||
<button class="btn btn-sm btn-outline-primary" style="padding: 5px 10px; font-size: 13px;"
|
||
onclick="openDetailModal(${item.member_id}, '${item.name}', '${item.phone}')">
|
||
상세보기
|
||
</button>
|
||
</td>
|
||
`;
|
||
tbody.appendChild(row);
|
||
});
|
||
|
||
currentRankingOffset += data.length;
|
||
|
||
// 더보기 감지 (데이터가 limit보다 적으면 더 이상 데이터 없음)
|
||
if (data.length < RANKING_LIMIT) {
|
||
if (rankingObserver) rankingObserver.disconnect();
|
||
} else if (!isLoadMore) {
|
||
setupRankingObserver();
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('순위 조회 실패:', error);
|
||
} finally {
|
||
isRankingLoading = false;
|
||
document.getElementById('rankingLoader').style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// 무한 스크롤 Observer 설정 (복구됨)
|
||
function setupRankingObserver() {
|
||
if (rankingObserver) rankingObserver.disconnect();
|
||
|
||
const sentinel = document.getElementById('rankingSentinel');
|
||
if (!sentinel) return;
|
||
|
||
rankingObserver = new IntersectionObserver((entries) => {
|
||
if (entries[0].isIntersecting && !isRankingLoading) {
|
||
loadRanking(true);
|
||
}
|
||
}, { threshold: 0.1 });
|
||
|
||
rankingObserver.observe(sentinel);
|
||
}
|
||
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. 출석순위 조회 (상단에 정의된 loadRanking 함수와 중복되어 제거됨)
|
||
|
||
|
||
// 날짜 필드 초기화 (영업일 기준)
|
||
async function initializeDates() {
|
||
try {
|
||
// 영업일 조회
|
||
const response = await fetch('/api/daily/check-status', { headers: getHeaders() });
|
||
let targetDate = new Date().toISOString().split('T')[0]; // 기본값: 오늘
|
||
|
||
if (response.ok) {
|
||
const status = await response.json();
|
||
if (status.business_date) {
|
||
targetDate = status.business_date;
|
||
}
|
||
}
|
||
|
||
// 각 탭의 날짜 필드 설정 및 디스플레이 업데이트
|
||
const dateFields = [
|
||
{ input: 'waitingDate', display: 'waitingDateDisplay' },
|
||
{ input: 'statusDate', display: 'statusDateDisplay' },
|
||
{ input: 'newMemberDate', display: 'newMemberDateDisplay' },
|
||
{ input: 'rankingDate', display: 'rankingDateDisplay' },
|
||
{ input: 'individualDate', display: 'individualDateDisplay' }
|
||
];
|
||
|
||
dateFields.forEach(field => {
|
||
const inputEl = document.getElementById(field.input);
|
||
if (inputEl) {
|
||
inputEl.value = targetDate;
|
||
// 디스플레이 업데이트 (formatDateToDisplay 호출)
|
||
updateDateDisplay(inputEl, field.display);
|
||
}
|
||
});
|
||
|
||
console.log('Date fields initialized to:', targetDate);
|
||
|
||
} catch (error) {
|
||
console.error('Initial date setup failed:', error);
|
||
// 실패 시 오늘 날짜로 폴백
|
||
const today = new Date().toISOString().split('T')[0];
|
||
const fields = ['waitingDate', 'statusDate', 'newMemberDate', 'rankingDate', 'individualDate'];
|
||
fields.forEach(id => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.value = today;
|
||
});
|
||
}
|
||
}
|
||
|
||
// 페이지 초기화 함수
|
||
async function initPage() {
|
||
await initializeDates(); // 날짜 먼저 설정
|
||
loadWaitingStatus(); // 그 다음 데이터 로드
|
||
initSSE(); // SSE 연결
|
||
}
|
||
|
||
// 초기 로드 실행
|
||
initPage();
|
||
|
||
// 페이지 종료 시 SSE 연결 닫기
|
||
document.addEventListener('DOMContentLoaded', function () {
|
||
window.addEventListener('beforeunload', () => {
|
||
closeSSE();
|
||
});
|
||
});
|
||
</script>
|
||
</body>
|
||
|
||
</html> |