- 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>
1790 lines
71 KiB
HTML
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, '"')}"
|
|
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, '"')}"
|
|
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, '"')}"
|
|
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, '"')}"
|
|
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> |