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

90
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,90 @@
import React from 'react';
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { useAuth } from './auth/AuthContext';
import { Role } from './types';
import { Layout } from './components/Layout';
import { LoginPage } from './pages/LoginPage';
import { ChangePasswordPage } from './pages/ChangePasswordPage';
import { DashboardPage } from './pages/DashboardPage';
import { VisitRequestListPage } from './pages/VisitRequestListPage';
import { VisitRequestFormPage } from './pages/VisitRequestFormPage';
import { ApprovalQueuePage } from './pages/ApprovalQueuePage';
import { AccessConsolePage } from './pages/AccessConsolePage';
import { BadgePage } from './pages/BadgePage';
import { PublicPassPage } from './pages/PublicPassPage';
import { KioskPage } from './pages/KioskPage';
import { BlacklistPage } from './pages/BlacklistPage';
import { ReportPage } from './pages/ReportPage';
/** Requires a logged-in user; optionally one of the given roles. */
const Protected: React.FC<{ roles?: Role[]; children: React.ReactNode }> = ({ roles, children }) => {
const { user, loading, hasRole } = useAuth();
if (loading) {
return <div className="center-screen"> </div>;
}
if (!user) {
return <Navigate to="/login" replace />;
}
if (user.mustChangePassword) {
return <Navigate to="/change-password" replace />;
}
if (roles && !hasRole(...roles)) {
return <div className="center-screen"> .</div>;
}
return <Layout>{children}</Layout>;
};
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/change-password" element={<ChangePasswordPage />} />
{/* Public visitor pass — opened from the SMS link, no login. */}
<Route path="/pass/:token" element={<PublicPassPage />} />
{/* Public entrance kiosk — visitor self check-in/out, no login. */}
<Route path="/kiosk" element={<KioskPage />} />
<Route path="/dashboard" element={<Protected><DashboardPage /></Protected>} />
<Route path="/visit-requests" element={<Protected><VisitRequestListPage /></Protected>} />
<Route path="/visit-requests/new" element={<Protected><VisitRequestFormPage /></Protected>} />
<Route
path="/approvals"
element={
<Protected roles={['ADMIN']}>
<ApprovalQueuePage />
</Protected>
}
/>
<Route
path="/access"
element={
<Protected roles={['HOST', 'SECURITY', 'ADMIN']}>
<AccessConsolePage />
</Protected>
}
/>
<Route path="/badge/:id" element={<Protected><BadgePage /></Protected>} />
<Route
path="/blacklist"
element={
<Protected roles={['ADMIN']}>
<BlacklistPage />
</Protected>
}
/>
<Route
path="/reports"
element={
<Protected roles={['SECURITY', 'ADMIN']}>
<ReportPage />
</Protected>
}
/>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</BrowserRouter>
);
}

143
frontend/src/api.ts Normal file
View File

