feat(web): 감사 로그·발송 내역 관리 화면 추가
- AuditLogPage(/audit): 최근 관리 행위(승인/반려·블랙리스트) 조회. - DeliveryOutboxPage(/deliveries): 출입증 발송 내역 조회(성공/실패/전체 필터) + 실패건 수동 재발송. - api.ts/types에 listAudit·listDeliveries·retryDelivery + AuditLog·PassDelivery 타입 추가. - Layout 네비(ADMIN)와 App 라우팅(ADMIN 가드) 연결, common.css에 row-gap·cell-error 유틸 추가. 검증: tsc+vite 빌드 통과. 런타임 승인 후 GET /api/admin/audit·/deliveries 응답이 타입과 일치 확인. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,8 @@ import { PublicPassPage } from './pages/PublicPassPage';
|
|||||||
import { KioskPage } from './pages/KioskPage';
|
import { KioskPage } from './pages/KioskPage';
|
||||||
import { BlacklistPage } from './pages/BlacklistPage';
|
import { BlacklistPage } from './pages/BlacklistPage';
|
||||||
import { ReportPage } from './pages/ReportPage';
|
import { ReportPage } from './pages/ReportPage';
|
||||||
|
import { AuditLogPage } from './pages/AuditLogPage';
|
||||||
|
import { DeliveryOutboxPage } from './pages/DeliveryOutboxPage';
|
||||||
|
|
||||||
/** Requires a logged-in user; optionally one of the given roles. */
|
/** Requires a logged-in user; optionally one of the given roles. */
|
||||||
const Protected: React.FC<{ roles?: Role[]; children: React.ReactNode }> = ({ roles, children }) => {
|
const Protected: React.FC<{ roles?: Role[]; children: React.ReactNode }> = ({ roles, children }) => {
|
||||||
@@ -81,6 +83,22 @@ export default function App() {
|
|||||||
</Protected>
|
</Protected>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/audit"
|
||||||
|
element={
|
||||||
|
<Protected roles={['ADMIN']}>
|
||||||
|
<AuditLogPage />
|
||||||
|
</Protected>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/deliveries"
|
||||||
|
element={
|
||||||
|
<Protected roles={['ADMIN']}>
|
||||||
|
<DeliveryOutboxPage />
|
||||||
|
</Protected>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
|||||||
@@ -2,13 +2,16 @@ import {
|
|||||||
AccessAction,
|
AccessAction,
|
||||||
AccessRecord,
|
AccessRecord,
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
|
AuditLog,
|
||||||
BlacklistCreate,
|
BlacklistCreate,
|
||||||
BlacklistItem,
|
BlacklistItem,
|
||||||
ChangePasswordRequest,
|
ChangePasswordRequest,
|
||||||
CurrentUser,
|
CurrentUser,
|
||||||
|
DeliveryStatus,
|
||||||
ExcelImportResult,
|
ExcelImportResult,
|
||||||
InsideVisitor,
|
InsideVisitor,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
|
PassDelivery,
|
||||||
StatsSummary,
|
StatsSummary,
|
||||||
PublicPass,
|
PublicPass,
|
||||||
VisitRequestCreate,
|
VisitRequestCreate,
|
||||||
@@ -153,3 +156,12 @@ export const deleteBlacklist = (id: number) =>
|
|||||||
// ===== Reports =====
|
// ===== Reports =====
|
||||||
export const reportDownloadUrl = (from: string, to: string) =>
|
export const reportDownloadUrl = (from: string, to: string) =>
|
||||||
`/api/reports/visits.xlsx?from=${from}&to=${to}`;
|
`/api/reports/visits.xlsx?from=${from}&to=${to}`;
|
||||||
|
|
||||||
|
// ===== Audit log (ADMIN) =====
|
||||||
|
export const listAudit = () => request<AuditLog[]>('/admin/audit');
|
||||||
|
|
||||||
|
// ===== Pass delivery outbox (ADMIN) =====
|
||||||
|
export const listDeliveries = (status?: DeliveryStatus) =>
|
||||||
|
request<PassDelivery[]>(`/admin/deliveries${status ? `?status=${status}` : ''}`);
|
||||||
|
export const retryDelivery = (id: number) =>
|
||||||
|
request<PassDelivery>(`/admin/deliveries/${id}/retry`, { method: 'POST' });
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
|||||||
{hasRole('HOST', 'SECURITY', 'ADMIN') && <NavLink to="/access">출입콘솔</NavLink>}
|
{hasRole('HOST', 'SECURITY', 'ADMIN') && <NavLink to="/access">출입콘솔</NavLink>}
|
||||||
{hasRole('SECURITY', 'ADMIN') && <NavLink to="/reports">리포트</NavLink>}
|
{hasRole('SECURITY', 'ADMIN') && <NavLink to="/reports">리포트</NavLink>}
|
||||||
{hasRole('ADMIN') && <NavLink to="/blacklist">블랙리스트</NavLink>}
|
{hasRole('ADMIN') && <NavLink to="/blacklist">블랙리스트</NavLink>}
|
||||||
|
{hasRole('ADMIN') && <NavLink to="/deliveries">발송내역</NavLink>}
|
||||||
|
{hasRole('ADMIN') && <NavLink to="/audit">감사로그</NavLink>}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="user-box">
|
<div className="user-box">
|
||||||
<span className="user-name">
|
<span className="user-name">
|
||||||
|
|||||||
68
frontend/src/pages/AuditLogPage.tsx
Normal file
68
frontend/src/pages/AuditLogPage.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { listAudit } from '../api';
|
||||||
|
import { AuditLog } from '../types';
|
||||||
|
import { formatDateTime } from '../status';
|
||||||
|
|
||||||
|
const ACTION_LABEL: Record<AuditLog['action'], string> = {
|
||||||
|
APPROVE: '승인',
|
||||||
|
REJECT: '반려',
|
||||||
|
BLACKLIST_ADD: '블랙리스트 등록',
|
||||||
|
BLACKLIST_REMOVE: '블랙리스트 해제',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTION_CLASS: Record<AuditLog['action'], string> = {
|
||||||
|
APPROVE: 'green',
|
||||||
|
REJECT: 'red',
|
||||||
|
BLACKLIST_ADD: 'red',
|
||||||
|
BLACKLIST_REMOVE: 'gray',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AuditLogPage: React.FC = () => {
|
||||||
|
const [items, setItems] = useState<AuditLog[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const load = () => {
|
||||||
|
setLoading(true);
|
||||||
|
listAudit()
|
||||||
|
.then(setItems)
|
||||||
|
.catch((e) => setError(e instanceof Error ? e.message : '조회 실패'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(load, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="page-head">
|
||||||
|
<h2>감사 로그</h2>
|
||||||
|
<button className="btn-ghost" onClick={load} disabled={loading}>새로고침</button>
|
||||||
|
</div>
|
||||||
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h3>최근 관리 행위 ({items.length})</h3>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="muted">{loading ? '불러오는 중…' : '기록이 없습니다.'}</p>
|
||||||
|
) : (
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>시각</th><th>수행자</th><th>행위</th><th>대상</th><th>상세</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((a) => (
|
||||||
|
<tr key={a.id}>
|
||||||
|
<td>{formatDateTime(a.at)}</td>
|
||||||
|
<td>{a.actorUsername || '시스템'}</td>
|
||||||
|
<td><span className={`badge badge-${ACTION_CLASS[a.action]}`}>{ACTION_LABEL[a.action] ?? a.action}</span></td>
|
||||||
|
<td>{a.targetType ? `${a.targetType}${a.targetId != null ? ` #${a.targetId}` : ''}` : '-'}</td>
|
||||||
|
<td>{a.detail || '-'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
91
frontend/src/pages/DeliveryOutboxPage.tsx
Normal file
91
frontend/src/pages/DeliveryOutboxPage.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { listDeliveries, retryDelivery } from '../api';
|
||||||
|
import { DeliveryStatus, PassDelivery } from '../types';
|
||||||
|
import { formatDateTime } from '../status';
|
||||||
|
|
||||||
|
type Filter = 'ALL' | DeliveryStatus;
|
||||||
|
|
||||||
|
export const DeliveryOutboxPage: React.FC = () => {
|
||||||
|
const [items, setItems] = useState<PassDelivery[]>([]);
|
||||||
|
const [filter, setFilter] = useState<Filter>('FAILED');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [retryingId, setRetryingId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const load = () => {
|
||||||
|
setLoading(true);
|
||||||
|
listDeliveries(filter === 'ALL' ? undefined : filter)
|
||||||
|
.then(setItems)
|
||||||
|
.catch((e) => setError(e instanceof Error ? e.message : '조회 실패'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(load, [filter]);
|
||||||
|
|
||||||
|
const onRetry = async (id: number) => {
|
||||||
|
setError(null);
|
||||||
|
setRetryingId(id);
|
||||||
|
try {
|
||||||
|
await retryDelivery(id);
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '재발송 실패');
|
||||||
|
} finally {
|
||||||
|
setRetryingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="page-head">
|
||||||
|
<h2>출입증 발송 내역</h2>
|
||||||
|
<div className="row-gap">
|
||||||
|
<select value={filter} onChange={(e) => setFilter(e.target.value as Filter)}>
|
||||||
|
<option value="FAILED">실패</option>
|
||||||
|
<option value="SENT">성공</option>
|
||||||
|
<option value="ALL">전체</option>
|
||||||
|
</select>
|
||||||
|
<button className="btn-ghost" onClick={load} disabled={loading}>새로고침</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h3>발송 기록 ({items.length})</h3>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="muted">{loading ? '불러오는 중…' : '해당 조건의 발송 기록이 없습니다.'}</p>
|
||||||
|
) : (
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>시각</th><th>방문신청</th><th>채널</th><th>수신처</th>
|
||||||
|
<th>상태</th><th>시도</th><th>오류</th><th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((d) => (
|
||||||
|
<tr key={d.id}>
|
||||||
|
<td>{formatDateTime(d.updatedAt || d.createdAt)}</td>
|
||||||
|
<td>#{d.visitRequestId}</td>
|
||||||
|
<td>{d.channel || '-'}</td>
|
||||||
|
<td>{d.recipient || '-'}</td>
|
||||||
|
<td><span className={`badge badge-${d.status === 'SENT' ? 'green' : 'red'}`}>
|
||||||
|
{d.status === 'SENT' ? '성공' : '실패'}</span></td>
|
||||||
|
<td>{d.attempts}</td>
|
||||||
|
<td className="cell-error" title={d.lastError || ''}>{d.lastError || '-'}</td>
|
||||||
|
<td>
|
||||||
|
{d.status === 'FAILED' && (
|
||||||
|
<button className="btn-link" onClick={() => onRetry(d.id)} disabled={retryingId === d.id}>
|
||||||
|
{retryingId === d.id ? '재발송 중…' : '재발송'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -302,3 +302,9 @@ button:disabled { opacity: .55; cursor: not-allowed; }
|
|||||||
.form-grid { grid-template-columns: 1fr; }
|
.form-grid { grid-template-columns: 1fr; }
|
||||||
.nav { display: none; }
|
.nav { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* inline row of controls (e.g. filter + refresh in page-head) */
|
||||||
|
.row-gap { display: flex; gap: 8px; align-items: center; }
|
||||||
|
|
||||||
|
/* long delivery error text: keep the row compact, reveal full text on hover (title attr) */
|
||||||
|
.cell-error { max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--red, #c0392b); }
|
||||||
|
|||||||
@@ -143,3 +143,28 @@ export interface BlacklistCreate {
|
|||||||
contact?: string;
|
contact?: string;
|
||||||
reason: string;
|
reason: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AuditLog {
|
||||||
|
id: number;
|
||||||
|
at: string;
|
||||||
|
actorId?: number;
|
||||||
|
actorUsername?: string;
|
||||||
|
action: 'APPROVE' | 'REJECT' | 'BLACKLIST_ADD' | 'BLACKLIST_REMOVE';
|
||||||
|
targetType?: string;
|
||||||
|
targetId?: number;
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DeliveryStatus = 'SENT' | 'FAILED';
|
||||||
|
|
||||||
|
export interface PassDelivery {
|
||||||
|
id: number;
|
||||||
|
visitRequestId: number;
|
||||||
|
channel?: string;
|
||||||
|
recipient?: string;
|
||||||
|
status: DeliveryStatus;
|
||||||
|
attempts: number;
|
||||||
|
lastError?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user