- 자동 매핑 버그 수정: 이름만으로 자동 연결되던 문제 해결 - 매핑되지 않은 약국 목록 API 추가 (/api/pharmacies/available) - 사용자 연결 드롭다운에서 매핑 가능한 약국만 표시하도록 개선 - 기존 잘못된 매핑 초기화하여 명시적 링크만 허용 - UI 텍스트 업데이트: "Headscale 사용자 목록" → "PQON 사용자 목록" - UI 텍스트 업데이트: "Headscale 네트워크 사용자" → "PharmQ-ON 사용자" - 사이드 메뉴 링크 변경: "Headplane UI" → "Medivault" (https://medivault.co.kr/) - SQLAlchemy or_ import 추가하여 복합 조건 쿼리 지원 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
501 lines
18 KiB
HTML
501 lines
18 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}사용자 관리 - 팜큐 약국 관리 시스템{% endblock %}
|
|
|
|
{% block breadcrumb %}
|
|
<nav aria-label="breadcrumb">
|
|
<ol class="breadcrumb">
|
|
<li class="breadcrumb-item"><a href="{{ url_for('dashboard') }}">대시보드</a></li>
|
|
<li class="breadcrumb-item active">사용자 관리</li>
|
|
</ol>
|
|
</nav>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="row mb-4">
|
|
<div class="col-12">
|
|
<div class="d-flex justify-content-between align-items-center">
|
|
<div>
|
|
<h1 class="h2 mb-0">
|
|
<i class="fas fa-users text-primary"></i>
|
|
사용자 관리
|
|
</h1>
|
|
<p class="text-muted">PharmQ-ON 사용자 및 약국 매칭 관리</p>
|
|
</div>
|
|
<div class="d-flex gap-2">
|
|
<button class="btn btn-outline-secondary" onclick="refreshUserList()">
|
|
<i class="fas fa-sync-alt"></i> 새로고침
|
|
</button>
|
|
<button class="btn btn-primary" onclick="showCreateUserModal()">
|
|
<i class="fas fa-plus"></i> 새 사용자
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 통계 카드 -->
|
|
<div class="row mb-4">
|
|
<div class="col-lg-3 col-md-6 mb-3">
|
|
<div class="card stat-card">
|
|
<div class="card-body text-center">
|
|
<div class="stat-number" id="totalUsers">0</div>
|
|
<div class="stat-label">
|
|
<i class="fas fa-users"></i> 총 사용자 수
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-lg-3 col-md-6 mb-3">
|
|
<div class="card" style="background: linear-gradient(135deg, #48bb78 0%, #38a169 100%); color: white;">
|
|
<div class="card-body text-center">
|
|
<div class="stat-number" id="mappedUsers">0</div>
|
|
<div class="stat-label">
|
|
<i class="fas fa-link"></i> 약국 연결됨
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-lg-3 col-md-6 mb-3">
|
|
<div class="card" style="background: linear-gradient(135deg, #ed8936 0%, #dd6b20 100%); color: white;">
|
|
<div class="card-body text-center">
|
|
<div class="stat-number" id="unmappedUsers">0</div>
|
|
<div class="stat-label">
|
|
<i class="fas fa-unlink"></i> 연결 필요
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-lg-3 col-md-6 mb-3">
|
|
<div class="card" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white;">
|
|
<div class="card-body text-center">
|
|
<div class="stat-number" id="totalNodes">0</div>
|
|
<div class="stat-label">
|
|
<i class="fas fa-desktop"></i> 총 연결 노드
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 사용자 목록 -->
|
|
<div class="row">
|
|
<div class="col-12">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h5 class="mb-0">
|
|
<i class="fas fa-list"></i> PQON 사용자 목록
|
|
</h5>
|
|
</div>
|
|
<div class="card-body" id="usersTableContainer">
|
|
<div class="text-center py-4">
|
|
<div class="spinner-border text-primary" role="status">
|
|
<span class="visually-hidden">로딩 중...</span>
|
|
</div>
|
|
<p class="mt-3">사용자 목록을 로드하고 있습니다...</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 새 사용자 생성 모달 -->
|
|
<div class="modal fade" id="createUserModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">
|
|
<i class="fas fa-user-plus"></i> 새 Headscale 사용자 생성
|
|
</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<form id="createUserForm">
|
|
<div class="mb-3">
|
|
<label for="userName" class="form-label">사용자 이름 <span class="text-danger">*</span></label>
|
|
<input type="text" class="form-control" id="userName" required
|
|
placeholder="예: pharmacy3, admin_user 등">
|
|
<div class="form-text">영문, 숫자, 언더스코어만 사용 가능합니다.</div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label for="displayName" class="form-label">표시 이름</label>
|
|
<input type="text" class="form-control" id="displayName"
|
|
placeholder="예: 제3약국, 관리자 등">
|
|
</div>
|
|
<div class="mb-3">
|
|
<label for="email" class="form-label">이메일</label>
|
|
<input type="email" class="form-control" id="email"
|
|
placeholder="user@example.com">
|
|
</div>
|
|
<div class="alert alert-info">
|
|
<i class="fas fa-info-circle"></i>
|
|
<strong>주의:</strong> 생성된 사용자는 약국 관리 페이지에서 별도로 약국 정보와 연결해야 합니다.
|
|
</div>
|
|
</form>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">취소</button>
|
|
<button type="button" class="btn btn-primary" onclick="createUser()">
|
|
<i class="fas fa-plus"></i> 사용자 생성
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 사용자-약국 매칭 모달 -->
|
|
<div class="modal fade" id="linkPharmacyModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">
|
|
<i class="fas fa-link"></i> 약국 연결
|
|
</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<p>사용자 <strong id="linkUserName"></strong>를 약국과 연결합니다.</p>
|
|
<div class="mb-3">
|
|
<label for="pharmacySelect" class="form-label">연결할 약국 선택</label>
|
|
<select class="form-select" id="pharmacySelect" required>
|
|
<option value="">약국을 선택하세요</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">취소</button>
|
|
<button type="button" class="btn btn-primary" onclick="linkPharmacy()">
|
|
<i class="fas fa-link"></i> 연결
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script>
|
|
let currentUsers = [];
|
|
let currentPharmacies = [];
|
|
let selectedUserId = null;
|
|
|
|
// 페이지 로드 시 실행
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
loadUsers();
|
|
loadPharmacies();
|
|
});
|
|
|
|
// 사용자 목록 로드
|
|
function loadUsers() {
|
|
fetch('/api/users')
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
currentUsers = data.users;
|
|
renderUsersTable();
|
|
updateStats();
|
|
} else {
|
|
showToast('사용자 목록 로드 실패: ' + data.error, 'danger');
|
|
document.getElementById('usersTableContainer').innerHTML =
|
|
`<div class="alert alert-danger">
|
|
<i class="fas fa-exclamation-triangle"></i>
|
|
사용자 목록을 로드할 수 없습니다: ${data.error}
|
|
</div>`;
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('사용자 목록 로드 오류:', error);
|
|
showToast('사용자 목록 로드 중 오류가 발생했습니다.', 'danger');
|
|
});
|
|
}
|
|
|
|
// 약국 목록 로드
|
|
function loadPharmacies() {
|
|
// FARMQ 약국 데이터 직접 조회
|
|
farmq_session = get_farmq_session()
|
|
try {
|
|
pharmacies = farmq_session.query(PharmacyInfo).filter(
|
|
PharmacyInfo.status == 'active'
|
|
).all()
|
|
|
|
currentPharmacies = pharmacies.map(p => ({
|
|
id: p.id,
|
|
pharmacy_name: p.pharmacy_name,
|
|
manager_name: p.manager_name
|
|
}))
|
|
} catch (error) {
|
|
console.log('약국 목록 로드 중 오류:', error);
|
|
// 임시 데이터 사용
|
|
currentPharmacies = [
|
|
{id: 1, pharmacy_name: '양구청춘약국', manager_name: '김영빈'},
|
|
{id: 2, pharmacy_name: '성진약국', manager_name: '박성진'}
|
|
];
|
|
}
|
|
}
|
|
|
|
// 사용자 테이블 렌더링
|
|
function renderUsersTable() {
|
|
const container = document.getElementById('usersTableContainer');
|
|
|
|
if (currentUsers.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="text-center py-4">
|
|
<i class="fas fa-users fa-3x text-muted mb-3"></i>
|
|
<p>등록된 Headscale 사용자가 없습니다.</p>
|
|
<button class="btn btn-primary" onclick="showCreateUserModal()">
|
|
<i class="fas fa-plus"></i> 첫 번째 사용자 생성
|
|
</button>
|
|
</div>`;
|
|
return;
|
|
}
|
|
|
|
let tableHTML = `
|
|
<div class="table-responsive">
|
|
<table class="table table-hover">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th>사용자 정보</th>
|
|
<th>연결된 약국</th>
|
|
<th>연결된 노드</th>
|
|
<th>생성일</th>
|
|
<th>액션</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>`;
|
|
|
|
currentUsers.forEach(user => {
|
|
const createdDate = new Date(user.created_at.seconds * 1000).toLocaleDateString('ko-KR');
|
|
const isLinked = user.pharmacy !== null;
|
|
|
|
tableHTML += `
|
|
<tr>
|
|
<td>
|
|
<div class="d-flex align-items-center">
|
|
<div class="me-3">
|
|
<i class="fas fa-user-circle fa-2x ${isLinked ? 'text-success' : 'text-muted'}"></i>
|
|
</div>
|
|
<div>
|
|
<strong>${user.name}</strong>
|
|
<div class="small text-muted">ID: ${user.id}</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
${isLinked ?
|
|
`<div>
|
|
<strong>${user.pharmacy.name}</strong>
|
|
<div class="small text-muted">${user.pharmacy.manager}</div>
|
|
<span class="badge bg-success">연결됨</span>
|
|
</div>` :
|
|
`<span class="badge bg-warning">연결 필요</span>`
|
|
}
|
|
</td>
|
|
<td>
|
|
<span class="badge bg-info">${user.node_count || 0}개</span>
|
|
</td>
|
|
<td>
|
|
<div class="small">${createdDate}</div>
|
|
</td>
|
|
<td>
|
|
<div class="btn-group btn-group-sm">
|
|
${!isLinked ?
|
|
`<button class="btn btn-outline-success"
|
|
onclick="showLinkPharmacyModal('${user.name}')" title="약국 연결">
|
|
<i class="fas fa-link"></i>
|
|
</button>` : ''
|
|
}
|
|
<button class="btn btn-outline-danger"
|
|
onclick="confirmDeleteUser('${user.name}')" title="사용자 삭제">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>`;
|
|
});
|
|
|
|
tableHTML += `
|
|
</tbody>
|
|
</table>
|
|
</div>`;
|
|
|
|
container.innerHTML = tableHTML;
|
|
}
|
|
|
|
// 통계 업데이트
|
|
function updateStats() {
|
|
const totalUsers = currentUsers.length;
|
|
const mappedUsers = currentUsers.filter(u => u.pharmacy !== null).length;
|
|
const unmappedUsers = totalUsers - mappedUsers;
|
|
const totalNodes = currentUsers.reduce((sum, u) => sum + (u.node_count || 0), 0);
|
|
|
|
document.getElementById('totalUsers').textContent = totalUsers;
|
|
document.getElementById('mappedUsers').textContent = mappedUsers;
|
|
document.getElementById('unmappedUsers').textContent = unmappedUsers;
|
|
document.getElementById('totalNodes').textContent = totalNodes;
|
|
}
|
|
|
|
// 새 사용자 모달 표시
|
|
function showCreateUserModal() {
|
|
const modal = new bootstrap.Modal(document.getElementById('createUserModal'));
|
|
document.getElementById('createUserForm').reset();
|
|
modal.show();
|
|
}
|
|
|
|
// 사용자 생성
|
|
function createUser() {
|
|
const userName = document.getElementById('userName').value.trim();
|
|
const displayName = document.getElementById('displayName').value.trim();
|
|
const email = document.getElementById('email').value.trim();
|
|
|
|
if (!userName) {
|
|
showToast('사용자 이름을 입력해주세요.', 'warning');
|
|
return;
|
|
}
|
|
|
|
const userData = {
|
|
name: userName,
|
|
display_name: displayName,
|
|
email: email
|
|
};
|
|
|
|
showToast('사용자 생성 중...', 'info');
|
|
|
|
fetch('/api/users', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(userData)
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
showToast(`사용자 "${userName}"가 성공적으로 생성되었습니다.`, 'success');
|
|
bootstrap.Modal.getInstance(document.getElementById('createUserModal')).hide();
|
|
setTimeout(loadUsers, 1000);
|
|
} else {
|
|
showToast('사용자 생성 실패: ' + data.error, 'danger');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('사용자 생성 오류:', error);
|
|
showToast('사용자 생성 중 오류가 발생했습니다.', 'danger');
|
|
});
|
|
}
|
|
|
|
// 사용자 삭제 확인
|
|
function confirmDeleteUser(userName) {
|
|
if (confirm(`정말로 사용자 "${userName}"를 삭제하시겠습니까?\n\n삭제된 사용자의 모든 노드도 함께 제거됩니다.`)) {
|
|
deleteUser(userName);
|
|
}
|
|
}
|
|
|
|
// 사용자 삭제
|
|
function deleteUser(userName) {
|
|
showToast(`사용자 "${userName}" 삭제 중...`, 'info');
|
|
|
|
fetch(`/api/users/${userName}/delete`, {
|
|
method: 'DELETE'
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
showToast(`사용자 "${userName}"가 성공적으로 삭제되었습니다.`, 'success');
|
|
setTimeout(loadUsers, 1000);
|
|
} else {
|
|
showToast('사용자 삭제 실패: ' + data.error, 'danger');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('사용자 삭제 오류:', error);
|
|
showToast('사용자 삭제 중 오류가 발생했습니다.', 'danger');
|
|
});
|
|
}
|
|
|
|
// 약국 연결 모달 표시
|
|
function showLinkPharmacyModal(userName) {
|
|
document.getElementById('linkUserName').textContent = userName;
|
|
selectedUserId = userName;
|
|
|
|
// 매핑 가능한 약국 목록을 API에서 가져오기
|
|
const select = document.getElementById('pharmacySelect');
|
|
select.innerHTML = '<option value="">로딩 중...</option>';
|
|
|
|
fetch('/api/pharmacies/available')
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
select.innerHTML = '<option value="">약국을 선택하세요</option>';
|
|
|
|
if (data.pharmacies.length === 0) {
|
|
select.innerHTML = '<option value="">매핑 가능한 약국이 없습니다</option>';
|
|
} else {
|
|
data.pharmacies.forEach(pharmacy => {
|
|
select.innerHTML += `<option value="${pharmacy.id}">${pharmacy.pharmacy_name} (${pharmacy.manager_name})</option>`;
|
|
});
|
|
}
|
|
} else {
|
|
select.innerHTML = '<option value="">약국 목록 로드 실패</option>';
|
|
showToast('약국 목록을 불러오는데 실패했습니다.', 'error');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('약국 목록 로드 오류:', error);
|
|
select.innerHTML = '<option value="">약국 목록 로드 실패</option>';
|
|
showToast('약국 목록을 불러오는데 실패했습니다.', 'error');
|
|
});
|
|
|
|
const modal = new bootstrap.Modal(document.getElementById('linkPharmacyModal'));
|
|
modal.show();
|
|
}
|
|
|
|
// 약국 연결
|
|
function linkPharmacy() {
|
|
const pharmacyId = document.getElementById('pharmacySelect').value;
|
|
if (!pharmacyId) {
|
|
showToast('약국을 선택해주세요.', 'warning');
|
|
return;
|
|
}
|
|
|
|
if (!selectedUserId) {
|
|
showToast('사용자 정보가 없습니다.', 'error');
|
|
return;
|
|
}
|
|
|
|
showToast('약국 연결 중...', 'info');
|
|
|
|
fetch(`/api/users/${selectedUserId}/link-pharmacy`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
pharmacy_id: parseInt(pharmacyId)
|
|
})
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
showToast(data.message, 'success');
|
|
bootstrap.Modal.getInstance(document.getElementById('linkPharmacyModal')).hide();
|
|
setTimeout(loadUsers, 1000);
|
|
} else {
|
|
showToast('약국 연결 실패: ' + data.error, 'danger');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('약국 연결 오류:', error);
|
|
showToast('약국 연결 중 오류가 발생했습니다.', 'danger');
|
|
});
|
|
}
|
|
|
|
// 목록 새로고침
|
|
function refreshUserList() {
|
|
showToast('사용자 목록을 새로고침 중...', 'info');
|
|
loadUsers();
|
|
}
|
|
</script>
|
|
{% endblock %} |