@@ -0,0 +1,143 @@
import {
AccessAction,
AccessRecord,
ApiResponse,
BlacklistCreate,
BlacklistItem,
ChangePasswordRequest,
CurrentUser,
ExcelImportResult,
InsideVisitor,
LoginRequest,
StatsSummary,
PublicPass,
VisitRequestCreate,
VisitRequestView,
Zone,
} from './types';
const BASE = '/api';
/** Endpoints whose 401 must NOT trigger a redirect (login probe / public pages). */
const NO_REDIRECT_ON_401 = ['/auth/me', '/auth/login', '/auth/logout', '/public/'];
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
credentials: 'include',
...init,
});
// Session expired / not authenticated on a protected call → send to login.
if (
res.status === 401 &&
!NO_REDIRECT_ON_401.some((p) => path.startsWith(p)) &&
typeof window !== 'undefined' &&
!['/login', '/kiosk'].includes(window.location.pathname) &&
!window.location.pathname.startsWith('/pass/')
) {
window.location.assign('/login');
}
let body: ApiResponse<T> | null = null;
try {
body = (await res.json()) as ApiResponse<T>;
} catch {
// non-JSON (shouldn't happen with our envelope)
}
if (!res.ok || !body) {
throw new Error(body?.message || `HTTP ${res.status}`);
}
return body.data as T;
}
function jsonInit(method: string, payload: unknown): RequestInit {
return {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
};
}
// ===== Auth =====
export const login = (req: LoginRequest) =>
request<CurrentUser>('/auth/login', jsonInit('POST', req));
export const logout = () =>
request<string>('/auth/logout', { method: 'POST' });
export const getCurrentUser = () => request<CurrentUser>('/auth/me');
export const changePassword = (req: ChangePasswordRequest) =>
request<string>('/auth/change-password', jsonInit('POST', req));
// ===== Zones =====
export const listZones = () => request<Zone[]>('/zones');
// ===== Visit requests =====
export const listVisitRequests = () =>
request<VisitRequestView[]>('/visit-requests');
export const listPendingRequests = () =>
request<VisitRequestView[]>('/visit-requests/pending');
export const getVisitRequest = (id: number) =>
request<VisitRequestView>(`/visit-requests/${id}`);
export const createVisitRequest = (req: VisitRequestCreate) =>
request<VisitRequestView>('/visit-requests', jsonInit('POST', req));
export const cancelVisitRequest = (id: number) =>
request<VisitRequestView>(`/visit-requests/${id}/cancel`, { method: 'POST' });
export const uploadVisitRequests = (file: File) => {
const form = new FormData();
form.append('file', file);
return request<ExcelImportResult>('/visit-requests/upload', {
method: 'POST',
body: form,
});
};
// ===== Approvals =====
export const approveRequest = (id: number, comment?: string) =>
request<VisitRequestView>(`/approvals/${id}/approve`, jsonInit('POST', { comment }));
export const rejectRequest = (id: number, comment?: string) =>
request<VisitRequestView>(`/approvals/${id}/reject`, jsonInit('POST', { comment }));
// ===== Access console (check-in / out, currently inside) =====
export const searchApprovedForCheckIn = (q: string) =>
request<VisitRequestView[]>(`/access/search?q=${encodeURIComponent(q)}`);
export const checkIn = (payload: { qrToken?: string; visitRequestId?: number; gateId?: string }) =>
request<AccessAction>('/access/check-in', jsonInit('POST', payload));
export const checkOut = (payload: { qrToken?: string; visitRequestId?: number; gateId?: string }) =>
request<AccessAction>('/access/check-out', jsonInit('POST', payload));
export const listInside = () => request<InsideVisitor[]>('/access/inside');
export const listTodayAccess = () => request<AccessRecord[]>('/access/today');
// ===== Passes / badge =====
export const getPass = (id: number) => request<VisitRequestView>(`/passes/${id}`);
export const passQrUrl = (id: number) => `/api/passes/${id}/qr.png`;
// ===== Public pass + self-service kiosk (no login, token-based) =====
export const getPublicPass = (token: string) => request<PublicPass>(`/public/passes/${token}`);
export const publicPassQrUrl = (token: string) => `/api/public/passes/${token}/qr.png`;
export const publicCheckIn = (token: string) =>
request<AccessAction>(`/public/passes/${token}/check-in`, { method: 'POST' });
export const publicCheckOut = (token: string) =>
request<AccessAction>(`/public/passes/${token}/check-out`, { method: 'POST' });
// ===== Stats =====
export const getStatsSummary = () => request<StatsSummary>('/stats/summary');
// ===== Blacklist (ADMIN) =====
export const listBlacklist = () => request<BlacklistItem[]>('/blacklist');
export const addBlacklist = (req: BlacklistCreate) =>
request<BlacklistItem>('/blacklist', jsonInit('POST', req));
export const deleteBlacklist = (id: number) =>
request<string>(`/blacklist/${id}`, { method: 'DELETE' });
// ===== Reports =====
export const reportDownloadUrl = (from: string, to: string) =>
`/api/reports/visits.xlsx?from=${from}&to=${to}`;

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

@@ -0,0 +1,54 @@
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { getCurrentUser } from '../api';
import { CurrentUser, Role } from '../types';
interface AuthState {
user: CurrentUser | null;
loading: boolean;
refresh: () => Promise<void>;
clear: () => void;
hasRole: (...roles: Role[]) => boolean;
}
const AuthContext = createContext<AuthState | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<CurrentUser | null>(null);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
setLoading(true);
try {
setUser(await getCurrentUser());
} catch {
setUser(null);
} finally {
setLoading(false);
}
}, []);
const clear = useCallback(() => setUser(null), []);
const hasRole = useCallback(
(...roles: Role[]) => !!user && roles.some((r) => user.roles.includes(r)),
[user],
);
useEffect(() => {
void refresh();
}, [refresh]);
return (
<AuthContext.Provider value={{ user, loading, refresh, clear, hasRole }}>
{children}
</AuthContext.Provider>
);
};
export function useAuth(): AuthState {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error('useAuth must be used within AuthProvider');
}
return ctx;
}

33
frontend/src/chime.ts Normal file
View File

