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:
unknown
2026-07-03 08:59:41 +09:00
commit f0c30d8005
130 changed files with 8884 additions and 0 deletions

View 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>
);
};