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:
3
frontend/.dockerignore
Normal file
3
frontend/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
14
frontend/Dockerfile
Normal file
14
frontend/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
# ===== build =====
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ===== serve (nginx serves SPA + proxies /api to backend) =====
|
||||
FROM nginx:stable-alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>IT센터 출입자관리시스템</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
23
frontend/nginx.conf
Normal file
23
frontend/nginx.conf
Normal file
@@ -0,0 +1,23 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA client-side routing: fall back to index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy API calls to the backend service (session cookie preserved)
|
||||
location /api/ {
|
||||
proxy_pass http://app:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cookie_path / /;
|
||||
}
|
||||
}
|
||||
2028
frontend/package-lock.json
generated
Normal file
2028
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
frontend/package.json
Normal file
26
frontend/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "acs-frontend",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"date-fns": "^4.4.0",
|
||||
"jsqr": "^1.4.0",
|
||||
"react": "^19.0.0",
|
||||
"react-datepicker": "^9.1.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.5"
|
||||
}
|
||||
}
|
||||
90
frontend/src/App.tsx
Normal file
90
frontend/src/App.tsx
Normal 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
143
frontend/src/api.ts
Normal 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}`;
|
||||
BIN
frontend/src/assets/bok-badge.png
Normal file
BIN
frontend/src/assets/bok-badge.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
BIN
frontend/src/assets/bok-removebg.png
Normal file
BIN
frontend/src/assets/bok-removebg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
54
frontend/src/auth/AuthContext.tsx
Normal file
54
frontend/src/auth/AuthContext.tsx
Normal 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
33
frontend/src/chime.ts
Normal 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 */
|
||||
}
|
||||
}
|
||||
55
frontend/src/components/DateTimePicker.tsx
Normal file
55
frontend/src/components/DateTimePicker.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
52
frontend/src/components/Dialog.tsx
Normal file
52
frontend/src/components/Dialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
53
frontend/src/components/Layout.tsx
Normal file
53
frontend/src/components/Layout.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
34
frontend/src/components/PasswordInput.tsx
Normal file
34
frontend/src/components/PasswordInput.tsx
Normal 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
13
frontend/src/main.tsx
Normal 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>,
|
||||
);
|
||||
160
frontend/src/pages/AccessConsolePage.tsx
Normal file
160
frontend/src/pages/AccessConsolePage.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
checkIn,
|
||||
checkOut,
|
||||
listTodayAccess,
|
||||
searchApprovedForCheckIn,
|
||||
} from '../api';
|
||||
import { AccessRecord, VisitRequestView } from '../types';
|
||||
import { formatShort } from '../status';
|
||||
|
||||
/**
|
||||
* Staff access console (login: 담당자/보안/관리자). Name search → force check-in/out,
|
||||
* plus today's full access log (신청 입/퇴장 + 실제 입/퇴장, 퇴실자 포함).
|
||||
* Visitor self-service (QR scan) is the separate public kiosk (/kiosk).
|
||||
*/
|
||||
export const AccessConsolePage: React.FC = () => {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<VisitRequestView[]>([]);
|
||||
const [records, setRecords] = useState<AccessRecord[]>([]);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const loadRecords = () => {
|
||||
listTodayAccess().then(setRecords).catch(() => setRecords([]));
|
||||
};
|
||||
|
||||
// Poll so kiosk self check-in/out shows up here without a manual refresh.
|
||||
useEffect(() => {
|
||||
loadRecords();
|
||||
const t = setInterval(loadRecords, 3000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
|
||||
const insideIds = new Set(records.filter((r) => r.inside).map((r) => r.visitRequestId));
|
||||
// Entered and already left today → no re-entry allowed.
|
||||
const exitedIds = new Set(records.filter((r) => !r.inside).map((r) => r.visitRequestId));
|
||||
|
||||
const wrap = async (fn: () => Promise<void>) => {
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await fn();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '처리 실패');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
void wrap(async () => {
|
||||
setResults(await searchApprovedForCheckIn(query.trim()));
|
||||
});
|
||||
};
|
||||
|
||||
const forceCheckIn = (id: number) =>
|
||||
wrap(async () => {
|
||||
const res = await checkIn({ visitRequestId: id, gateId: 'STAFF' });
|
||||
setNotice(`${res.visitorName} — ${res.message}`);
|
||||
loadRecords();
|
||||
});
|
||||
|
||||
const forceCheckOut = (id: number) =>
|
||||
wrap(async () => {
|
||||
const res = await checkOut({ visitRequestId: id });
|
||||
setNotice(`${res.visitorName} — ${res.message}`);
|
||||
loadRecords();
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head"><h2>출입 콘솔 (직원용)</h2></div>
|
||||
|
||||
{notice && <div className="alert alert-info">{notice}</div>}
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
<div className="card">
|
||||
<h3>이름으로 검색 · 강제 입/퇴장</h3>
|
||||
<form onSubmit={onSearch} className="inline-form">
|
||||
<input
|
||||
placeholder="방문자 이름"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<button className="btn-ghost" type="submit" disabled={busy}>검색</button>
|
||||
</form>
|
||||
{results.length > 0 && (
|
||||
<table className="table" style={{ marginTop: 12 }}>
|
||||
<thead>
|
||||
<tr><th>방문자</th><th>회사</th><th>구역</th><th>처리</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td>{r.visitorName}</td>
|
||||
<td>{r.company || '-'}</td>
|
||||
<td>{r.zoneName || '-'}</td>
|
||||
<td>
|
||||
{insideIds.has(r.id) ? (
|
||||
<button className="btn-danger" disabled={busy} onClick={() => forceCheckOut(r.id)}>퇴장</button>
|
||||
) : exitedIds.has(r.id) ? (
|
||||
<span className="muted">금일 완료</span>
|
||||
) : (
|
||||
<button className="btn-success" disabled={busy} onClick={() => forceCheckIn(r.id)}>입장</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginTop: 20 }}>
|
||||
<h3>금일 출입 현황 ({records.length})</h3>
|
||||
{records.length === 0 ? (
|
||||
<p className="muted">금일 입장 기록이 없습니다.</p>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>방문자</th><th>회사</th><th>구역</th>
|
||||
<th>신청 입장</th><th>신청 퇴장</th>
|
||||
<th>실제 입장</th><th>실제 퇴장</th>
|
||||
<th>상태</th><th>처리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((r) => (
|
||||
<tr key={r.visitRequestId}>
|
||||
<td>{r.visitorName}</td>
|
||||
<td>{r.company || '-'}</td>
|
||||
<td>{r.zoneName || '-'}</td>
|
||||
<td>{formatShort(r.visitFrom)}</td>
|
||||
<td>{formatShort(r.visitTo)}</td>
|
||||
<td>{formatShort(r.checkInAt)}</td>
|
||||
<td>{formatShort(r.checkOutAt)}</td>
|
||||
<td>
|
||||
<span className={`badge badge-${r.inside ? 'green' : 'gray'}`}>
|
||||
{r.inside ? '재실중' : '퇴실'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{r.inside && (
|
||||
<button className="btn-danger" disabled={busy} onClick={() => forceCheckOut(r.visitRequestId)}>퇴장</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
146
frontend/src/pages/ApprovalQueuePage.tsx
Normal file
146
frontend/src/pages/ApprovalQueuePage.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { approveRequest, listPendingRequests, rejectRequest } from '../api';
|
||||
import { VisitRequestView } from '../types';
|
||||
import { formatVisitRange } from '../status';
|
||||
import { Dialog } from '../components/Dialog';
|
||||
|
||||
type SortKey = 'visitorName' | 'company' | 'zoneName' | 'purpose' | 'visitFrom';
|
||||
|
||||
const COLUMNS: { key: SortKey; label: string }[] = [
|
||||
{ key: 'visitorName', label: '방문자' },
|
||||
{ key: 'company', label: '회사' },
|
||||
{ key: 'zoneName', label: '출입구역' },
|
||||
{ key: 'purpose', label: '출입목적' },
|
||||
{ key: 'visitFrom', label: '출입기간' },
|
||||
];
|
||||
|
||||
export const ApprovalQueuePage: React.FC = () => {
|
||||
const [items, setItems] = useState<VisitRequestView[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busyId, setBusyId] = useState<number | null>(null);
|
||||
const [sortKey, setSortKey] = useState<SortKey | null>(null);
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
||||
const [rejectingId, setRejectingId] = useState<number | null>(null);
|
||||
|
||||
const onSort = (key: SortKey) => {
|
||||
if (sortKey === key) {
|
||||
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const sortedItems = useMemo(() => {
|
||||
if (!sortKey) return items;
|
||||
const arr = [...items];
|
||||
arr.sort((a, b) => {
|
||||
const cmp = sortKey === 'visitFrom'
|
||||
? new Date(a.visitFrom).getTime() - new Date(b.visitFrom).getTime()
|
||||
: String(a[sortKey] ?? '').localeCompare(String(b[sortKey] ?? ''), 'ko');
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
return arr;
|
||||
}, [items, sortKey, sortDir]);
|
||||
|
||||
const load = () => {
|
||||
setLoading(true);
|
||||
listPendingRequests()
|
||||
.then(setItems)
|
||||
.catch((e) => setError(e instanceof Error ? e.message : '조회 실패'))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(load, []);
|
||||
|
||||
const approve = async (id: number) => {
|
||||
setError(null);
|
||||
setBusyId(id);
|
||||
try {
|
||||
await approveRequest(id);
|
||||
load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '처리 실패');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmReject = async (comment?: string) => {
|
||||
const id = rejectingId;
|
||||
setRejectingId(null);
|
||||
if (id == null) return;
|
||||
setError(null);
|
||||
setBusyId(id);
|
||||
try {
|
||||
await rejectRequest(id, comment);
|
||||
load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '처리 실패');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head"><h2>승인 대기 ({items.length})</h2></div>
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
<div className="card">
|
||||
{loading ? (
|
||||
<p className="muted">불러오는 중…</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="muted">승인 대기 중인 신청이 없습니다.</p>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
{COLUMNS.map((c) => (
|
||||
<th
|
||||
key={c.key}
|
||||
onClick={() => onSort(c.key)}
|
||||
style={{ cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap' }}
|
||||
title="클릭하여 정렬"
|
||||
>
|
||||
{c.label}{sortKey === c.key ? (sortDir === 'asc' ? ' ▲' : ' ▼') : ''}
|
||||
</th>
|
||||
))}
|
||||
<th>처리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedItems.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td>{r.visitorName}</td>
|
||||
<td>{r.company || '-'}</td>
|
||||
<td>{r.zoneName || '-'}</td>
|
||||
<td>{r.purpose}</td>
|
||||
<td>{formatVisitRange(r.visitFrom, r.visitTo)}</td>
|
||||
<td className="action-cell">
|
||||
<button className="btn-success" disabled={busyId === r.id} onClick={() => approve(r.id)}>승인</button>
|
||||
<button className="btn-danger" disabled={busyId === r.id} onClick={() => setRejectingId(r.id)}>반려</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rejectingId != null && (
|
||||
<Dialog
|
||||
title="반려 처리"
|
||||
message="반려 사유를 입력하세요 (선택)."
|
||||
withInput
|
||||
inputPlaceholder="반려 사유"
|
||||
confirmLabel="반려"
|
||||
danger
|
||||
onConfirm={confirmReject}
|
||||
onCancel={() => setRejectingId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
60
frontend/src/pages/BadgePage.tsx
Normal file
60
frontend/src/pages/BadgePage.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { getPass, passQrUrl } from '../api';
|
||||
import { VisitRequestView } from '../types';
|
||||
import { formatVisitRange } from '../status';
|
||||
|
||||
export const BadgePage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [pass, setPass] = useState<VisitRequestView | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
getPass(Number(id))
|
||||
.then(setPass)
|
||||
.catch((e) => setError(e instanceof Error ? e.message : '조회 실패'));
|
||||
}, [id]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head"><h2>출입증</h2></div>
|
||||
<div className="alert alert-error">{error}</div>
|
||||
<button className="btn-ghost" onClick={() => navigate(-1)}>뒤로</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!pass) {
|
||||
return <div className="muted">불러오는 중…</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head no-print">
|
||||
<h2>출입증</h2>
|
||||
<div className="head-actions">
|
||||
<button className="btn-ghost" onClick={() => navigate(-1)}>뒤로</button>
|
||||
<button className="btn-primary" onClick={() => window.print()}>인쇄</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="badge-sheet">
|
||||
<div className="badge-card">
|
||||
<div className="badge-head">IT센터 출입증</div>
|
||||
<div className="badge-name">{pass.visitorName}</div>
|
||||
<div className="badge-company">{pass.company || '-'}</div>
|
||||
<img className="badge-qr" src={passQrUrl(pass.id)} alt="출입 QR" />
|
||||
<div className="badge-meta">
|
||||
<div><b>{pass.zoneName || '-'}</b></div>
|
||||
<div><b>{pass.hostName}</b></div>
|
||||
<div><b>{formatVisitRange(pass.visitFrom, pass.visitTo)}</b></div>
|
||||
</div>
|
||||
<div className="badge-foot">전산실 입장 시 QR을 출입관리시스템에 입력하세요</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
110
frontend/src/pages/BlacklistPage.tsx
Normal file
110
frontend/src/pages/BlacklistPage.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { addBlacklist, deleteBlacklist, listBlacklist } from '../api';
|
||||
import { BlacklistItem } from '../types';
|
||||
import { formatDateTime } from '../status';
|
||||
import { Dialog } from '../components/Dialog';
|
||||
|
||||
export const BlacklistPage: React.FC = () => {
|
||||
const [items, setItems] = useState<BlacklistItem[]>([]);
|
||||
const [form, setForm] = useState({ name: '', company: '', contact: '', reason: '' });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [liftingId, setLiftingId] = useState<number | null>(null);
|
||||
|
||||
const load = () => {
|
||||
listBlacklist()
|
||||
.then(setItems)
|
||||
.catch((e) => setError(e instanceof Error ? e.message : '조회 실패'));
|
||||
};
|
||||
|
||||
useEffect(load, []);
|
||||
|
||||
const update = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setForm({ ...form, [k]: e.target.value });
|
||||
|
||||
const onAdd = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await addBlacklist({
|
||||
name: form.name,
|
||||
company: form.company || undefined,
|
||||
contact: form.contact || undefined,
|
||||
reason: form.reason,
|
||||
});
|
||||
setForm({ name: '', company: '', contact: '', reason: '' });
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '등록 실패');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmLift = async () => {
|
||||
const id = liftingId;
|
||||
setLiftingId(null);
|
||||
if (id == null) return;
|
||||
try {
|
||||
await deleteBlacklist(id);
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '해제 실패');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head"><h2>블랙리스트 관리</h2></div>
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
<form className="card form-grid" onSubmit={onAdd}>
|
||||
<label className="field"><span>이름 *</span><input value={form.name} onChange={update('name')} required /></label>
|
||||
<label className="field"><span>회사/소속</span><input value={form.company} onChange={update('company')} /></label>
|
||||
<label className="field"><span>연락처</span><input value={form.contact} onChange={update('contact')} /></label>
|
||||
<label className="field"><span>차단 사유 *</span><input value={form.reason} onChange={update('reason')} required /></label>
|
||||
<div className="form-actions span-2">
|
||||
<button className="btn-primary" type="submit" disabled={busy}>차단 등록</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="card">
|
||||
<h3>차단 명단 ({items.length})</h3>
|
||||
{items.length === 0 ? (
|
||||
<p className="muted">등록된 차단 명단이 없습니다.</p>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr><th>이름</th><th>회사</th><th>연락처</th><th>사유</th><th>등록자</th><th>등록일</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((b) => (
|
||||
<tr key={b.id}>
|
||||
<td>{b.name}</td>
|
||||
<td>{b.company || '-'}</td>
|
||||
<td>{b.contact || '-'}</td>
|
||||
<td>{b.reason}</td>
|
||||
<td>{b.createdByName || '-'}</td>
|
||||
<td>{formatDateTime(b.createdAt)}</td>
|
||||
<td><button className="btn-link-danger" onClick={() => setLiftingId(b.id)}>해제</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{liftingId != null && (
|
||||
<Dialog
|
||||
title="차단 해제"
|
||||
message="이 차단을 해제하시겠습니까?"
|
||||
confirmLabel="해제"
|
||||
danger
|
||||
onConfirm={confirmLift}
|
||||
onCancel={() => setLiftingId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
66
frontend/src/pages/ChangePasswordPage.tsx
Normal file
66
frontend/src/pages/ChangePasswordPage.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { changePassword } from '../api';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { PasswordInput } from '../components/PasswordInput';
|
||||
|
||||
export const ChangePasswordPage: React.FC = () => {
|
||||
const [oldPassword, setOldPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const { user, refresh } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (newPassword !== confirm) {
|
||||
setError('새 비밀번호가 일치하지 않습니다.');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await changePassword({ oldPassword, newPassword });
|
||||
await refresh();
|
||||
navigate('/dashboard', { replace: true });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '변경 실패');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="center-screen">
|
||||
<form className="card auth-card" onSubmit={onSubmit}>
|
||||
<h1 className="auth-title">비밀번호 변경</h1>
|
||||
<p className="auth-sub">
|
||||
{user?.mustChangePassword
|
||||
? '최초 로그인 시 비밀번호를 변경해야 합니다.'
|
||||
: '새 비밀번호를 입력하세요.'}
|
||||
</p>
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
<label className="field">
|
||||
<span>현재 비밀번호</span>
|
||||
<PasswordInput value={oldPassword} onChange={(e) => setOldPassword(e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>새 비밀번호 (8자 이상)</span>
|
||||
<PasswordInput value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>새 비밀번호 확인</span>
|
||||
<PasswordInput value={confirm} onChange={(e) => setConfirm(e.target.value)} />
|
||||
</label>
|
||||
|
||||
<button className="btn-primary full" type="submit" disabled={busy}>
|
||||
{busy ? '변경 중…' : '변경하기'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
75
frontend/src/pages/DashboardPage.tsx
Normal file
75
frontend/src/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getStatsSummary, listVisitRequests } from '../api';
|
||||
import { StatsSummary, VisitRequestView } from '../types';
|
||||
import { STATUS_CLASS, STATUS_LABEL, formatDateTime } from '../status';
|
||||
|
||||
export const DashboardPage: React.FC = () => {
|
||||
const [items, setItems] = useState<VisitRequestView[]>([]);
|
||||
const [stats, setStats] = useState<StatsSummary | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getStatsSummary().then(setStats).catch(() => setStats(null));
|
||||
listVisitRequests()
|
||||
.then(setItems)
|
||||
.catch((e) => setError(e instanceof Error ? e.message : '조회 실패'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const recent = items.slice(0, 8);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head">
|
||||
<h2>대시보드</h2>
|
||||
<Link className="btn-primary" to="/visit-requests/new">+ 출입 신청</Link>
|
||||
</div>
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
<div className="stat-grid">
|
||||
<StatCard label="오늘 출입 예정" value={stats?.todayVisits ?? 0} accent="blue" />
|
||||
<StatCard label="현재 재실" value={stats?.currentlyInside ?? 0} accent="green" />
|
||||
<StatCard label="승인 대기" value={stats?.pending ?? 0} accent="amber" />
|
||||
<StatCard label="전체 신청" value={stats?.total ?? 0} accent="gray" />
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>최근 출입 신청</h3>
|
||||
{loading ? (
|
||||
<p className="muted">불러오는 중…</p>
|
||||
) : recent.length === 0 ? (
|
||||
<p className="muted">신청 내역이 없습니다.</p>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>방문자</th><th>회사</th><th>출입구역</th><th>출입 일시</th><th>상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recent.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td>{r.visitorName}</td>
|
||||
<td>{r.company || '-'}</td>
|
||||
<td>{r.zoneName || '-'}</td>
|
||||
<td>{formatDateTime(r.visitFrom)}</td>
|
||||
<td><span className={`badge badge-${STATUS_CLASS[r.status]}`}>{STATUS_LABEL[r.status]}</span></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const StatCard: React.FC<{ label: string; value: number; accent: string }> = ({ label, value, accent }) => (
|
||||
<div className={`stat-card accent-${accent}`}>
|
||||
<div className="stat-value">{value}</div>
|
||||
<div className="stat-label">{label}</div>
|
||||
</div>
|
||||
);
|
||||
132
frontend/src/pages/KioskPage.tsx
Normal file
132
frontend/src/pages/KioskPage.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getPublicPass, publicCheckIn, publicCheckOut } from '../api';
|
||||
import { PublicPass } from '../types';
|
||||
import { formatVisitRange } from '../status';
|
||||
import { useQrScanner } from '../useQrScanner';
|
||||
import { playChime } from '../chime';
|
||||
|
||||
/**
|
||||
* Public entrance kiosk (no login). The visitor scans their phone QR, the
|
||||
* approved pass is shown, and they tap 입장/퇴장 to self check-in/out.
|
||||
* This is the only screen a visitor ever sees.
|
||||
*/
|
||||
export const KioskPage: React.FC = () => {
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [pass, setPass] = useState<PublicPass | null>(null);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [manual, setManual] = useState('');
|
||||
|
||||
const resolveToken = async (raw: string) => {
|
||||
const t = raw.trim();
|
||||
if (!t || busy || token) return;
|
||||
setError(null);
|
||||
scanner.stop();
|
||||
setBusy(true);
|
||||
try {
|
||||
const p = await getPublicPass(t);
|
||||
setToken(t);
|
||||
setPass(p);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '출입증 조회 실패');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const scanner = useQrScanner({ onDecode: (t) => void resolveToken(t) });
|
||||
|
||||
const act = async (dir: 'in' | 'out') => {
|
||||
if (!token || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = dir === 'in' ? await publicCheckIn(token) : await publicCheckOut(token);
|
||||
playChime();
|
||||
setResult(`${r.visitorName} — ${r.message}`);
|
||||
setToken(null);
|
||||
setPass(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '처리 실패');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setToken(null);
|
||||
setPass(null);
|
||||
setResult(null);
|
||||
setError(null);
|
||||
setManual('');
|
||||
};
|
||||
|
||||
// Show the "○○ — 입장/퇴장 처리되었습니다" result for 3s, then return to the scan prompt.
|
||||
useEffect(() => {
|
||||
if (!result) return;
|
||||
const t = setTimeout(() => setResult(null), 3000);
|
||||
return () => clearTimeout(t);
|
||||
}, [result]);
|
||||
|
||||
return (
|
||||
<div className="kiosk">
|
||||
<div className="kiosk-card">
|
||||
<div className="badge-head">IT센터 출입 · 셀프 체크인</div>
|
||||
|
||||
{error && <div className="alert alert-error" style={{ marginTop: 16 }}>{error}</div>}
|
||||
|
||||
{result ? (
|
||||
<div className="kiosk-result">
|
||||
<p className="kiosk-ok">✅ {result}</p>
|
||||
<button className="btn-primary full" onClick={() => { reset(); void scanner.start(); }}>카메라 시작</button>
|
||||
</div>
|
||||
) : pass ? (
|
||||
<div className="kiosk-pass">
|
||||
<div className="badge-name">{pass.visitorName}</div>
|
||||
<div className="badge-company">{pass.company || '-'}</div>
|
||||
<div className="badge-meta">
|
||||
<div><b>{pass.zoneName || '-'}</b></div>
|
||||
<div><b>{formatVisitRange(pass.visitFrom, pass.visitTo)}</b></div>
|
||||
</div>
|
||||
<p className="muted">
|
||||
{pass.completedToday ? '금일 출입이 완료되었습니다.' : pass.inside ? '현재 재실 중입니다.' : '입장 전 상태입니다.'}
|
||||
</p>
|
||||
<div className="kiosk-actions">
|
||||
{pass.completedToday ? null : pass.inside ? (
|
||||
<button className="btn-danger full" disabled={busy} onClick={() => act('out')}>퇴장</button>
|
||||
) : (
|
||||
<button className="btn-success full" disabled={busy} onClick={() => act('in')}>입장</button>
|
||||
)}
|
||||
<button className="btn-ghost full" disabled={busy} onClick={reset}>취소</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="kiosk-scan">
|
||||
<div className="qr-scanner" hidden={!scanner.active}>
|
||||
<video ref={scanner.videoRef} muted playsInline />
|
||||
<div className="qr-scanner-guide" />
|
||||
</div>
|
||||
<canvas ref={scanner.canvasRef} style={{ display: 'none' }} />
|
||||
<p className="muted">{scanner.active ? '휴대폰의 QR을 카메라에 비춰주세요.' : '카메라를 시작한 뒤 QR을 비춰주세요.'}</p>
|
||||
<button
|
||||
type="button"
|
||||
className={scanner.active ? 'btn-danger full' : 'btn-primary full'}
|
||||
onClick={() => (scanner.active ? scanner.stop() : void scanner.start())}
|
||||
>
|
||||
{scanner.active ? '카메라 중지' : '📷 카메라 시작'}
|
||||
</button>
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); void resolveToken(manual); }}
|
||||
className="inline-form"
|
||||
style={{ marginTop: 12 }}
|
||||
>
|
||||
<input placeholder="QR 토큰 직접 입력" value={manual} onChange={(e) => setManual(e.target.value)} />
|
||||
<button className="btn-ghost" type="submit" disabled={busy || !manual.trim()}>확인</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
65
frontend/src/pages/LoginPage.tsx
Normal file
65
frontend/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { login } from '../api';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { PasswordInput } from '../components/PasswordInput';
|
||||
import bokBadge from '../assets/bok-removebg.png';
|
||||
|
||||
export const LoginPage: React.FC = () => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const { refresh } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const user = await login({ username, password });
|
||||
await refresh();
|
||||
navigate(user.mustChangePassword ? '/change-password' : '/dashboard', { replace: true });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '로그인 실패');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="center-screen">
|
||||
<form className="card auth-card" onSubmit={onSubmit}>
|
||||
<img src={bokBadge} className="auth-badge" alt="" />
|
||||
<h1 className="auth-title">IT센터 출입자관리</h1>
|
||||
<p className="auth-sub">시스템에 로그인하세요</p>
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
<label className="field">
|
||||
<span>아이디</span>
|
||||
<input
|
||||
className="ime-en"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoFocus
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>비밀번호</span>
|
||||
<PasswordInput value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
</label>
|
||||
|
||||
<button className="btn-primary full" type="submit" disabled={busy || !username || !password}>
|
||||
{busy ? '로그인 중…' : '로그인'}
|
||||
</button>
|
||||
|
||||
<p className="hint">초기 계정: admin / security / host (비밀번호 ChangeMe123!)</p>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
53
frontend/src/pages/PublicPassPage.tsx
Normal file
53
frontend/src/pages/PublicPassPage.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { getPublicPass, publicPassQrUrl } from '../api';
|
||||
import { PublicPass } from '../types';
|
||||
import { formatVisitRange } from '../status';
|
||||
|
||||
/**
|
||||
* Public visitor pass opened from the SMS link (no login). Shows the QR the
|
||||
* visitor presents to the access console's webcam.
|
||||
*/
|
||||
export const PublicPassPage: React.FC = () => {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const [pass, setPass] = useState<PublicPass | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
getPublicPass(token)
|
||||
.then(setPass)
|
||||
.catch((e) => setError(e instanceof Error ? e.message : '조회 실패'));
|
||||
}, [token]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="badge-sheet">
|
||||
<div className="badge-card">
|
||||
<div className="badge-head">IT센터 출입증</div>
|
||||
<div className="alert alert-error" style={{ marginTop: 16 }}>{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!pass || !token) {
|
||||
return <div className="center-screen">불러오는 중…</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="badge-sheet">
|
||||
<div className="badge-card">
|
||||
<div className="badge-head">IT센터 출입증</div>
|
||||
<div className="badge-name">{pass.visitorName}</div>
|
||||
<div className="badge-company">{pass.company || '-'}</div>
|
||||
<img className="badge-qr" src={publicPassQrUrl(token)} alt="출입 QR" />
|
||||
<div className="badge-meta">
|
||||
<div><b>{pass.zoneName || '-'}</b></div>
|
||||
<div><b>{formatVisitRange(pass.visitFrom, pass.visitTo)}</b></div>
|
||||
</div>
|
||||
<div className="badge-foot">전산실 입장 시 QR을 출입관리시스템에 입력하세요</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
50
frontend/src/pages/ReportPage.tsx
Normal file
50
frontend/src/pages/ReportPage.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React, { useState } from 'react';
|
||||
import { reportDownloadUrl } from '../api';
|
||||
|
||||
function todayISO(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
function monthAgoISO(): string {
|
||||
const d = new Date();
|
||||
d.setMonth(d.getMonth() - 1);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export const ReportPage: React.FC = () => {
|
||||
const [from, setFrom] = useState(monthAgoISO());
|
||||
const [to, setTo] = useState(todayISO());
|
||||
|
||||
const onDownload = () => {
|
||||
// Trigger a download in-place via a temporary anchor. Using window.open left
|
||||
// a blank tab behind (the .xlsx response has no HTML to render). The anchor's
|
||||
// download attribute makes the browser save the file without navigating away.
|
||||
// Same-origin, so the session cookie is sent automatically (dev proxy / nginx).
|
||||
const a = document.createElement('a');
|
||||
a.href = reportDownloadUrl(from, to);
|
||||
a.download = `visits_${from}_${to}.xlsx`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head"><h2>방문 리포트</h2></div>
|
||||
|
||||
<div className="card">
|
||||
<p className="muted">기간을 선택하고 엑셀(.xlsx) 파일로 내려받습니다. (방문 시작일 기준)</p>
|
||||
<div className="report-row">
|
||||
<label className="field">
|
||||
<span>시작일</span>
|
||||
<input type="date" value={from} onChange={(e) => setFrom(e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>종료일</span>
|
||||
<input type="date" value={to} onChange={(e) => setTo(e.target.value)} />
|
||||
</label>
|
||||
<button className="btn-primary" onClick={onDownload}>엑셀 다운로드</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
180
frontend/src/pages/VisitRequestFormPage.tsx
Normal file
180
frontend/src/pages/VisitRequestFormPage.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { createVisitRequest } from '../api';
|
||||
import { DateTimePicker } from '../components/DateTimePicker';
|
||||
|
||||
const ZONE_OPTIONS = [
|
||||
'4층전산실', '5층전산실', '3층사무실', '4층사무실', '5층사무실',
|
||||
'종합상황실', 'BMT실', '의사결정실', '기타',
|
||||
];
|
||||
const PURPOSE_OPTIONS = ['유지점검', '장비반입', '업무협의', '공사', '기타'];
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
export const VisitRequestFormPage: React.FC = () => {
|
||||
const [form, setForm] = useState({
|
||||
visitorName: '',
|
||||
company: '',
|
||||
contact: '',
|
||||
email: '',
|
||||
vehicleNo: '',
|
||||
zone: '',
|
||||
zoneEtc: '',
|
||||
purpose: '',
|
||||
purposeEtc: '',
|
||||
visitFrom: '',
|
||||
visitTo: '',
|
||||
});
|
||||
const [consent, setConsent] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const update = (k: keyof typeof form) => (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
|
||||
) => setForm({ ...form, [k]: e.target.value });
|
||||
|
||||
/** Returns the first Korean validation error, or null if valid. */
|
||||
const validate = (): string | null => {
|
||||
if (!form.visitorName.trim()) return '방문자 이름을 입력하세요.';
|
||||
if (!form.contact.trim()) return '방문자 연락처를 입력하세요.';
|
||||
if (form.email.trim() && !EMAIL_RE.test(form.email.trim()))
|
||||
return '이메일 형식이 올바르지 않습니다. (예: name@example.com)';
|
||||
if (!form.zone) return '출입 구역을 선택하세요.';
|
||||
if (form.zone === '기타' && !form.zoneEtc.trim()) return '기타 출입 구역을 입력하세요.';
|
||||
if (!form.purpose) return '출입 목적을 선택하세요.';
|
||||
if (form.purpose === '기타' && !form.purposeEtc.trim()) return '기타 출입 목적을 입력하세요.';
|
||||
if (!form.visitFrom) return '출입 일시를 입력하세요.';
|
||||
if (!form.visitTo) return '퇴실 일시를 입력하세요.';
|
||||
if (new Date(form.visitTo) < new Date(form.visitFrom))
|
||||
return '퇴실 일시는 출입 일시보다 빠를 수 없습니다.';
|
||||
if (!consent) return '개인정보 사용 및 저장에 동의해야 신청할 수 있습니다.';
|
||||
return null;
|
||||
};
|
||||
|
||||
const onSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const message = validate();
|
||||
if (message) {
|
||||
setError(message);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await createVisitRequest({
|
||||
visitorName: form.visitorName.trim(),
|
||||
company: form.company.trim() || undefined,
|
||||
contact: form.contact.trim(),
|
||||
email: form.email.trim() || undefined,
|
||||
vehicleNo: form.vehicleNo.trim() || undefined,
|
||||
zoneName: form.zone === '기타' ? form.zoneEtc.trim() : form.zone,
|
||||
purpose: form.purpose === '기타' ? form.purposeEtc.trim() : form.purpose,
|
||||
visitFrom: form.visitFrom,
|
||||
visitTo: form.visitTo,
|
||||
});
|
||||
navigate('/visit-requests', { replace: true });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '신청 실패');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head"><h2>출입 신청</h2></div>
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
{/* noValidate: use our Korean messages instead of the browser's native popups */}
|
||||
<form className="card form-grid" onSubmit={onSubmit} noValidate>
|
||||
<label className="field">
|
||||
<span>방문자 이름 *</span>
|
||||
<input className="ime-ko" value={form.visitorName} onChange={update('visitorName')} autoFocus />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>회사/소속</span>
|
||||
<input value={form.company} onChange={update('company')} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>연락처 * <em className="hint-inline">: 입력형식은 010-0000-0000 로 작성해 주세요.</em></span>
|
||||
<input type="tel" value={form.contact} onChange={update('contact')} placeholder="010-0000-0000" />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>이메일</span>
|
||||
<input className="ime-en" type="email" value={form.email} onChange={update('email')} placeholder="name@example.com" />
|
||||
</label>
|
||||
|
||||
<label className="field span-2">
|
||||
<span>차량번호 <em className="hint-inline">: 차량번호가 5부제에 해당될 경우 출입이 제한됩니다.</em></span>
|
||||
<input className="ime-ko" value={form.vehicleNo} onChange={update('vehicleNo')} />
|
||||
</label>
|
||||
|
||||
<label className="field">
|
||||
<span>출입 구역 *</span>
|
||||
<select value={form.zone} onChange={update('zone')}>
|
||||
<option value="">선택하세요</option>
|
||||
{ZONE_OPTIONS.map((z) => <option key={z} value={z}>{z}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{form.zone === '기타' ? (
|
||||
<label className="field">
|
||||
<span>기타 구역 입력 *</span>
|
||||
<input value={form.zoneEtc} onChange={update('zoneEtc')} placeholder="출입 구역을 입력하세요" />
|
||||
</label>
|
||||
) : <div />}
|
||||
|
||||
<label className="field">
|
||||
<span>출입 목적 *</span>
|
||||
<select value={form.purpose} onChange={update('purpose')}>
|
||||
<option value="">선택하세요</option>
|
||||
{PURPOSE_OPTIONS.map((p) => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{form.purpose === '기타' ? (
|
||||
<label className="field">
|
||||
<span>기타 목적 입력 *</span>
|
||||
<input value={form.purposeEtc} onChange={update('purposeEtc')} placeholder="출입 목적을 입력하세요" />
|
||||
</label>
|
||||
) : <div />}
|
||||
|
||||
<label className="field">
|
||||
<span>출입 일시 *</span>
|
||||
<DateTimePicker
|
||||
value={form.visitFrom}
|
||||
onChange={(v) => setForm((f) => ({ ...f, visitFrom: v }))}
|
||||
placeholder="출입 일시 선택"
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>퇴실 일시 *</span>
|
||||
<DateTimePicker
|
||||
value={form.visitTo}
|
||||
onChange={(v) => setForm((f) => ({ ...f, visitTo: v }))}
|
||||
placeholder="퇴실 일시 선택"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="span-2 consent-box">
|
||||
<label className="consent-label">
|
||||
<input type="checkbox" checked={consent} onChange={(e) => setConsent(e.target.checked)} />
|
||||
<span>
|
||||
<b>[개인정보 수집·이용 동의]</b><br />
|
||||
· 수집 항목: 이름, 연락처, 이메일, 차량번호<br />
|
||||
· 수집·이용 목적: IT센터 출입 신청 접수 및 출입자 관리<br />
|
||||
· 보유·이용 기간: 수집일로부터 1년 (기간 경과 시 지체 없이 파기)<br />
|
||||
· 귀하는 개인정보 수집·이용에 동의를 거부할 권리가 있으며, 동의하지 않을 경우 출입 신청이 제한됩니다.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-actions span-2">
|
||||
<button type="button" className="btn-ghost" onClick={() => navigate(-1)}>취소</button>
|
||||
<button type="submit" className="btn-primary" disabled={busy}>
|
||||
{busy ? '신청 중…' : '출입 신청'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
120
frontend/src/pages/VisitRequestListPage.tsx
Normal file
120
frontend/src/pages/VisitRequestListPage.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { cancelVisitRequest, listVisitRequests, uploadVisitRequests } from '../api';
|
||||
import { VisitRequestView } from '../types';
|
||||
import { STATUS_CLASS, STATUS_LABEL, formatVisitRange } from '../status';
|
||||
import { Dialog } from '../components/Dialog';
|
||||
|
||||
export const VisitRequestListPage: React.FC = () => {
|
||||
const [items, setItems] = useState<VisitRequestView[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [cancelingId, setCancelingId] = useState<number | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const load = () => {
|
||||
setLoading(true);
|
||||
listVisitRequests()
|
||||
.then(setItems)
|
||||
.catch((e) => setError(e instanceof Error ? e.message : '조회 실패'))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(load, []);
|
||||
|
||||
const confirmCancel = async () => {
|
||||
const id = cancelingId;
|
||||
setCancelingId(null);
|
||||
if (id == null) return;
|
||||
try {
|
||||
await cancelVisitRequest(id);
|
||||
load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '취소 실패');
|
||||
}
|
||||
};
|
||||
|
||||
const onUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const res = await uploadVisitRequests(file);
|
||||
setNotice(`업로드 완료: ${res.successCount}/${res.totalRows}건 등록` +
|
||||
(res.errors.length ? ` · 오류 ${res.errors.length}건` : ''));
|
||||
if (res.errors.length) setError(res.errors.join('\n'));
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '업로드 실패');
|
||||
} finally {
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head">
|
||||
<h2>출입 신청 목록</h2>
|
||||
<div className="head-actions">
|
||||
<button className="btn-ghost" onClick={() => fileRef.current?.click()}>엑셀 업로드</button>
|
||||
<input ref={fileRef} type="file" accept=".xlsx" hidden onChange={onUpload} />
|
||||
<Link className="btn-primary" to="/visit-requests/new">+ 출입 신청</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notice && <div className="alert alert-info">{notice}</div>}
|
||||
{error && <div className="alert alert-error" style={{ whiteSpace: 'pre-line' }}>{error}</div>}
|
||||
|
||||
<div className="card">
|
||||
{loading ? (
|
||||
<p className="muted">불러오는 중…</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="muted">신청 내역이 없습니다.</p>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>방문자</th><th>회사</th><th>출입구역</th><th>출입목적</th>
|
||||
<th>출입기간</th><th>상태</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td>{r.visitorName}</td>
|
||||
<td>{r.company || '-'}</td>
|
||||
<td>{r.zoneName || '-'}</td>
|
||||
<td>{r.purpose || '-'}</td>
|
||||
<td>{formatVisitRange(r.visitFrom, r.visitTo)}</td>
|
||||
<td><span className={`badge badge-${STATUS_CLASS[r.status]}`}>{STATUS_LABEL[r.status]}</span></td>
|
||||
<td className="row-actions">
|
||||
{r.status === 'APPROVED' && (
|
||||
<button className="btn-link" onClick={() => navigate(`/badge/${r.id}`)}>출입증</button>
|
||||
)}
|
||||
{(r.status === 'PENDING' || r.status === 'APPROVED') && (
|
||||
<button className="btn-link-danger" onClick={() => setCancelingId(r.id)}>취소</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{cancelingId != null && (
|
||||
<Dialog
|
||||
title="신청 취소"
|
||||
message="이 방문 신청을 취소하시겠습니까?"
|
||||
confirmLabel="취소 처리"
|
||||
danger
|
||||
onConfirm={confirmCancel}
|
||||
onCancel={() => setCancelingId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
66
frontend/src/status.ts
Normal file
66
frontend/src/status.ts
Normal 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)}`;
|
||||
}
|
||||
304
frontend/src/styles/common.css
Normal file
304
frontend/src/styles/common.css
Normal 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
145
frontend/src/types.ts
Normal 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;
|
||||
}
|
||||
137
frontend/src/useQrScanner.ts
Normal file
137
frontend/src/useQrScanner.ts
Normal 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
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
21
frontend/tsconfig.json
Normal file
21
frontend/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
1
frontend/tsconfig.node.tsbuildinfo
Normal file
1
frontend/tsconfig.node.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
||||
{"root":["./vite.config.ts"],"version":"5.9.3"}
|
||||
1
frontend/tsconfig.tsbuildinfo
Normal file
1
frontend/tsconfig.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
||||
{"root":["./src/app.tsx","./src/api.ts","./src/main.tsx","./src/status.ts","./src/types.ts","./src/auth/authcontext.tsx","./src/components/layout.tsx","./src/pages/approvalqueuepage.tsx","./src/pages/changepasswordpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/loginpage.tsx","./src/pages/visitrequestformpage.tsx","./src/pages/visitrequestlistpage.tsx"],"errors":true,"version":"5.9.3"}
|
||||
16
frontend/vite.config.ts
Normal file
16
frontend/vite.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
// Dev server proxies /api to the Spring Boot backend (no CORS needed in dev).
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user