Initial commit: IT센터 출입자관리시스템 (ACS)
방문자 사전신청·승인, 입·출입 체크인/아웃, QR 배지, 재실현황, 블랙리스트, 대시보드 통계, 방문 리포트(엑셀)까지 7단계 전 기능 구현. - backend: Spring Boot 3.4.5 / Java 21 (JDK 26 빌드), 세션 인증, JPA, H2/PostgreSQL, POI, ZXing, Flyway - frontend: React 19 / Vite 6 / TypeScript - infra: Docker Compose (db·app·web nginx), Flyway V1__init, Python 사용자 시드 - docs: 워크플로우 / 시퀀스 다이어그램(Mermaid) / 이슈·유의사항 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
146
frontend/src/pages/ApprovalQueuePage.tsx
Normal file
146
frontend/src/pages/ApprovalQueuePage.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { approveRequest, listPendingRequests, rejectRequest } from '../api';
|
||||
import { VisitRequestView } from '../types';
|
||||
import { formatVisitRange } from '../status';
|
||||
import { Dialog } from '../components/Dialog';
|
||||
|
||||
type SortKey = 'visitorName' | 'company' | 'zoneName' | 'purpose' | 'visitFrom';
|
||||
|
||||
const COLUMNS: { key: SortKey; label: string }[] = [
|
||||
{ key: 'visitorName', label: '방문자' },
|
||||
{ key: 'company', label: '회사' },
|
||||
{ key: 'zoneName', label: '출입구역' },
|
||||
{ key: 'purpose', label: '출입목적' },
|
||||
{ key: 'visitFrom', label: '출입기간' },
|
||||
];
|
||||
|
||||
export const ApprovalQueuePage: React.FC = () => {
|
||||
const [items, setItems] = useState<VisitRequestView[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busyId, setBusyId] = useState<number | null>(null);
|
||||
const [sortKey, setSortKey] = useState<SortKey | null>(null);
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
||||
const [rejectingId, setRejectingId] = useState<number | null>(null);
|
||||
|
||||
const onSort = (key: SortKey) => {
|
||||
if (sortKey === key) {
|
||||
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const sortedItems = useMemo(() => {
|
||||
if (!sortKey) return items;
|
||||
const arr = [...items];
|
||||
arr.sort((a, b) => {
|
||||
const cmp = sortKey === 'visitFrom'
|
||||
? new Date(a.visitFrom).getTime() - new Date(b.visitFrom).getTime()
|
||||
: String(a[sortKey] ?? '').localeCompare(String(b[sortKey] ?? ''), 'ko');
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
return arr;
|
||||
}, [items, sortKey, sortDir]);
|
||||
|
||||
const load = () => {
|
||||
setLoading(true);
|
||||
listPendingRequests()
|
||||
.then(setItems)
|
||||
.catch((e) => setError(e instanceof Error ? e.message : '조회 실패'))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(load, []);
|
||||
|
||||
const approve = async (id: number) => {
|
||||
setError(null);
|
||||
setBusyId(id);
|
||||
try {
|
||||
await approveRequest(id);
|
||||
load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '처리 실패');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmReject = async (comment?: string) => {
|
||||
const id = rejectingId;
|
||||
setRejectingId(null);
|
||||
if (id == null) return;
|
||||
setError(null);
|
||||
setBusyId(id);
|
||||
try {
|
||||
await rejectRequest(id, comment);
|
||||
load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '처리 실패');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head"><h2>승인 대기 ({items.length})</h2></div>
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
<div className="card">
|
||||
{loading ? (
|
||||
<p className="muted">불러오는 중…</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="muted">승인 대기 중인 신청이 없습니다.</p>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
{COLUMNS.map((c) => (
|
||||
<th
|
||||
key={c.key}
|
||||
onClick={() => onSort(c.key)}
|
||||
style={{ cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap' }}
|
||||
title="클릭하여 정렬"
|
||||
>
|
||||
{c.label}{sortKey === c.key ? (sortDir === 'asc' ? ' ▲' : ' ▼') : ''}
|
||||
</th>
|
||||
))}
|
||||
<th>처리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedItems.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td>{r.visitorName}</td>
|
||||
<td>{r.company || '-'}</td>
|
||||
<td>{r.zoneName || '-'}</td>
|
||||
<td>{r.purpose}</td>
|
||||
<td>{formatVisitRange(r.visitFrom, r.visitTo)}</td>
|
||||
<td className="action-cell">
|
||||
<button className="btn-success" disabled={busyId === r.id} onClick={() => approve(r.id)}>승인</button>
|
||||
<button className="btn-danger" disabled={busyId === r.id} onClick={() => setRejectingId(r.id)}>반려</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rejectingId != null && (
|
||||
<Dialog
|
||||
title="반려 처리"
|
||||
message="반려 사유를 입력하세요 (선택)."
|
||||
withInput
|
||||
inputPlaceholder="반려 사유"
|
||||
confirmLabel="반려"
|
||||
danger
|
||||
onConfirm={confirmReject}
|
||||
onCancel={() => setRejectingId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user