diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 95d5a67..15cbd5d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,6 +15,8 @@ import { PublicPassPage } from './pages/PublicPassPage'; import { KioskPage } from './pages/KioskPage'; import { BlacklistPage } from './pages/BlacklistPage'; 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. */ const Protected: React.FC<{ roles?: Role[]; children: React.ReactNode }> = ({ roles, children }) => { @@ -81,6 +83,22 @@ export default function App() { } /> + + + + } + /> + + + + } + /> } /> } /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index b81bc72..ff8727b 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -2,13 +2,16 @@ import { AccessAction, AccessRecord, ApiResponse, + AuditLog, BlacklistCreate, BlacklistItem, ChangePasswordRequest, CurrentUser, + DeliveryStatus, ExcelImportResult, InsideVisitor, LoginRequest, + PassDelivery, StatsSummary, PublicPass, VisitRequestCreate, @@ -153,3 +156,12 @@ export const deleteBlacklist = (id: number) => // ===== Reports ===== export const reportDownloadUrl = (from: string, to: string) => `/api/reports/visits.xlsx?from=${from}&to=${to}`; + +// ===== Audit log (ADMIN) ===== +export const listAudit = () => request('/admin/audit'); + +// ===== Pass delivery outbox (ADMIN) ===== +export const listDeliveries = (status?: DeliveryStatus) => + request(`/admin/deliveries${status ? `?status=${status}` : ''}`); +export const retryDelivery = (id: number) => + request(`/admin/deliveries/${id}/retry`, { method: 'POST' }); diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index bf8561e..6720d75 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -34,6 +34,8 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {hasRole('HOST', 'SECURITY', 'ADMIN') && 출입콘솔} {hasRole('SECURITY', 'ADMIN') && 리포트} {hasRole('ADMIN') && 블랙리스트} + {hasRole('ADMIN') && 발송내역} + {hasRole('ADMIN') && 감사로그}
diff --git a/frontend/src/pages/AuditLogPage.tsx b/frontend/src/pages/AuditLogPage.tsx new file mode 100644 index 0000000..c2122a0 --- /dev/null +++ b/frontend/src/pages/AuditLogPage.tsx @@ -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 = { + APPROVE: '승인', + REJECT: '반려', + BLACKLIST_ADD: '블랙리스트 등록', + BLACKLIST_REMOVE: '블랙리스트 해제', +}; + +const ACTION_CLASS: Record = { + APPROVE: 'green', + REJECT: 'red', + BLACKLIST_ADD: 'red', + BLACKLIST_REMOVE: 'gray', +}; + +export const AuditLogPage: React.FC = () => { + const [items, setItems] = useState([]); + const [error, setError] = useState(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 ( +
+
+

감사 로그

+ +
+ {error &&
{error}
} + +
+

최근 관리 행위 ({items.length})

+ {items.length === 0 ? ( +

{loading ? '불러오는 중…' : '기록이 없습니다.'}

+ ) : ( + + + + + + {items.map((a) => ( + + + + + + + + ))} + +
시각수행자행위대상상세
{formatDateTime(a.at)}{a.actorUsername || '시스템'}{ACTION_LABEL[a.action] ?? a.action}{a.targetType ? `${a.targetType}${a.targetId != null ? ` #${a.targetId}` : ''}` : '-'}{a.detail || '-'}
+ )} +
+
+ ); +}; diff --git a/frontend/src/pages/DeliveryOutboxPage.tsx b/frontend/src/pages/DeliveryOutboxPage.tsx new file mode 100644 index 0000000..71eedf0 --- /dev/null +++ b/frontend/src/pages/DeliveryOutboxPage.tsx @@ -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([]); + const [filter, setFilter] = useState('FAILED'); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [retryingId, setRetryingId] = useState(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 ( +
+
+

출입증 발송 내역

+
+ + +
+
+ {error &&
{error}
} + +
+

발송 기록 ({items.length})

+ {items.length === 0 ? ( +

{loading ? '불러오는 중…' : '해당 조건의 발송 기록이 없습니다.'}

+ ) : ( + + + + + + + + + {items.map((d) => ( + + + + + + + + + + + ))} + +
시각방문신청채널수신처상태시도오류
{formatDateTime(d.updatedAt || d.createdAt)}#{d.visitRequestId}{d.channel || '-'}{d.recipient || '-'} + {d.status === 'SENT' ? '성공' : '실패'}{d.attempts}{d.lastError || '-'} + {d.status === 'FAILED' && ( + + )} +
+ )} +
+
+ ); +}; diff --git a/frontend/src/styles/common.css b/frontend/src/styles/common.css index 0b2e922..136a7f5 100644 --- a/frontend/src/styles/common.css +++ b/frontend/src/styles/common.css @@ -302,3 +302,9 @@ button:disabled { opacity: .55; cursor: not-allowed; } .form-grid { grid-template-columns: 1fr; } .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); } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 1aed075..adfd516 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -143,3 +143,28 @@ export interface BlacklistCreate { contact?: 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; +}