기능 추가 — 옵저버 계정 및 현황 조회 포털

읽기 전용 옵저버 역할 추가. 신고 현황 확인만 가능하며 모든 쓰기 동작 차단.

- auth.py: require_viewer(admin+observer) 의존성 추가
- auth_router.py: register 엔드포인트에 role 파라미터 추가 (mechanic/observer)
- login.html: 회원가입 시 정비사/옵저버 역할 카드 선택 UI, 역할별 안내문구
- 로그인 후 observer → /pages/observer/dashboard.html 라우팅
- observer/dashboard.html: 통계 카드(상태별 건수) + 신고 현황 테이블(읽기전용)
- observer/reports.html: 상태·충전기ID·충전소명 필터 신고 목록
- accounts.html: 옵저버 필터·생성·승인 대기 역할 표시 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
byun
2026-06-01 15:25:47 +09:00
parent 81c3428aa1
commit 124ad0d165
7 changed files with 274 additions and 7 deletions

View File

@@ -87,3 +87,4 @@ def require_role(*roles):
require_admin = require_role("admin")
require_mechanic = require_role("mechanic", "admin")
require_manufacturer = require_role("manufacturer", "admin")
require_viewer = require_role("admin", "observer") # 읽기 전용 역할 포함

View File

@@ -35,14 +35,17 @@ def register(
name: str = Form(...),
phone: str = Form(""),
company: str = Form(""),
role: str = Form("mechanic"), # mechanic | observer
db: Session = Depends(get_db)
):
if role not in ("mechanic", "observer"):
role = "mechanic"
if db.query(models.User).filter_by(username=username).first():
raise HTTPException(400, "이미 사용 중인 아이디입니다.")
user = models.User(
username=username,
password_hash=hash_password(password),
role="mechanic",
role=role,
name=name,
phone=phone or None,
company=company or None,

View File

@@ -75,7 +75,7 @@ const Auth = (() => {
pending_approval: '승인대기', pending: '접수', in_progress: '처리중',
done: '완료', waiting: '부품대기', revisit: '재방문', closed: '상황종료',
registered: '등록', reviewing: '검토중', developing: '개발중',
deployed: '배포완료',
deployed: '배포완료', observer: '옵저버',
};
return `<span class="badge s-${status}">${map[status] || status}</span>`;
}

View File

@@ -55,6 +55,7 @@
<div style="display:flex;gap:10px;margin-bottom:14px;align-items:center;flex-wrap:wrap;">
<select id="fRole" onchange="load()" style="width:auto">
<option value="">전체</option><option value="mechanic">정비사</option>
<option value="observer">옵저버</option>
<option value="manufacturer">제조사</option><option value="admin">관리자</option>
</select>
<label style="display:flex;align-items:center;gap:6px;font-size:13px;cursor:pointer;">
@@ -80,6 +81,7 @@
<div class="form-group"><label>역할 <span class="req">*</span></label>
<select id="eRole" onchange="toggleFields()">
<option value="mechanic">정비사</option>
<option value="observer">옵저버</option>
<option value="manufacturer">제조사</option>
<option value="admin">관리자</option>
</select>
@@ -130,7 +132,7 @@ async function bulkDelete() {
catch(e) { alert('처리 중 오류가 발생했습니다: ' + e.message); }
}
const ROLE_LABEL = {admin:'관리자',mechanic:'정비사',manufacturer:'제조사'};
const ROLE_LABEL = {admin:'관리자',mechanic:'정비사',manufacturer:'제조사',observer:'옵저버'};
async function loadPending() {
const all = await API.get('/accounts');
@@ -141,7 +143,7 @@ async function loadPending() {
document.getElementById('pendingBadge').textContent = pending.length + '명 대기 중';
document.getElementById('pendingTbody').innerHTML = pending.map(u => `
<tr>
<td><strong>${u.name}</strong></td>
<td><strong>${u.name}</strong> <span style="font-size:11px;background:#F3F4F6;color:#374151;padding:1px 7px;border-radius:8px;font-weight:600;">${ROLE_LABEL[u.role]||u.role}</span></td>
<td style="color:var(--gray4)">${u.username}</td>
<td>${u.company ? `<span style="background:#EFF6FF;color:#1E40AF;font-size:11px;font-weight:600;padding:2px 8px;border-radius:8px">${u.company}</span>` : '<span style="color:var(--gray4)">-</span>'}</td>
<td>${u.phone||'-'}</td>

View File

@@ -65,7 +65,23 @@ body { display:flex; align-items:center; justify-content:center; min-height:100v
<!-- 회원가입 -->
<div class="pane" id="paneRegister">
<div class="reg-notice">
<div class="form-group" style="margin-bottom:12px">
<label>계정 유형 <span style="color:var(--red)">*</span></label>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:4px">
<label id="roleCardMechanic" onclick="selectRole('mechanic')" style="border:2px solid var(--accent);border-radius:8px;padding:10px 12px;cursor:pointer;text-align:center;transition:all .15s;background:#E3EDFF;">
<div style="font-size:18px;margin-bottom:2px">🔧</div>
<div style="font-size:13px;font-weight:700;color:var(--navy)">정비사</div>
<div style="font-size:11px;color:var(--gray4)">조치 입력·처리</div>
</label>
<label id="roleCardObserver" onclick="selectRole('observer')" style="border:2px solid var(--gray3);border-radius:8px;padding:10px 12px;cursor:pointer;text-align:center;transition:all .15s;">
<div style="font-size:18px;margin-bottom:2px">👁</div>
<div style="font-size:13px;font-weight:700;color:var(--navy)">옵저버</div>
<div style="font-size:11px;color:var(--gray4)">현황 조회만 가능</div>
</label>
</div>
<input type="hidden" id="regRole" value="mechanic">
</div>
<div id="regNotice" class="reg-notice">
📌 정비사 계정으로 가입됩니다.<br>
가입 후 <strong>관리자 승인</strong>이 완료되어야 로그인 가능합니다.
</div>
@@ -133,6 +149,7 @@ async function doLogin() {
Auth.save(data.access_token, data.role, data.name, data.user_id);
if (data.role === 'admin') location.href = '/pages/admin/dashboard.html';
else if (data.role === 'mechanic') location.href = '/pages/mechanic/dashboard.html';
else if (data.role === 'observer') location.href = '/pages/observer/dashboard.html';
else location.href = '/pages/manufacturer/dashboard.html';
} catch(e) {
document.getElementById('err').textContent = e.message;
@@ -140,6 +157,22 @@ async function doLogin() {
}
}
// ── 계정 유형 선택 ──
function selectRole(role) {
document.getElementById('regRole').value = role;
const mc = document.getElementById('roleCardMechanic');
const oc = document.getElementById('roleCardObserver');
if (role === 'mechanic') {
mc.style.cssText = 'border:2px solid var(--accent);border-radius:8px;padding:10px 12px;cursor:pointer;text-align:center;transition:all .15s;background:#E3EDFF;';
oc.style.cssText = 'border:2px solid var(--gray3);border-radius:8px;padding:10px 12px;cursor:pointer;text-align:center;transition:all .15s;';
document.getElementById('regNotice').innerHTML = '📌 정비사 계정으로 가입됩니다.<br>가입 후 <strong>관리자 승인</strong>이 완료되어야 로그인 가능합니다.';
} else {
oc.style.cssText = 'border:2px solid var(--accent);border-radius:8px;padding:10px 12px;cursor:pointer;text-align:center;transition:all .15s;background:#E3EDFF;';
mc.style.cssText = 'border:2px solid var(--gray3);border-radius:8px;padding:10px 12px;cursor:pointer;text-align:center;transition:all .15s;';
document.getElementById('regNotice').innerHTML = '👁 현황 조회 전용 계정입니다.<br>신고 등록·조치 등 쓰기 기능은 사용할 수 없습니다.<br>가입 후 <strong>관리자 승인</strong>이 완료되어야 로그인 가능합니다.';
}
}
// ── 제조사 목록 로드 (비인증) ──
async function loadCompanies() {
try {
@@ -178,6 +211,7 @@ async function doRegister() {
fd.append('name', name);
fd.append('phone', phone);
fd.append('company', company);
fd.append('role', document.getElementById('regRole').value);
const res = await fetch('/api/auth/register', { method:'POST', body: fd });
if (!res.ok) { const e = await res.json(); throw new Error(e.detail); }
document.getElementById('regOk').style.display = 'block';

View File

@@ -0,0 +1,124 @@
<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>현황 조회</title>
<link rel="stylesheet" href="/css/style.css">
<style>
.ro-badge{display:inline-block;padding:2px 10px;border-radius:10px;font-size:11px;font-weight:700;background:#EDE9FE;color:#5B21B6;margin-left:8px;vertical-align:middle;}
.stat-link{text-decoration:none;display:block;}
.stat-link:hover .stat{box-shadow:0 4px 16px rgba(0,0,0,.12);transform:translateY(-1px);transition:all .15s;}
.filter-bar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:14px;}
.filter-bar select,.filter-bar input{padding:7px 10px;border:1px solid var(--gray3);border-radius:6px;font-size:13px;font-family:inherit;color:var(--text);}
</style>
</head>
<body>
<nav class="nav">
<span class="nav-brand">⚡ EV AS 관리 <span class="ro-badge">👁 읽기전용</span></span>
<div id="navUser"></div>
</nav>
<div class="layout">
<div class="sidebar">
<div class="sidebar-section">메뉴</div>
<a href="/pages/observer/dashboard.html" class="active">📊 현황 대시보드</a>
<a href="/pages/observer/reports.html">📋 신고 목록</a>
</div>
<div class="main">
<!-- 통계 카드 -->
<div class="stats" id="statsRow" style="margin-bottom:24px">
<div class="stat"><div class="stat-num" id="sTotal">-</div><div class="stat-label">전체 신고</div></div>
<div class="stat warn"><div class="stat-num" id="sPendingApproval">-</div><div class="stat-label">승인대기</div></div>
<div class="stat warn"><div class="stat-num" id="sPending">-</div><div class="stat-label">접수</div></div>
<div class="stat warn"><div class="stat-num" id="sInProgress">-</div><div class="stat-label">처리중</div></div>
<div class="stat good"><div class="stat-num" id="sDone">-</div><div class="stat-label">완료</div></div>
<div class="stat"><div class="stat-num" id="sClosed">-</div><div class="stat-label">상황종료</div></div>
</div>
<!-- 신고 목록 -->
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px;flex-wrap:wrap;gap:8px;">
<div class="card-title" style="margin:0">📋 신고 현황</div>
<div class="filter-bar" style="margin:0">
<select id="fStatus" onchange="load()">
<option value="">전체 상태</option>
<option value="pending_approval">승인대기</option>
<option value="pending">접수</option>
<option value="in_progress">처리중</option>
<option value="waiting">부품대기</option>
<option value="revisit">재방문</option>
<option value="done">완료</option>
<option value="closed">상황종료</option>
</select>
<input type="text" id="fCharger" placeholder="충전기ID 검색" style="width:130px" oninput="load()">
</div>
</div>
<div class="tbl-wrap">
<table>
<thead><tr>
<th>접수번호</th><th>충전기ID</th><th>충전소명</th><th>문제유형</th>
<th>에러코드</th><th>신고일시</th><th>정비사</th><th>상태</th>
</tr></thead>
<tbody id="tbody"></tbody>
</table>
<div id="empty" style="display:none;text-align:center;padding:40px;color:var(--gray4);font-size:13px">신고 내역이 없습니다.</div>
</div>
</div>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/auth.js"></script>
<script>
Auth.require(['observer', 'admin']);
Auth.renderNav(document.getElementById('navUser'));
let _allReports = [];
async function loadStats() {
try {
const s = await API.get('/stats');
document.getElementById('sTotal').textContent = s.total ?? '-';
document.getElementById('sPendingApproval').textContent = s.pending_approval ?? 0;
document.getElementById('sPending').textContent = s.pending ?? 0;
document.getElementById('sInProgress').textContent = s.in_progress ?? 0;
document.getElementById('sDone').textContent = s.done ?? 0;
document.getElementById('sClosed').textContent = s.closed ?? 0;
} catch(e) { console.error(e); }
}
async function load() {
const status = document.getElementById('fStatus').value;
const chargerId = document.getElementById('fCharger').value.trim();
try {
let url = '/reports';
if (status) url += '?status=' + status;
_allReports = await API.get(url);
} catch(e) { _allReports = []; }
render(chargerId);
}
function render(chargerId) {
let rows = _allReports;
if (chargerId) rows = rows.filter(r => r.charger_id?.includes(chargerId));
const tbody = document.getElementById('tbody');
const empty = document.getElementById('empty');
if (!rows.length) { tbody.innerHTML = ''; empty.style.display = 'block'; return; }
empty.style.display = 'none';
tbody.innerHTML = rows.map(r => `
<tr style="cursor:default">
<td>#${r.id}</td>
<td><strong>${r.charger_id}</strong></td>
<td>${r.station_name || '-'}</td>
<td>${(r.issue_types || []).join(', ') || '-'}</td>
<td>${r.error_code || '-'}</td>
<td>${Auth.fmtDt(r.reported_at)}</td>
<td>${r.mechanic_name || '-'}</td>
<td>${Auth.statusBadge(r.status)}</td>
</tr>`).join('');
}
loadStats();
load();
</script>
</body></html>

View File

@@ -0,0 +1,103 @@
<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>신고 목록</title>
<link rel="stylesheet" href="/css/style.css">
<style>
.ro-badge{display:inline-block;padding:2px 10px;border-radius:10px;font-size:11px;font-weight:700;background:#EDE9FE;color:#5B21B6;margin-left:8px;vertical-align:middle;}
</style>
</head>
<body>
<nav class="nav">
<span class="nav-brand">⚡ EV AS 관리 <span class="ro-badge">👁 읽기전용</span></span>
<div id="navUser"></div>
</nav>
<div class="layout">
<div class="sidebar">
<div class="sidebar-section">메뉴</div>
<a href="/pages/observer/dashboard.html">📊 현황 대시보드</a>
<a href="/pages/observer/reports.html" class="active">📋 신고 목록</a>
</div>
<div class="main">
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;flex-wrap:wrap;gap:8px;">
<div class="card-title" style="margin:0">📋 신고 목록 <span id="totalBadge" style="font-size:12px;color:var(--gray4);font-weight:400"></span></div>
</div>
<!-- 필터 -->
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;">
<select id="fStatus" onchange="load()" style="padding:7px 10px;border:1px solid var(--gray3);border-radius:6px;font-size:13px;font-family:inherit;">
<option value="">전체 상태</option>
<option value="pending_approval">승인대기</option>
<option value="pending">접수</option>
<option value="in_progress">처리중</option>
<option value="waiting">부품대기</option>
<option value="revisit">재방문</option>
<option value="done">완료</option>
<option value="closed">상황종료</option>
</select>
<input type="text" id="fCharger" placeholder="충전기ID" style="padding:7px 10px;border:1px solid var(--gray3);border-radius:6px;font-size:13px;width:130px" oninput="render()">
<input type="text" id="fStation" placeholder="충전소명" style="padding:7px 10px;border:1px solid var(--gray3);border-radius:6px;font-size:13px;width:130px" oninput="render()">
</div>
<div class="tbl-wrap">
<table>
<thead><tr>
<th>접수번호</th><th>충전기ID</th><th>충전소명</th><th>CPO</th>
<th>문제유형</th><th>에러코드</th><th>신고일시</th>
<th>정비사</th><th>조치완료</th><th>상태</th>
</tr></thead>
<tbody id="tbody"></tbody>
</table>
<div id="empty" style="display:none;text-align:center;padding:40px;color:var(--gray4);font-size:13px">신고 내역이 없습니다.</div>
</div>
</div>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/auth.js"></script>
<script>
Auth.require(['observer', 'admin']);
Auth.renderNav(document.getElementById('navUser'));
let _allReports = [];
async function load() {
const status = document.getElementById('fStatus').value;
try {
_allReports = await API.get('/reports' + (status ? '?status=' + status : ''));
} catch(e) { _allReports = []; }
render();
}
function render() {
const cid = document.getElementById('fCharger').value.trim().toLowerCase();
const station = document.getElementById('fStation').value.trim().toLowerCase();
let rows = _allReports;
if (cid) rows = rows.filter(r => r.charger_id?.toLowerCase().includes(cid));
if (station) rows = rows.filter(r => r.station_name?.toLowerCase().includes(station));
document.getElementById('totalBadge').textContent = `(${rows.length}건)`;
const tbody = document.getElementById('tbody');
const empty = document.getElementById('empty');
if (!rows.length) { tbody.innerHTML = ''; empty.style.display = 'block'; return; }
empty.style.display = 'none';
tbody.innerHTML = rows.map(r => `
<tr style="cursor:default">
<td>#${r.id}</td>
<td><strong>${r.charger_id}</strong></td>
<td>${r.station_name || '-'}</td>
<td>${r.cpo_name || '-'}</td>
<td>${(r.issue_types || []).join(', ') || '-'}</td>
<td>${r.error_code || '-'}</td>
<td>${Auth.fmtDt(r.reported_at)}</td>
<td>${r.mechanic_name || '-'}</td>
<td>${r.mechanic_name && r.status === 'done' ? '완료' : '-'}</td>
<td>${Auth.statusBadge(r.status)}</td>
</tr>`).join('');
}
load();
</script>
</body></html>