feat: add admin visit request deletion
This commit is contained in:
@@ -111,6 +111,9 @@ export const createVisitRequest = (req: VisitRequestCreate) =>
|
|||||||
export const cancelVisitRequest = (id: number) =>
|
export const cancelVisitRequest = (id: number) =>
|
||||||
request<VisitRequestView>(`/visit-requests/${id}/cancel`, { method: 'POST' });
|
request<VisitRequestView>(`/visit-requests/${id}/cancel`, { method: 'POST' });
|
||||||
|
|
||||||
|
export const deleteVisitRequest = (id: number) =>
|
||||||
|
request<{ deleted: boolean }>(`/visit-requests/${id}`, { method: 'DELETE' });
|
||||||
|
|
||||||
export const uploadVisitRequests = (file: File) => {
|
export const uploadVisitRequests = (file: File) => {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append('file', file);
|
form.append('file', file);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { formatDateTime } from '../status';
|
|||||||
const ACTION_LABEL: Record<AuditLog['action'], string> = {
|
const ACTION_LABEL: Record<AuditLog['action'], string> = {
|
||||||
APPROVE: '승인',
|
APPROVE: '승인',
|
||||||
REJECT: '반려',
|
REJECT: '반려',
|
||||||
|
DELETE: '삭제',
|
||||||
BLACKLIST_ADD: '블랙리스트 등록',
|
BLACKLIST_ADD: '블랙리스트 등록',
|
||||||
BLACKLIST_REMOVE: '블랙리스트 해제',
|
BLACKLIST_REMOVE: '블랙리스트 해제',
|
||||||
ADMIN_CONFIG_UPDATE: '시스템 설정',
|
ADMIN_CONFIG_UPDATE: '시스템 설정',
|
||||||
@@ -14,6 +15,7 @@ const ACTION_LABEL: Record<AuditLog['action'], string> = {
|
|||||||
const ACTION_CLASS: Record<AuditLog['action'], string> = {
|
const ACTION_CLASS: Record<AuditLog['action'], string> = {
|
||||||
APPROVE: 'green',
|
APPROVE: 'green',
|
||||||
REJECT: 'red',
|
REJECT: 'red',
|
||||||
|
DELETE: 'red',
|
||||||
BLACKLIST_ADD: 'red',
|
BLACKLIST_ADD: 'red',
|
||||||
BLACKLIST_REMOVE: 'gray',
|
BLACKLIST_REMOVE: 'gray',
|
||||||
ADMIN_CONFIG_UPDATE: 'blue',
|
ADMIN_CONFIG_UPDATE: 'blue',
|
||||||
|
|||||||
@@ -40,8 +40,6 @@ export const DashboardPage: React.FC = () => {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const recent = items.slice(0, 8);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="page-head">
|
<div className="page-head">
|
||||||
@@ -60,10 +58,10 @@ export const DashboardPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3>최근 출입 신청</h3>
|
<h3>전체 출입 신청 ({items.length})</h3>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="muted">불러오는 중…</p>
|
<p className="muted">불러오는 중…</p>
|
||||||
) : recent.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<p className="muted">신청 내역이 없습니다.</p>
|
<p className="muted">신청 내역이 없습니다.</p>
|
||||||
) : (
|
) : (
|
||||||
<table className="table">
|
<table className="table">
|
||||||
@@ -73,7 +71,7 @@ export const DashboardPage: React.FC = () => {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{recent.map((r) => (
|
{items.map((r) => (
|
||||||
<tr
|
<tr
|
||||||
key={r.id}
|
key={r.id}
|
||||||
className="clickable-row"
|
className="clickable-row"
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { cancelVisitRequest, listVisitRequests, uploadVisitRequests } from '../api';
|
import { cancelVisitRequest, deleteVisitRequest, listVisitRequests, uploadVisitRequests } from '../api';
|
||||||
import { VisitRequestView } from '../types';
|
import { VisitRequestView } from '../types';
|
||||||
import { STATUS_CLASS, STATUS_LABEL, formatVisitRange } from '../status';
|
import { STATUS_CLASS, STATUS_LABEL, formatVisitRange } from '../status';
|
||||||
import { Dialog } from '../components/Dialog';
|
import { Dialog } from '../components/Dialog';
|
||||||
|
import { useAuth } from '../auth/AuthContext';
|
||||||
|
|
||||||
export const VisitRequestListPage: React.FC = () => {
|
export const VisitRequestListPage: React.FC = () => {
|
||||||
const [items, setItems] = useState<VisitRequestView[]>([]);
|
const [items, setItems] = useState<VisitRequestView[]>([]);
|
||||||
@@ -12,7 +13,9 @@ export const VisitRequestListPage: React.FC = () => {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
const [cancelingId, setCancelingId] = useState<number | null>(null);
|
const [cancelingId, setCancelingId] = useState<number | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState<VisitRequestView | null>(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { hasRole } = useAuth();
|
||||||
|
|
||||||
const load = () => {
|
const load = () => {
|
||||||
setLoading(true);
|
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<HTMLInputElement>) => {
|
const onUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@@ -98,6 +121,9 @@ export const VisitRequestListPage: React.FC = () => {
|
|||||||
{(r.status === 'PENDING' || r.status === 'APPROVED') && (
|
{(r.status === 'PENDING' || r.status === 'APPROVED') && (
|
||||||
<button className="btn-link-danger" onClick={() => setCancelingId(r.id)}>취소</button>
|
<button className="btn-link-danger" onClick={() => setCancelingId(r.id)}>취소</button>
|
||||||
)}
|
)}
|
||||||
|
{hasRole('ADMIN') && (
|
||||||
|
<button className="btn-link-danger" onClick={() => setDeleting(r)}>삭제</button>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -116,6 +142,19 @@ export const VisitRequestListPage: React.FC = () => {
|
|||||||
onCancel={() => setCancelingId(null)}
|
onCancel={() => setCancelingId(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{deleting != null && (
|
||||||
|
<Dialog
|
||||||
|
title="출입신청 삭제"
|
||||||
|
message={`${deleting.visitorName} / ${deleting.zoneName || '-'} 신청을 삭제합니다. 실제 입/퇴장 기록이 있는 신청은 삭제되지 않습니다. 계속하려면 "삭제"를 입력하세요.`}
|
||||||
|
withInput
|
||||||
|
inputPlaceholder="삭제"
|
||||||
|
confirmLabel="삭제"
|
||||||
|
danger
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
onCancel={() => setDeleting(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ export interface AuditLog {
|
|||||||
at: string;
|
at: string;
|
||||||
actorId?: number;
|
actorId?: number;
|
||||||
actorUsername?: string;
|
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;
|
targetType?: string;
|
||||||
targetId?: number;
|
targetId?: number;
|
||||||
detail?: string;
|
detail?: string;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface BusinessRouterDeps {
|
|||||||
|
|
||||||
interface VisitRow {
|
interface VisitRow {
|
||||||
id: string;
|
id: string;
|
||||||
|
visitor_id: string;
|
||||||
visitor_name: string;
|
visitor_name: string;
|
||||||
company: string | null;
|
company: string | null;
|
||||||
contact: 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)))!));
|
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) => {
|
router.post('/visit-requests/upload', upload.single('file'), asyncRoute(async (req, res) => {
|
||||||
const actor = await requireCurrentUser(dbQuery, req);
|
const actor = await requireCurrentUser(dbQuery, req);
|
||||||
ok(res, await importVisitWorkbook(dbQuery, actor, req.file));
|
ok(res, await importVisitWorkbook(dbQuery, actor, req.file));
|
||||||
|
|||||||
Reference in New Issue
Block a user