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:
160
frontend/src/pages/AccessConsolePage.tsx
Normal file
160
frontend/src/pages/AccessConsolePage.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
checkIn,
|
||||
checkOut,
|
||||
listTodayAccess,
|
||||
searchApprovedForCheckIn,
|
||||
} from '../api';
|
||||
import { AccessRecord, VisitRequestView } from '../types';
|
||||
import { formatShort } from '../status';
|
||||
|
||||
/**
|
||||
* Staff access console (login: 담당자/보안/관리자). Name search → force check-in/out,
|
||||
* plus today's full access log (신청 입/퇴장 + 실제 입/퇴장, 퇴실자 포함).
|
||||
* Visitor self-service (QR scan) is the separate public kiosk (/kiosk).
|
||||
*/
|
||||
export const AccessConsolePage: React.FC = () => {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<VisitRequestView[]>([]);
|
||||
const [records, setRecords] = useState<AccessRecord[]>([]);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const loadRecords = () => {
|
||||
listTodayAccess().then(setRecords).catch(() => setRecords([]));
|
||||
};
|
||||
|
||||
// Poll so kiosk self check-in/out shows up here without a manual refresh.
|
||||
useEffect(() => {
|
||||
loadRecords();
|
||||
const t = setInterval(loadRecords, 3000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
|
||||
const insideIds = new Set(records.filter((r) => r.inside).map((r) => r.visitRequestId));
|
||||
// Entered and already left today → no re-entry allowed.
|
||||
const exitedIds = new Set(records.filter((r) => !r.inside).map((r) => r.visitRequestId));
|
||||
|
||||
const wrap = async (fn: () => Promise<void>) => {
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await fn();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '처리 실패');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
void wrap(async () => {
|
||||
setResults(await searchApprovedForCheckIn(query.trim()));
|
||||
});
|
||||
};
|
||||
|
||||
const forceCheckIn = (id: number) =>
|
||||
wrap(async () => {
|
||||
const res = await checkIn({ visitRequestId: id, gateId: 'STAFF' });
|
||||
setNotice(`${res.visitorName} — ${res.message}`);
|
||||
loadRecords();
|
||||
});
|
||||
|
||||
const forceCheckOut = (id: number) =>
|
||||
wrap(async () => {
|
||||
const res = await checkOut({ visitRequestId: id });
|
||||
setNotice(`${res.visitorName} — ${res.message}`);
|
||||
loadRecords();
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head"><h2>출입 콘솔 (직원용)</h2></div>
|
||||
|
||||
{notice && <div className="alert alert-info">{notice}</div>}
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
<div className="card">
|
||||
<h3>이름으로 검색 · 강제 입/퇴장</h3>
|
||||
<form onSubmit={onSearch} className="inline-form">
|
||||
<input
|
||||
placeholder="방문자 이름"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<button className="btn-ghost" type="submit" disabled={busy}>검색</button>
|
||||
</form>
|
||||
{results.length > 0 && (
|
||||
<table className="table" style={{ marginTop: 12 }}>
|
||||
<thead>
|
||||
<tr><th>방문자</th><th>회사</th><th>구역</th><th>처리</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td>{r.visitorName}</td>
|
||||
<td>{r.company || '-'}</td>
|
||||
<td>{r.zoneName || '-'}</td>
|
||||
<td>
|
||||
{insideIds.has(r.id) ? (
|
||||
<button className="btn-danger" disabled={busy} onClick={() => forceCheckOut(r.id)}>퇴장</button>
|
||||
) : exitedIds.has(r.id) ? (
|
||||
<span className="muted">금일 완료</span>
|
||||
) : (
|
||||
<button className="btn-success" disabled={busy} onClick={() => forceCheckIn(r.id)}>입장</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginTop: 20 }}>
|
||||
<h3>금일 출입 현황 ({records.length})</h3>
|
||||
{records.length === 0 ? (
|
||||
<p className="muted">금일 입장 기록이 없습니다.</p>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>방문자</th><th>회사</th><th>구역</th>
|
||||
<th>신청 입장</th><th>신청 퇴장</th>
|
||||
<th>실제 입장</th><th>실제 퇴장</th>
|
||||
<th>상태</th><th>처리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((r) => (
|
||||
<tr key={r.visitRequestId}>
|
||||
<td>{r.visitorName}</td>
|
||||
<td>{r.company || '-'}</td>
|
||||
<td>{r.zoneName || '-'}</td>
|
||||
<td>{formatShort(r.visitFrom)}</td>
|
||||
<td>{formatShort(r.visitTo)}</td>
|
||||
<td>{formatShort(r.checkInAt)}</td>
|
||||
<td>{formatShort(r.checkOutAt)}</td>
|
||||
<td>
|
||||
<span className={`badge badge-${r.inside ? 'green' : 'gray'}`}>
|
||||
{r.inside ? '재실중' : '퇴실'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{r.inside && (
|
||||
<button className="btn-danger" disabled={busy} onClick={() => forceCheckOut(r.visitRequestId)}>퇴장</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
60
frontend/src/pages/BadgePage.tsx
Normal file
60
frontend/src/pages/BadgePage.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { getPass, passQrUrl } from '../api';
|
||||
import { VisitRequestView } from '../types';
|
||||
import { formatVisitRange } from '../status';
|
||||
|
||||
export const BadgePage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [pass, setPass] = useState<VisitRequestView | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
getPass(Number(id))
|
||||
.then(setPass)
|
||||
.catch((e) => setError(e instanceof Error ? e.message : '조회 실패'));
|
||||
}, [id]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head"><h2>출입증</h2></div>
|
||||
<div className="alert alert-error">{error}</div>
|
||||
<button className="btn-ghost" onClick={() => navigate(-1)}>뒤로</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!pass) {
|
||||
return <div className="muted">불러오는 중…</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head no-print">
|
||||
<h2>출입증</h2>
|
||||
<div className="head-actions">
|
||||
<button className="btn-ghost" onClick={() => navigate(-1)}>뒤로</button>
|
||||
<button className="btn-primary" onClick={() => window.print()}>인쇄</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="badge-sheet">
|
||||
<div className="badge-card">
|
||||
<div className="badge-head">IT센터 출입증</div>
|
||||
<div className="badge-name">{pass.visitorName}</div>
|
||||
<div className="badge-company">{pass.company || '-'}</div>
|
||||
<img className="badge-qr" src={passQrUrl(pass.id)} alt="출입 QR" />
|
||||
<div className="badge-meta">
|
||||
<div><b>{pass.zoneName || '-'}</b></div>
|
||||
<div><b>{pass.hostName}</b></div>
|
||||
<div><b>{formatVisitRange(pass.visitFrom, pass.visitTo)}</b></div>
|
||||
</div>
|
||||
<div className="badge-foot">전산실 입장 시 QR을 출입관리시스템에 입력하세요</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
110
frontend/src/pages/BlacklistPage.tsx
Normal file
110
frontend/src/pages/BlacklistPage.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { addBlacklist, deleteBlacklist, listBlacklist } from '../api';
|
||||
import { BlacklistItem } from '../types';
|
||||
import { formatDateTime } from '../status';
|
||||
import { Dialog } from '../components/Dialog';
|
||||
|
||||
export const BlacklistPage: React.FC = () => {
|
||||
const [items, setItems] = useState<BlacklistItem[]>([]);
|
||||
const [form, setForm] = useState({ name: '', company: '', contact: '', reason: '' });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [liftingId, setLiftingId] = useState<number | null>(null);
|
||||
|
||||
const load = () => {
|
||||
listBlacklist()
|
||||
.then(setItems)
|
||||
.catch((e) => setError(e instanceof Error ? e.message : '조회 실패'));
|
||||
};
|
||||
|
||||
useEffect(load, []);
|
||||
|
||||
const update = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setForm({ ...form, [k]: e.target.value });
|
||||
|
||||
const onAdd = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await addBlacklist({
|
||||
name: form.name,
|
||||
company: form.company || undefined,
|
||||
contact: form.contact || undefined,
|
||||
reason: form.reason,
|
||||
});
|
||||
setForm({ name: '', company: '', contact: '', reason: '' });
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '등록 실패');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmLift = async () => {
|
||||
const id = liftingId;
|
||||
setLiftingId(null);
|
||||
if (id == null) return;
|
||||
try {
|
||||
await deleteBlacklist(id);
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '해제 실패');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head"><h2>블랙리스트 관리</h2></div>
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
<form className="card form-grid" onSubmit={onAdd}>
|
||||
<label className="field"><span>이름 *</span><input value={form.name} onChange={update('name')} required /></label>
|
||||
<label className="field"><span>회사/소속</span><input value={form.company} onChange={update('company')} /></label>
|
||||
<label className="field"><span>연락처</span><input value={form.contact} onChange={update('contact')} /></label>
|
||||
<label className="field"><span>차단 사유 *</span><input value={form.reason} onChange={update('reason')} required /></label>
|
||||
<div className="form-actions span-2">
|
||||
<button className="btn-primary" type="submit" disabled={busy}>차단 등록</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="card">
|
||||
<h3>차단 명단 ({items.length})</h3>
|
||||
{items.length === 0 ? (
|
||||
<p className="muted">등록된 차단 명단이 없습니다.</p>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr><th>이름</th><th>회사</th><th>연락처</th><th>사유</th><th>등록자</th><th>등록일</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((b) => (
|
||||
<tr key={b.id}>
|
||||
<td>{b.name}</td>
|
||||
<td>{b.company || '-'}</td>
|
||||
<td>{b.contact || '-'}</td>
|
||||
<td>{b.reason}</td>
|
||||
<td>{b.createdByName || '-'}</td>
|
||||
<td>{formatDateTime(b.createdAt)}</td>
|
||||
<td><button className="btn-link-danger" onClick={() => setLiftingId(b.id)}>해제</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{liftingId != null && (
|
||||
<Dialog
|
||||
title="차단 해제"
|
||||
message="이 차단을 해제하시겠습니까?"
|
||||
confirmLabel="해제"
|
||||
danger
|
||||
onConfirm={confirmLift}
|
||||
onCancel={() => setLiftingId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
66
frontend/src/pages/ChangePasswordPage.tsx
Normal file
66
frontend/src/pages/ChangePasswordPage.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { changePassword } from '../api';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { PasswordInput } from '../components/PasswordInput';
|
||||
|
||||
export const ChangePasswordPage: React.FC = () => {
|
||||
const [oldPassword, setOldPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const { user, refresh } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (newPassword !== confirm) {
|
||||
setError('새 비밀번호가 일치하지 않습니다.');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await changePassword({ oldPassword, newPassword });
|
||||
await refresh();
|
||||
navigate('/dashboard', { replace: true });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '변경 실패');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="center-screen">
|
||||
<form className="card auth-card" onSubmit={onSubmit}>
|
||||
<h1 className="auth-title">비밀번호 변경</h1>
|
||||
<p className="auth-sub">
|
||||
{user?.mustChangePassword
|
||||
? '최초 로그인 시 비밀번호를 변경해야 합니다.'
|
||||
: '새 비밀번호를 입력하세요.'}
|
||||
</p>
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
<label className="field">
|
||||
<span>현재 비밀번호</span>
|
||||
<PasswordInput value={oldPassword} onChange={(e) => setOldPassword(e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>새 비밀번호 (8자 이상)</span>
|
||||
<PasswordInput value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>새 비밀번호 확인</span>
|
||||
<PasswordInput value={confirm} onChange={(e) => setConfirm(e.target.value)} />
|
||||
</label>
|
||||
|
||||
<button className="btn-primary full" type="submit" disabled={busy}>
|
||||
{busy ? '변경 중…' : '변경하기'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
75
frontend/src/pages/DashboardPage.tsx
Normal file
75
frontend/src/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getStatsSummary, listVisitRequests } from '../api';
|
||||
import { StatsSummary, VisitRequestView } from '../types';
|
||||
import { STATUS_CLASS, STATUS_LABEL, formatDateTime } from '../status';
|
||||
|
||||
export const DashboardPage: React.FC = () => {
|
||||
const [items, setItems] = useState<VisitRequestView[]>([]);
|
||||
const [stats, setStats] = useState<StatsSummary | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getStatsSummary().then(setStats).catch(() => setStats(null));
|
||||
listVisitRequests()
|
||||
.then(setItems)
|
||||
.catch((e) => setError(e instanceof Error ? e.message : '조회 실패'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const recent = items.slice(0, 8);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head">
|
||||
<h2>대시보드</h2>
|
||||
<Link className="btn-primary" to="/visit-requests/new">+ 출입 신청</Link>
|
||||
</div>
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
<div className="stat-grid">
|
||||
<StatCard label="오늘 출입 예정" value={stats?.todayVisits ?? 0} accent="blue" />
|
||||
<StatCard label="현재 재실" value={stats?.currentlyInside ?? 0} accent="green" />
|
||||
<StatCard label="승인 대기" value={stats?.pending ?? 0} accent="amber" />
|
||||
<StatCard label="전체 신청" value={stats?.total ?? 0} accent="gray" />
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>최근 출입 신청</h3>
|
||||
{loading ? (
|
||||
<p className="muted">불러오는 중…</p>
|
||||
) : recent.length === 0 ? (
|
||||
<p className="muted">신청 내역이 없습니다.</p>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>방문자</th><th>회사</th><th>출입구역</th><th>출입 일시</th><th>상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recent.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td>{r.visitorName}</td>
|
||||
<td>{r.company || '-'}</td>
|
||||
<td>{r.zoneName || '-'}</td>
|
||||
<td>{formatDateTime(r.visitFrom)}</td>
|
||||
<td><span className={`badge badge-${STATUS_CLASS[r.status]}`}>{STATUS_LABEL[r.status]}</span></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const StatCard: React.FC<{ label: string; value: number; accent: string }> = ({ label, value, accent }) => (
|
||||
<div className={`stat-card accent-${accent}`}>
|
||||
<div className="stat-value">{value}</div>
|
||||
<div className="stat-label">{label}</div>
|
||||
</div>
|
||||
);
|
||||
132
frontend/src/pages/KioskPage.tsx
Normal file
132
frontend/src/pages/KioskPage.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getPublicPass, publicCheckIn, publicCheckOut } from '../api';
|
||||
import { PublicPass } from '../types';
|
||||
import { formatVisitRange } from '../status';
|
||||
import { useQrScanner } from '../useQrScanner';
|
||||
import { playChime } from '../chime';
|
||||
|
||||
/**
|
||||
* Public entrance kiosk (no login). The visitor scans their phone QR, the
|
||||
* approved pass is shown, and they tap 입장/퇴장 to self check-in/out.
|
||||
* This is the only screen a visitor ever sees.
|
||||
*/
|
||||
export const KioskPage: React.FC = () => {
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [pass, setPass] = useState<PublicPass | null>(null);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [manual, setManual] = useState('');
|
||||
|
||||
const resolveToken = async (raw: string) => {
|
||||
const t = raw.trim();
|
||||
if (!t || busy || token) return;
|
||||
setError(null);
|
||||
scanner.stop();
|
||||
setBusy(true);
|
||||
try {
|
||||
const p = await getPublicPass(t);
|
||||
setToken(t);
|
||||
setPass(p);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '출입증 조회 실패');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const scanner = useQrScanner({ onDecode: (t) => void resolveToken(t) });
|
||||
|
||||
const act = async (dir: 'in' | 'out') => {
|
||||
if (!token || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = dir === 'in' ? await publicCheckIn(token) : await publicCheckOut(token);
|
||||
playChime();
|
||||
setResult(`${r.visitorName} — ${r.message}`);
|
||||
setToken(null);
|
||||
setPass(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '처리 실패');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setToken(null);
|
||||
setPass(null);
|
||||
setResult(null);
|
||||
setError(null);
|
||||
setManual('');
|
||||
};
|
||||
|
||||
// Show the "○○ — 입장/퇴장 처리되었습니다" result for 3s, then return to the scan prompt.
|
||||
useEffect(() => {
|
||||
if (!result) return;
|
||||
const t = setTimeout(() => setResult(null), 3000);
|
||||
return () => clearTimeout(t);
|
||||
}, [result]);
|
||||
|
||||
return (
|
||||
<div className="kiosk">
|
||||
<div className="kiosk-card">
|
||||
<div className="badge-head">IT센터 출입 · 셀프 체크인</div>
|
||||
|
||||
{error && <div className="alert alert-error" style={{ marginTop: 16 }}>{error}</div>}
|
||||
|
||||
{result ? (
|
||||
<div className="kiosk-result">
|
||||
<p className="kiosk-ok">✅ {result}</p>
|
||||
<button className="btn-primary full" onClick={() => { reset(); void scanner.start(); }}>카메라 시작</button>
|
||||
</div>
|
||||
) : pass ? (
|
||||
<div className="kiosk-pass">
|
||||
<div className="badge-name">{pass.visitorName}</div>
|
||||
<div className="badge-company">{pass.company || '-'}</div>
|
||||
<div className="badge-meta">
|
||||
<div><b>{pass.zoneName || '-'}</b></div>
|
||||
<div><b>{formatVisitRange(pass.visitFrom, pass.visitTo)}</b></div>
|
||||
</div>
|
||||
<p className="muted">
|
||||
{pass.completedToday ? '금일 출입이 완료되었습니다.' : pass.inside ? '현재 재실 중입니다.' : '입장 전 상태입니다.'}
|
||||
</p>
|
||||
<div className="kiosk-actions">
|
||||
{pass.completedToday ? null : pass.inside ? (
|
||||
<button className="btn-danger full" disabled={busy} onClick={() => act('out')}>퇴장</button>
|
||||
) : (
|
||||
<button className="btn-success full" disabled={busy} onClick={() => act('in')}>입장</button>
|
||||
)}
|
||||
<button className="btn-ghost full" disabled={busy} onClick={reset}>취소</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="kiosk-scan">
|
||||
<div className="qr-scanner" hidden={!scanner.active}>
|
||||
<video ref={scanner.videoRef} muted playsInline />
|
||||
<div className="qr-scanner-guide" />
|
||||
</div>
|
||||
<canvas ref={scanner.canvasRef} style={{ display: 'none' }} />
|
||||
<p className="muted">{scanner.active ? '휴대폰의 QR을 카메라에 비춰주세요.' : '카메라를 시작한 뒤 QR을 비춰주세요.'}</p>
|
||||
<button
|
||||
type="button"
|
||||
className={scanner.active ? 'btn-danger full' : 'btn-primary full'}
|
||||
onClick={() => (scanner.active ? scanner.stop() : void scanner.start())}
|
||||
>
|
||||
{scanner.active ? '카메라 중지' : '📷 카메라 시작'}
|
||||
</button>
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); void resolveToken(manual); }}
|
||||
className="inline-form"
|
||||
style={{ marginTop: 12 }}
|
||||
>
|
||||
<input placeholder="QR 토큰 직접 입력" value={manual} onChange={(e) => setManual(e.target.value)} />
|
||||
<button className="btn-ghost" type="submit" disabled={busy || !manual.trim()}>확인</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
65
frontend/src/pages/LoginPage.tsx
Normal file
65
frontend/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { login } from '../api';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { PasswordInput } from '../components/PasswordInput';
|
||||
import bokBadge from '../assets/bok-removebg.png';
|
||||
|
||||
export const LoginPage: React.FC = () => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const { refresh } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const user = await login({ username, password });
|
||||
await refresh();
|
||||
navigate(user.mustChangePassword ? '/change-password' : '/dashboard', { replace: true });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '로그인 실패');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="center-screen">
|
||||
<form className="card auth-card" onSubmit={onSubmit}>
|
||||
<img src={bokBadge} className="auth-badge" alt="" />
|
||||
<h1 className="auth-title">IT센터 출입자관리</h1>
|
||||
<p className="auth-sub">시스템에 로그인하세요</p>
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
<label className="field">
|
||||
<span>아이디</span>
|
||||
<input
|
||||
className="ime-en"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoFocus
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>비밀번호</span>
|
||||
<PasswordInput value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
</label>
|
||||
|
||||
<button className="btn-primary full" type="submit" disabled={busy || !username || !password}>
|
||||
{busy ? '로그인 중…' : '로그인'}
|
||||
</button>
|
||||
|
||||
<p className="hint">초기 계정: admin / security / host (비밀번호 ChangeMe123!)</p>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
53
frontend/src/pages/PublicPassPage.tsx
Normal file
53
frontend/src/pages/PublicPassPage.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { getPublicPass, publicPassQrUrl } from '../api';
|
||||
import { PublicPass } from '../types';
|
||||
import { formatVisitRange } from '../status';
|
||||
|
||||
/**
|
||||
* Public visitor pass opened from the SMS link (no login). Shows the QR the
|
||||
* visitor presents to the access console's webcam.
|
||||
*/
|
||||
export const PublicPassPage: React.FC = () => {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const [pass, setPass] = useState<PublicPass | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
getPublicPass(token)
|
||||
.then(setPass)
|
||||
.catch((e) => setError(e instanceof Error ? e.message : '조회 실패'));
|
||||
}, [token]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="badge-sheet">
|
||||
<div className="badge-card">
|
||||
<div className="badge-head">IT센터 출입증</div>
|
||||
<div className="alert alert-error" style={{ marginTop: 16 }}>{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!pass || !token) {
|
||||
return <div className="center-screen">불러오는 중…</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="badge-sheet">
|
||||
<div className="badge-card">
|
||||
<div className="badge-head">IT센터 출입증</div>
|
||||
<div className="badge-name">{pass.visitorName}</div>
|
||||
<div className="badge-company">{pass.company || '-'}</div>
|
||||
<img className="badge-qr" src={publicPassQrUrl(token)} alt="출입 QR" />
|
||||
<div className="badge-meta">
|
||||
<div><b>{pass.zoneName || '-'}</b></div>
|
||||
<div><b>{formatVisitRange(pass.visitFrom, pass.visitTo)}</b></div>
|
||||
</div>
|
||||
<div className="badge-foot">전산실 입장 시 QR을 출입관리시스템에 입력하세요</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
50
frontend/src/pages/ReportPage.tsx
Normal file
50
frontend/src/pages/ReportPage.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React, { useState } from 'react';
|
||||
import { reportDownloadUrl } from '../api';
|
||||
|
||||
function todayISO(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
function monthAgoISO(): string {
|
||||
const d = new Date();
|
||||
d.setMonth(d.getMonth() - 1);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export const ReportPage: React.FC = () => {
|
||||
const [from, setFrom] = useState(monthAgoISO());
|
||||
const [to, setTo] = useState(todayISO());
|
||||
|
||||
const onDownload = () => {
|
||||
// Trigger a download in-place via a temporary anchor. Using window.open left
|
||||
// a blank tab behind (the .xlsx response has no HTML to render). The anchor's
|
||||
// download attribute makes the browser save the file without navigating away.
|
||||
// Same-origin, so the session cookie is sent automatically (dev proxy / nginx).
|
||||
const a = document.createElement('a');
|
||||
a.href = reportDownloadUrl(from, to);
|
||||
a.download = `visits_${from}_${to}.xlsx`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head"><h2>방문 리포트</h2></div>
|
||||
|
||||
<div className="card">
|
||||
<p className="muted">기간을 선택하고 엑셀(.xlsx) 파일로 내려받습니다. (방문 시작일 기준)</p>
|
||||
<div className="report-row">
|
||||
<label className="field">
|
||||
<span>시작일</span>
|
||||
<input type="date" value={from} onChange={(e) => setFrom(e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>종료일</span>
|
||||
<input type="date" value={to} onChange={(e) => setTo(e.target.value)} />
|
||||
</label>
|
||||
<button className="btn-primary" onClick={onDownload}>엑셀 다운로드</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
180
frontend/src/pages/VisitRequestFormPage.tsx
Normal file
180
frontend/src/pages/VisitRequestFormPage.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { createVisitRequest } from '../api';
|
||||
import { DateTimePicker } from '../components/DateTimePicker';
|
||||
|
||||
const ZONE_OPTIONS = [
|
||||
'4층전산실', '5층전산실', '3층사무실', '4층사무실', '5층사무실',
|
||||
'종합상황실', 'BMT실', '의사결정실', '기타',
|
||||
];
|
||||
const PURPOSE_OPTIONS = ['유지점검', '장비반입', '업무협의', '공사', '기타'];
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export const VisitRequestFormPage: React.FC = () => {
|
||||
const [form, setForm] = useState({
|
||||
visitorName: '',
|
||||
company: '',
|
||||
contact: '',
|
||||
email: '',
|
||||
vehicleNo: '',
|
||||
zone: '',
|
||||
zoneEtc: '',
|
||||
purpose: '',
|
||||
purposeEtc: '',
|
||||
visitFrom: '',
|
||||
visitTo: '',
|
||||
});
|
||||
const [consent, setConsent] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const update = (k: keyof typeof form) => (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
|
||||
) => setForm({ ...form, [k]: e.target.value });
|
||||
|
||||
/** Returns the first Korean validation error, or null if valid. */
|
||||
const validate = (): string | null => {
|
||||
if (!form.visitorName.trim()) return '방문자 이름을 입력하세요.';
|
||||
if (!form.contact.trim()) return '방문자 연락처를 입력하세요.';
|
||||
if (form.email.trim() && !EMAIL_RE.test(form.email.trim()))
|
||||
return '이메일 형식이 올바르지 않습니다. (예: name@example.com)';
|
||||
if (!form.zone) return '출입 구역을 선택하세요.';
|
||||
if (form.zone === '기타' && !form.zoneEtc.trim()) return '기타 출입 구역을 입력하세요.';
|
||||
if (!form.purpose) return '출입 목적을 선택하세요.';
|
||||
if (form.purpose === '기타' && !form.purposeEtc.trim()) return '기타 출입 목적을 입력하세요.';
|
||||
if (!form.visitFrom) return '출입 일시를 입력하세요.';
|
||||
if (!form.visitTo) return '퇴실 일시를 입력하세요.';
|
||||
if (new Date(form.visitTo) < new Date(form.visitFrom))
|
||||
return '퇴실 일시는 출입 일시보다 빠를 수 없습니다.';
|
||||
if (!consent) return '개인정보 사용 및 저장에 동의해야 신청할 수 있습니다.';
|
||||
return null;
|
||||
};
|
||||
|
||||
const onSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const message = validate();
|
||||
if (message) {
|
||||
setError(message);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await createVisitRequest({
|
||||
visitorName: form.visitorName.trim(),
|
||||
company: form.company.trim() || undefined,
|
||||
contact: form.contact.trim(),
|
||||
email: form.email.trim() || undefined,
|
||||
vehicleNo: form.vehicleNo.trim() || undefined,
|
||||
zoneName: form.zone === '기타' ? form.zoneEtc.trim() : form.zone,
|
||||
purpose: form.purpose === '기타' ? form.purposeEtc.trim() : form.purpose,
|
||||
visitFrom: form.visitFrom,
|
||||
visitTo: form.visitTo,
|
||||
});
|
||||
navigate('/visit-requests', { replace: true });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '신청 실패');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head"><h2>출입 신청</h2></div>
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
{/* noValidate: use our Korean messages instead of the browser's native popups */}
|
||||
<form className="card form-grid" onSubmit={onSubmit} noValidate>
|
||||
<label className="field">
|
||||
<span>방문자 이름 *</span>
|
||||
<input className="ime-ko" value={form.visitorName} onChange={update('visitorName')} autoFocus />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>회사/소속</span>
|
||||
<input value={form.company} onChange={update('company')} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>연락처 * <em className="hint-inline">: 입력형식은 010-0000-0000 로 작성해 주세요.</em></span>
|
||||
<input type="tel" value={form.contact} onChange={update('contact')} placeholder="010-0000-0000" />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>이메일</span>
|
||||
<input className="ime-en" type="email" value={form.email} onChange={update('email')} placeholder="name@example.com" />
|
||||
</label>
|
||||
|
||||
<label className="field span-2">
|
||||
<span>차량번호 <em className="hint-inline">: 차량번호가 5부제에 해당될 경우 출입이 제한됩니다.</em></span>
|
||||
<input className="ime-ko" value={form.vehicleNo} onChange={update('vehicleNo')} />
|
||||
</label>
|
||||
|
||||
<label className="field">
|
||||
<span>출입 구역 *</span>
|
||||
<select value={form.zone} onChange={update('zone')}>
|
||||
<option value="">선택하세요</option>
|
||||
{ZONE_OPTIONS.map((z) => <option key={z} value={z}>{z}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{form.zone === '기타' ? (
|
||||
<label className="field">
|
||||
<span>기타 구역 입력 *</span>
|
||||
<input value={form.zoneEtc} onChange={update('zoneEtc')} placeholder="출입 구역을 입력하세요" />
|
||||
</label>
|
||||
) : <div />}
|
||||
|
||||
<label className="field">
|
||||
<span>출입 목적 *</span>
|
||||
<select value={form.purpose} onChange={update('purpose')}>
|
||||
<option value="">선택하세요</option>
|
||||
{PURPOSE_OPTIONS.map((p) => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{form.purpose === '기타' ? (
|
||||
<label className="field">
|
||||
<span>기타 목적 입력 *</span>
|
||||
<input value={form.purposeEtc} onChange={update('purposeEtc')} placeholder="출입 목적을 입력하세요" />
|
||||
</label>
|
||||
) : <div />}
|
||||
|
||||
<label className="field">
|
||||
<span>출입 일시 *</span>
|
||||
<DateTimePicker
|
||||
value={form.visitFrom}
|
||||
onChange={(v) => setForm((f) => ({ ...f, visitFrom: v }))}
|
||||
placeholder="출입 일시 선택"
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>퇴실 일시 *</span>
|
||||
<DateTimePicker
|
||||
value={form.visitTo}
|
||||
onChange={(v) => setForm((f) => ({ ...f, visitTo: v }))}
|
||||
placeholder="퇴실 일시 선택"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="span-2 consent-box">
|
||||
<label className="consent-label">
|
||||
<input type="checkbox" checked={consent} onChange={(e) => setConsent(e.target.checked)} />
|
||||
<span>
|
||||
<b>[개인정보 수집·이용 동의]</b><br />
|
||||
· 수집 항목: 이름, 연락처, 이메일, 차량번호<br />
|
||||
· 수집·이용 목적: IT센터 출입 신청 접수 및 출입자 관리<br />
|
||||
· 보유·이용 기간: 수집일로부터 1년 (기간 경과 시 지체 없이 파기)<br />
|
||||
· 귀하는 개인정보 수집·이용에 동의를 거부할 권리가 있으며, 동의하지 않을 경우 출입 신청이 제한됩니다.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-actions span-2">
|
||||
<button type="button" className="btn-ghost" onClick={() => navigate(-1)}>취소</button>
|
||||
<button type="submit" className="btn-primary" disabled={busy}>
|
||||
{busy ? '신청 중…' : '출입 신청'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
120
frontend/src/pages/VisitRequestListPage.tsx
Normal file
120
frontend/src/pages/VisitRequestListPage.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { cancelVisitRequest, listVisitRequests, uploadVisitRequests } from '../api';
|
||||
import { VisitRequestView } from '../types';
|
||||
import { STATUS_CLASS, STATUS_LABEL, formatVisitRange } from '../status';
|
||||
import { Dialog } from '../components/Dialog';
|
||||
|
||||
export const VisitRequestListPage: React.FC = () => {
|
||||
const [items, setItems] = useState<VisitRequestView[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [cancelingId, setCancelingId] = useState<number | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const load = () => {
|
||||
setLoading(true);
|
||||
listVisitRequests()
|
||||
.then(setItems)
|
||||
.catch((e) => setError(e instanceof Error ? e.message : '조회 실패'))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(load, []);
|
||||
|
||||
const confirmCancel = async () => {
|
||||
const id = cancelingId;
|
||||
setCancelingId(null);
|
||||
if (id == null) return;
|
||||
try {
|
||||
await cancelVisitRequest(id);
|
||||
load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '취소 실패');
|
||||
}
|
||||
};
|
||||
|
||||
const onUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const res = await uploadVisitRequests(file);
|
||||
setNotice(`업로드 완료: ${res.successCount}/${res.totalRows}건 등록` +
|
||||
(res.errors.length ? ` · 오류 ${res.errors.length}건` : ''));
|
||||
if (res.errors.length) setError(res.errors.join('\n'));
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '업로드 실패');
|
||||
} finally {
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head">
|
||||
<h2>출입 신청 목록</h2>
|
||||
<div className="head-actions">
|
||||
<button className="btn-ghost" onClick={() => fileRef.current?.click()}>엑셀 업로드</button>
|
||||
<input ref={fileRef} type="file" accept=".xlsx" hidden onChange={onUpload} />
|
||||
<Link className="btn-primary" to="/visit-requests/new">+ 출입 신청</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notice && <div className="alert alert-info">{notice}</div>}
|
||||
{error && <div className="alert alert-error" style={{ whiteSpace: 'pre-line' }}>{error}</div>}
|
||||
|
||||
<div className="card">
|
||||
{loading ? (
|
||||
<p className="muted">불러오는 중…</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="muted">신청 내역이 없습니다.</p>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>방문자</th><th>회사</th><th>출입구역</th><th>출입목적</th>
|
||||
<th>출입기간</th><th>상태</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.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><span className={`badge badge-${STATUS_CLASS[r.status]}`}>{STATUS_LABEL[r.status]}</span></td>
|
||||
<td className="row-actions">
|
||||
{r.status === 'APPROVED' && (
|
||||
<button className="btn-link" onClick={() => navigate(`/badge/${r.id}`)}>출입증</button>
|
||||
)}
|
||||
{(r.status === 'PENDING' || r.status === 'APPROVED') && (
|
||||
<button className="btn-link-danger" onClick={() => setCancelingId(r.id)}>취소</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{cancelingId != null && (
|
||||
<Dialog
|
||||
title="신청 취소"
|
||||
message="이 방문 신청을 취소하시겠습니까?"
|
||||
confirmLabel="취소 처리"
|
||||
danger
|
||||
onConfirm={confirmCancel}
|
||||
onCancel={() => setCancelingId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user