@@ -0,0 +1,33 @@
// Simple two-tone "띵동" chime via Web Audio — no audio asset needed.
// Must be triggered from a user gesture (e.g. button click) to satisfy autoplay policy.
let ctx: AudioContext | null = null;
function tone(audio: AudioContext, freq: number, startAt: number, dur: number) {
const osc = audio.createOscillator();
const gain = audio.createGain();
osc.type = 'sine';
osc.frequency.value = freq;
osc.connect(gain);
gain.connect(audio.destination);
gain.gain.setValueAtTime(0.0001, startAt);
gain.gain.exponentialRampToValueAtTime(0.35, startAt + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, startAt + dur);
osc.start(startAt);
osc.stop(startAt + dur + 0.02);
}
/** Plays a descending two-note "ding-dong" chime. Safe no-op if audio is unavailable. */
export function playChime(): void {
try {
const Ctor = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
if (!Ctor) return;
ctx = ctx ?? new Ctor();
if (ctx.state === 'suspended') void ctx.resume();
const now = ctx.currentTime;
tone(ctx, 784, now, 0.35); // "띵" (G5)
tone(ctx, 523.25, now + 0.18, 0.45); // "동" (C5)
} catch {
/* ignore audio errors */
}
}

View File

@@ -0,0 +1,55 @@
import React, { useRef } from 'react';
import DatePicker, { registerLocale } from 'react-datepicker';
import { ko } from 'date-fns/locale';
import 'react-datepicker/dist/react-datepicker.css';
registerLocale('ko', ko);
interface Props {
/** datetime-local string, e.g. "2026-07-02T08:24". */
value: string;
onChange: (value: string) => void;
placeholder?: string;
}
const pad = (n: number) => String(n).padStart(2, '0');
/** Date → "YYYY-MM-DDTHH:mm" (local), the format the form/back-end expect. */
function toLocalString(d: Date): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/**
* Korean-localized date+time picker that replaces the browser-native
* <input type="datetime-local"> (whose popup labels/border/buttons cannot be
* styled). Keeps the calendar open until the user presses [입력] so the choice
* is explicit.
*/
export const DateTimePicker: React.FC<Props> = ({ value, onChange, placeholder }) => {
const ref = useRef<DatePicker>(null);
return (
<DatePicker
ref={ref}
selected={value ? new Date(value) : null}
onChange={(d: Date | null) => d && onChange(toLocalString(d))}
showTimeSelect
timeIntervals={5}
timeCaption="시간"
timeFormat="a K:mm"
dateFormat="yyyy.MM.dd (eee) a K:mm"
dateFormatCalendar="yyyy.M월"
locale="ko"
shouldCloseOnSelect={false}
placeholderText={placeholder ?? '날짜와 시간을 선택하세요'}
className="dt-input"
popperClassName="acs-datepicker"
>
<div className="dt-actions">
<button type="button" className="btn-primary dt-confirm" onClick={() => ref.current?.setOpen(false)}>
</button>
</div>
</DatePicker>
);
};

View File

