feat: add public visitor application flow
This commit is contained in:
387
frontend/src/pages/PublicVisitApplicationPage.tsx
Normal file
387
frontend/src/pages/PublicVisitApplicationPage.tsx
Normal file
@@ -0,0 +1,387 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
confirmVisitorVerification,
|
||||
createVisitorApplication,
|
||||
listPurposeCodes,
|
||||
startVisitorVerification,
|
||||
} from '../api';
|
||||
import { DateTimePicker } from '../components/DateTimePicker';
|
||||
import { PurposeCode, VisitorVerificationMethod } from '../types';
|
||||
import bokBadge from '../assets/bok-badge.png';
|
||||
|
||||
const SERVER_ROOM_OPTIONS = ['4층전산실', '5층전산실'];
|
||||
const ROOM_OPTIONS = ['4층종합상황실', '4층CMT실', '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 DEFAULT_WATCHER1 = { name: '류관순', team: 'IT전략국', contact: '313' };
|
||||
const AFFILIATION_OPTIONS = [
|
||||
'IT센터관리팀', 'IT서비스팀', '네트워크팀', '클라우드팀', 'RTGS시스템팀',
|
||||
'금융IT인프라팀', '정보인프라팀', 'AI플랫폼팀', '보안운영팀', '보안관제반',
|
||||
'IT리스크팀', 'IT기획팀', '정보기획팀', 'IT전략국',
|
||||
];
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
const normalizePhoneText = (value: string): string =>
|
||||
value
|
||||
.replace(/[0-9]/g, (char) => String(char.charCodeAt(0) - 0xff10))
|
||||
.replace(/[-ー–—]/g, '-')
|
||||
.replace(/\s+/g, '');
|
||||
|
||||
const formatPhoneLike = (value: string): string => {
|
||||
const compact = normalizePhoneText(value);
|
||||
const digits = compact.replace(/\D/g, '');
|
||||
if (!/^[\d-]*$/.test(compact)) return compact;
|
||||
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 =>
|
||||
normalizePhoneText(value).replace(/[^\d-]/g, '').slice(0, 20);
|
||||
|
||||
const sameDate = (a: Date, b: Date): boolean =>
|
||||
a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
||||
|
||||
const phoneDigits = (value: string): string => normalizePhoneText(value).replace(/\D/g, '');
|
||||
|
||||
export const PublicVisitApplicationPage: React.FC = () => {
|
||||
const [form, setForm] = useState({
|
||||
visitorName: '',
|
||||
company: '',
|
||||
contact: '',
|
||||
email: '',
|
||||
vehicleNo: '',
|
||||
serverRooms: [] as string[],
|
||||
room: '',
|
||||
roomEtc: '',
|
||||
purpose: '',
|
||||
purposeEtc: '',
|
||||
workName: '',
|
||||
controlName: '',
|
||||
controlTeam: '',
|
||||
controlContact: '',
|
||||
watcher1Name: DEFAULT_WATCHER1.name,
|
||||
watcher1Team: DEFAULT_WATCHER1.team,
|
||||
watcher1Contact: DEFAULT_WATCHER1.contact,
|
||||
watcher2Name: '',
|
||||
watcher2Team: '',
|
||||
watcher2Contact: '',
|
||||
visitFrom: '',
|
||||
visitTo: '',
|
||||
});
|
||||
const [purposeCodes, setPurposeCodes] = useState<PurposeCode[]>(FALLBACK_PURPOSE_CODES);
|
||||
const [verificationMethod, setVerificationMethod] = useState<VisitorVerificationMethod>('PHONE');
|
||||
const [verificationId, setVerificationId] = useState('');
|
||||
const [verificationCode, setVerificationCode] = useState('');
|
||||
const [verificationToken, setVerificationToken] = useState('');
|
||||
const [verificationHint, setVerificationHint] = useState('');
|
||||
const [consent, setConsent] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [done, setDone] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const contactInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
listPurposeCodes().then(setPurposeCodes).catch(() => setPurposeCodes(FALLBACK_PURPOSE_CODES));
|
||||
}, []);
|
||||
|
||||
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 updateContact = (k: 'contact' | 'controlContact' | 'watcher2Contact') => (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const next = sanitizePhoneInput(e.target.value);
|
||||
setForm({ ...form, [k]: next });
|
||||
if (k === 'contact' && verificationMethod === 'PHONE') {
|
||||
setVerificationToken('');
|
||||
setVerificationId('');
|
||||
}
|
||||
};
|
||||
|
||||
const clearContactVerification = () => {
|
||||
if (verificationMethod === 'PHONE') {
|
||||
setVerificationToken('');
|
||||
setVerificationId('');
|
||||
}
|
||||
};
|
||||
|
||||
const currentVisitorContact = (): string => sanitizePhoneInput(contactInputRef.current?.value ?? form.contact);
|
||||
|
||||
const formatVisitorContactInput = () => {
|
||||
if (!contactInputRef.current) return;
|
||||
contactInputRef.current.value = formatPhoneLike(contactInputRef.current.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 validate = (): string | null => {
|
||||
if (!form.visitorName.trim()) return '방문자 이름을 입력하세요.';
|
||||
if (!form.company.trim()) return '회사/소속을 입력하세요.';
|
||||
if (!currentVisitorContact()) return '연락처를 입력하세요.';
|
||||
if (form.email.trim() && !EMAIL_RE.test(form.email.trim())) return '이메일 형식을 확인하세요.';
|
||||
if (verificationMethod === 'EMAIL' && !form.email.trim()) return '이메일 인증을 선택한 경우 이메일이 필요합니다.';
|
||||
if (!verificationToken) return '본인확인을 완료하세요.';
|
||||
if (form.serverRooms.length === 0 && !form.room) return '방문 구역을 선택하세요.';
|
||||
if (form.room === '기타' && !form.roomEtc.trim()) return '기타 구역을 입력하세요.';
|
||||
if (!form.purpose) return '방문 목적을 선택하세요.';
|
||||
if (selectedPurpose?.customAllowed && !form.purposeEtc.trim()) return '기타 방문 목적을 입력하세요.';
|
||||
if (!form.controlName.trim()) return '출입통제담당자 이름을 입력하세요.';
|
||||
if (!form.controlTeam.trim()) return '출입통제담당자 소속을 선택하세요.';
|
||||
if (!form.visitFrom) return '방문 일시를 선택하세요.';
|
||||
if (!form.visitTo) return '퇴실 예정일시를 선택하세요.';
|
||||
const from = new Date(form.visitFrom);
|
||||
const to = new Date(form.visitTo);
|
||||
if (from > to) return '퇴실 예정일시는 방문 일시 이후여야 합니다.';
|
||||
if (!sameDate(from, to)) return '퇴실 예정일이 다음 날이면 날짜별로 나누어 신청하세요.';
|
||||
if (!consent) return '개인정보 수집 및 이용에 동의해야 신청할 수 있습니다.';
|
||||
return null;
|
||||
};
|
||||
|
||||
const requestVerification = async () => {
|
||||
setError(null);
|
||||
setVerificationToken('');
|
||||
const currentContact = currentVisitorContact();
|
||||
formatVisitorContactInput();
|
||||
const target = verificationMethod === 'PHONE' ? phoneDigits(currentContact) : form.email.trim();
|
||||
if (!target) {
|
||||
setError(verificationMethod === 'PHONE' ? '휴대폰번호를 입력하세요.' : '이메일을 입력하세요.');
|
||||
return;
|
||||
}
|
||||
if (verificationMethod === 'PHONE' && target.length < 10) {
|
||||
setError(`휴대폰번호를 확인하세요. 현재 숫자 ${target.length}자리입니다.`);
|
||||
return;
|
||||
}
|
||||
if (verificationMethod === 'EMAIL' && !EMAIL_RE.test(form.email.trim())) {
|
||||
setError('이메일 형식을 확인하세요.');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await startVisitorVerification(verificationMethod, target);
|
||||
setVerificationId(result.verificationId);
|
||||
setVerificationHint(result.devCode ? `리허설 인증번호: ${result.devCode}` : '인증번호를 발송했습니다.');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '인증번호 요청에 실패했습니다.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmVerification = async () => {
|
||||
setError(null);
|
||||
if (!verificationId) {
|
||||
setError('인증번호를 먼저 요청하세요.');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await confirmVisitorVerification(verificationId, verificationCode);
|
||||
setVerificationToken(result.verificationToken);
|
||||
setVerificationHint('본인확인이 완료되었습니다.');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '인증번호 확인에 실패했습니다.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const message = validate();
|
||||
if (message) {
|
||||
setError(message);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await createVisitorApplication({
|
||||
visitorName: form.visitorName.trim(),
|
||||
company: form.company.trim(),
|
||||
contact: formatPhoneLike(currentVisitorContact()),
|
||||
email: form.email.trim() || undefined,
|
||||
vehicleNo: form.vehicleNo.trim() || undefined,
|
||||
zoneName: form.serverRooms[0] || undefined,
|
||||
roomZone: form.room === '기타' ? form.roomEtc.trim() : form.room || 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,
|
||||
controlName: form.controlName.trim() || undefined,
|
||||
controlTeam: form.controlTeam.trim() || undefined,
|
||||
controlContact: formatPhoneLike(form.controlContact.trim()) || undefined,
|
||||
watcher1Name: form.watcher1Name,
|
||||
watcher1Team: form.watcher1Team,
|
||||
watcher1Contact: form.watcher1Contact,
|
||||
watcher2Name: form.watcher2Name.trim() || undefined,
|
||||
watcher2Team: form.watcher2Team.trim() || undefined,
|
||||
watcher2Contact: formatPhoneLike(form.watcher2Contact.trim()) || undefined,
|
||||
visitFrom: form.visitFrom,
|
||||
visitTo: form.visitTo,
|
||||
verificationMethod,
|
||||
verificationToken,
|
||||
});
|
||||
setDone(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '방문신청 접수에 실패했습니다.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<main className="public-visit-shell">
|
||||
<section className="public-visit-card public-visit-complete">
|
||||
<img src={bokBadge} alt="" className="public-visit-badge" />
|
||||
<h1>방문신청이 접수되었습니다.</h1>
|
||||
<p>담당자가 신청 내용을 확인한 뒤 출입신청으로 등록합니다.</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="public-visit-shell">
|
||||
<form className="public-visit-card visit-form" onSubmit={onSubmit} noValidate>
|
||||
<div className="public-visit-head">
|
||||
<img src={bokBadge} alt="" className="public-visit-badge" />
|
||||
<div>
|
||||
<h1>방문신청</h1>
|
||||
<p>IT센터 출입 사전 신청</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset className="form-group span-2">
|
||||
<legend>방문자</legend>
|
||||
<div className="group-grid">
|
||||
<label className="field"><span>방문자 이름 <b className="required">*</b></span><input value={form.visitorName} onChange={update('visitorName')} autoFocus /></label>
|
||||
<label className="field"><span>회사/소속 <b className="required">*</b></span><input value={form.company} onChange={update('company')} /></label>
|
||||
<label className="field">
|
||||
<span>연락처 <b className="required">*</b></span>
|
||||
<input
|
||||
ref={contactInputRef}
|
||||
className="phone-input"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
defaultValue={form.contact}
|
||||
onChange={clearContactVerification}
|
||||
onBlur={(e) => { e.currentTarget.value = formatPhoneLike(e.currentTarget.value); }}
|
||||
placeholder="010-0000-0000"
|
||||
/>
|
||||
</label>
|
||||
<label className="field"><span>이메일</span><input type="email" value={form.email} onChange={update('email')} placeholder="name@example.com" /></label>
|
||||
<label className="field"><span>차량번호</span><input value={form.vehicleNo} onChange={update('vehicleNo')} /></label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="form-group span-2">
|
||||
<legend>본인확인</legend>
|
||||
<div className="public-verification-grid">
|
||||
<label className="radio-inline"><input type="radio" checked={verificationMethod === 'PHONE'} onChange={() => setVerificationMethod('PHONE')} /> 휴대폰 인증</label>
|
||||
<label className="radio-inline"><input type="radio" checked={verificationMethod === 'EMAIL'} onChange={() => setVerificationMethod('EMAIL')} /> 이메일 인증</label>
|
||||
<button type="button" className="btn-ghost" onClick={requestVerification} disabled={busy || Boolean(verificationToken)}>인증번호 요청</button>
|
||||
<input value={verificationCode} onChange={(e) => setVerificationCode(e.target.value.replace(/\D/g, '').slice(0, 6))} placeholder="인증번호 6자리" />
|
||||
<button type="button" className="btn-ghost" onClick={confirmVerification} disabled={busy || Boolean(verificationToken)}>확인</button>
|
||||
{verificationHint && <span className="verification-hint">{verificationHint}</span>}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="form-group span-2">
|
||||
<legend>방문 내용</legend>
|
||||
<div className="group-grid">
|
||||
<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')} /></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')} /></label>}
|
||||
<label className="field"><span>작업명</span><input value={form.workName} onChange={update('workName')} /></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>이름 <b className="required">*</b></span><input value={form.controlName} onChange={update('controlName')} /></label>
|
||||
<label className="field">
|
||||
<span>소속 <b className="required">*</b></span>
|
||||
<select value={form.controlTeam} onChange={update('controlTeam')}>
|
||||
<option value="">선택하세요</option>
|
||||
{AFFILIATION_OPTIONS.map((team) => <option key={team} value={team}>{team}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field"><span>연락처</span><input className="phone-input" type="text" inputMode="tel" value={form.controlContact} onChange={updateContact('controlContact')} placeholder="내선번호/휴대폰번호" /></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={form.watcher1Name} readOnly /></label>
|
||||
<label className="field"><span>소속</span><input value={form.watcher1Team} readOnly /></label>
|
||||
<label className="field"><span>연락처</span><input className="phone-input" value={form.watcher1Contact} readOnly /></label>
|
||||
</div>
|
||||
|
||||
<div className="subsection-title">현장감시자2 <em className="hint-inline">: 센터내 상주직원을 알고 있는 경우만 입력</em></div>
|
||||
<div className="group-grid">
|
||||
<label className="field"><span>이름</span><input 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((team) => <option key={team} value={team}>{team}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field"><span>연락처</span><input className="phone-input" type="text" inputMode="tel" value={form.watcher2Contact} onChange={updateContact('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>[방문자 개인정보 수집·이용 동의 <span className="required">*</span>]</b><br />
|
||||
수집 항목: 이름, 연락처, 이메일, 차량번호<br />
|
||||
이용 목적: IT센터 방문신청 접수 및 출입자 관리<br />
|
||||
<span className="consent-retention-warning">보유 기간: 출입 완료 후 개인정보는 삭제처리</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-actions span-2">
|
||||
{error && <span className="form-error" role="alert">{error}</span>}
|
||||
<button type="submit" className="btn-primary" disabled={busy}>{busy ? '처리 중...' : '방문신청'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { createVisitRequest, getWatcher1Settings, listPurposeCodes } from '../api';
|
||||
import { createVisitRequest, getWatcher1Settings, listPurposeCodes, listVisitorApplications } from '../api';
|
||||
import { DateTimePicker } from '../components/DateTimePicker';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { PurposeCode, Watcher1Settings } from '../types';
|
||||
import { PurposeCode, VisitorApplicationView, Watcher1Settings } from '../types';
|
||||
|
||||
// 코드 시트 목록을 콤보/체크박스에 반영.
|
||||
// 전산실: 체크박스(다중). 선택한 개수만큼 신청/QR이 생성된다.
|
||||
@@ -46,15 +46,24 @@ const isLaterCalendarDate = (later: Date, earlier: Date): boolean => {
|
||||
};
|
||||
|
||||
const formatPhoneLike = (value: string): string => {
|
||||
const digits = value.replace(/\D/g, '');
|
||||
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)}`;
|
||||
}
|
||||
return value;
|
||||
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: '',
|
||||
@@ -77,6 +86,10 @@ export const VisitRequestFormPage: React.FC = () => {
|
||||
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();
|
||||
|
||||
@@ -93,7 +106,7 @@ export const VisitRequestFormPage: React.FC = () => {
|
||||
|
||||
const updateFormattedContact = (k: 'contact' | 'watcher2Contact') => (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
) => setForm({ ...form, [k]: formatPhoneLike(e.target.value) });
|
||||
) => setForm({ ...form, [k]: sanitizePhoneInput(e.target.value) });
|
||||
|
||||
const toggleServerRoom = (room: string) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setForm((f) => ({
|
||||
@@ -103,6 +116,47 @@ export const VisitRequestFormPage: React.FC = () => {
|
||||
: 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 '방문자 이름을 입력하세요.';
|
||||
@@ -139,6 +193,7 @@ export const VisitRequestFormPage: React.FC = () => {
|
||||
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()),
|
||||
@@ -168,7 +223,46 @@ export const VisitRequestFormPage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head"><h2>출입 신청</h2></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>
|
||||
@@ -185,7 +279,7 @@ export const VisitRequestFormPage: React.FC = () => {
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>연락처 <b className="required">*</b></span>
|
||||
<input type="tel" value={form.contact} onChange={updateFormattedContact('contact')} placeholder="010-0000-0000" />
|
||||
<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>
|
||||
@@ -278,7 +372,7 @@ export const VisitRequestFormPage: React.FC = () => {
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>연락처</span>
|
||||
<input value="" placeholder="내선번호/휴대폰번호" readOnly />
|
||||
<input className="phone-input" value="" placeholder="내선번호/휴대폰번호" readOnly />
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
@@ -297,7 +391,7 @@ export const VisitRequestFormPage: React.FC = () => {
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>연락처</span>
|
||||
<input value={watcher1.contact} readOnly />
|
||||
<input className="phone-input" value={watcher1.contact} readOnly />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -316,7 +410,7 @@ export const VisitRequestFormPage: React.FC = () => {
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>연락처</span>
|
||||
<input value={form.watcher2Contact} onChange={updateFormattedContact('watcher2Contact')} placeholder="내선번호/휴대폰번호" />
|
||||
<input className="phone-input" type="text" inputMode="tel" value={form.watcher2Contact} onChange={updateFormattedContact('watcher2Contact')} placeholder="내선번호/휴대폰번호" />
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
Reference in New Issue
Block a user