Initial commit: IT센터 출입자관리시스템 (ACS)

방문자 사전신청·승인, 입·출입 체크인/아웃, QR 배지, 재실현황,
블랙리스트, 대시보드 통계, 방문 리포트(엑셀)까지 7단계 전 기능 구현.

- backend: Spring Boot 3.4.5 / Java 21 (JDK 26 빌드), 세션 인증, JPA, H2/PostgreSQL, POI, ZXing, Flyway
- frontend: React 19 / Vite 6 / TypeScript
- infra: Docker Compose (db·app·web nginx), Flyway V1__init, Python 사용자 시드
- docs: 워크플로우 / 시퀀스 다이어그램(Mermaid) / 이슈·유의사항

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
unknown
2026-07-03 08:59:41 +09:00
commit f0c30d8005
130 changed files with 8884 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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