feat: add public visitor application flow

This commit is contained in:
unknown
2026-08-03 17:11:04 +09:00
parent 7ff45fb4ec
commit 7239c21a7d
10 changed files with 1041 additions and 13 deletions

View File

@@ -12,6 +12,7 @@ import { ApprovalQueuePage } from './pages/ApprovalQueuePage';
import { AccessConsolePage } from './pages/AccessConsolePage';
import { BadgePage } from './pages/BadgePage';
import { PublicPassPage } from './pages/PublicPassPage';
import { PublicVisitApplicationPage } from './pages/PublicVisitApplicationPage';
import { KioskPage } from './pages/KioskPage';
import { BlacklistPage } from './pages/BlacklistPage';
import { ReportPage } from './pages/ReportPage';
@@ -45,6 +46,7 @@ export default function App() {
<Route path="/change-password" element={<ChangePasswordPage />} />
{/* Public visitor pass — opened from the SMS link, no login. */}
<Route path="/pass/:token" element={<PublicPassPage />} />
<Route path="/visit" element={<PublicVisitApplicationPage />} />
{/* Public entrance kiosk — visitor self check-in/out, no login. */}
<Route path="/kiosk" element={<KioskPage />} />

View File

@@ -21,6 +21,11 @@ import {
StatsSummary,
PublicPass,
Team,
VisitorApplicationCreate,
VisitorApplicationView,
VisitorVerificationConfirmResult,
VisitorVerificationMethod,
VisitorVerificationStartResult,
VisitRequestCreate,
VisitRequestResetResult,
VisitRequestView,
@@ -97,6 +102,22 @@ export const changePassword = (req: ChangePasswordRequest) =>
export const listZones = () => request<Zone[]>('/zones');
export const listPurposeCodes = () => request<PurposeCode[]>('/purpose-codes');
// ===== Public visitor application =====
export const startVisitorVerification = (method: VisitorVerificationMethod, target: string) =>
request<VisitorVerificationStartResult>('/public/visitor-verifications', jsonInit('POST', { method, target }));
export const confirmVisitorVerification = (verificationId: string, code: string) =>
request<VisitorVerificationConfirmResult>(
`/public/visitor-verifications/${encodeURIComponent(verificationId)}/confirm`,
jsonInit('POST', { code }),
);
export const createVisitorApplication = (payload: VisitorApplicationCreate) =>
request<VisitorApplicationView>('/public/visitor-applications', jsonInit('POST', payload));
export const listVisitorApplications = (q?: string) =>
request<VisitorApplicationView[]>(`/visitor-applications${q ? `?q=${encodeURIComponent(q)}` : ''}`);
// ===== Visit requests =====
export const listVisitRequests = () =>
request<VisitRequestView[]>('/visit-requests');

View File

@@ -41,6 +41,7 @@ export const DateTimePicker: React.FC<Props> = ({ value, onChange, placeholder }
placeholderText={placeholder ?? '날짜와 시간을 선택하세요'}
className="dt-input"
popperClassName="acs-datepicker"
popperPlacement="bottom-end"
renderCustomHeader={({ date, decreaseMonth, increaseMonth, prevMonthButtonDisabled, nextMonthButtonDisabled }) => (
<div className="dt-picker-header">
<button type="button" className="dt-picker-nav" onClick={decreaseMonth} disabled={prevMonthButtonDisabled} aria-label="이전 달">
@@ -51,7 +52,7 @@ export const DateTimePicker: React.FC<Props> = ({ value, onChange, placeholder }
<button type="button" className="dt-picker-nav" onClick={increaseMonth} disabled={nextMonthButtonDisabled} aria-label="다음 달">
</button>
<button type="button" className="dt-picker-close" onClick={() => pickerRef.current?.setOpen(false)} aria-label="닫기">
<button type="button" className="dt-picker-close" onClick={() => pickerRef.current?.setOpen(false)} aria-label="닫기" title="닫기">
×
</button>
</div>

View 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(/[-]/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>
);
};

View File

@@ -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>

View File

@@ -121,8 +121,24 @@ a { color: inherit; text-decoration: none; }
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
font-family: inherit;
letter-spacing: 0;
background: #fff;
}
.field input[type="tel"],
.phone-input {
font-family: 'Malgun Gothic', 'Segoe UI', system-ui, sans-serif !important;
font-size: 13px !important;
font-weight: 400 !important;
font-stretch: normal !important;
font-variant-numeric: proportional-nums !important;
font-feature-settings: "tnum" 0 !important;
letter-spacing: 0 !important;
word-spacing: 0 !important;
direction: ltr !important;
unicode-bidi: plaintext !important;
text-align: left !important;
}
.field input:focus, .field select:focus { outline: 2px solid #bfdbfe; border-color: var(--primary); }
/* react-datepicker: make the input fill the field like the native ones */
@@ -136,7 +152,8 @@ a { color: inherit; text-decoration: none; }
.dt-picker-nav,
.dt-picker-close { width: 28px; height: 28px; border: 1px solid var(--border); border-radius: 6px; background: #fff; color: var(--text); font-size: 18px; line-height: 1; cursor: pointer; }
.dt-picker-nav:disabled { opacity: 0.4; cursor: not-allowed; }
.dt-picker-close { color: #b42318; font-size: 20px; }
.dt-picker-close { color: #b42318; font-size: 22px; font-weight: 800; display: inline-flex; align-items: center; justify-content: center; }
.dt-picker-close:hover { background: #fee2e2; border-color: #fecaca; }
/* In-app modal dialog (replaces window.prompt/confirm) */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 16px; }
@@ -350,6 +367,7 @@ button:disabled { opacity: .55; cursor: not-allowed; }
.badge-amber { background: #fef3c7; color: #b45309; }
.badge-red { background: #fee2e2; color: #b91c1c; }
.badge-gray { background: #e2e8f0; color: #475569; }
.badge-blue { background: #dbeafe; color: #1d4ed8; }
/* ===== Alerts ===== */
.alert { padding: 12px 14px; border-radius: 8px; margin-bottom: 16px; font-size: 14px; }
@@ -358,6 +376,143 @@ button:disabled { opacity: .55; cursor: not-allowed; }
.muted { color: var(--muted); }
/* ===== Public visitor application ===== */
.public-visit-shell {
min-height: 100vh;
padding: 24px 18px;
background: linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%);
}
.public-visit-card {
width: min(1160px, 100%);
margin: 0 auto;
background: #fff;
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.08);
}
.public-visit-card.visit-form {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px 18px;
padding: 16px;
}
.public-visit-card .span-2 {
grid-column: 1 / -1;
}
.public-visit-card input,
.public-visit-card select,
.public-visit-card textarea,
.public-visit-card button {
font-family: 'Segoe UI', 'Malgun Gothic', system-ui, sans-serif;
font-size: 13px;
font-weight: 400;
font-variant-numeric: proportional-nums;
font-feature-settings: "tnum" 0;
letter-spacing: 0 !important;
word-spacing: 0;
}
.public-visit-card .btn-primary,
.public-visit-card .btn-ghost {
font-weight: 600;
}
.public-visit-head {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 12px;
padding: 4px 4px 8px;
}
.public-visit-head h1 {
margin: 0;
font-size: 24px;
}
.public-visit-head p {
margin: 2px 0 0;
color: var(--muted);
font-size: 13px;
}
.public-visit-badge {
width: 40px;
height: 40px;
object-fit: contain;
}
.public-verification-grid {
display: grid;
grid-template-columns: repeat(2, minmax(120px, max-content)) minmax(120px, auto) minmax(160px, 1fr) auto;
gap: 8px;
align-items: center;
}
.radio-inline {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: #334155;
}
.public-verification-grid input {
min-width: 0;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
}
.verification-hint {
grid-column: 1 / -1;
color: var(--primary-dark);
font-size: 13px;
font-weight: 700;
}
.public-visit-complete {
max-width: 520px;
padding: 32px;
text-align: center;
}
.public-visit-complete .public-visit-badge {
width: 64px;
height: 64px;
}
.public-visit-complete h1 {
margin: 14px 0 8px;
font-size: 24px;
}
.public-visit-complete p {
margin: 0;
color: var(--muted);
}
/* ===== Visitor application import ===== */
.import-panel {
padding: 12px;
}
.import-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 8px;
margin-top: 10px;
}
.import-item {
display: grid;
gap: 4px;
text-align: left;
border: 1px solid var(--border);
border-radius: 8px;
background: #fff;
padding: 10px 12px;
}
.import-item:hover {
border-color: #93c5fd;
background: #f8fbff;
}
.import-item strong {
font-size: 14px;
}
.import-item span {
color: var(--muted);
font-size: 12px;
overflow-wrap: anywhere;
}
/* ===== Access console ===== */
.console-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items: start; }
.inline-form { display: flex; gap: 8px; }
@@ -492,6 +647,10 @@ button:disabled { opacity: .55; cursor: not-allowed; }
@media (max-width: 720px) {
.stat-grid { grid-template-columns: repeat(2, 1fr); }
.form-grid { grid-template-columns: 1fr; }
.public-visit-shell { padding: 12px 8px; }
.public-visit-card.visit-form { grid-template-columns: 1fr; padding: 10px; }
.public-verification-grid { grid-template-columns: 1fr; }
.public-verification-grid .btn-ghost { width: 100%; }
.group-grid { grid-template-columns: 1fr; }
.detail-grid,
.detail-item {

View File

@@ -105,6 +105,7 @@ export type VisitStatus =
| 'EXPIRED';
export interface VisitRequestCreate {
sourceApplicationId?: number;
visitorName: string;
company?: string;
contact: string;
@@ -127,6 +128,61 @@ export interface VisitRequestCreate {
visitTo: string;
}
export type VisitorVerificationMethod = 'PHONE' | 'EMAIL';
export interface VisitorVerificationStart {
method: VisitorVerificationMethod;
target: string;
}
export interface VisitorVerificationStartResult {
verificationId: string;
expiresAt: string;
deliveryStatus: string;
devCode?: string;
}
export interface VisitorVerificationConfirmResult {
verificationToken: string;
}
export interface VisitorApplicationCreate {
visitorName: string;
company?: string;
contact?: string;
email?: string;
vehicleNo?: string;
zoneName?: string;
roomZone?: string;
purpose: string;
purposeCode?: string;
purposeDetail?: string;
workName?: string;
controlName?: string;
controlTeam?: string;
controlContact?: string;
watcher1Name?: string;
watcher1Team?: string;
watcher1Contact?: string;
watcher2Name?: string;
watcher2Team?: string;
watcher2Contact?: string;
visitFrom: string;
visitTo: string;
verificationMethod: VisitorVerificationMethod;
verificationToken: string;
}
export interface VisitorApplicationView extends Omit<VisitorApplicationCreate, 'verificationToken'> {
id: number;
createdAt: string;
status: string;
verificationTarget: string;
verifiedAt?: string;
importedVisitRequestId?: number;
importedAt?: string;
}
export interface VisitRequestView {
id: number;
visitorName: string;

View File

@@ -0,0 +1,43 @@
CREATE TABLE IF NOT EXISTS visitor_verifications (
id VARCHAR(36) PRIMARY KEY,
created_at TIMESTAMP NOT NULL DEFAULT now(),
verification_method VARCHAR(10) NOT NULL,
target VARCHAR(120) NOT NULL,
code_hash VARCHAR(64) NOT NULL,
expires_at TIMESTAMP NOT NULL,
verified_at TIMESTAMP,
verification_token VARCHAR(64)
);
CREATE INDEX IF NOT EXISTS idx_visitor_verifications_token ON visitor_verifications (verification_token);
CREATE TABLE IF NOT EXISTS visitor_applications (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now(),
status VARCHAR(20) NOT NULL DEFAULT 'SUBMITTED',
visitor_name VARCHAR(80) NOT NULL,
company VARCHAR(120),
contact VARCHAR(40),
email VARCHAR(120),
vehicle_no VARCHAR(20),
zone_name VARCHAR(80),
room_zone VARCHAR(80),
purpose VARCHAR(255) NOT NULL,
purpose_code VARCHAR(40),
purpose_detail VARCHAR(255),
work_name VARCHAR(255),
watcher2_name VARCHAR(80),
watcher2_team VARCHAR(80),
watcher2_contact VARCHAR(60),
visit_from TIMESTAMP NOT NULL,
visit_to TIMESTAMP NOT NULL,
verification_method VARCHAR(10) NOT NULL,
verification_target VARCHAR(120) NOT NULL,
verification_id VARCHAR(36),
verified_at TIMESTAMP,
imported_visit_request_id BIGINT,
imported_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_visitor_applications_status ON visitor_applications (status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_visitor_applications_contact ON visitor_applications (contact);
CREATE INDEX IF NOT EXISTS idx_visitor_applications_email ON visitor_applications (email);

View File

@@ -0,0 +1,7 @@
ALTER TABLE visitor_applications
ADD COLUMN IF NOT EXISTS control_name VARCHAR(80),
ADD COLUMN IF NOT EXISTS control_team VARCHAR(80),
ADD COLUMN IF NOT EXISTS control_contact VARCHAR(60),
ADD COLUMN IF NOT EXISTS watcher1_name VARCHAR(80),
ADD COLUMN IF NOT EXISTS watcher1_team VARCHAR(80),
ADD COLUMN IF NOT EXISTS watcher1_contact VARCHAR(60);

View File

@@ -3,7 +3,7 @@ import { Router } from 'express';
import multer from 'multer';
import ExcelJS from 'exceljs';
import QRCode from 'qrcode';
import { randomUUID } from 'node:crypto';
import { createHash, randomInt, randomUUID } from 'node:crypto';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { ok } from '../http/apiResponse.js';
@@ -597,6 +597,87 @@ async function createOneVisit(dbQuery: QueryFn, actor: UserRow, body: Record<str
return toVisit((await visitById(dbQuery, Number(created.rows[0].id)))!);
}
interface VisitorApplicationRow {
id: string;
created_at: string | Date;
status: string;
visitor_name: string;
company: string | null;
contact: string | null;
email: string | null;
vehicle_no: string | null;
zone_name: string | null;
room_zone: string | null;
purpose: string;
purpose_code: string | null;
purpose_detail: string | null;
work_name: string | null;
control_name: string | null;
control_team: string | null;
control_contact: string | null;
watcher1_name: string | null;
watcher1_team: string | null;
watcher1_contact: string | null;
watcher2_name: string | null;
watcher2_team: string | null;
watcher2_contact: string | null;
visit_from: string | Date;
visit_to: string | Date;
verification_method: string;
verification_target: string;
verified_at: string | Date | null;
imported_visit_request_id: string | null;
imported_at: string | Date | null;
}
function normalizeDigits(value: string): string {
return value.replace(/\D/g, '');
}
function normalizeVerificationTarget(method: string, value: string): string {
const target = value.trim();
return method === 'PHONE' ? normalizeDigits(target) : target.toLowerCase();
}
function hashVerificationCode(id: string, target: string, code: string): string {
return createHash('sha256').update(`${id}:${target}:${code}:${env.sessionSecret}`).digest('hex');
}
function toVisitorApplication(row: VisitorApplicationRow) {
return {
id: Number(row.id),
createdAt: toIso(row.created_at)!,
status: row.status,
visitorName: row.visitor_name,
company: row.company ?? undefined,
contact: row.contact ?? undefined,
email: row.email ?? undefined,
vehicleNo: row.vehicle_no ?? undefined,
zoneName: row.zone_name ?? undefined,
roomZone: row.room_zone ?? undefined,
purpose: row.purpose,
purposeCode: row.purpose_code ?? undefined,
purposeDetail: row.purpose_detail ?? undefined,
workName: row.work_name ?? undefined,
controlName: row.control_name ?? undefined,
controlTeam: row.control_team ?? undefined,
controlContact: row.control_contact ?? undefined,
watcher1Name: row.watcher1_name ?? undefined,
watcher1Team: row.watcher1_team ?? undefined,
watcher1Contact: row.watcher1_contact ?? undefined,
watcher2Name: row.watcher2_name ?? undefined,
watcher2Team: row.watcher2_team ?? undefined,
watcher2Contact: row.watcher2_contact ?? undefined,
visitFrom: toIso(row.visit_from)!,
visitTo: toIso(row.visit_to)!,
verificationMethod: row.verification_method,
verificationTarget: row.verification_target,
verifiedAt: toIso(row.verified_at),
importedVisitRequestId: row.imported_visit_request_id ? Number(row.imported_visit_request_id) : undefined,
importedAt: toIso(row.imported_at),
};
}
export function createBusinessRouter(deps: BusinessRouterDeps = { query }): Router {
const router = Router();
const dbQuery = deps.query;
@@ -612,6 +693,170 @@ export function createBusinessRouter(deps: BusinessRouterDeps = { query }): Rout
})));
}));
router.post('/public/visitor-verifications', asyncRoute(async (req, res) => {
const method = String(req.body?.method ?? '').trim().toUpperCase();
if (!['PHONE', 'EMAIL'].includes(method)) {
throw new ApiError(400, '인증수단을 선택하세요.');
}
const target = normalizeVerificationTarget(method, required(req.body?.target, '인증 대상'));
if (method === 'PHONE' && target.length < 10) throw new ApiError(400, '휴대폰번호를 확인하세요.');
if (method === 'EMAIL' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(target)) throw new ApiError(400, '이메일 형식을 확인하세요.');
const id = randomUUID();
const code = String(randomInt(100000, 1000000));
await dbQuery(
`
INSERT INTO visitor_verifications (id, verification_method, target, code_hash, expires_at)
VALUES ($1, $2, $3, $4, now() + interval '5 minutes')
`,
[id, method, target, hashVerificationCode(id, target, code)],
);
let deliveryStatus = 'DEV';
if (method === 'PHONE') {
const delivery = await sendSms({ to: target, text: `[ACS 방문신청] 인증번호는 ${code} 입니다.` });
deliveryStatus = delivery.status;
}
ok(res, {
verificationId: id,
expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
deliveryStatus,
devCode: env.nodeEnv === 'production' ? undefined : code,
});
}));
router.post('/public/visitor-verifications/:id/confirm', asyncRoute(async (req, res) => {
const id = String(req.params.id);
const code = String(req.body?.code ?? '').trim();
const result = await dbQuery<{ id: string; target: string; code_hash: string; expires_at: string | Date; verified_at: string | Date | null }>(
'SELECT id, target, code_hash, expires_at, verified_at FROM visitor_verifications WHERE id = $1',
[id],
);
const row = result.rows[0];
if (!row) throw new ApiError(404, '인증 요청을 찾을 수 없습니다.');
if (row.verified_at) throw new ApiError(400, '이미 확인된 인증번호입니다.');
if (new Date(row.expires_at).getTime() < Date.now()) throw new ApiError(400, '인증번호가 만료되었습니다.');
if (!/^\d{6}$/.test(code) || hashVerificationCode(id, row.target, code) !== row.code_hash) {
throw new ApiError(400, '인증번호가 일치하지 않습니다.');
}
const token = randomUUID().replaceAll('-', '');
await dbQuery(
'UPDATE visitor_verifications SET verified_at = now(), verification_token = $2 WHERE id = $1',
[id, token],
);
ok(res, { verificationToken: token });
}));
router.post('/public/visitor-applications', asyncRoute(async (req, res) => {
const method = String(req.body?.verificationMethod ?? '').trim().toUpperCase();
if (!['PHONE', 'EMAIL'].includes(method)) throw new ApiError(400, '인증수단을 선택하세요.');
const visitorName = required(req.body?.visitorName, '방문자 이름');
const company = required(req.body?.company, '회사/소속');
const controlName = required(req.body?.controlName, '출입통제담당자 이름');
const controlTeam = required(req.body?.controlTeam, '출입통제담당자 소속');
const contact = req.body?.contact ? String(req.body.contact).trim() : '';
const email = req.body?.email ? String(req.body.email).trim() : '';
const verificationTarget = normalizeVerificationTarget(method, method === 'PHONE' ? contact : email);
if (!verificationTarget) throw new ApiError(400, '인증 대상과 신청서 연락처가 일치해야 합니다.');
const token = required(req.body?.verificationToken, '본인확인');
const verification = await dbQuery<{ id: string; target: string; verification_method: string; verified_at: string | Date | null }>(
'SELECT id, target, verification_method, verified_at FROM visitor_verifications WHERE verification_token = $1',
[token],
);
const verified = verification.rows[0];
if (!verified?.verified_at) throw new ApiError(400, '본인확인을 완료하세요.');
if (verified.verification_method !== method || verified.target !== verificationTarget) {
throw new ApiError(400, '인증 대상과 신청서 연락처가 일치해야 합니다.');
}
const visitFrom = new Date(required(req.body?.visitFrom, '방문 일시'));
const visitTo = new Date(required(req.body?.visitTo, '퇴실 예정일시'));
if (visitFrom > visitTo) throw new ApiError(400, '퇴실 예정일시는 방문 일시 이후여야 합니다.');
if (!sameDate(visitFrom, visitTo)) throw new ApiError(400, '퇴실 예정일이 다음 날이면 날짜별로 나누어 신청하세요.');
const purpose = await resolvePurpose(
dbQuery,
req.body?.purposeCode ? String(req.body.purposeCode) : undefined,
String(req.body?.purpose ?? ''),
req.body?.purposeDetail ? String(req.body.purposeDetail) : undefined,
);
const zoneName = req.body?.zoneName ? String(req.body.zoneName).trim() : null;
const roomZone = req.body?.roomZone ? String(req.body.roomZone).trim() : null;
if (!zoneName && !roomZone) throw new ApiError(400, '방문 구역을 선택하세요.');
const defaultWatcher1 = await watcher1(dbQuery);
const inserted = await dbQuery<VisitorApplicationRow>(
`
INSERT INTO visitor_applications (
visitor_name, company, contact, email, vehicle_no,
zone_name, room_zone, purpose, purpose_code, purpose_detail, work_name,
control_name, control_team, control_contact,
watcher1_name, watcher1_team, watcher1_contact,
watcher2_name, watcher2_team, watcher2_contact,
visit_from, visit_to, verification_method, verification_target, verification_id, verified_at
)
VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8, $9, $10, $11,
$12, $13, $14,
$15, $16, $17,
$18, $19, $20,
$21, $22, $23, $24, $25, now()
)
RETURNING *
`,
[
visitorName,
company,
contact || null,
email || null,
req.body?.vehicleNo ? String(req.body.vehicleNo).trim() : null,
zoneName,
roomZone,
purpose.display,
purpose.code,
purpose.detail,
req.body?.workName ? String(req.body.workName).trim() : null,
controlName,
controlTeam,
req.body?.controlContact ? String(req.body.controlContact).trim() : null,
req.body?.watcher1Name ? String(req.body.watcher1Name).trim() : defaultWatcher1.name,
req.body?.watcher1Team ? String(req.body.watcher1Team).trim() : defaultWatcher1.team,
req.body?.watcher1Contact ? String(req.body.watcher1Contact).trim() : defaultWatcher1.contact,
req.body?.watcher2Name ? String(req.body.watcher2Name).trim() : null,
req.body?.watcher2Team ? String(req.body.watcher2Team).trim() : null,
req.body?.watcher2Contact ? String(req.body.watcher2Contact).trim() : null,
visitFrom,
visitTo,
method,
verificationTarget,
verified.id,
],
);
ok(res, toVisitorApplication(inserted.rows[0]));
}));
router.get('/visitor-applications', asyncRoute(async (req, res) => {
await requireCurrentUser(dbQuery, req);
const q = String(req.query.q ?? '').trim();
const where = q
? "WHERE status = 'SUBMITTED' AND (visitor_name ILIKE $1 OR company ILIKE $1 OR contact ILIKE $1 OR email ILIKE $1)"
: "WHERE status = 'SUBMITTED'";
const params = q ? [`%${q}%`] : [];
const result = await dbQuery<VisitorApplicationRow>(
`
SELECT *
FROM visitor_applications
${where}
ORDER BY created_at DESC
LIMIT 50
`,
params,
);
ok(res, result.rows.map(toVisitorApplication));
}));
router.get('/visit-requests', asyncRoute(async (req, res) => {
await requireCurrentUser(dbQuery, req);
ok(res, await listVisitRows(dbQuery));
@@ -652,6 +897,19 @@ export function createBusinessRouter(deps: BusinessRouterDeps = { query }): Rout
created.push(visit);
}
}
if (req.body?.sourceApplicationId && created[0]) {
await dbQuery(
`
UPDATE visitor_applications
SET status = 'IMPORTED',
imported_visit_request_id = $2,
imported_at = now(),
updated_at = now()
WHERE id = $1 AND status = 'SUBMITTED'
`,
[Number(req.body.sourceApplicationId), created[0].id],
);
}
ok(res, created);
}));