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:
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user