From 7239c21a7d8b506fba18d7c9c3b491ecf9fe9443 Mon Sep 17 00:00:00 2001 From: unknown <0420301@BOKNET.INTRA> Date: Mon, 3 Aug 2026 17:11:04 +0900 Subject: [PATCH] feat: add public visitor application flow --- frontend/src/App.tsx | 2 + frontend/src/api.ts | 21 + frontend/src/components/DateTimePicker.tsx | 3 +- .../src/pages/PublicVisitApplicationPage.tsx | 387 ++++++++++++++++++ frontend/src/pages/VisitRequestFormPage.tsx | 114 +++++- frontend/src/styles/common.css | 161 +++++++- frontend/src/types.ts | 56 +++ migrations/008_visitor_applications.sql | 43 ++ migrations/009_visitor_application_staff.sql | 7 + server/routes/business.ts | 260 +++++++++++- 10 files changed, 1041 insertions(+), 13 deletions(-) create mode 100644 frontend/src/pages/PublicVisitApplicationPage.tsx create mode 100644 migrations/008_visitor_applications.sql create mode 100644 migrations/009_visitor_application_staff.sql diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8512c98..3663edb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> {/* Public visitor pass — opened from the SMS link, no login. */} } /> + } /> {/* Public entrance kiosk — visitor self check-in/out, no login. */} } /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 651d52f..755341b 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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('/zones'); export const listPurposeCodes = () => request('/purpose-codes'); +// ===== Public visitor application ===== +export const startVisitorVerification = (method: VisitorVerificationMethod, target: string) => + request('/public/visitor-verifications', jsonInit('POST', { method, target })); + +export const confirmVisitorVerification = (verificationId: string, code: string) => + request( + `/public/visitor-verifications/${encodeURIComponent(verificationId)}/confirm`, + jsonInit('POST', { code }), + ); + +export const createVisitorApplication = (payload: VisitorApplicationCreate) => + request('/public/visitor-applications', jsonInit('POST', payload)); + +export const listVisitorApplications = (q?: string) => + request(`/visitor-applications${q ? `?q=${encodeURIComponent(q)}` : ''}`); + // ===== Visit requests ===== export const listVisitRequests = () => request('/visit-requests'); diff --git a/frontend/src/components/DateTimePicker.tsx b/frontend/src/components/DateTimePicker.tsx index bd3c975..be3ec80 100644 --- a/frontend/src/components/DateTimePicker.tsx +++ b/frontend/src/components/DateTimePicker.tsx @@ -41,6 +41,7 @@ export const DateTimePicker: React.FC = ({ value, onChange, placeholder } placeholderText={placeholder ?? '날짜와 시간을 선택하세요'} className="dt-input" popperClassName="acs-datepicker" + popperPlacement="bottom-end" renderCustomHeader={({ date, decreaseMonth, increaseMonth, prevMonthButtonDisabled, nextMonthButtonDisabled }) => ( @@ -51,7 +52,7 @@ export const DateTimePicker: React.FC = ({ value, onChange, placeholder } › - pickerRef.current?.setOpen(false)} aria-label="닫기"> + pickerRef.current?.setOpen(false)} aria-label="닫기" title="닫기"> × diff --git a/frontend/src/pages/PublicVisitApplicationPage.tsx b/frontend/src/pages/PublicVisitApplicationPage.tsx new file mode 100644 index 0000000..6abfca8 --- /dev/null +++ b/frontend/src/pages/PublicVisitApplicationPage.tsx @@ -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(FALLBACK_PURPOSE_CODES); + const [verificationMethod, setVerificationMethod] = useState('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(null); + const [done, setDone] = useState(false); + const [busy, setBusy] = useState(false); + const contactInputRef = useRef(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, + ) => setForm({ ...form, [k]: e.target.value }); + + const updateContact = (k: 'contact' | 'controlContact' | 'watcher2Contact') => ( + e: React.ChangeEvent, + ) => { + 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) => + 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 ( + + + + 방문신청이 접수되었습니다. + 담당자가 신청 내용을 확인한 뒤 출입신청으로 등록합니다. + + + ); + } + + return ( + + + + + + 방문신청 + IT센터 출입 사전 신청 + + + + + 방문자 + + 방문자 이름 * + 회사/소속 * + + 연락처 * + { e.currentTarget.value = formatPhoneLike(e.currentTarget.value); }} + placeholder="010-0000-0000" + /> + + 이메일 + 차량번호 + + + + + 본인확인 + + setVerificationMethod('PHONE')} /> 휴대폰 인증 + setVerificationMethod('EMAIL')} /> 이메일 인증 + 인증번호 요청 + setVerificationCode(e.target.value.replace(/\D/g, '').slice(0, 6))} placeholder="인증번호 6자리" /> + 확인 + {verificationHint && {verificationHint}} + + + + + 방문 내용 + + + 출입 전산실 + + {SERVER_ROOM_OPTIONS.map((z) => ( + + + {z} + + ))} + + + 추가 구역선택하세요{ROOM_OPTIONS.map((r) => {r})} + {form.room === '기타' && 기타 구역 *} + 방문 목적 *선택하세요{purposeCodes.map((p) => {p.name})} + {selectedPurpose?.customAllowed && 기타 목적 *} + 작업명 + 방문 일시 * setForm((f) => ({ ...f, visitFrom: v }))} placeholder="방문 일시 선택" /> + 퇴실 예정일시 * setForm((f) => ({ ...f, visitTo: v }))} placeholder="퇴실 예정일시 선택" /> + + + + + 출입통제담당자(당행직원) + + 이름 * + + 소속 * + + 선택하세요 + {AFFILIATION_OPTIONS.map((team) => {team})} + + + 연락처 + + + + + 현장감시자 + 현장감시자1 : IT센터 사무보조원 자동 지정 + + 이름 + 소속 + 연락처 + + + 현장감시자2 : 센터내 상주직원을 알고 있는 경우만 입력 + + 이름 + + 소속 + + 선택하세요 + {AFFILIATION_OPTIONS.map((team) => {team})} + + + 연락처 + + + + + + setConsent(e.target.checked)} /> + + [방문자 개인정보 수집·이용 동의 *] + 수집 항목: 이름, 연락처, 이메일, 차량번호 + 이용 목적: IT센터 방문신청 접수 및 출입자 관리 + 보유 기간: 출입 완료 후 개인정보는 삭제처리 + + + + + + {error && {error}} + {busy ? '처리 중...' : '방문신청'} + + + + ); +}; diff --git a/frontend/src/pages/VisitRequestFormPage.tsx b/frontend/src/pages/VisitRequestFormPage.tsx index 218315f..23e546b 100644 --- a/frontend/src/pages/VisitRequestFormPage.tsx +++ b/frontend/src/pages/VisitRequestFormPage.tsx @@ -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(null); const [busy, setBusy] = useState(false); + const [showImport, setShowImport] = useState(false); + const [importQuery, setImportQuery] = useState(''); + const [importCandidates, setImportCandidates] = useState([]); + const [importLoading, setImportLoading] = useState(false); const navigate = useNavigate(); const { user } = useAuth(); @@ -93,7 +106,7 @@ export const VisitRequestFormPage: React.FC = () => { const updateFormattedContact = (k: 'contact' | 'watcher2Contact') => ( e: React.ChangeEvent, - ) => setForm({ ...form, [k]: formatPhoneLike(e.target.value) }); + ) => setForm({ ...form, [k]: sanitizePhoneInput(e.target.value) }); const toggleServerRoom = (room: string) => (e: React.ChangeEvent) => 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 ( - 출입 신청 + + 출입 신청 + + {form.sourceApplicationId && 방문신청 #{form.sourceApplicationId}} + setShowImport((v) => !v)}> + 방문신청 가져오기 + + + + + {showImport && ( + + + setImportQuery(e.target.value)} + placeholder="이름, 회사, 연락처, 이메일 검색" + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + void loadVisitorApplications(); + } + }} + /> + + {importLoading ? '조회 중' : '조회'} + + + + {importCandidates.map((item) => ( + applyVisitorApplication(item)}> + {item.visitorName} + {item.company ?? '-'} / {item.contact ?? item.email ?? '-'} + {item.zoneName ?? item.roomZone ?? '-'} / {new Date(item.visitFrom).toLocaleString('ko-KR')} + + ))} + {!importLoading && importCandidates.length === 0 && 조회된 방문신청이 없습니다.} + + + )} {/* noValidate: use our Korean messages instead of the browser's native popups */} @@ -185,7 +279,7 @@ export const VisitRequestFormPage: React.FC = () => { 연락처 * - + 이메일 @@ -278,7 +372,7 @@ export const VisitRequestFormPage: React.FC = () => { 연락처 - + @@ -297,7 +391,7 @@ export const VisitRequestFormPage: React.FC = () => { 연락처 - + @@ -316,7 +410,7 @@ export const VisitRequestFormPage: React.FC = () => { 연락처 - + diff --git a/frontend/src/styles/common.css b/frontend/src/styles/common.css index 06513c5..e2fddd3 100644 --- a/frontend/src/styles/common.css +++ b/frontend/src/styles/common.css @@ -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 { diff --git a/frontend/src/types.ts b/frontend/src/types.ts index b8a028b..a78b718 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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 { + id: number; + createdAt: string; + status: string; + verificationTarget: string; + verifiedAt?: string; + importedVisitRequestId?: number; + importedAt?: string; +} + export interface VisitRequestView { id: number; visitorName: string; diff --git a/migrations/008_visitor_applications.sql b/migrations/008_visitor_applications.sql new file mode 100644 index 0000000..b56cf57 --- /dev/null +++ b/migrations/008_visitor_applications.sql @@ -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); diff --git a/migrations/009_visitor_application_staff.sql b/migrations/009_visitor_application_staff.sql new file mode 100644 index 0000000..30df11a --- /dev/null +++ b/migrations/009_visitor_application_staff.sql @@ -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); diff --git a/server/routes/business.ts b/server/routes/business.ts index 974a0e9..fd0fbe4 100644 --- a/server/routes/business.ts +++ b/server/routes/business.ts @@ -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 { + 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( + ` + 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( + ` + 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); }));
담당자가 신청 내용을 확인한 뒤 출입신청으로 등록합니다.
IT센터 출입 사전 신청
조회된 방문신청이 없습니다.