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

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

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

1790 lines
71 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>
.admin-container {
max-width: 1600px;
margin: 0 auto;
padding: 30px;
}
.admin-header {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
padding: 40px;
border-radius: 10px;
margin-bottom: 30px;
text-align: center;
position: relative;
}
.admin-header h1 {
font-size: 36px;
margin-bottom: 10px;
}
.admin-header p {
font-size: 18px;
opacity: 0.95;
}
.logout-btn {
position: absolute;
top: 20px;
right: 30px;
padding: 10px 20px;
background: rgba(255, 255, 255, 0.2);
color: white;
border: 2px solid white;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.3s;
}
.logout-btn:hover {
background: white;
color: #f5576c;
}
.stats-overview {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: white;
padding: 25px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
text-align: center;
}
.stat-card h3 {
font-size: 14px;
color: #7f8c8d;
margin-bottom: 10px;
}
.stat-card .value {
font-size: 32px;
font-weight: 700;
color: #667eea;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.section-header h2 {
font-size: 24px;
color: #333;
}
.franchises-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 20px;
margin-bottom: 40px;
}
.franchise-card {
background: white;
padding: 25px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
transition: all 0.3s;
border-left: 4px solid #667eea;
}
.franchise-card:hover {
transform: translateY(-5px);
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.1);
}
.franchise-card h3 {
font-size: 20px;
margin-bottom: 8px;
color: #333;
cursor: pointer;
transition: color 0.2s;
}
.franchise-card h3:hover {
color: #667eea;
}
.franchise-card .code {
font-size: 13px;
color: #7f8c8d;
margin-bottom: 15px;
}
.franchise-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin: 15px 0;
}
.franchise-stat {
display: flex;
flex-direction: column;
padding: 10px;
background: #f8f9fa;
border-radius: 5px;
}
.franchise-stat label {
font-size: 12px;
color: #7f8c8d;
margin-bottom: 4px;
}
.franchise-stat span {
font-size: 18px;
font-weight: 600;
color: #667eea;
}
.franchise-actions {
display: flex;
gap: 10px;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #e0e0e0;
}
.stores-section {
display: none;
margin-top: 15px;
padding-top: 15px;
border-top: 2px solid #667eea;
}
.stores-section.show {
display: block;
}
.stores-section h4 {
font-size: 16px;
margin-bottom: 12px;
color: #667eea;
}
.store-item {
background: #f8f9fa;
padding: 12px;
border-radius: 5px;
margin-bottom: 8px;
display: flex;
justify-content: space-between;
align-items: center;
}
.store-item .store-info {
flex: 1;
}
.store-item .store-name {
font-weight: 600;
color: #333;
font-size: 14px;
}
.store-item .store-code {
font-size: 12px;
color: #7f8c8d;
margin-top: 2px;
}
.badge {
display: inline-block;
padding: 5px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
}
.badge.active {
background: #d4edda;
color: #155724;
}
.badge.inactive {
background: #f8d7da;
color: #721c24;
}
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
transition: opacity 0.3s ease;
}
.modal.show {
display: flex;
justify-content: center;
align-items: center;
opacity: 1;
}
.modal-content {
background: white;
padding: 40px;
border-radius: 16px;
max-width: 480px;
width: 90%;
max-height: 85vh;
overflow-y: auto;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
transform: scale(0.95);
transition: transform 0.3s ease;
}
.modal.show .modal-content {
transform: scale(1);
}
.modal-header {
font-size: 24px;
font-weight: 700;
margin-bottom: 24px;
color: #1f2937;
text-align: center;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 600;
font-size: 14px;
color: #4b5563;
}
.form-group input,
.form-group select {
width: 100%;
padding: 12px 16px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 15px;
color: #111827;
background-color: #f9fafb;
transition: all 0.2s ease;
box-sizing: border-box;
/* Ensure padding doesn't affect width */
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #667eea;
background-color: white;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.modal-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 32px;
padding-top: 20px;
border-top: 1px solid #f3f4f6;
}
.modal-actions button {
padding: 10px 20px;
border-radius: 8px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
font-size: 14px;
}
.modal-actions .btn-secondary {
background-color: #f3f4f6;
color: #4b5563;
border: 1px solid #e5e7eb;
}
.modal-actions .btn-secondary:hover {
background-color: #e5e7eb;
color: #1f2937;
}
/* Dashboard 2.0 Styles */
.admin-view {
display: none;
animation: fadeIn 0.3s ease;
}
.admin-view.active {
display: block;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.stat-card {
cursor: pointer;
transition: all 0.3s;
border: 2px solid transparent;
}
.stat-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
border-color: #667eea;
}
.data-table-container {
background: white;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
overflow: hidden;
margin-bottom: 30px;
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.data-table th,
.data-table td {
padding: 15px 20px;
text-align: left;
border-bottom: 1px solid #eee;
}
.data-table th {
background: #f8f9fa;
font-weight: 600;
color: #4b5563;
}
.data-table tr:hover {
background: #f8f9fa;
}
.view-controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.search-bar {
display: flex;
gap: 10px;
}
.search-bar input {
padding: 10px 15px;
border: 1px solid #d1d5db;
border-radius: 6px;
width: 300px;
}
.status-badge {
padding: 4px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
}
.status-badge.active {
background: #d1fae5;
color: #065f46;
}
.status-badge.inactive {
background: #fee2e2;
color: #991b1b;
}
</style>
</head>
<body>
<div class="admin-container">
<div class="admin-header">
<button class="logout-btn" onclick="logout()">로그아웃</button>
<h1>시스템 관리</h1>
<p>전체 프랜차이즈 및 매장 통합 관리</p>
</div>
<!-- 통계 개요 -->
<div class="stats-overview" id="statsOverview">
<!-- 통계 카드가 동적으로 추가됩니다 -->
</div>
<!-- 프랜차이즈 관리 섹션 -->
<!-- 뷰 컨테이너 -->
<div id="views-container">
<!-- 1. 프랜차이즈 관리 뷰 (기본) -->
<div id="view-franchises" class="admin-view active">
<div class="section-header">
<h2>프랜차이즈 관리</h2>
<div style="display: flex; gap: 10px;">
<button class="btn btn-secondary" onclick="toggleFranchiseView('all')">전체 보기</button>
<button class="btn btn-secondary" onclick="toggleFranchiseView('active')">활성만 보기</button>
<button class="btn btn-primary" onclick="showAddFranchiseModal()">+ 새 프랜차이즈 추가</button>
</div>
</div>
<div class="franchises-grid" id="franchisesGrid">
<!-- 프랜차이즈 카드가 동적으로 추가됩니다 -->
</div>
</div>
<!-- 2. 사용자 관리 뷰 -->
<div id="view-users" class="admin-view">
<div class="section-header">
<h2>전체 사용자 관리</h2>
<div class="view-controls">
<div class="search-bar">
<input type="text" id="userSearch" placeholder="이름 또는 ID 검색..." onkeyup="filterUsers()">
</div>
</div>
</div>
<div class="data-table-container">
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>사용자명</th>
<th>역할</th>
<th>소속 프랜차이즈</th>
<th>소속 매장</th>
<th>상태</th>
<th>관리</th>
</tr>
</thead>
<tbody id="usersTableBody">
<tr>
<td colspan="7" style="text-align: center;">데이터 로딩 중...</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 3. 매장 관리 뷰 -->
<div id="view-stores" class="admin-view">
<div class="section-header">
<h2>전체 매장 관리</h2>
<div style="display: flex; gap: 10px;">
<button class="btn btn-secondary" onclick="loadAllStores(false)">전체 보기</button>
<button class="btn btn-secondary" onclick="loadAllStores(true)">활성 매장만 보기</button>
</div>
</div>
<div id="storesContainer">
<!-- 매장 리스트가 여기에 로드됨 -->
</div>
</div>
<!-- 4. 회원 관리 뷰 -->
<div id="view-members" class="admin-view">
<div class="section-header">
<h2>전체 회원 관리</h2>
<div class="view-controls">
<div class="search-bar">
<input type="text" id="memberSearchInput" placeholder="이름 또는 전화번호 뒷자리..."
onkeyup="if(event.key === 'Enter') handleMemberSearch()">
<button class="btn btn-primary" onclick="handleMemberSearch()">검색</button>
</div>
</div>
</div>
<div class="data-table-container">
<table class="data-table">
<thead>
<tr>
<th>이름</th>
<th>전화번호</th>
<th>프랜차이즈</th>
<th>등록 매장</th>
<th>가입일</th>
</tr>
</thead>
<tbody id="membersTableBody">
<tr>
<td colspan="5" style="text-align: center; padding: 40px; color: #666;">회원을 검색해주세요.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- 프랜차이즈 추가 모달 -->
<!-- 사용자 수정 모달 -->
<div id="editUserModal" class="modal">
<div class="modal-content">
<h2 class="modal-header">사용자 정보 수정</h2>
<form id="editUserForm">
<input type="hidden" id="editUserId">
<div class="form-group">
<label>사용자명</label>
<input type="text" id="editUserUsername" readonly
style="background-color: #eee; cursor: not-allowed;">
</div>
<div class="form-group">
<label for="editUserPassword">비밀번호 변경 (선택사항)</label>
<input type="password" id="editUserPassword" placeholder="변경할 경우에만 입력하세요">
</div>
<div class="form-group">
<label for="editUserRole">역할 *</label>
<select id="editUserRole" required onchange="toggleEditUserStoreSelect()">
<option value="system_admin">슈퍼관리자</option>
<option value="franchise_admin">프랜차이즈 관리자</option>
<option value="franchise_manager">중간 관리자</option>
<option value="store_admin">매장 관리자</option>
</select>
</div>
<div class="form-group" id="editUserFranchiseGroup" style="display: none;">
<label for="editUserFranchiseId">소속 프랜차이즈 *</label>
<select id="editUserFranchiseId" onchange="loadEditUserStores(this.value)">
<option value="">프랜차이즈 선택</option>
</select>
</div>
<div class="form-group" id="editUserStoreGroup" style="display: none;">
<label for="editUserStoreId">소속 매장 *</label>
<select id="editUserStoreId">
<option value="">매장 선택</option>
</select>
</div>
<div class="form-group" id="editUserMultiStoreGroup" style="display: none;">
<label>관리할 매장 선택 *</label>
<div id="editUserMultiStoreContainer"
style="max-height: 150px; overflow-y: auto; border: 1px solid #d1d5db; border-radius: 8px; padding: 10px;">
<!-- Checkboxes populated dynamically -->
</div>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeModal('editUserModal')">취소</button>
<button type="submit" class="btn btn-primary">저장</button>
</div>
</form>
</div>
</div>
<div id="addFranchiseModal" class="modal">
<div class="modal-content">
<h2 class="modal-header">새 프랜차이즈 추가</h2>
<form id="addFranchiseForm">
<div class="form-group">
<label for="franchiseName">프랜차이즈명 *</label>
<input type="text" id="franchiseName" required>
</div>
<div class="form-group">
<label for="franchiseCode">프랜차이즈 코드 *</label>
<input type="text" id="franchiseCode" required placeholder="예: FRAN001">
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary"
onclick="closeModal('addFranchiseModal')">취소</button>
<button type="submit" class="btn btn-primary">추가</button>
</div>
</form>
</div>
</div>
<!-- 프랜차이즈 수정 모달 -->
<div id="editFranchiseModal" class="modal">
<div class="modal-content">
<h2 class="modal-header">프랜차이즈 수정</h2>
<form id="editFranchiseForm">
<input type="hidden" id="editFranchiseId">
<div class="form-group">
<label for="editFranchiseName">프랜차이즈명 *</label>
<input type="text" id="editFranchiseName" required>
</div>
<div class="form-group">
<label for="editFranchiseCode">프랜차이즈 코드 *</label>
<input type="text" id="editFranchiseCode" required>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary"
onclick="closeModal('editFranchiseModal')">취소</button>
<button type="submit" class="btn btn-primary">수정</button>
</div>
</form>
</div>
</div>
<!-- 프랜차이즈 관리자 생성 모달 -->
<div id="addAdminModal" class="modal">
<div class="modal-content">
<h2 class="modal-header">프랜차이즈 관리자 생성</h2>
<form id="addAdminForm">
<input type="hidden" id="adminFranchiseId">
<p style="margin-bottom: 20px; color: #666;">프랜차이즈: <strong id="adminFranchiseName"></strong></p>
<div class="form-group">
<label for="adminUsername">사용자명 *</label>
<input type="text" id="adminUsername" required>
</div>
<div class="form-group">
<label for="adminPassword">비밀번호 *</label>
<input type="password" id="adminPassword" required>
</div>
<div class="form-group">
<label for="adminRole">역할 *</label>
<select id="adminRole" required onchange="toggleAdminStoreSelect()">
<option value="franchise_admin">프랜차이즈 최종 관리자</option>
<option value="franchise_manager">프랜차이즈 중간 관리자</option>
<option value="store_admin">매장 관리자</option>
</select>
</div>
<div class="form-group" id="adminStoreSelectGroup" style="display: none;">
<label for="adminStoreId">매장 선택 *</label>
<select id="adminStoreId">
<option value="">매장을 선택하세요</option>
</select>
</div>
<div class="form-group" id="adminStoreMultiSelectGroup" style="display: none;">
<label>관리할 매장 선택 *</label>
<div id="adminStoreMultiSelectContainer"
style="max-height: 150px; overflow-y: auto; border: 1px solid #d1d5db; border-radius: 8px; padding: 10px;">
<!-- Checkboxes will be populated here -->
</div>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeModal('addAdminModal')">취소</button>
<button type="submit" class="btn btn-primary">생성</button>
</div>
</form>
</div>
</div>
<!-- 매장 추가 모달 -->
<div id="addStoreModal" class="modal">
<div class="modal-content">
<h2 class="modal-header">새 매장 추가</h2>
<form id="addStoreForm">
<input type="hidden" id="storeFranchiseId">
<p style="margin-bottom: 20px; color: #666;">프랜차이즈: <strong id="storeFranchiseName"></strong></p>
<div class="form-group">
<label for="storeName">매장명 *</label>
<input type="text" id="storeName" required placeholder="예: 셀스타 강남점">
</div>
<p style="font-size: 13px; color: #666; margin: 10px 0;">
💡 매장 코드는 자동으로 생성됩니다.
</p>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeModal('addStoreModal')">취소</button>
<button type="submit" class="btn btn-primary">추가</button>
</div>
</form>
</div>
</div>
<!-- 회원 관리 설정 모달 -->
<div id="memberSettingsModal" class="modal">
<div class="modal-content">
<h2 class="modal-header">회원 관리 설정</h2>
<form id="memberSettingsForm">
<input type="hidden" id="memberSettingsFranchiseId">
<p style="margin-bottom: 20px; color: #666;">프랜차이즈: <strong id="memberSettingsFranchiseName"></strong>
</p>
<p style="margin-bottom: 20px; color: #666; font-size: 14px;">프랜차이즈 전체의 회원 관리 정책을 설정합니다.</p>
<div class="form-group">
<label style="font-weight: 600; font-size: 15px; margin-bottom: 12px;">회원 통합 관리 방식</label>
<div style="margin-bottom: 12px;">
<label
style="display: flex; align-items: start; cursor: pointer; padding: 12px; border: 2px solid #e5e7eb; border-radius: 8px; transition: all 0.2s;"
class="radio-option">
<input type="radio" name="memberType" value="store"
style="margin-top: 3px; margin-right: 12px;" required>
<div>
<div style="font-weight: 600; color: #1f2937; margin-bottom: 4px;">매장별 (Store Type)
</div>
<div style="font-size: 13px; color: #6b7280;">각 매장별로 회원을 독립적으로 관리합니다. (같은 번호라도 매장별로 등록
가능)</div>
</div>
</label>
</div>
<div>
<label
style="display: flex; align-items: start; cursor: pointer; padding: 12px; border: 2px solid #e5e7eb; border-radius: 8px; transition: all 0.2s;"
class="radio-option">
<input type="radio" name="memberType" value="franchise"
style="margin-top: 3px; margin-right: 12px;" required>
<div>
<div style="font-weight: 600; color: #1f2937; margin-bottom: 4px;">프랜차이즈통합 (Franchise
Type)</div>
<div style="font-size: 13px; color: #6b7280;">프랜차이즈 전체에서 회원을 통합 관리합니다. (하나의 번호로 모든 등록된
매장 등록 가능)</div>
</div>
</label>
</div>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary"
onclick="closeModal('memberSettingsModal')">취소</button>
<button type="submit" class="btn btn-primary">설정 저장</button>
</div>
</form>
</div>
</div>
<script>
const API_BASE = '/api/system';
let franchisesData = [];
let statsData = null;
// 토큰 가져오기
function getToken() {
return localStorage.getItem('access_token');
}
// API 요청 헤더
function getHeaders() {
return {
'Authorization': `Bearer ${getToken()}`,
'Content-Type': 'application/json'
};
}
// 통계 로드
async function loadStats() {
try {
const response = await fetch(`${API_BASE}/stats`, {
headers: getHeaders()
});
if (response.ok) {
statsData = await response.json();
updateStatsOverview();
} else {
const error = await response.json();
alert(error.detail || '통계 로드 실패');
}
} catch (error) {
console.error('통계 로드 실패:', error);
}
}
// 통계 개요 업데이트
function updateStatsOverview() {
const overview = document.getElementById('statsOverview');
overview.innerHTML = `
<div class="stat-card" onclick="showView('franchises'); toggleFranchiseView('all')">
<h3>총 프랜차이즈</h3>
<div class="value">${statsData.total_franchises}</div>
</div>
<div class="stat-card" onclick="showView('franchises'); toggleFranchiseView('active')">
<h3>활성 프랜차이즈</h3>
<div class="value">${statsData.active_franchises}</div>
</div>
<div class="stat-card" onclick="showView('stores'); loadAllStores(false)">
<h3>총 매장</h3>
<div class="value">${statsData.total_stores}</div>
</div>
<div class="stat-card" onclick="showView('stores'); loadAllStores(true)">
<h3>활성 매장</h3>
<div class="value">${statsData.active_stores}</div>
</div>
<div class="stat-card" onclick="showView('users')">
<h3>총 사용자</h3>
<div class="value">${statsData.total_users}</div>
</div>
<div class="stat-card" onclick="showView('members')">
<h3>총 회원</h3>
<div class="value">${statsData.total_members}</div>
</div>
`;
}
// 프랜차이즈 목록 로드
async function loadFranchises() {
try {
const response = await fetch(`${API_BASE}/franchises`, {
headers: getHeaders()
});
if (response.ok) {
franchisesData = await response.json();
updateFranchisesGrid();
} else {
const error = await response.json();
alert(error.detail || '프랜차이즈 목록 로드 실패');
}
} catch (error) {
console.error('프랜차이즈 목록 로드 실패:', error);
}
}
// 프랜차이즈 그리드 업데이트
function updateFranchisesGrid() {
const grid = document.getElementById('franchisesGrid');
if (franchisesData.length === 0) {
grid.innerHTML = '<p style="grid-column: 1/-1; text-align: center; color: #999;">등록된 프랜차이즈가 없습니다.</p>';
return;
}
grid.innerHTML = '';
franchisesData.forEach(franchise => {
// 통계 데이터 찾기
const stats = statsData?.franchises?.find(f => f.franchise_id === franchise.id);
const card = document.createElement('div');
card.className = 'franchise-card';
card.innerHTML = `
<h3 onclick="toggleStores(${franchise.id})" title="클릭하여 매장 목록 보기">${franchise.name} <span style="font-size: 14px;">▼</span></h3>
<div class="code">코드: ${franchise.code}</div>
<span class="badge ${franchise.is_active ? 'active' : 'inactive'}">
${franchise.is_active ? '활성' : '비활성'}
</span>
<div class="franchise-stats">
<div class="franchise-stat">
<label>매장 수</label>
<span>${stats?.stores_count || 0}</span>
</div>
<div class="franchise-stat">
<label>활성 매장</label>
<span>${stats?.active_stores_count || 0}</span>
</div>
<div class="franchise-stat">
<label>사용자</label>
<span>${stats?.users_count || 0}</span>
</div>
<div class="franchise-stat">
<label>회원</label>
<span>${stats?.members_count || 0}</span>
</div>
</div>
<div class="stores-section" id="stores-${franchise.id}">
<h4>매장 목록</h4>
<div class="stores-list" id="stores-list-${franchise.id}">
<p style="text-align: center; color: #999;">로딩 중...</p>
</div>
</div>
<div class="franchise-actions">
<button class="btn btn-sm btn-info"
data-franchise-id="${franchise.id}"
data-franchise-name="${franchise.name.replace(/"/g, '&quot;')}"
onclick="manageFranchise(this.dataset.franchiseId, this.dataset.franchiseName)"
title="프랜차이즈 상세 관리 화면으로 이동">
관리
</button>
<button class="btn btn-sm btn-primary" onclick="showEditFranchiseModal(${franchise.id})">수정</button>
<button class="btn btn-sm btn-success"
data-franchise-id="${franchise.id}"
data-franchise-name="${franchise.name.replace(/"/g, '&quot;')}"
onclick="showAddAdminModal(this.dataset.franchiseId, this.dataset.franchiseName)">
관리자 추가
</button>
<button class="btn btn-sm btn-primary"
data-franchise-id="${franchise.id}"
data-franchise-name="${franchise.name.replace(/"/g, '&quot;')}"
onclick="showAddStoreModal(this.dataset.franchiseId, this.dataset.franchiseName)">
매장추가
</button>
<button class="btn btn-sm btn-info"
data-franchise-id="${franchise.id}"
data-franchise-name="${franchise.name.replace(/"/g, '&quot;')}"
data-member-type="${franchise.member_type}"
onclick="showMemberSettingsModal(this.dataset.franchiseId, this.dataset.franchiseName, this.dataset.memberType)">
회원관리
</button>
${franchise.is_active
? `<button class="btn btn-sm btn-danger" onclick="deactivateFranchise(${franchise.id})">비활성화</button>`
: `<button class="btn btn-sm btn-success" onclick="activateFranchise(${franchise.id})">활성화</button>`
}
</div>
`;
grid.appendChild(card);
});
}
// 매장 목록 토글
async function toggleStores(franchiseId) {
const storesSection = document.getElementById(`stores-${franchiseId}`);
if (storesSection.classList.contains('show')) {
storesSection.classList.remove('show');
} else {
// 매장 목록 로드
await loadFranchiseStores(franchiseId);
storesSection.classList.add('show');
}
}
// 특정 프랜차이즈의 매장 목록 로드
async function loadFranchiseStores(franchiseId) {
const storesList = document.getElementById(`stores-list-${franchiseId}`);
try {
const response = await fetch(`${API_BASE}/franchises/${franchiseId}/stores`, {
headers: getHeaders()
});
if (response.ok) {
const stores = await response.json();
if (stores.length === 0) {
storesList.innerHTML = '<p style="text-align: center; color: #999;">등록된 매장이 없습니다.</p>';
return;
}
storesList.innerHTML = stores.map(store => `
<div class="store-item">
<div class="store-info">
<div class="store-name">${store.name}</div>
<div class="store-code">코드: ${store.code}</div>
</div>
<span class="badge ${store.is_active ? 'active' : 'inactive'}">
${store.is_active ? '활성' : '비활성'}
</span>
</div>
`).join('');
} else {
storesList.innerHTML = '<p style="text-align: center; color: #e74c3c;">매장 목록을 불러올 수 없습니다.</p>';
}
} catch (error) {
console.error('매장 목록 로드 실패:', error);
storesList.innerHTML = '<p style="text-align: center; color: #e74c3c;">오류가 발생했습니다.</p>';
}
}
// 모달 관리
function showAddFranchiseModal() {
document.getElementById('addFranchiseModal').classList.add('show');
}
function showEditFranchiseModal(franchiseId) {
const franchise = franchisesData.find(f => f.id === franchiseId);
if (!franchise) return;
document.getElementById('editFranchiseId').value = franchise.id;
document.getElementById('editFranchiseName').value = franchise.name;
document.getElementById('editFranchiseCode').value = franchise.code;
document.getElementById('editFranchiseModal').classList.add('show');
}
async function showAddAdminModal(franchiseId, franchiseName) {
document.getElementById('adminFranchiseId').value = franchiseId;
document.getElementById('adminFranchiseName').textContent = franchiseName;
// 초기화
document.getElementById('adminUsername').value = '';
document.getElementById('adminPassword').value = '';
document.getElementById('adminRole').value = 'franchise_admin';
toggleAdminStoreSelect();
// 매장 목록 로드
const storeSelect = document.getElementById('adminStoreId');
const storeMultiContainer = document.getElementById('adminStoreMultiSelectContainer');
storeSelect.innerHTML = '<option value="">로딩 중...</option>';
// Check if storeMultiContainer exists (it should now)
if (storeMultiContainer) {
storeMultiContainer.innerHTML = '<p style="color: #999;">로딩 중...</p>';
}
try {
const response = await fetch(`${API_BASE}/franchises/${franchiseId}/stores`, {
headers: getHeaders()
});
if (response.ok) {
const stores = await response.json();
// Single Select Populate
storeSelect.innerHTML = '<option value="">매장을 선택하세요</option>';
stores.forEach(store => {
const option = document.createElement('option');
option.value = store.id;
option.textContent = `${store.name} (${store.code})`;
storeSelect.appendChild(option);
});
// Multi Select Populate
if (storeMultiContainer) {
storeMultiContainer.innerHTML = '';
if (stores.length === 0) {
storeMultiContainer.innerHTML = '<p style="color: #999;">등록된 매장이 없습니다.</p>';
} else {
stores.forEach(store => {
const div = document.createElement('div');
div.style.marginBottom = '5px';
div.innerHTML = `
<label style="display: flex; align-items: center; cursor: pointer; font-weight: normal;">
<input type="checkbox" value="${store.id}" style="width: auto; margin-right: 8px;">
${store.name} (${store.code})
</label>
`;
storeMultiContainer.appendChild(div);
});
}
}
} else {
storeSelect.innerHTML = '<option value="">매장 목록 로드 실패</option>';
if (storeMultiContainer) storeMultiContainer.innerHTML = '<p style="color: red;">매장 목록 로드 실패</p>';
}
} catch (error) {
console.error('매장 목록 로드 실패:', error);
storeSelect.innerHTML = '<option value="">오류 발생</option>';
if (storeMultiContainer) storeMultiContainer.innerHTML = '<p style="color: red;">오류 발생</p>';
}
document.getElementById('addAdminModal').classList.add('show');
}
function toggleAdminStoreSelect() {
const role = document.getElementById('adminRole').value;
const storeGroup = document.getElementById('adminStoreSelectGroup');
const storeSelect = document.getElementById('adminStoreId');
const multiStoreGroup = document.getElementById('adminStoreMultiSelectGroup');
// Reset visibility
storeGroup.style.display = 'none';
if (multiStoreGroup) multiStoreGroup.style.display = 'none';
storeSelect.required = false;
if (role === 'store_admin') {
storeGroup.style.display = 'block';
storeSelect.required = true;
} else if (role === 'franchise_manager') {
if (multiStoreGroup) multiStoreGroup.style.display = 'block';
} else {
// franchise_admin
storeSelect.value = '';
}
}
function showMemberSettingsModal(franchiseId, franchiseName, memberType) {
document.getElementById('memberSettingsFranchiseId').value = franchiseId;
document.getElementById('memberSettingsFranchiseName').textContent = franchiseName;
// 현재 설정값 선택
const radioButtons = document.querySelectorAll('input[name="memberType"]');
radioButtons.forEach(radio => {
radio.checked = (radio.value === memberType);
// 선택된 항목 스타일 업데이트
const label = radio.closest('.radio-option');
if (radio.checked) {
label.style.borderColor = '#667eea';
label.style.backgroundColor = '#f0f4ff';
} else {
label.style.borderColor = '#e5e7eb';
label.style.backgroundColor = 'white';
}
});
document.getElementById('memberSettingsModal').classList.add('show');
}
function showAddStoreModal(franchiseId, franchiseName) {
document.getElementById('storeFranchiseId').value = franchiseId;
document.getElementById('storeFranchiseName').textContent = franchiseName;
document.getElementById('addStoreModal').classList.add('show');
}
function closeModal(modalId) {
document.getElementById(modalId).classList.remove('show');
}
// 프랜차이즈 추가
document.getElementById('addFranchiseForm').addEventListener('submit', async (e) => {
e.preventDefault();
const data = {
name: document.getElementById('franchiseName').value,
code: document.getElementById('franchiseCode').value
};
try {
const response = await fetch(`${API_BASE}/franchises`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(data)
});
if (response.ok) {
alert('프랜차이즈가 추가되었습니다.');
closeModal('addFranchiseModal');
document.getElementById('addFranchiseForm').reset();
await loadFranchises();
await loadStats();
} else {
const error = await response.json();
alert(error.detail || '프랜차이즈 추가 실패');
}
} catch (error) {
console.error('프랜차이즈 추가 실패:', error);
alert('프랜차이즈 추가 중 오류가 발생했습니다.');
}
});
// 프랜차이즈 수정
document.getElementById('editFranchiseForm').addEventListener('submit', async (e) => {
e.preventDefault();
const franchiseId = document.getElementById('editFranchiseId').value;
const data = {
name: document.getElementById('editFranchiseName').value,
code: document.getElementById('editFranchiseCode').value
};
try {
const response = await fetch(`${API_BASE}/franchises/${franchiseId}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify(data)
});
if (response.ok) {
alert('프랜차이즈가 수정되었습니다.');
closeModal('editFranchiseModal');
await loadFranchises();
} else {
const error = await response.json();
alert(error.detail || '프랜차이즈 수정 실패');
}
} catch (error) {
console.error('프랜차이즈 수정 실패:', error);
alert('프랜차이즈 수정 중 오류가 발생했습니다.');
}
});
// 프랜차이즈 관리자 추가
document.getElementById('addAdminForm').addEventListener('submit', async (e) => {
e.preventDefault();
const franchiseId = document.getElementById('adminFranchiseId').value;
const role = document.getElementById('adminRole').value;
const storeId = document.getElementById('adminStoreId').value;
const data = {
username: document.getElementById('adminUsername').value,
password: document.getElementById('adminPassword').value,
role: role,
franchise_id: parseInt(franchiseId),
store_id: null,
managed_store_ids: []
};
if (role === 'store_admin') {
if (!storeId) {
alert('매장을 선택해주세요.');
return;
}
data.store_id = parseInt(storeId);
} else if (role === 'franchise_manager') {
const checkboxes = document.querySelectorAll('#adminStoreMultiSelectContainer input[type="checkbox"]:checked');
const storeIds = Array.from(checkboxes).map(cb => parseInt(cb.value));
if (storeIds.length === 0) {
alert('최소 하나 이상의 매장을 선택해주세요.');
return;
}
data.managed_store_ids = storeIds;
}
try {
// 엔드포인트 변경: /users (범용 사용자 생성)
const response = await fetch(`${API_BASE}/franchises/${franchiseId}/users`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(data)
});
if (response.ok) {
alert('관리자가 생성되었습니다.');
closeModal('addAdminModal');
document.getElementById('addAdminForm').reset();
await loadStats();
} else {
const error = await response.json();
alert(error.detail || '관리자 생성 실패');
}
} catch (error) {
console.error('관리자 생성 실패:', error);
alert('관리자 생성 중 오류가 발생했습니다.');
}
});
// 회원 관리 설정 저장
document.getElementById('memberSettingsForm').addEventListener('submit', async (e) => {
e.preventDefault();
const franchiseId = document.getElementById('memberSettingsFranchiseId').value;
const memberType = document.querySelector('input[name="memberType"]:checked').value;
try {
const response = await fetch(`${API_BASE}/franchises/${franchiseId}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ member_type: memberType })
});
if (response.ok) {
alert('회원 관리 설정이 저장되었습니다.');
closeModal('memberSettingsModal');
await loadFranchises();
} else {
const error = await response.json();
alert(error.detail || '설정 저장 실패');
}
} catch (error) {
console.error('설정 저장 실패:', error);
alert('설정 저장 중 오류가 발생했습니다.');
}
});
// 라디오 버튼 선택 시 스타일 업데이트
document.querySelectorAll('input[name="memberType"]').forEach(radio => {
radio.addEventListener('change', function () {
document.querySelectorAll('.radio-option').forEach(label => {
label.style.borderColor = '#e5e7eb';
label.style.backgroundColor = 'white';
});
if (this.checked) {
const label = this.closest('.radio-option');
label.style.borderColor = '#667eea';
label.style.backgroundColor = '#f0f4ff';
}
});
});
// 매장 추가
document.getElementById('addStoreForm').addEventListener('submit', async (e) => {
e.preventDefault();
const franchiseId = document.getElementById('storeFranchiseId').value;
const name = document.getElementById('storeName').value;
try {
const response = await fetch(`${API_BASE}/franchises/${franchiseId}/stores`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify({ name: name })
});
if (response.ok) {
alert('매장이 추가되었습니다.');
closeModal('addStoreModal');
document.getElementById('addStoreForm').reset();
// 프랜차이즈 목록 및 통계 새로고침
await loadStats();
await loadFranchises();
} else {
const error = await response.json();
alert(error.detail || '매장 추가 실패');
}
} catch (error) {
console.error('매장 추가 실패:', error);
alert('매장 추가 중 오류가 발생했습니다.');
}
});
// 프랜차이즈 비활성화
async function deactivateFranchise(franchiseId) {
if (!confirm('이 프랜차이즈를 비활성화하시겠습니까?')) return;
try {
const response = await fetch(`${API_BASE}/franchises/${franchiseId}`, {
method: 'DELETE',
headers: getHeaders()
});
if (response.status === 204) {
alert('프랜차이즈가 비활성화되었습니다.');
await loadFranchises();
await loadStats();
} else {
const error = await response.json();
alert(error.detail || '프랜차이즈 비활성화 실패');
}
} catch (error) {
console.error('프랜차이즈 비활성화 실패:', error);
}
}
// 프랜차이즈 활성화
async function activateFranchise(franchiseId) {
try {
const response = await fetch(`${API_BASE}/franchises/${franchiseId}/activate`, {
method: 'POST',
headers: getHeaders()
});
if (response.ok) {
alert('프랜차이즈가 활성화되었습니다.');
await loadFranchises();
await loadStats();
} else {
const error = await response.json();
alert(error.detail || '프랜차이즈 활성화 실패');
}
} catch (error) {
console.error('프랜차이즈 활성화 실패:', error);
}
}
// 프랜차이즈 관리 화면으로 이동 (새 탭)
function manageFranchise(franchiseId, franchiseName) {
// 프랜차이즈 정보를 localStorage에 임시 저장 (새 탭에서 사용)
const franchiseInfo = {
id: franchiseId,
name: franchiseName,
isSuperAdmin: true
};
localStorage.setItem('superadmin_franchise_context', JSON.stringify(franchiseInfo));
// 새 탭에서 프랜차이즈 관리 페이지 열기
window.open(`/admin?franchise_id=${franchiseId}`, '_blank');
}
// 로그아웃 (logout.js에서 처리)
// ========== Dashboard 2.0 Logic ==========
let allUsersData = [];
async function showView(viewId) {
// Hide all views
document.querySelectorAll('.admin-view').forEach(el => el.classList.remove('active'));
// Show selected view
document.getElementById(`view-${viewId}`).classList.add('active');
// Load data if needed
if (viewId === 'users') {
await loadAllUsers();
} else if (viewId === 'stores') {
// Stores loaded by click handler usually, but default to all if just tab switched
if (document.getElementById('storesContainer').innerHTML.trim() === '') {
await loadAllStores(false);
}
}
}
// --- Franchise View Logic ---
function toggleFranchiseView(mode) {
const grid = document.getElementById('franchisesGrid');
const cards = grid.getElementsByClassName('franchise-card');
Array.from(cards).forEach(card => {
if (mode === 'active') {
const badge = card.querySelector('.badge');
if (badge.classList.contains('inactive')) {
card.style.display = 'none';
} else {
card.style.display = 'block';
}
} else {
card.style.display = 'block';
}
});
}
// --- User View Logic ---
async function loadAllUsers() {
const tbody = document.getElementById('usersTableBody');
tbody.innerHTML = '<tr><td colspan="7" style="text-align: center;">로딩 중...</td></tr>';
try {
const response = await fetch(`${API_BASE}/users`, { headers: getHeaders() });
if (response.ok) {
allUsersData = await response.json();
renderUsersTable(allUsersData);
} else {
tbody.innerHTML = '<tr><td colspan="7" style="text-align: center; color: red;">데이터 로드 실패</td></tr>';
}
} catch (error) {
console.error(error);
tbody.innerHTML = '<tr><td colspan="7" style="text-align: center; color: red;">오류 발생</td></tr>';
}
}
function renderUsersTable(users) {
const tbody = document.getElementById('usersTableBody');
if (users.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align: center;">사용자가 없습니다.</td></tr>';
return;
}
tbody.innerHTML = users.map(user => `
<tr>
<td>${user.id}</td>
<td><strong>${user.username}</strong></td>
<td>${getRoleName(user.role)}</td>
<td>${user.franchise_name || '-'}</td>
<td>${user.store_name || '-'}</td>
<td><span class="status-badge ${user.is_active ? 'active' : 'inactive'}">${user.is_active ? '활성' : '비활성'}</span></td>
<td>
<button class="btn btn-sm btn-primary" onclick="showEditUserModal(${user.id})">수정</button>
</td>
</tr>
`).join('');
}
function getRoleName(role) {
const map = {
'system_admin': '슈퍼관리자',
'franchise_admin': '프랜차이즈 관리자',
'franchise_manager': '중간 관리자',
'store_admin': '매장 관리자'
};
return map[role] || role;
}
function filterUsers() {
const query = document.getElementById('userSearch').value.toLowerCase();
const filtered = allUsersData.filter(user =>
user.username.toLowerCase().includes(query) ||
(user.franchise_name && user.franchise_name.toLowerCase().includes(query)) ||
(user.store_name && user.store_name.toLowerCase().includes(query))
);
renderUsersTable(filtered);
}
// --- Edit User Logic ---
async function showEditUserModal(userId) {
const user = allUsersData.find(u => u.id === userId);
if (!user) return;
document.getElementById('editUserId').value = user.id;
document.getElementById('editUserUsername').value = user.username;
document.getElementById('editUserPassword').value = ''; // Reset password
document.getElementById('editUserRole').value = user.role;
// Populate Franchises
const franchiseSelect = document.getElementById('editUserFranchiseId');
franchiseSelect.innerHTML = '<option value="">프랜차이즈 선택</option>';
// Need to fetch franchises if not loaded? We have franchisesData from init()
// But franchisesData might not be refreshed if we just loaded users.
// Better to use franchisesData if available.
if (franchisesData && franchisesData.length > 0) {
franchisesData.forEach(f => {
const opt = document.createElement('option');
opt.value = f.id;
opt.textContent = `${f.name} (${f.code})`;
franchiseSelect.appendChild(opt);
});
} else {
// Try loading?
// await loadFranchises(); // Assume loaded
}
if (user.franchise_id) {
franchiseSelect.value = user.franchise_id;
// Load stores for this franchise
await loadEditUserStores(user.franchise_id, user.store_id, user.managed_stores || []); // managed_stores might not be in user object if not extended.
// Wait! UserListResponse doesn't include managed_stores list!
// I need to fetch user details? Or include it in UserListResponse?
// For editing, it's safer to fetch user details FRESH.
// Let's implement fetch user details
} else {
// Clear stores
document.getElementById('editUserStoreId').innerHTML = '<option value="">매장 선택</option>';
}
toggleEditUserStoreSelect();
document.getElementById('editUserModal').classList.add('show');
}
async function loadEditUserStores(franchiseId, selectedStoreId = null, managedStores = []) {
const storeSelect = document.getElementById('editUserStoreId');
const multiStoreContainer = document.getElementById('editUserMultiStoreContainer');
storeSelect.innerHTML = '<option value="">로딩 중...</option>';
multiStoreContainer.innerHTML = '로딩 중...';
if (!franchiseId) {
storeSelect.innerHTML = '<option value="">프랜차이즈를 먼저 선택하세요</option>';
multiStoreContainer.innerHTML = '';
return;
}
try {
const response = await fetch(`${API_BASE}/franchises/${franchiseId}/stores`, { headers: getHeaders() });
if (response.ok) {
const stores = await response.json();
storeSelect.innerHTML = '<option value="">매장 선택</option>';
multiStoreContainer.innerHTML = '';
stores.forEach(s => {
// Single Select
const opt = document.createElement('option');
opt.value = s.id;
opt.textContent = `${s.name} (${s.code})`;
storeSelect.appendChild(opt);
// Multi Select
const div = document.createElement('div');
div.style.marginBottom = '5px';
// Check if managed
// managedStores is array of objects? or IDs?
// User schema has `managed_stores: List[Store]`.
// So we need to check IDs.
const isChecked = managedStores.some(ms => ms.id === s.id);
div.innerHTML = `
<label style="display: flex; align-items: center; cursor: pointer; font-weight: normal;">
<input type="checkbox" value="${s.id}" ${isChecked ? 'checked' : ''} style="width: auto; margin-right: 8px;">
${s.name}
</label>
`;
multiStoreContainer.appendChild(div);
});
if (selectedStoreId) {
storeSelect.value = selectedStoreId;
}
}
} catch (error) {
console.error(error);
storeSelect.innerHTML = '<option value="">로드 실패</option>';
}
}
function toggleEditUserStoreSelect() {
const role = document.getElementById('editUserRole').value;
const franchiseGroup = document.getElementById('editUserFranchiseGroup');
const storeGroup = document.getElementById('editUserStoreGroup');
const multiStoreGroup = document.getElementById('editUserMultiStoreGroup');
// Reset
franchiseGroup.style.display = 'none';
storeGroup.style.display = 'none';
multiStoreGroup.style.display = 'none';
if (role === 'system_admin') {
// No franchise/store needed
} else if (role === 'franchise_admin') {
franchiseGroup.style.display = 'block';
} else if (role === 'franchise_manager') {
franchiseGroup.style.display = 'block';
multiStoreGroup.style.display = 'block';
} else if (role === 'store_admin') {
franchiseGroup.style.display = 'block';
storeGroup.style.display = 'block';
}
}
// Handle User Update
document.getElementById('editUserForm').addEventListener('submit', async (e) => {
e.preventDefault();
const userId = document.getElementById('editUserId').value;
const role = document.getElementById('editUserRole').value;
const data = {
role: role,
franchise_id: document.getElementById('editUserFranchiseId').value ? parseInt(document.getElementById('editUserFranchiseId').value) : null
};
const password = document.getElementById('editUserPassword').value;
if (password) data.password = password;
if (role === 'store_admin') {
const storeId = document.getElementById('editUserStoreId').value;
if (!storeId) { alert('매장을 선택하세요'); return; }
data.store_id = parseInt(storeId);
} else if (role === 'franchise_manager') {
const checkboxes = document.querySelectorAll('#editUserMultiStoreContainer input[type="checkbox"]:checked');
const storeIds = Array.from(checkboxes).map(cb => parseInt(cb.value));
data.managed_store_ids = storeIds;
}
try {
const response = await fetch(`${API_BASE}/users/${userId}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify(data)
});
if (response.ok) {
alert('사용자 정보가 수정되었습니다.');
closeModal('editUserModal');
await loadAllUsers();
} else {
const error = await response.json();
alert(error.detail || '수정 실패');
}
} catch (error) {
console.error(error);
alert('오류 발생');
}
});
// --- Store View Logic ---
async function loadAllStores(onlyActive) {
const container = document.getElementById('storesContainer');
container.innerHTML = '<p style="text-align: center; padding: 20px;">로딩 중...</p>';
try {
const response = await fetch(`${API_BASE}/stores`, { headers: getHeaders() });
if (response.ok) {
let stores = await response.json();
if (onlyActive) {
stores = stores.filter(s => s.is_active);
}
renderStoresGrouped(stores);
} else {
container.innerHTML = '<p style="text-align: center; color: red;">매장 로드 실패</p>';
}
} catch (error) {
console.error(error);
container.innerHTML = '<p style="text-align: center; color: red;">오류 발생</p>';
}
}
function renderStoresGrouped(stores) {
const container = document.getElementById('storesContainer');
// Group by Franchise Name
const grouped = {};
stores.forEach(store => {
const key = store.franchise_name || '미분류';
if (!grouped[key]) grouped[key] = [];
grouped[key].push(store);
});
if (Object.keys(grouped).length === 0) {
container.innerHTML = '<p style="text-align: center; padding: 20px;">매장이 없습니다.</p>';
return;
}
let html = '';
for (const [franchiseName, storeList] of Object.entries(grouped)) {
html += `
<div style="margin-bottom: 30px; background: white; padding: 20px; border-radius: 10px; box-shadow: 0 2px 5px rgba(0,0,0,0.05);">
<h3 style="margin-bottom: 15px; border-bottom: 1px solid #eee; padding-bottom: 10px; color: #333;">${franchiseName}</h3>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 15px;">
${storeList.map(store => `
<div style="background: #f9fafb; padding: 15px; border-radius: 8px; border: 1px solid #eee;">
<div style="font-weight: 600; margin-bottom: 5px;">${store.name}</div>
<div style="font-size: 13px; color: #666; margin-bottom: 5px;">코드: ${store.code}</div>
<span class="status-badge ${store.is_active ? 'active' : 'inactive'}">${store.is_active ? '활성' : '비활성'}</span>
</div>
`).join('')}
</div>
</div>
`;
}
container.innerHTML = html;
}
// --- Member View Logic ---
async function handleMemberSearch() {
const q = document.getElementById('memberSearchInput').value;
const tbody = document.getElementById('membersTableBody');
if (!q) {
// Load recent if empty?
tbody.innerHTML = '<tr><td colspan="5" style="text-align: center;">전체 조회 중...</td></tr>';
} else {
tbody.innerHTML = '<tr><td colspan="5" style="text-align: center;">검색 중...</td></tr>';
}
try {
const url = q ? `${API_BASE}/members?q=${encodeURIComponent(q)}` : `${API_BASE}/members`;
const response = await fetch(url, { headers: getHeaders() });
if (response.ok) {
const members = await response.json();
renderMembersTable(members);
} else {
tbody.innerHTML = '<tr><td colspan="5" style="text-align: center; color: red;">검색 실패</td></tr>';
}
} catch (error) {
console.error(error);
tbody.innerHTML = '<tr><td colspan="5" style="text-align: center; color: red;">오류 발생</td></tr>';
}
}
function renderMembersTable(members) {
const tbody = document.getElementById('membersTableBody');
if (members.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align: center;">결과가 없습니다.</td></tr>';
return;
}
tbody.innerHTML = members.map(member => `
<tr>
<td><strong>${member.name}</strong></td>
<td>${member.phone}</td>
<td>${member.franchise_name || '-'}</td>
<td>${member.store_name}</td>
<td>${new Date(member.created_at).toLocaleDateString()}</td>
</tr>
`).join('');
}
// 초기 로드
async function init() {
const token = getToken();
if (!token) {
alert('로그인이 필요합니다.');
window.location.href = '/login';
return;
}
await loadStats();
await loadFranchises();
}
init();
</script>
<script src="/static/js/logout.js"></script>
</body>
</html>