feat: add public visitor application flow
This commit is contained in:
@@ -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