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:
unknown
2026-07-03 16:03:51 +09:00
parent f7aaeaba2a
commit 17d6036bca
7 changed files with 222 additions and 0 deletions

View 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>
);
};

View 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>
);
};