@@ -0,0 +1,52 @@
import React, { useState } from 'react';
interface Props {
title: string;
message?: string;
/** Show an optional text field and pass its value to onConfirm. */
withInput?: boolean;
inputPlaceholder?: string;
confirmLabel?: string;
cancelLabel?: string;
danger?: boolean;
onConfirm: (text?: string) => void;
onCancel: () => void;
}
/**
* In-app modal replacing window.prompt/confirm (unsupported in embedded browsers).
* With `withInput`, collects an optional text value (e.g. rejection reason).
*/
export const Dialog: React.FC<Props> = ({
title, message, withInput, inputPlaceholder, confirmLabel = '확인', cancelLabel = '취소',
danger, onConfirm, onCancel,
}) => {
const [text, setText] = useState('');
return (
<div className="modal-overlay" onClick={onCancel}>
<div className="modal-box" onClick={(e) => e.stopPropagation()}>
<h3 className="modal-title">{title}</h3>
{message && <p className="modal-message">{message}</p>}
{withInput && (
<textarea
className="modal-input"
placeholder={inputPlaceholder}
value={text}
onChange={(e) => setText(e.target.value)}
autoFocus
rows={3}
/>
)}
<div className="modal-actions">
<button className="btn-ghost" onClick={onCancel}>{cancelLabel}</button>
<button
className={danger ? 'btn-danger' : 'btn-primary'}
onClick={() => onConfirm(withInput ? text.trim() || undefined : undefined)}
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,53 @@
import React from 'react';
import { Link, NavLink, useNavigate } from 'react-router-dom';
import { useAuth } from '../auth/AuthContext';
import { logout } from '../api';
import bokBadge from '../assets/bok-badge.png';
/** Display labels for role codes shown in the top bar (HOST is shown as USER). */
const ROLE_LABEL: Record<string, string> = { ADMIN: 'ADMIN', SECURITY: 'SECURITY', HOST: 'USER' };
/** Shared app chrome: top bar with role-aware nav + logout. */
export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { user, clear, hasRole } = useAuth();
const navigate = useNavigate();
const onLogout = async () => {
try {
await logout();
} finally {
clear();
navigate('/login', { replace: true });
}
};
return (
<div className="app-shell">
<header className="topbar">
<Link to="/dashboard" className="brand">
<img src={bokBadge} alt="" className="brand-badge" />
IT센터
</Link>
<nav className="nav">
<NavLink to="/visit-requests"></NavLink>
{hasRole('ADMIN') && <NavLink to="/approvals"></NavLink>}
{hasRole('HOST', 'SECURITY', 'ADMIN') && <NavLink to="/access"></NavLink>}
{hasRole('SECURITY', 'ADMIN') && <NavLink to="/reports"></NavLink>}
{hasRole('ADMIN') && <NavLink to="/blacklist"></NavLink>}
</nav>
<div className="user-box">
<span className="user-name">
{user?.fullName}
<span className="role-tags">
{user?.roles.map((r) => (
<span key={r} className="role-tag">{ROLE_LABEL[r] ?? r}</span>
))}
</span>
</span>
<button className="btn-ghost" onClick={onLogout}></button>
</div>
</header>
<main className="content">{children}</main>
</div>
);
};

View File

@@ -0,0 +1,34 @@
import React, { useState } from 'react';
interface Props {
value: string;
onChange: React.ChangeEventHandler<HTMLInputElement>;
autoFocus?: boolean;
placeholder?: string;
}
/** Password field with a show/hide (eye) toggle. */
export const PasswordInput: React.FC<Props> = ({ value, onChange, autoFocus, placeholder }) => {
const [show, setShow] = useState(false);
return (
<div className="password-wrap">
<input
type={show ? 'text' : 'password'}
value={value}
onChange={onChange}
autoFocus={autoFocus}
placeholder={placeholder}
/>
<button
type="button"
className="password-toggle"
tabIndex={-1}
onClick={() => setShow((s) => !s)}
aria-label={show ? '비밀번호 숨기기' : '비밀번호 표시'}
title={show ? '비밀번호 숨기기' : '비밀번호 표시'}
>
{show ? '🙈' : '👁️'}
</button>
</div>
);
};

13
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { AuthProvider } from './auth/AuthContext';
import './styles/common.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<AuthProvider>
<App />
</AuthProvider>
</React.StrictMode>,
);

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

66
frontend/src/status.ts Normal file
View File

