import React, { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { createVisitRequest, getWatcher1Settings, listPurposeCodes, listVisitorApplications } from '../api'; import { DateTimePicker } from '../components/DateTimePicker'; import { useAuth } from '../auth/AuthContext'; import { PurposeCode, VisitorApplicationView, Watcher1Settings } from '../types'; // 코드 시트 목록을 콤보/체크박스에 반영. // 전산실: 체크박스(다중). 선택한 개수만큼 신청/QR이 생성된다. const SERVER_ROOM_OPTIONS = ['4층전산실', '5층전산실']; // 추가 구역: 콤보박스(코드 시트 장소 중 전산실 외). 부가정보로만 기록. '기타' 선택 시 자유 입력. const ROOM_OPTIONS = ['4층종합상황실', '4층BMT실', '3층사무실', '기타']; const FALLBACK_PURPOSE_CODES: PurposeCode[] = [ { id: 1, code: 'INSPECTION', name: '점검', sortOrder: 10, active: true, customAllowed: false }, { id: 2, code: 'WORK', name: '작업', sortOrder: 20, active: true, customAllowed: false }, { id: 3, code: 'TOUR', name: '견학', sortOrder: 30, active: true, customAllowed: false }, { id: 4, code: 'MEETING', name: '회의', sortOrder: 40, active: true, customAllowed: false }, { id: 5, code: 'CLEANING', name: '청소', sortOrder: 50, active: true, customAllowed: false }, { id: 6, code: 'ETC', name: '기타', sortOrder: 900, active: true, customAllowed: true }, ]; // 소속(코드 시트) — 내부 팀. 담당자·감시자 팀 콤보에 사용. const AFFILIATION_OPTIONS = [ 'IT센터관리팀', 'IT서비스팀', '네트워크팀', '클라우드팀', 'RTGS시스템팀', '금융IT인프라팀', '정보인프라팀', 'AI플랫폼팀', '보안운영팀', '보안관제반', 'IT리스크팀', 'IT기획팀', '정보기획팀', 'IT전략국', ]; // 현장감시자1 — 고정 인원(백엔드 FIXED_WATCHER1과 동일 값 유지). const FALLBACK_WATCHER1: Watcher1Settings = { name: '류관순', team: 'IT전략국', contact: '313' }; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const NEXT_DAY_EXIT_MESSAGE = `퇴장일이 출입일 다음날 이후가 되는 경우는 2건으로 신청해야 합니다 예컨대, 2026.07.16 18:00~2026.07.17 04:00 이라면, 아래와 같이 2건으로 등록해야 합니다. 1건) 2026.07.16 18:00~2026.07.16 24:00 2건) 2026.07.17 00:00~2026.07.17 04:00`; const EXIT_BEFORE_ENTRY_MESSAGE = '퇴장일시는 출입일시 이후로 입력해야 합니다.'; const shouldShowValidationAlert = (message: string): boolean => message === NEXT_DAY_EXIT_MESSAGE || message === EXIT_BEFORE_ENTRY_MESSAGE; const isLaterCalendarDate = (later: Date, earlier: Date): boolean => { const laterDay = new Date(later.getFullYear(), later.getMonth(), later.getDate()); const earlierDay = new Date(earlier.getFullYear(), earlier.getMonth(), earlier.getDate()); return laterDay > earlierDay; }; const formatPhoneLike = (value: string): string => { const compact = value.replace(/\s+/g, ''); const digits = compact.replace(/\D/g, ''); if (!/^[\d\s-]*$/.test(value)) return value; if (digits.length === 11) { return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7)}`; } if (digits.length === 10) { return `${digits.slice(0, 3)}-${digits.slice(3, 6)}-${digits.slice(6)}`; } return compact; }; const sanitizePhoneInput = (value: string): string => value.replace(/\s+/g, '').replace(/[^\d-]/g, '').slice(0, 20); export const VisitRequestFormPage: React.FC = () => { const [form, setForm] = useState({ sourceApplicationId: undefined as number | undefined, visitorName: '', company: '', contact: '', email: '', vehicleNo: '', serverRooms: [] as string[], room: '', roomEtc: '', purpose: '', purposeEtc: '', workName: '', watcher2Name: '', watcher2Team: '', watcher2Contact: '', visitFrom: '', visitTo: '', }); const [purposeCodes, setPurposeCodes] = useState(FALLBACK_PURPOSE_CODES); const [watcher1, setWatcher1] = useState(FALLBACK_WATCHER1); const [consent, setConsent] = useState(false); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const [showImport, setShowImport] = useState(false); const [importQuery, setImportQuery] = useState(''); const [importCandidates, setImportCandidates] = useState([]); const [importLoading, setImportLoading] = useState(false); const navigate = useNavigate(); const { user } = useAuth(); useEffect(() => { listPurposeCodes().then(setPurposeCodes).catch(() => setPurposeCodes(FALLBACK_PURPOSE_CODES)); getWatcher1Settings().then(setWatcher1).catch(() => setWatcher1(FALLBACK_WATCHER1)); }, []); const selectedPurpose = purposeCodes.find((p) => p.code === form.purpose); const update = (k: keyof typeof form) => ( e: React.ChangeEvent, ) => setForm({ ...form, [k]: e.target.value }); const updateFormattedContact = (k: 'contact' | 'watcher2Contact') => ( e: React.ChangeEvent, ) => setForm({ ...form, [k]: sanitizePhoneInput(e.target.value) }); const toggleServerRoom = (room: string) => (e: React.ChangeEvent) => setForm((f) => ({ ...f, serverRooms: e.target.checked ? [...f.serverRooms, room] : f.serverRooms.filter((r) => r !== room), })); const loadVisitorApplications = async () => { setImportLoading(true); setError(null); try { setImportCandidates(await listVisitorApplications(importQuery.trim() || undefined)); } catch (err) { setError(err instanceof Error ? err.message : '방문신청 목록을 불러오지 못했습니다.'); } finally { setImportLoading(false); } }; const applyVisitorApplication = (item: VisitorApplicationView) => { const serverRooms = item.zoneName && SERVER_ROOM_OPTIONS.includes(item.zoneName) ? [item.zoneName] : []; const etcRoom = ROOM_OPTIONS[ROOM_OPTIONS.length - 1]; const roomValue = item.roomZone && ROOM_OPTIONS.includes(item.roomZone) ? item.roomZone : item.roomZone ? etcRoom : ''; setForm((f) => ({ ...f, sourceApplicationId: item.id, visitorName: item.visitorName, company: item.company ?? '', contact: item.contact ?? '', email: item.email ?? '', vehicleNo: item.vehicleNo ?? '', serverRooms, room: roomValue, roomEtc: roomValue === etcRoom ? item.roomZone ?? '' : '', purpose: item.purposeCode ?? '', purposeEtc: item.purposeDetail ?? '', workName: item.workName ?? '', watcher2Name: item.watcher2Name ?? '', watcher2Team: item.watcher2Team ?? '', watcher2Contact: item.watcher2Contact ?? '', visitFrom: item.visitFrom.slice(0, 16), visitTo: item.visitTo.slice(0, 16), })); setConsent(true); setShowImport(false); setError(null); }; /** 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.serverRooms.length === 0 && !form.room) return '출입 구역(전산실 또는 추가 구역)을 최소 1개 이상 선택하세요.'; if (form.room === '기타' && !form.roomEtc.trim()) return '기타 추가 구역을 입력하세요.'; if (!form.purpose) return '출입 목적을 선택하세요.'; if (selectedPurpose?.customAllowed && !form.purposeEtc.trim()) return '기타 출입 목적을 입력하세요.'; if (!form.visitFrom) return '출입 일시를 입력하세요.'; if (!form.visitTo) return '퇴실 일시를 입력하세요.'; const visitFrom = new Date(form.visitFrom); const visitTo = new Date(form.visitTo); if (visitFrom > visitTo) return EXIT_BEFORE_ENTRY_MESSAGE; if (isLaterCalendarDate(visitTo, visitFrom)) return NEXT_DAY_EXIT_MESSAGE; if (!consent) return '개인정보 사용 및 저장에 동의해야 신청할 수 있습니다.'; return null; }; const onSubmit = async (e: React.FormEvent) => { e.preventDefault(); const message = validate(); if (message) { setError(message); if (shouldShowValidationAlert(message)) { window.alert(message); } return; } setError(null); setBusy(true); try { const roomZone = form.room === '기타' ? form.roomEtc.trim() : form.room; await createVisitRequest({ sourceApplicationId: form.sourceApplicationId, visitorName: form.visitorName.trim(), company: form.company.trim() || undefined, contact: formatPhoneLike(form.contact.trim()), email: form.email.trim() || undefined, vehicleNo: form.vehicleNo.trim() || undefined, serverRooms: form.serverRooms, roomZone: roomZone || undefined, purpose: selectedPurpose?.customAllowed ? form.purposeEtc.trim() : selectedPurpose?.name ?? form.purpose, purposeCode: selectedPurpose?.code ?? form.purpose, purposeDetail: selectedPurpose?.customAllowed ? form.purposeEtc.trim() : undefined, workName: form.workName.trim() || undefined, watcher2Name: form.watcher2Name.trim() || undefined, watcher2Team: form.watcher2Team.trim() || undefined, watcher2Contact: formatPhoneLike(form.watcher2Contact.trim()) || undefined, visitFrom: form.visitFrom, visitTo: form.visitTo, }); navigate('/visit-requests', { replace: true }); } catch (err) { setError(err instanceof Error ? err.message : '신청 실패'); } finally { setBusy(false); } }; return (

출입 신청

{form.sourceApplicationId && 방문신청 #{form.sourceApplicationId}}
{showImport && (
setImportQuery(e.target.value)} placeholder="이름, 회사, 연락처, 이메일 검색" onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); void loadVisitorApplications(); } }} />
{importCandidates.map((item) => ( ))} {!importLoading && importCandidates.length === 0 &&

조회된 방문신청이 없습니다.

}
)} {/* noValidate: use our Korean messages instead of the browser's native popups */}
방문자
출입 전산실
{SERVER_ROOM_OPTIONS.map((z) => ( ))}
{form.room === '기타' ? ( ) : null} {selectedPurpose?.customAllowed ? ( ) : null}
출입통제담당자
현장감시자
현장감시자1 : IT센터 사무보조원 (자동 지정)
현장감시자2 : 작업을 입회할 상주직원
{error && {error}}
); };