444 lines
20 KiB
TypeScript
444 lines
20 KiB
TypeScript
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<PurposeCode[]>(FALLBACK_PURPOSE_CODES);
|
|
const [watcher1, setWatcher1] = useState<Watcher1Settings>(FALLBACK_WATCHER1);
|
|
const [consent, setConsent] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const [showImport, setShowImport] = useState(false);
|
|
const [importQuery, setImportQuery] = useState('');
|
|
const [importCandidates, setImportCandidates] = useState<VisitorApplicationView[]>([]);
|
|
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<HTMLInputElement | HTMLSelectElement>,
|
|
) => setForm({ ...form, [k]: e.target.value });
|
|
|
|
const updateFormattedContact = (k: 'contact' | 'watcher2Contact') => (
|
|
e: React.ChangeEvent<HTMLInputElement>,
|
|
) => setForm({ ...form, [k]: sanitizePhoneInput(e.target.value) });
|
|
|
|
const toggleServerRoom = (room: string) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
|
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 (
|
|
<div>
|
|
<div className="page-head">
|
|
<h2>출입 신청</h2>
|
|
<div className="head-actions">
|
|
{form.sourceApplicationId && <span className="badge badge-blue">방문신청 #{form.sourceApplicationId}</span>}
|
|
<button type="button" className="btn-ghost" onClick={() => setShowImport((v) => !v)}>
|
|
방문신청 가져오기
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{showImport && (
|
|
<section className="card import-panel">
|
|
<div className="inline-form">
|
|
<input
|
|
value={importQuery}
|
|
onChange={(e) => setImportQuery(e.target.value)}
|
|
placeholder="이름, 회사, 연락처, 이메일 검색"
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
void loadVisitorApplications();
|
|
}
|
|
}}
|
|
/>
|
|
<button type="button" className="btn-primary" onClick={loadVisitorApplications} disabled={importLoading}>
|
|
{importLoading ? '조회 중' : '조회'}
|
|
</button>
|
|
</div>
|
|
<div className="import-list">
|
|
{importCandidates.map((item) => (
|
|
<button key={item.id} type="button" className="import-item" onClick={() => applyVisitorApplication(item)}>
|
|
<strong>{item.visitorName}</strong>
|
|
<span>{item.company ?? '-'} / {item.contact ?? item.email ?? '-'}</span>
|
|
<span>{item.zoneName ?? item.roomZone ?? '-'} / {new Date(item.visitFrom).toLocaleString('ko-KR')}</span>
|
|
</button>
|
|
))}
|
|
{!importLoading && importCandidates.length === 0 && <p className="muted">조회된 방문신청이 없습니다.</p>}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{/* noValidate: use our Korean messages instead of the browser's native popups */}
|
|
<form className="card form-grid visit-form" onSubmit={onSubmit} noValidate>
|
|
<fieldset className="form-group span-2">
|
|
<legend>방문자</legend>
|
|
<div className="group-grid">
|
|
<label className="field">
|
|
<span>방문자 이름 <b className="required">*</b></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>연락처 <b className="required">*</b></span>
|
|
<input className="phone-input" type="text" inputMode="tel" autoComplete="off" value={form.contact} onChange={updateFormattedContact('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>
|
|
|
|
<div className="field">
|
|
<span>출입 전산실</span>
|
|
<div className="checkbox-row">
|
|
{SERVER_ROOM_OPTIONS.map((z) => (
|
|
<label key={z} className="checkbox-inline">
|
|
<input
|
|
type="checkbox"
|
|
checked={form.serverRooms.includes(z)}
|
|
onChange={toggleServerRoom(z)}
|
|
/>
|
|
<span>{z}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<label className="field">
|
|
<span>추가 구역</span>
|
|
<select value={form.room} onChange={update('room')}>
|
|
<option value="">선택하세요</option>
|
|
{ROOM_OPTIONS.map((r) => <option key={r} value={r}>{r}</option>)}
|
|
</select>
|
|
</label>
|
|
{form.room === '기타' ? (
|
|
<label className="field">
|
|
<span>기타 구역 입력 <b className="required">*</b></span>
|
|
<input value={form.roomEtc} onChange={update('roomEtc')} placeholder="추가 구역을 입력하세요" />
|
|
</label>
|
|
) : null}
|
|
|
|
<label className="field">
|
|
<span>차량번호</span>
|
|
<input className="ime-ko" value={form.vehicleNo} onChange={update('vehicleNo')} placeholder="차량번호가 5부제에 해당될 경우 출입이 제한됩니다." />
|
|
</label>
|
|
|
|
<label className="field">
|
|
<span>출입 목적 <b className="required">*</b></span>
|
|
<select value={form.purpose} onChange={update('purpose')}>
|
|
<option value="">선택하세요</option>
|
|
{purposeCodes.map((p) => <option key={p.code} value={p.code}>{p.name}</option>)}
|
|
</select>
|
|
</label>
|
|
{selectedPurpose?.customAllowed ? (
|
|
<label className="field">
|
|
<span>기타 목적 입력 <b className="required">*</b></span>
|
|
<input value={form.purposeEtc} onChange={update('purposeEtc')} placeholder="출입 목적을 입력하세요" />
|
|
</label>
|
|
) : null}
|
|
|
|
<label className="field">
|
|
<span>작업명</span>
|
|
<input className="ime-ko" value={form.workName} onChange={update('workName')} placeholder="작업 내용을 구체적으로 입력하세요" />
|
|
</label>
|
|
|
|
<label className="field">
|
|
<span>출입 일시 <b className="required">*</b></span>
|
|
<DateTimePicker
|
|
value={form.visitFrom}
|
|
onChange={(v) => setForm((f) => ({ ...f, visitFrom: v }))}
|
|
placeholder="출입 일시 선택"
|
|
/>
|
|
</label>
|
|
<label className="field">
|
|
<span>퇴실 예정일시 <b className="required">*</b></span>
|
|
<DateTimePicker
|
|
value={form.visitTo}
|
|
onChange={(v) => setForm((f) => ({ ...f, visitTo: v }))}
|
|
placeholder="퇴실 예정일시 선택"
|
|
/>
|
|
</label>
|
|
</div>
|
|
</fieldset>
|
|
|
|
<fieldset className="form-group span-2">
|
|
<legend>출입통제담당자</legend>
|
|
<div className="group-grid">
|
|
<label className="field">
|
|
<span>이름</span>
|
|
<input value={user?.fullName ?? ''} readOnly />
|
|
</label>
|
|
<label className="field">
|
|
<span>담당팀</span>
|
|
<input value={user?.department ?? ''} readOnly />
|
|
</label>
|
|
<label className="field">
|
|
<span>연락처</span>
|
|
<input className="phone-input" value="" placeholder="내선번호/휴대폰번호" readOnly />
|
|
</label>
|
|
</div>
|
|
</fieldset>
|
|
|
|
<fieldset className="form-group span-2">
|
|
<legend>현장감시자</legend>
|
|
<div className="subsection-title">현장감시자1 <em className="hint-inline">: IT센터 사무보조원 (자동 지정)</em></div>
|
|
<div className="group-grid">
|
|
<label className="field">
|
|
<span>이름</span>
|
|
<input value={watcher1.name} readOnly />
|
|
</label>
|
|
<label className="field">
|
|
<span>소속</span>
|
|
<input value={watcher1.team} readOnly />
|
|
</label>
|
|
<label className="field">
|
|
<span>연락처</span>
|
|
<input className="phone-input" value={watcher1.contact} readOnly />
|
|
</label>
|
|
</div>
|
|
|
|
<div className="subsection-title">현장감시자2 <em className="hint-inline">: 작업을 입회할 상주직원</em></div>
|
|
<div className="group-grid">
|
|
<label className="field">
|
|
<span>이름</span>
|
|
<input className="ime-ko" value={form.watcher2Name} onChange={update('watcher2Name')} />
|
|
</label>
|
|
<label className="field">
|
|
<span>소속</span>
|
|
<select value={form.watcher2Team} onChange={update('watcher2Team')}>
|
|
<option value="">선택하세요</option>
|
|
{AFFILIATION_OPTIONS.map((a) => <option key={a} value={a}>{a}</option>)}
|
|
</select>
|
|
</label>
|
|
<label className="field">
|
|
<span>연락처</span>
|
|
<input className="phone-input" type="text" inputMode="tel" value={form.watcher2Contact} onChange={updateFormattedContact('watcher2Contact')} placeholder="내선번호/휴대폰번호" />
|
|
</label>
|
|
</div>
|
|
</fieldset>
|
|
|
|
<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 />
|
|
<span className="consent-retention-warning">
|
|
· 보유·이용 기간: 전산실 퇴장 등록시 입력된 방문자 이름, 연락처, 이메일, 차량번호는 바로 삭제
|
|
</span><br />
|
|
· 방문자 개인정보 수집·이용에 동의를 거부할 권리가 있으며, 동의하지 않을 경우 출입 신청이 제한됨을 고지
|
|
</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="form-actions span-2">
|
|
{error && <span className="form-error" role="alert">{error}</span>}
|
|
<button type="button" className="btn-ghost" onClick={() => navigate(-1)}>취소</button>
|
|
<button type="submit" className="btn-primary" disabled={busy}>
|
|
{busy ? '신청 중…' : '출입 신청'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
};
|