@@ -0,0 +1,66 @@
import { VisitStatus } from './types';
export const STATUS_LABEL: Record<VisitStatus, string> = {
DRAFT: '임시저장',
PENDING: '승인대기',
APPROVED: '승인완료',
REJECTED: '반려',
CANCELLED: '취소',
EXPIRED: '만료',
};
/** CSS class suffix for the status badge color. */
export const STATUS_CLASS: Record<VisitStatus, string> = {
DRAFT: 'gray',
PENDING: 'amber',
APPROVED: 'green',
REJECTED: 'red',
CANCELLED: 'gray',
EXPIRED: 'gray',
};
export function formatDateTime(iso?: string): string {
if (!iso) return '-';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
const pad = (n: number) => String(n).padStart(2, '0');
/** Compact `MM.DD HH:mm` for dense tables. */
export function formatShort(iso?: string): string {
if (!iso) return '-';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return `${pad(d.getMonth() + 1)}.${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/** Compact `YYYY.MM.DD HH:mm` for tight table cells. */
function compact(d: Date): string {
return `${d.getFullYear()}.${pad(d.getMonth() + 1)}.${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/**
* Compact visit period for table cells.
* Same day → `2026.07.01 16:29 ~ 16:33`, otherwise → `2026.07.01 16:29 ~ 2026.07.02 09:00`.
*/
export function formatVisitRange(fromIso?: string, toIso?: string): string {
if (!fromIso) return '-';
const from = new Date(fromIso);
if (Number.isNaN(from.getTime())) return fromIso;
if (!toIso) return compact(from);
const to = new Date(toIso);
if (Number.isNaN(to.getTime())) return `${compact(from)} ~ ${toIso}`;
const sameDay =
from.getFullYear() === to.getFullYear() &&
from.getMonth() === to.getMonth() &&
from.getDate() === to.getDate();
return `${compact(from)} ~ ${sameDay ? `${pad(to.getHours())}:${pad(to.getMinutes())}` : compact(to)}`;
}

View File

@@ -0,0 +1,304 @@
:root {
--primary: #2563eb;
--primary-dark: #1d4ed8;
--bg: #f1f5f9;
--surface: #ffffff;
--border: #e2e8f0;
--text: #0f172a;
--muted: #64748b;
--green: #16a34a;
--amber: #d97706;
--red: #dc2626;
--gray: #64748b;
--blue: #2563eb;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: 'Segoe UI', 'Malgun Gothic', system-ui, sans-serif;
background: var(--bg);
color: var(--text);
overflow-x: hidden;
}
a { color: inherit; text-decoration: none; }
/* ===== Layout ===== */
.app-shell { min-height: 100vh; }
.topbar {
display: flex;
align-items: center;
gap: 12px;
padding: 0 16px;
height: 58px;
background: var(--surface);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 10;
width: 100%;
box-sizing: border-box;
}
.brand { display: inline-flex; align-items: center; gap: 8px; font-weight: 700; font-size: 15px; white-space: nowrap; flex-shrink: 0; cursor: pointer; color: var(--text); }
.brand:hover { color: var(--primary); }
.brand-badge { height: 24px; width: auto; display: block; }
/* nav takes the middle space and scrolls internally if too narrow, so the
brand (left) and user/logout (right) always stay visible. */
.nav {
display: flex;
gap: 2px;
flex: 1 1 auto;
min-width: 0;
flex-wrap: nowrap;
overflow-x: auto;
scrollbar-width: none;
}
.nav::-webkit-scrollbar { display: none; }
.nav a {
padding: 8px 10px;
border-radius: 8px;
color: var(--muted);
font-weight: 500;
white-space: nowrap;
}
.nav a:hover { background: var(--bg); color: var(--text); }
.nav a.active { background: #eff6ff; color: var(--primary); }
.user-box { display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
.user-name { font-size: 14px; font-weight: 600; display: flex; align-items: center; gap: 8px; white-space: nowrap; }
.role-tags { display: inline-flex; gap: 4px; }
.role-tag {
background: #eef2ff; color: #4338ca;
font-size: 11px; font-weight: 700;
padding: 2px 6px; border-radius: 6px;
}
.content { max-width: 1080px; margin: 0 auto; padding: 28px 24px; }
/* ===== Center (auth) ===== */
.center-screen {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.auth-card { width: 360px; text-align: center; }
.auth-badge { display: block; width: 72px; height: 72px; margin: 0 auto 12px; object-fit: contain; }
.auth-card .field { text-align: left; }
.auth-title { margin: 0 0 4px; font-size: 20px; }
.auth-sub { margin: 0 0 20px; color: var(--muted); font-size: 14px; }
.hint { margin-top: 16px; font-size: 12px; color: var(--muted); text-align: center; }
/* IME hints: works in Whale/Firefox (English default / Korean default); ignored elsewhere. */
.ime-en { ime-mode: inactive; }
.ime-ko { ime-mode: active; }
/* ===== Cards / page head ===== */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
.card h3 { margin-top: 0; }
.page-head {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 20px;
}
.page-head h2 { margin: 0; }
.head-actions { display: flex; gap: 8px; }
/* ===== Fields / forms ===== */
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
.field > span { font-size: 13px; font-weight: 600; color: #334155; }
.field input, .field select, .field textarea {
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 14px;
background: #fff;
}
.field input:focus, .field select:focus { outline: 2px solid #bfdbfe; border-color: var(--primary); }
/* react-datepicker: make the input fill the field like the native ones */
.field .react-datepicker-wrapper { width: 100%; }
.field .react-datepicker__input-container input { width: 100%; box-sizing: border-box; }
/* clearer popup border + shadow, and a full-width [입력] footer button */
.acs-datepicker .react-datepicker { border: 1px solid var(--border); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); }
.dt-actions { padding: 8px; border-top: 1px solid var(--border); }
.dt-confirm { width: 100%; }
/* In-app modal dialog (replaces window.prompt/confirm) */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 16px; }
.modal-box { background: #fff; border-radius: 12px; padding: 20px; width: 420px; max-width: 100%; box-shadow: 0 12px 32px rgba(0,0,0,0.2); }
.modal-title { margin: 0 0 8px; font-size: 16px; }
.modal-message { margin: 0 0 12px; color: var(--muted); font-size: 14px; }
.modal-input { width: 100%; box-sizing: border-box; padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; font-size: 14px; resize: vertical; }
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
/* Public entrance kiosk */
.kiosk { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; background: var(--bg, #f1f5f9); }
.kiosk-card { width: 420px; max-width: 100%; background: #fff; border: 1px solid var(--border); border-radius: 16px; padding: 24px; box-shadow: 0 10px 30px rgba(0,0,0,0.08); text-align: center; }
.kiosk-actions { display: flex; flex-direction: column; gap: 10px; margin-top: 16px; }
.kiosk-result { margin-top: 16px; }
.kiosk-ok { font-size: 18px; font-weight: 700; margin: 12px 0 20px; }
.btn-primary.full, .btn-success.full, .btn-danger.full, .btn-ghost.full { width: 100%; padding: 14px; font-size: 16px; }
/* Password field with show/hide toggle */
.password-wrap { position: relative; display: flex; }
.password-wrap input { flex: 1; width: 100%; padding-right: 42px; }
.password-toggle {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
font-size: 16px;
line-height: 1;
padding: 4px 6px;
cursor: pointer;
opacity: .8;
}
.password-toggle:hover { opacity: 1; }
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 18px;
}
.form-grid .span-2 { grid-column: 1 / -1; }
.form-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 8px; }
/* faint inline helper next to a label */
.hint-inline { font-weight: 400; font-style: normal; color: var(--muted); font-size: 12px; }
/* consent checkbox block */
.consent-box {
background: #f8fafc;
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 14px;
margin-bottom: 8px;
}
.consent-label { display: flex; gap: 10px; align-items: flex-start; cursor: pointer; font-size: 13px; color: #334155; line-height: 1.5; }
.consent-label input { margin-top: 2px; width: 16px; height: 16px; flex-shrink: 0; }
/* ===== Buttons ===== */
button { font-family: inherit; cursor: pointer; }
.btn-primary, .btn-ghost, .btn-success, .btn-danger {
padding: 9px 16px; border-radius: 8px; font-weight: 600; font-size: 14px;
border: 1px solid transparent;
}
.btn-primary { background: var(--primary); color: #fff; }
.btn-primary:hover { background: var(--primary-dark); }
.btn-primary.full { width: 100%; }
.btn-ghost { background: #fff; border-color: var(--border); color: var(--text); }
.btn-ghost:hover { background: var(--bg); }
.btn-success { background: var(--green); color: #fff; }
.btn-danger { background: var(--red); color: #fff; }
button:disabled { opacity: .55; cursor: not-allowed; }
.btn-link-danger { background: none; border: none; color: var(--red); font-weight: 600; }
.action-cell { display: flex; gap: 8px; }
/* ===== Stats ===== */
.stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 20px; }
.stat-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: 12px; padding: 18px 20px;
border-left: 4px solid var(--gray);
}
.stat-card.accent-blue { border-left-color: var(--blue); }
.stat-card.accent-amber { border-left-color: var(--amber); }
.stat-card.accent-green { border-left-color: var(--green); }
.stat-card.accent-gray { border-left-color: var(--gray); }
.stat-value { font-size: 30px; font-weight: 800; }
.stat-label { color: var(--muted); font-size: 13px; margin-top: 2px; }
/* ===== Table ===== */
.table { width: 100%; border-collapse: collapse; }
.table th, .table td {
text-align: left; padding: 11px 12px;
border-bottom: 1px solid var(--border);
font-size: 14px;
}
.table th { color: var(--muted); font-weight: 600; font-size: 12px; text-transform: none; }
.table tbody tr:hover { background: #f8fafc; }
/* ===== Badges ===== */
.badge {
display: inline-block; padding: 3px 10px; border-radius: 999px;
font-size: 12px; font-weight: 700;
}
.badge-green { background: #dcfce7; color: #15803d; }
.badge-amber { background: #fef3c7; color: #b45309; }
.badge-red { background: #fee2e2; color: #b91c1c; }
.badge-gray { background: #e2e8f0; color: #475569; }
/* ===== Alerts ===== */
.alert { padding: 12px 14px; border-radius: 8px; margin-bottom: 16px; font-size: 14px; }
.alert-error { background: #fee2e2; color: #b91c1c; }
.alert-info { background: #dbeafe; color: #1e40af; }
.muted { color: var(--muted); }
/* ===== Access console ===== */
.console-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items: start; }
.inline-form { display: flex; gap: 8px; }
.inline-form input { flex: 1; padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; font-size: 14px; }
.inline-form input:focus { outline: 2px solid #bfdbfe; border-color: var(--primary); }
.qr-scanner { position: relative; margin-top: 12px; max-width: 360px; aspect-ratio: 4 / 3; border-radius: 10px; overflow: hidden; background: #000; }
.qr-scanner video { width: 100%; height: 100%; object-fit: cover; display: block; }
.qr-scanner-guide { position: absolute; inset: 18%; border: 3px solid rgba(255, 255, 255, 0.85); border-radius: 12px; box-shadow: 0 0 0 100vmax rgba(0, 0, 0, 0.25); pointer-events: none; }
@media (max-width: 720px) {
.console-grid { grid-template-columns: 1fr; }
}
.btn-link { background: none; border: none; color: var(--primary); font-weight: 600; }
.row-actions { display: flex; gap: 10px; }
/* ===== Report ===== */
.report-row { display: flex; align-items: flex-end; gap: 16px; }
.report-row .field { margin-bottom: 0; }
/* ===== Badge ===== */
.badge-sheet { display: flex; justify-content: center; padding: 12px; }
.badge-card {
width: 320px;
background: #fff;
border: 2px solid var(--primary);
border-radius: 16px;
padding: 24px;
text-align: center;
box-shadow: 0 6px 24px rgba(0,0,0,.08);
}
.badge-head {
background: var(--primary); color: #fff;
font-weight: 700; padding: 8px; border-radius: 8px; margin-bottom: 16px;
}
.badge-name { font-size: 26px; font-weight: 800; }
.badge-company { color: var(--muted); margin-bottom: 16px; }
.badge-qr { width: 200px; height: 200px; image-rendering: pixelated; }
.badge-meta { text-align: center; margin: 16px 0; font-size: 13px; }
.badge-meta > div { padding: 4px 0; border-bottom: 1px dashed var(--border); }
.badge-foot { font-size: 12px; color: var(--muted); margin-top: 8px; }
@media print {
.topbar, .no-print { display: none !important; }
.content { padding: 0; }
body { background: #fff; }
.badge-card { box-shadow: none; }
}
@media (max-width: 720px) {
.stat-grid { grid-template-columns: repeat(2, 1fr); }
.form-grid { grid-template-columns: 1fr; }
.nav { display: none; }
}

145
frontend/src/types.ts Normal file
View File

@@ -0,0 +1,145 @@
export interface ApiResponse<T> {
code: number;
message: string;
data: T | null;
}
export type Role = 'ADMIN' | 'SECURITY' | 'HOST';
export interface CurrentUser {
id: number;
username: string;
fullName: string;
roles: Role[];
mustChangePassword: boolean;
}
export interface LoginRequest {
username: string;
password: string;
}
export interface ChangePasswordRequest {
oldPassword: string;
newPassword: string;
}
export interface Zone {
id: number;
code: string;
name: string;
securityLevel: number;
}
export type VisitStatus =
| 'DRAFT'
| 'PENDING'
| 'APPROVED'
| 'REJECTED'
| 'CANCELLED'
| 'EXPIRED';
export interface VisitRequestCreate {
visitorName: string;
company?: string;
contact: string;
email?: string;
vehicleNo?: string;
zoneName?: string;
purpose: string;
visitFrom: string; // ISO local datetime
visitTo: string;
}
export interface VisitRequestView {
id: number;
visitorName: string;
company?: string;
contact?: string;
vehicleNo?: string;
hostId: number;
hostName: string;
hostDepartment?: string;
zoneName?: string;
purpose: string;
visitFrom: string;
visitTo: string;
status: VisitStatus;
qrToken?: string;
createdAt: string;
}
export interface PublicPass {
visitorName: string;
company?: string;
zoneName?: string;
visitFrom: string;
visitTo: string;
/** Whether the visitor is currently inside — drives the kiosk 입장/퇴장 button. */
inside: boolean;
/** Whether the visit already completed (entered & exited) today — blocks re-entry. */
completedToday: boolean;
}
export interface ExcelImportResult {
totalRows: number;
successCount: number;
errors: string[];
success: boolean;
}
export interface InsideVisitor {
visitRequestId: number;
visitorName: string;
company?: string;
zoneName?: string;
hostName: string;
checkInAt: string;
}
export interface AccessRecord {
visitRequestId: number;
visitorName: string;
company?: string;
zoneName?: string;
visitFrom: string;
visitTo: string;
checkInAt?: string;
checkOutAt?: string;
inside: boolean;
}
export interface AccessAction {
visitRequestId: number;
visitorName: string;
direction: 'IN' | 'OUT';
eventAt: string;
gateOpened: boolean;
message: string;
}
export interface StatsSummary {
todayVisits: number;
pending: number;
approved: number;
currentlyInside: number;
total: number;
}
export interface BlacklistItem {
id: number;
name: string;
company?: string;
contact?: string;
reason: string;
active: boolean;
createdByName?: string;
createdAt: string;
}
export interface BlacklistCreate {
name: string;
company?: string;
contact?: string;
reason: string;
}

View File

@@ -0,0 +1,137 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import jsQR from 'jsqr';
/** How long (ms) to ignore repeat decodes of the same token after a hit. */
const COOLDOWN_MS = 2500;
interface Options {
/** Called with the decoded QR text (the visitor's qrToken). */
onDecode: (text: string) => void;
}
interface Scanner {
videoRef: React.RefObject<HTMLVideoElement | null>;
canvasRef: React.RefObject<HTMLCanvasElement | null>;
active: boolean;
error: string | null;
start: () => void;
stop: () => void;
}
/**
* Webcam QR scanner. Streams the camera into a <video>, samples frames onto an
* offscreen <canvas>, and decodes with jsQR each animation frame. Requires a
* secure context (https or localhost) — browsers block getUserMedia otherwise.
*/
export function useQrScanner({ onDecode }: Options): Scanner {
const videoRef = useRef<HTMLVideoElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const rafRef = useRef<number | null>(null);
const lastHitRef = useRef<{ text: string; at: number } | null>(null);
const onDecodeRef = useRef(onDecode);
onDecodeRef.current = onDecode;
const [active, setActive] = useState(false);
const [error, setError] = useState<string | null>(null);
const stop = useCallback(() => {
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
if (streamRef.current) {
streamRef.current.getTracks().forEach((t) => t.stop());
streamRef.current = null;
}
if (videoRef.current) {
videoRef.current.srcObject = null;
}
lastHitRef.current = null;
setActive(false);
}, []);
const tick = useCallback(() => {
// Stopped (e.g. after a successful/failed decode called stop()): don't reschedule.
if (!streamRef.current) {
return;
}
const video = videoRef.current;
const canvas = canvasRef.current;
if (!video || !canvas || video.readyState !== video.HAVE_ENOUGH_DATA) {
rafRef.current = requestAnimationFrame(tick);
return;
}
const w = video.videoWidth;
const h = video.videoHeight;
if (w === 0 || h === 0) {
rafRef.current = requestAnimationFrame(tick);
return;
}
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) {
rafRef.current = requestAnimationFrame(tick);
return;
}
ctx.drawImage(video, 0, 0, w, h);
const image = ctx.getImageData(0, 0, w, h);
const result = jsQR(image.data, w, h, { inversionAttempts: 'dontInvert' });
if (result && result.data) {
const text = result.data.trim();
const prev = lastHitRef.current;
const now = performance.now();
const isRepeat = prev && prev.text === text && now - prev.at < COOLDOWN_MS;
if (text && !isRepeat) {
lastHitRef.current = { text, at: now };
onDecodeRef.current(text);
}
}
rafRef.current = requestAnimationFrame(tick);
}, []);
const start = useCallback(async () => {
setError(null);
// eslint-disable-next-line no-console
console.log('[qr-scanner] start() called; mediaDevices=', !!navigator.mediaDevices,
'getUserMedia=', !!navigator.mediaDevices?.getUserMedia, 'secureContext=', window.isSecureContext);
if (!navigator.mediaDevices?.getUserMedia) {
setError('카메라를 사용할 수 없습니다. http://localhost 로 접속했는지 확인하세요. (IP 주소로는 카메라가 차단됩니다.)');
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'user' },
});
// eslint-disable-next-line no-console
console.log('[qr-scanner] getUserMedia OK, tracks:', stream.getTracks().length);
streamRef.current = stream;
setActive(true);
const video = videoRef.current;
if (video) {
video.srcObject = stream;
video.setAttribute('playsinline', 'true');
await video.play();
}
rafRef.current = requestAnimationFrame(tick);
} catch (e) {
// eslint-disable-next-line no-console
console.error('[qr-scanner] getUserMedia failed:', e);
const name = e instanceof DOMException ? e.name : '';
if (name === 'NotAllowedError') {
setError('카메라 사용 권한이 거부되었습니다. 브라우저 주소창의 카메라 아이콘에서 허용해 주세요.');
} else if (name === 'NotFoundError') {
setError('사용 가능한 카메라를 찾을 수 없습니다.');
} else {
setError('카메라를 시작할 수 없습니다: ' + (e instanceof Error ? e.message : String(e)));
}
stop();
}
}, [tick, stop]);
// Ensure the camera is released if the component unmounts while scanning.
useEffect(() => stop, [stop]);
return { videoRef, canvasRef, active, error, start, stop };
}

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />