- 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>
563 lines
21 KiB
HTML
563 lines
21 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>
|
|
.members-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.search-section {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.search-section input {
|
|
flex: 1;
|
|
padding: 12px;
|
|
border: 2px solid #ecf0f1;
|
|
border-radius: 6px;
|
|
font-size: 16px;
|
|
}
|
|
|
|
.actions-bar {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.members-table {
|
|
overflow-x: auto;
|
|
}
|
|
|
|
.member-item {
|
|
display: grid;
|
|
grid-template-columns: 60px 1fr 150px 180px 150px;
|
|
gap: 15px;
|
|
padding: 20px;
|
|
background: #fff;
|
|
border-bottom: 1px solid #ecf0f1;
|
|
align-items: center;
|
|
transition: background 0.2s;
|
|
}
|
|
|
|
.member-item:hover {
|
|
background: #f8f9fa;
|
|
}
|
|
|
|
.member-item:first-child {
|
|
border-top-left-radius: 10px;
|
|
border-top-right-radius: 10px;
|
|
}
|
|
|
|
.member-item:last-child {
|
|
border-bottom: none;
|
|
border-bottom-left-radius: 10px;
|
|
border-bottom-right-radius: 10px;
|
|
}
|
|
|
|
.member-number {
|
|
font-weight: 600;
|
|
color: #7f8c8d;
|
|
text-align: center;
|
|
}
|
|
|
|
.member-info .name {
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
color: #2c3e50;
|
|
margin-bottom: 5px;
|
|
}
|
|
|
|
.member-info .date {
|
|
font-size: 12px;
|
|
color: #7f8c8d;
|
|
}
|
|
|
|
.member-phone {
|
|
font-size: 20px;
|
|
font-weight: 700;
|
|
color: #2980b9;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.member-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
.member-item {
|
|
grid-template-columns: 1fr;
|
|
gap: 10px;
|
|
}
|
|
|
|
.members-header {
|
|
flex-direction: column;
|
|
gap: 15px;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.actions-bar {
|
|
flex-wrap: wrap;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
<div class="container">
|
|
<div class="members-header">
|
|
<div>
|
|
<h1>회원 관리</h1>
|
|
<p class="subtitle">회원 등록, 조회, 수정</p>
|
|
</div>
|
|
<a href="/" class="btn btn-secondary">← 메인으로</a>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="search-section">
|
|
<input type="text" id="searchInput" placeholder="이름 또는 핸드폰 뒷자리 4자리 검색..."
|
|
onkeyup="handleSearchKeyup(event)">
|
|
<button class="btn btn-primary" onclick="searchMembers()">검색</button>
|
|
</div>
|
|
|
|
<div class="actions-bar">
|
|
<button class="btn btn-success" onclick="openAddModal()">회원 등록</button>
|
|
<button class="btn btn-warning" onclick="openExcelModal()">엑셀 일괄등록</button>
|
|
</div>
|
|
|
|
<div class="members-table" id="membersTable">
|
|
<div class="loading">
|
|
<div class="spinner"></div>
|
|
<p>로딩 중...</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 회원 등록/수정 모달 -->
|
|
<div id="memberModal" class="modal">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h2 id="modalTitle">회원 등록</h2>
|
|
</div>
|
|
<form id="memberForm" onsubmit="saveMember(event)">
|
|
<div class="form-group">
|
|
<label>이름</label>
|
|
<input type="text" id="memberName" class="form-control" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>핸드폰번호</label>
|
|
<div style="display: flex; align-items: center; gap: 8px;">
|
|
<div
|
|
style="padding: 12px; background: #ecf0f1; border-radius: 6px; font-weight: 600; color: #7f8c8d;">
|
|
010-</div>
|
|
<input type="tel" id="memberPhone" class="form-control" placeholder="0000-0000" maxlength="9"
|
|
required style="flex: 1;">
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>바코드 (선택)</label>
|
|
<input type="text" id="memberBarcode" class="form-control" placeholder="바코드 스캔 또는 입력">
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" onclick="closeModal('memberModal')">취소</button>
|
|
<button type="submit" class="btn btn-primary">저장</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 엑셀 업로드 모달 -->
|
|
<div id="excelModal" class="modal">
|
|
<div class="modal-content" style="max-width: 700px;">
|
|
<div class="modal-header">
|
|
<h2>엑셀 일괄등록</h2>
|
|
</div>
|
|
<div>
|
|
<div class="alert alert-info">
|
|
<strong>엑셀 파일 형식:</strong><br>
|
|
1열: 이름, 2열: 핸드폰번호 (010-0000-0000 또는 01000000000)<br>
|
|
첫 번째 행은 헤더로 간주되어 스킵됩니다.
|
|
</div>
|
|
<div class="form-group">
|
|
<label>엑셀 파일 선택</label>
|
|
<input type="file" id="excelFile" class="form-control" accept=".xlsx,.xls">
|
|
</div>
|
|
<button class="btn btn-primary" onclick="uploadExcel()">검수하기</button>
|
|
|
|
<div id="excelResult" style="display:none; margin-top:20px;">
|
|
<h3>검수 결과</h3>
|
|
<div id="excelSummary" class="alert alert-info"></div>
|
|
|
|
<div id="invalidList" style="display:none;">
|
|
<h4 style="color:#e74c3c; margin-bottom:10px;">오류 목록</h4>
|
|
<div
|
|
style="max-height:200px; overflow-y:auto; border:1px solid #ecf0f1; border-radius:6px; padding:10px; background:#f8f9fa;">
|
|
<table class="table" style="font-size:12px;">
|
|
<thead>
|
|
<tr>
|
|
<th>행</th>
|
|
<th>이름</th>
|
|
<th>핸드폰</th>
|
|
<th>오류</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="invalidTableBody"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<button class="btn btn-success" id="confirmExcelBtn" style="margin-top:15px;"
|
|
onclick="confirmExcelUpload()">
|
|
최종 등록
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button class="btn btn-secondary" onclick="closeModal('excelModal')">닫기</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let members = [];
|
|
let currentMemberId = null;
|
|
let validMembers = [];
|
|
|
|
// Helper function to get headers with store ID
|
|
function getHeaders(additionalHeaders = {}) {
|
|
const headers = { ...additionalHeaders };
|
|
const storeId = localStorage.getItem('selected_store_id');
|
|
if (storeId) {
|
|
headers['X-Store-Id'] = storeId;
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
async function loadMembers() {
|
|
const table = document.getElementById('membersTable');
|
|
// 초기 로드 시 안내 메시지만 표시
|
|
table.innerHTML = `
|
|
<div class="empty-state">
|
|
<div class="icon">🔍</div>
|
|
<p>이름 또는 핸드폰번호로 회원을 검색하세요</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderMembers(data) {
|
|
const table = document.getElementById('membersTable');
|
|
|
|
if (data.length === 0) {
|
|
table.innerHTML = '<div class="empty-state"><div class="icon">👥</div><p>등록된 회원이 없습니다</p></div>';
|
|
return;
|
|
}
|
|
|
|
table.innerHTML = '';
|
|
data.forEach((member, idx) => {
|
|
const item = document.createElement('div');
|
|
item.className = 'member-item';
|
|
|
|
const date = new Date(member.created_at);
|
|
const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
|
|
|
// 핸드폰 번호 포맷팅 (010-0000-0000)
|
|
let formattedPhone = member.phone;
|
|
if (member.phone.length === 11) {
|
|
formattedPhone = member.phone.replace(/(\d{3})(\d{4})(\d{4})/, '$1-$2-$3');
|
|
}
|
|
|
|
item.innerHTML = `
|
|
<div class="member-number">${idx + 1}</div>
|
|
<div class="member-info">
|
|
<div class="name">${member.name}</div>
|
|
<div class="date">등록일: ${dateStr}</div>
|
|
</div>
|
|
<div style="font-family: monospace; color: #7f8c8d;">${member.barcode || '-'}</div>
|
|
<div class="member-phone">${formattedPhone}</div>
|
|
<div class="member-actions">
|
|
<button class="btn btn-sm btn-primary" onclick="openEditModal(${member.id})">수정</button>
|
|
<button class="btn btn-sm btn-danger" onclick="deleteMember(${member.id})">삭제</button>
|
|
</div>
|
|
`;
|
|
table.appendChild(item);
|
|
});
|
|
}
|
|
|
|
async function searchMembers() {
|
|
const searchText = document.getElementById('searchInput').value.trim();
|
|
|
|
if (!searchText) {
|
|
alert('검색어를 입력해주세요.');
|
|
return;
|
|
}
|
|
|
|
const table = document.getElementById('membersTable');
|
|
table.innerHTML = '<div class="loading"><div class="spinner"></div><p>검색 중...</p></div>';
|
|
|
|
try {
|
|
const response = await fetch(`/api/members/?search=${encodeURIComponent(searchText)}&limit=1000`, {
|
|
headers: getHeaders()
|
|
});
|
|
const data = await response.json();
|
|
|
|
// 검색 결과를 members 배열에 저장 (수정 시 사용)
|
|
members = data;
|
|
|
|
if (data.length === 0) {
|
|
table.innerHTML = `
|
|
<div class="empty-state">
|
|
<div class="icon">🔍</div>
|
|
<p>검색 결과가 없습니다</p>
|
|
</div>
|
|
`;
|
|
} else {
|
|
renderMembers(data);
|
|
}
|
|
} catch (error) {
|
|
console.error('검색 실패:', error);
|
|
table.innerHTML = '<div class="empty-state"><p>검색 중 오류가 발생했습니다</p></div>';
|
|
}
|
|
}
|
|
|
|
function handleSearchKeyup(event) {
|
|
if (event.key === 'Enter') {
|
|
searchMembers();
|
|
}
|
|
}
|
|
|
|
function openAddModal() {
|
|
currentMemberId = null;
|
|
document.getElementById('modalTitle').textContent = '회원 등록';
|
|
document.getElementById('memberName').value = '';
|
|
document.getElementById('memberPhone').value = '';
|
|
document.getElementById('memberBarcode').value = '';
|
|
document.getElementById('memberModal').classList.add('active');
|
|
}
|
|
|
|
function openEditModal(memberId) {
|
|
const member = members.find(m => m.id === memberId);
|
|
if (!member) return;
|
|
|
|
currentMemberId = memberId;
|
|
document.getElementById('modalTitle').textContent = '회원 수정';
|
|
document.getElementById('memberName').value = member.name;
|
|
// 010을 제외한 나머지 부분만 표시 (010XXXXXXXX -> XXXX-XXXX)
|
|
const phoneWithoutPrefix = member.phone.substring(3);
|
|
const formatted = phoneWithoutPrefix.length === 8
|
|
? phoneWithoutPrefix.substring(0, 4) + '-' + phoneWithoutPrefix.substring(4)
|
|
: phoneWithoutPrefix;
|
|
document.getElementById('memberPhone').value = formatted;
|
|
document.getElementById('memberBarcode').value = member.barcode || '';
|
|
document.getElementById('memberModal').classList.add('active');
|
|
}
|
|
|
|
async function saveMember(event) {
|
|
event.preventDefault();
|
|
|
|
const name = document.getElementById('memberName').value.trim();
|
|
const phoneInput = document.getElementById('memberPhone').value.trim().replace(/-/g, '');
|
|
const barcode = document.getElementById('memberBarcode').value.trim() || null;
|
|
|
|
if (!name || !phoneInput) {
|
|
alert('모든 항목을 입력해주세요.');
|
|
return;
|
|
}
|
|
|
|
// 8자리 숫자인지 확인
|
|
if (!/^\d{8}$/.test(phoneInput)) {
|
|
alert('핸드폰번호를 정확히 입력해주세요. (8자리 숫자)');
|
|
return;
|
|
}
|
|
|
|
// 010을 앞에 붙여서 완전한 번호 생성
|
|
const phone = '010' + phoneInput;
|
|
|
|
try {
|
|
let response;
|
|
if (currentMemberId) {
|
|
// 수정
|
|
response = await fetch(`/api/members/${currentMemberId}`, {
|
|
method: 'PUT',
|
|
headers: getHeaders({ 'Content-Type': 'application/json' }),
|
|
body: JSON.stringify({ name, phone, barcode })
|
|
});
|
|
} else {
|
|
// 등록
|
|
response = await fetch('/api/members/', {
|
|
method: 'POST',
|
|
headers: getHeaders({ 'Content-Type': 'application/json' }),
|
|
body: JSON.stringify({ name, phone, barcode })
|
|
});
|
|
}
|
|
|
|
if (response.ok) {
|
|
alert('저장되었습니다.');
|
|
closeModal('memberModal');
|
|
// 검색어가 있으면 다시 검색, 없으면 초기 화면
|
|
const searchText = document.getElementById('searchInput').value.trim();
|
|
if (searchText) {
|
|
searchMembers();
|
|
} else {
|
|
loadMembers();
|
|
}
|
|
} else {
|
|
const error = await response.json();
|
|
alert(error.detail || '저장에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error('저장 실패:', error);
|
|
alert('저장 중 오류가 발생했습니다.');
|
|
}
|
|
}
|
|
|
|
async function deleteMember(memberId) {
|
|
const member = members.find(m => m.id === memberId);
|
|
if (!confirm(`${member.name} 회원을 삭제하시겠습니까?`)) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/members/${memberId}`, {
|
|
method: 'DELETE'
|
|
});
|
|
|
|
if (response.ok) {
|
|
alert('삭제되었습니다.');
|
|
// 검색어가 있으면 다시 검색, 없으면 초기 화면
|
|
const searchText = document.getElementById('searchInput').value.trim();
|
|
if (searchText) {
|
|
searchMembers();
|
|
} else {
|
|
loadMembers();
|
|
}
|
|
} else {
|
|
const error = await response.json();
|
|
alert(error.detail || '삭제에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error('삭제 실패:', error);
|
|
alert('삭제 중 오류가 발생했습니다.');
|
|
}
|
|
}
|
|
|
|
function openExcelModal() {
|
|
document.getElementById('excelFile').value = '';
|
|
document.getElementById('excelResult').style.display = 'none';
|
|
document.getElementById('excelModal').classList.add('active');
|
|
}
|
|
|
|
async function uploadExcel() {
|
|
const fileInput = document.getElementById('excelFile');
|
|
if (!fileInput.files.length) {
|
|
alert('파일을 선택해주세요.');
|
|
return;
|
|
}
|
|
|
|
const formData = new FormData();
|
|
formData.append('file', fileInput.files[0]);
|
|
|
|
try {
|
|
const response = await fetch('/api/members/upload-excel', {
|
|
method: 'POST',
|
|
headers: getHeaders(),
|
|
body: formData
|
|
});
|
|
|
|
if (response.ok) {
|
|
const result = await response.json();
|
|
showExcelResult(result);
|
|
} else {
|
|
const error = await response.json();
|
|
alert(error.detail || '파일 처리에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error('업로드 실패:', error);
|
|
alert('업로드 중 오류가 발생했습니다.');
|
|
}
|
|
}
|
|
|
|
function showExcelResult(result) {
|
|
validMembers = result.valid_members;
|
|
|
|
document.getElementById('excelSummary').innerHTML = `
|
|
총 <strong>${result.total_count}</strong>개 항목 중
|
|
<strong style="color:#27ae60;">${result.valid_count}개 유효</strong>,
|
|
<strong style="color:#e74c3c;">${result.invalid_count}개 오류</strong>
|
|
`;
|
|
|
|
if (result.invalid_count > 0) {
|
|
const tbody = document.getElementById('invalidTableBody');
|
|
tbody.innerHTML = '';
|
|
|
|
result.invalid_members.forEach(item => {
|
|
const tr = document.createElement('tr');
|
|
tr.innerHTML = `
|
|
<td>${item.row}</td>
|
|
<td>${item.name}</td>
|
|
<td>${item.phone}</td>
|
|
<td style="color:#e74c3c;">${item.errors.join(', ')}</td>
|
|
`;
|
|
tbody.appendChild(tr);
|
|
});
|
|
|
|
document.getElementById('invalidList').style.display = 'block';
|
|
}
|
|
|
|
document.getElementById('confirmExcelBtn').disabled = result.valid_count === 0;
|
|
document.getElementById('excelResult').style.display = 'block';
|
|
}
|
|
|
|
async function confirmExcelUpload() {
|
|
if (!confirm(`${validMembers.length}명을 등록하시겠습니까?`)) return;
|
|
|
|
try {
|
|
const response = await fetch('/api/members/bulk', {
|
|
method: 'POST',
|
|
headers: getHeaders({ 'Content-Type': 'application/json' }),
|
|
body: JSON.stringify({ members: validMembers })
|
|
});
|
|
|
|
if (response.ok) {
|
|
const result = await response.json();
|
|
alert(result.message);
|
|
closeModal('excelModal');
|
|
// 엑셀 등록 후 초기 화면
|
|
loadMembers();
|
|
} else {
|
|
const error = await response.json();
|
|
alert(error.detail || '등록에 실패했습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error('등록 실패:', error);
|
|
alert('등록 중 오류가 발생했습니다.');
|
|
}
|
|
}
|
|
|
|
function closeModal(modalId) {
|
|
document.getElementById(modalId).classList.remove('active');
|
|
}
|
|
|
|
// 전화번호 입력 포맷팅 (0000-0000)
|
|
document.getElementById('memberPhone').addEventListener('input', function (e) {
|
|
let value = e.target.value.replace(/[^0-9]/g, '');
|
|
if (value.length > 4) {
|
|
value = value.slice(0, 4) + '-' + value.slice(4, 8);
|
|
}
|
|
e.target.value = value;
|
|
});
|
|
|
|
// 초기 로드
|
|
loadMembers();
|
|
</script>
|
|
</body>
|
|
|
|
</html> |