Files
waiting-system/templates/members.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

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>