방문자 사전신청·승인, 입·출입 체크인/아웃, 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>
181 lines
7.4 KiB
TypeScript
181 lines
7.4 KiB
TypeScript
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>
|
|
);
|
|
};
|