diff --git a/frontend/src/api.ts b/frontend/src/api.ts index ee35bb0..7afc481 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -111,6 +111,9 @@ export const createVisitRequest = (req: VisitRequestCreate) => export const cancelVisitRequest = (id: number) => request(`/visit-requests/${id}/cancel`, { method: 'POST' }); +export const deleteVisitRequest = (id: number) => + request<{ deleted: boolean }>(`/visit-requests/${id}`, { method: 'DELETE' }); + export const uploadVisitRequests = (file: File) => { const form = new FormData(); form.append('file', file); diff --git a/frontend/src/pages/AuditLogPage.tsx b/frontend/src/pages/AuditLogPage.tsx index 8d44f1a..fa86d43 100644 --- a/frontend/src/pages/AuditLogPage.tsx +++ b/frontend/src/pages/AuditLogPage.tsx @@ -6,6 +6,7 @@ import { formatDateTime } from '../status'; const ACTION_LABEL: Record = { APPROVE: '승인', REJECT: '반려', + DELETE: '삭제', BLACKLIST_ADD: '블랙리스트 등록', BLACKLIST_REMOVE: '블랙리스트 해제', ADMIN_CONFIG_UPDATE: '시스템 설정', @@ -14,6 +15,7 @@ const ACTION_LABEL: Record = { const ACTION_CLASS: Record = { APPROVE: 'green', REJECT: 'red', + DELETE: 'red', BLACKLIST_ADD: 'red', BLACKLIST_REMOVE: 'gray', ADMIN_CONFIG_UPDATE: 'blue', diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 826599f..eafd479 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -40,8 +40,6 @@ export const DashboardPage: React.FC = () => { .finally(() => setLoading(false)); }, []); - const recent = items.slice(0, 8); - return (
@@ -60,10 +58,10 @@ export const DashboardPage: React.FC = () => {
-

최근 출입 신청

+

전체 출입 신청 ({items.length})

{loading ? (

불러오는 중…

- ) : recent.length === 0 ? ( + ) : items.length === 0 ? (

신청 내역이 없습니다.

) : ( @@ -73,7 +71,7 @@ export const DashboardPage: React.FC = () => { - {recent.map((r) => ( + {items.map((r) => ( { const [items, setItems] = useState([]); @@ -12,7 +13,9 @@ export const VisitRequestListPage: React.FC = () => { const [loading, setLoading] = useState(true); const fileRef = useRef(null); const [cancelingId, setCancelingId] = useState(null); + const [deleting, setDeleting] = useState(null); const navigate = useNavigate(); + const { hasRole } = useAuth(); const load = () => { setLoading(true); @@ -36,6 +39,26 @@ export const VisitRequestListPage: React.FC = () => { } }; + const confirmDelete = async (text?: string) => { + const target = deleting; + if (!target) return; + if (text !== '삭제') { + setDeleting(null); + setError('삭제하려면 확인 입력란에 "삭제"를 입력하세요.'); + return; + } + setDeleting(null); + setError(null); + setNotice(null); + try { + await deleteVisitRequest(target.id); + setNotice(`${target.visitorName} 신청을 삭제했습니다.`); + load(); + } catch (e) { + setError(e instanceof Error ? e.message : '삭제 실패'); + } + }; + const onUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; @@ -98,6 +121,9 @@ export const VisitRequestListPage: React.FC = () => { {(r.status === 'PENDING' || r.status === 'APPROVED') && ( )} + {hasRole('ADMIN') && ( + + )} ))} @@ -116,6 +142,19 @@ export const VisitRequestListPage: React.FC = () => { onCancel={() => setCancelingId(null)} /> )} + + {deleting != null && ( + setDeleting(null)} + /> + )} ); }; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 98aa5df..27eaec9 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -259,7 +259,7 @@ export interface AuditLog { at: string; actorId?: number; actorUsername?: string; - action: 'APPROVE' | 'REJECT' | 'BLACKLIST_ADD' | 'BLACKLIST_REMOVE' | 'ADMIN_CONFIG_UPDATE'; + action: 'APPROVE' | 'REJECT' | 'DELETE' | 'BLACKLIST_ADD' | 'BLACKLIST_REMOVE' | 'ADMIN_CONFIG_UPDATE'; targetType?: string; targetId?: number; detail?: string; diff --git a/server/routes/business.ts b/server/routes/business.ts index d2e205d..88a9dc2 100644 --- a/server/routes/business.ts +++ b/server/routes/business.ts @@ -18,6 +18,7 @@ interface BusinessRouterDeps { interface VisitRow { id: string; + visitor_id: string; visitor_name: string; company: string | null; contact: string | null; @@ -582,6 +583,33 @@ export function createBusinessRouter(deps: BusinessRouterDeps = { query }): Rout ok(res, toVisit((await visitById(dbQuery, Number(req.params.id)))!)); })); + router.delete('/visit-requests/:id', asyncRoute(async (req, res) => { + const actor = await requireAdmin(dbQuery, req); + const id = Number(req.params.id); + const visit = await visitById(dbQuery, id); + if (!visit) throw new ApiError(404, '방문 신청을 찾을 수 없습니다.'); + + const events = await dbQuery<{ count: string }>( + 'SELECT count(*) AS count FROM access_events WHERE visit_request_id = $1', + [id], + ); + if (Number(events.rows[0]?.count ?? 0) > 0) { + throw new ApiError(400, '실제 입/퇴장 기록이 있는 신청은 삭제할 수 없습니다.'); + } + + await dbQuery('DELETE FROM pass_deliveries WHERE visit_request_id = $1', [id]); + await dbQuery('DELETE FROM approvals WHERE visit_request_id = $1', [id]); + await dbQuery('DELETE FROM visit_requests WHERE id = $1', [id]); + await dbQuery( + `DELETE FROM visitors v + WHERE v.id = $1 + AND NOT EXISTS (SELECT 1 FROM visit_requests vr WHERE vr.visitor_id = v.id)`, + [Number(visit.visitor_id)], + ); + await audit(dbQuery, actor, 'DELETE', 'VISIT_REQUEST', id, `신청 삭제: ${visit.visitor_name} / ${visit.zone_name ?? '-'}`); + ok(res, { deleted: true }); + })); + router.post('/visit-requests/upload', upload.single('file'), asyncRoute(async (req, res) => { const actor = await requireCurrentUser(dbQuery, req); ok(res, await importVisitWorkbook(dbQuery, actor, req.file));