diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 7afc481..2712a09 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -21,6 +21,7 @@ import { PublicPass, Team, VisitRequestCreate, + VisitRequestResetResult, VisitRequestView, AdminUser, Watcher1Settings, @@ -194,6 +195,8 @@ export const runSmsDiagnostics = () => request('/admin/sms/diagnostics', { method: 'POST' }); export const runEmailDiagnostics = () => request('/admin/email/diagnostics', { method: 'POST' }); +export const resetVisitRequestTestData = (confirm: string) => + request('/admin/visit-requests/reset-test-data', jsonInit('POST', { confirm })); export const listAdminUsers = () => request('/admin/users'); export const updateAdminUserRoles = (id: number, roles: Role[]) => request(`/admin/users/${id}/roles`, jsonInit('PUT', { roles })); diff --git a/frontend/src/pages/AdminManagementPage.tsx b/frontend/src/pages/AdminManagementPage.tsx index f3ed875..0586ea2 100644 --- a/frontend/src/pages/AdminManagementPage.tsx +++ b/frontend/src/pages/AdminManagementPage.tsx @@ -14,14 +14,15 @@ import { updateAdminUserRoles, runEmailDiagnostics, runSmsDiagnostics, + resetVisitRequestTestData, uploadAdminTeams, uploadAdminUserRoles, updateWatcher1Settings, getWatcher1Settings, } from '../api'; -import { AdminExcelImportResult, AdminUser, EmailDiagnosticsResult, PurposeCode, Role, SmsDiagnosticsResult, Team, Watcher1Settings } from '../types'; +import { AdminExcelImportResult, AdminUser, EmailDiagnosticsResult, PurposeCode, Role, SmsDiagnosticsResult, Team, VisitRequestResetResult, Watcher1Settings } from '../types'; -type Tab = 'watcher' | 'purpose' | 'teams' | 'roles' | 'sms' | 'email'; +type Tab = 'watcher' | 'purpose' | 'teams' | 'roles' | 'sms' | 'email' | 'data'; const EMPTY_PURPOSE = { code: '', @@ -141,6 +142,8 @@ export const AdminManagementPage: React.FC = () => { const [userRoleUploadResult, setUserRoleUploadResult] = useState(null); const [smsDiagnostics, setSmsDiagnostics] = useState(null); const [emailDiagnostics, setEmailDiagnostics] = useState(null); + const [resetConfirm, setResetConfirm] = useState(''); + const [resetResult, setResetResult] = useState(null); const [notice, setNotice] = useState(null); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); @@ -203,6 +206,27 @@ export const AdminManagementPage: React.FC = () => { } }; + const resetVisitData = async () => { + if (resetConfirm.trim() !== '초기화') { + setError('확인 입력란에 "초기화"를 입력하세요.'); + return; + } + setBusy(true); + setError(null); + setNotice(null); + setResetResult(null); + try { + const result = await resetVisitRequestTestData(resetConfirm); + setResetResult(result); + setResetConfirm(''); + setNotice(`출입신청 테스트 데이터 초기화 완료: 신청 ${result.visitRequests}건 삭제`); + } catch (err) { + setError(err instanceof Error ? err.message : '테스트 데이터 초기화 실패'); + } finally { + setBusy(false); + } + }; + const editPurpose = (p: PurposeCode) => { setEditingPurposeId(p.id); setPurposeForm({ @@ -440,6 +464,7 @@ export const AdminManagementPage: React.FC = () => { + {tab === 'watcher' && ( @@ -616,6 +641,43 @@ export const AdminManagementPage: React.FC = () => { )} + {tab === 'data' && ( +
+
+

출입신청 테스트 데이터 초기화

+
+

+ 출입신청, 승인 기록, 입/퇴장 기록, 출입증 발송 기록을 삭제합니다. 사용자, 팀, 권한, 출입목적 코드, 시스템 설정, 블랙리스트는 유지됩니다. +

+
+ setResetConfirm(e.target.value)} + placeholder="초기화" + /> + +
+ {resetResult && ( + + + + + + + + +
출입신청{resetResult.visitRequests}건
방문자{resetResult.visitors}건
승인 기록{resetResult.approvals}건
입/퇴장 기록{resetResult.accessEvents}건
출입증 발송 기록{resetResult.passDeliveries}건
+ )} +
+ )} + {tab === 'sms' && (
diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 27eaec9..2a66972 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -197,6 +197,14 @@ export interface AdminExcelImportResult { rows: AdminExcelImportRow[]; } +export interface VisitRequestResetResult { + visitRequests: number; + visitors: number; + approvals: number; + accessEvents: number; + passDeliveries: number; +} + export interface InsideVisitor { visitRequestId: number; visitorName: string; diff --git a/server/routes/admin.ts b/server/routes/admin.ts index c264d80..33416b6 100644 --- a/server/routes/admin.ts +++ b/server/routes/admin.ts @@ -62,6 +62,14 @@ interface ImportResult { rows: ImportRow[]; } +interface VisitRequestResetResult { + visitRequests: number; + visitors: number; + approvals: number; + accessEvents: number; + passDeliveries: number; +} + interface AdminRouterDeps { query: QueryFn; } @@ -481,6 +489,50 @@ export function createAdminRouter(deps: AdminRouterDeps = { query }): Router { ok(res, await diagnoseEmailConnectivity()); })); + router.post('/visit-requests/reset-test-data', asyncRoute(async (req, res) => { + const actor = await requireAdmin(dbQuery, req); + if (String(req.body?.confirm ?? '').trim() !== '초기화') { + throw new ApiError(400, '확인 입력란에 "초기화"를 입력하세요.'); + } + + const targetRequests = await dbQuery<{ id: string; visitor_id: string }>( + 'SELECT id, visitor_id FROM visit_requests', + ); + const visitorIds = [...new Set(targetRequests.rows.map((row) => Number(row.visitor_id)))]; + + const passDeliveries = await dbQuery<{ id: string }>('DELETE FROM pass_deliveries RETURNING id'); + const approvals = await dbQuery<{ id: string }>('DELETE FROM approvals RETURNING id'); + const accessEvents = await dbQuery<{ id: string }>('DELETE FROM access_events RETURNING id'); + const visitRequests = await dbQuery<{ id: string }>('DELETE FROM visit_requests RETURNING id'); + const visitors = visitorIds.length > 0 + ? await dbQuery<{ id: string }>( + ` + DELETE FROM visitors v + WHERE v.id = ANY($1::bigint[]) + AND NOT EXISTS (SELECT 1 FROM visit_requests vr WHERE vr.visitor_id = v.id) + RETURNING id + `, + [visitorIds], + ) + : { rows: [] }; + + const result: VisitRequestResetResult = { + visitRequests: visitRequests.rows.length, + visitors: visitors.rows.length, + approvals: approvals.rows.length, + accessEvents: accessEvents.rows.length, + passDeliveries: passDeliveries.rows.length, + }; + await audit( + dbQuery, + actor, + 'VISIT_REQUEST', + null, + `출입신청 테스트 데이터 초기화: 신청 ${result.visitRequests}건, 방문자 ${result.visitors}건, 승인 ${result.approvals}건, 입퇴장 ${result.accessEvents}건, 발송 ${result.passDeliveries}건`, + ); + ok(res, result); + })); + router.get('/teams', asyncRoute(async (req, res) => { await requireAdmin(dbQuery, req); ok(res, await listTeams(dbQuery));