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

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

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

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

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

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

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

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

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

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

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

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