Compare commits
9 Commits
3efe493f9f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7239c21a7d | ||
|
|
7ff45fb4ec | ||
|
|
05068877db | ||
|
|
d4e59fbf9f | ||
|
|
6c511bf355 | ||
|
|
eaecfae330 | ||
|
|
eec18e7179 | ||
|
|
dee612fc5c | ||
|
|
7cbc660679 |
@@ -11,3 +11,5 @@ node_modules
|
|||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
docs
|
docs
|
||||||
|
!docs/
|
||||||
|
!docs/form_sample.xlsx
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ RUN npm ci --omit=dev && npm cache clean --force
|
|||||||
COPY --from=build /app/dist ./dist
|
COPY --from=build /app/dist ./dist
|
||||||
COPY --from=build /app/frontend/dist ./frontend/dist
|
COPY --from=build /app/frontend/dist ./frontend/dist
|
||||||
COPY migrations ./migrations
|
COPY migrations ./migrations
|
||||||
|
COPY docs/form_sample.xlsx ./docs/form_sample.xlsx
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
|
|||||||
@@ -843,3 +843,52 @@
|
|||||||
- `ACS_MAIL_HOST`, `ACS_MAIL_PORT`, `ACS_MAIL_FROM`, `ACS_MAIL_USERNAME`, `ACS_MAIL_PASSWORD`, `ACS_MAIL_SMTP_AUTH`, `ACS_MAIL_STARTTLS`, `ACS_MAIL_TIMEOUT_MS` 환경변수를 서버에서 읽도록 추가.
|
- `ACS_MAIL_HOST`, `ACS_MAIL_PORT`, `ACS_MAIL_FROM`, `ACS_MAIL_USERNAME`, `ACS_MAIL_PASSWORD`, `ACS_MAIL_SMTP_AUTH`, `ACS_MAIL_STARTTLS`, `ACS_MAIL_TIMEOUT_MS` 환경변수를 서버에서 읽도록 추가.
|
||||||
- 실제 메일 발송 없이 서버 컨테이너에서 SMTP host/port TCP 연결만 점검.
|
- 실제 메일 발송 없이 서버 컨테이너에서 SMTP host/port TCP 연결만 점검.
|
||||||
- 시스템관리 화면에 `Email 점검` 탭을 추가하고 host, port, 발신자, 인증/STARTTLS 설정 여부, 계정/비밀번호 설정 여부, 연결 성공/실패, 소요시간, 오류 메시지를 표시.
|
- 시스템관리 화면에 `Email 점검` 탭을 추가하고 host, port, 발신자, 인증/STARTTLS 설정 여부, 계정/비밀번호 설정 여부, 연결 성공/실패, 소요시간, 오류 메시지를 표시.
|
||||||
|
- 2026-07-22 발표자료 화면 캡처 테스트 및 ACS 운영 흐름 보정:
|
||||||
|
- 개발/배포 원칙 재확인:
|
||||||
|
- 소스 변경은 반드시 로컬에서 수정 및 테스트 완료 후 서버 배포.
|
||||||
|
- 서버에서만 소스 수정하는 방식은 금지.
|
||||||
|
- 운영 DB 데이터 정리는 필요 시 서버 DB 또는 운영 UI에서 별도 수행.
|
||||||
|
- 로컬/서버 버전 정렬:
|
||||||
|
- 서버에 반영된 변경이 로컬과 어긋난 상태를 확인하고, 로컬 저장소를 원격 최신 기준으로 맞춤.
|
||||||
|
- 이후 모든 변경은 로컬 수정, 로컬 검증, 커밋, push, Coolify Redeploy 순서로 진행.
|
||||||
|
- 운영 테스트 데이터 정리:
|
||||||
|
- `시스템관리 > 데이터 정리 > 출입신청 테스트 데이터 초기화` 기능으로 비정상 테스트 데이터를 정리.
|
||||||
|
- 출입신청, 방문자, 승인 기록, 입/퇴장 기록, 출입증 발송 기록을 삭제하고 사용자/팀/권한/목적 코드/시스템 설정/블랙리스트는 유지.
|
||||||
|
- 승인대기 화면 UX 보정:
|
||||||
|
- 승인 버튼 클릭 시 반려 버튼까지 함께 처리 중처럼 보이는 문제 수정.
|
||||||
|
- 행 단위 busy 상태를 `approve`/`reject` 액션별로 분리.
|
||||||
|
- 커밋: `ed972a3 fix: align access console status actions`.
|
||||||
|
- 출입콘솔 상태 및 수기 입장 기능 보정:
|
||||||
|
- `입장대기` 별도 표시를 제거하고 `승인완료`로 통일.
|
||||||
|
- 출입콘솔 금일 출입 현황 목록에서도 관리자/담당자가 승인완료 건을 직접 `입장` 처리할 수 있도록 보정.
|
||||||
|
- 입장/퇴장 처리 성공 메시지에 출입구역을 함께 표시하도록 보정.
|
||||||
|
- 커밋: `3efe493 fix: show access action zone`.
|
||||||
|
- 키오스크 QR 처리 보정:
|
||||||
|
- QR에 토큰만이 아니라 `/pass/{token}` 전체 URL이 들어오는 경우를 처리.
|
||||||
|
- 키오스크에서 스캔한 전체 URL에서 토큰만 추출하도록 수정.
|
||||||
|
- public pass API 호출 시 토큰을 URL 인코딩하도록 수정.
|
||||||
|
- 키오스크에서 입장 후 같은 QR을 다시 입력하면 퇴장 처리 버튼이 표시되는 흐름 확인.
|
||||||
|
- 커밋: `7c57960 fix: parse kiosk qr pass urls`.
|
||||||
|
- 대시보드 출입 상태 반영 보정:
|
||||||
|
- 키오스크/출입콘솔 입퇴장 후 대시보드가 최초 조회 상태에 머무르는 문제 수정.
|
||||||
|
- 대시보드를 3초 주기로 자동 갱신하도록 변경.
|
||||||
|
- 커밋: `7cbc660 fix: refresh dashboard access state`.
|
||||||
|
- 출입신청 목록 상태 반영 보정:
|
||||||
|
- 신청 자체의 상태만 표시하여 실제 입퇴장 후에도 `승인완료`로 남던 문제 수정.
|
||||||
|
- 오늘 출입 현황을 함께 조회하고 `재실중`/`퇴장`을 신청 상태보다 우선 표시.
|
||||||
|
- 3초 주기 자동 갱신 추가.
|
||||||
|
- 커밋: `dee612f fix: show access state in request list`.
|
||||||
|
- 보고서 상태 반영 보정:
|
||||||
|
- 보고서 화면에서도 실제 입퇴장 상태가 아닌 신청 상태만 보이던 문제 수정.
|
||||||
|
- 오늘 출입 현황을 함께 조회하고 `재실중`/`퇴장`을 우선 표시.
|
||||||
|
- 커밋: `eec18e7 fix: show access state in reports`.
|
||||||
|
- 검증:
|
||||||
|
- 각 수정마다 `npm --prefix frontend exec tsc -- --noEmit` 통과.
|
||||||
|
- 각 수정마다 `npm run typecheck` 통과.
|
||||||
|
- Vite/esbuild는 샌드박스에서 `spawn EPERM`이 발생하여 권한 밖에서 `npm run build` 재실행 후 통과.
|
||||||
|
- Coolify Redeploy 후 서버 화면에서 주요 흐름 재확인.
|
||||||
|
- 발표자료 캡처:
|
||||||
|
- `C:\ai-bok\20260729\AIdev2.pptm` 12~45페이지 화면 캡처 완료.
|
||||||
|
- 비고:
|
||||||
|
- 서버 URL: `https://acs.apps.bokdev.in`.
|
||||||
|
- 테스트 중 발견된 화면 상태 불일치는 대시보드, 출입신청 목록, 보고서 순으로 동일 원칙에 맞춰 보정.
|
||||||
|
|||||||
BIN
docs/form_sample.xlsx
Normal file
BIN
docs/form_sample.xlsx
Normal file
Binary file not shown.
@@ -12,6 +12,7 @@ import { ApprovalQueuePage } from './pages/ApprovalQueuePage';
|
|||||||
import { AccessConsolePage } from './pages/AccessConsolePage';
|
import { AccessConsolePage } from './pages/AccessConsolePage';
|
||||||
import { BadgePage } from './pages/BadgePage';
|
import { BadgePage } from './pages/BadgePage';
|
||||||
import { PublicPassPage } from './pages/PublicPassPage';
|
import { PublicPassPage } from './pages/PublicPassPage';
|
||||||
|
import { PublicVisitApplicationPage } from './pages/PublicVisitApplicationPage';
|
||||||
import { KioskPage } from './pages/KioskPage';
|
import { KioskPage } from './pages/KioskPage';
|
||||||
import { BlacklistPage } from './pages/BlacklistPage';
|
import { BlacklistPage } from './pages/BlacklistPage';
|
||||||
import { ReportPage } from './pages/ReportPage';
|
import { ReportPage } from './pages/ReportPage';
|
||||||
@@ -45,6 +46,7 @@ export default function App() {
|
|||||||
<Route path="/change-password" element={<ChangePasswordPage />} />
|
<Route path="/change-password" element={<ChangePasswordPage />} />
|
||||||
{/* Public visitor pass — opened from the SMS link, no login. */}
|
{/* Public visitor pass — opened from the SMS link, no login. */}
|
||||||
<Route path="/pass/:token" element={<PublicPassPage />} />
|
<Route path="/pass/:token" element={<PublicPassPage />} />
|
||||||
|
<Route path="/visit" element={<PublicVisitApplicationPage />} />
|
||||||
{/* Public entrance kiosk — visitor self check-in/out, no login. */}
|
{/* Public entrance kiosk — visitor self check-in/out, no login. */}
|
||||||
<Route path="/kiosk" element={<KioskPage />} />
|
<Route path="/kiosk" element={<KioskPage />} />
|
||||||
|
|
||||||
|
|||||||
@@ -15,11 +15,17 @@ import {
|
|||||||
LoginRequest,
|
LoginRequest,
|
||||||
PassDelivery,
|
PassDelivery,
|
||||||
PurposeCode,
|
PurposeCode,
|
||||||
|
ReportVisitView,
|
||||||
Role,
|
Role,
|
||||||
SmsDiagnosticsResult,
|
SmsDiagnosticsResult,
|
||||||
StatsSummary,
|
StatsSummary,
|
||||||
PublicPass,
|
PublicPass,
|
||||||
Team,
|
Team,
|
||||||
|
VisitorApplicationCreate,
|
||||||
|
VisitorApplicationView,
|
||||||
|
VisitorVerificationConfirmResult,
|
||||||
|
VisitorVerificationMethod,
|
||||||
|
VisitorVerificationStartResult,
|
||||||
VisitRequestCreate,
|
VisitRequestCreate,
|
||||||
VisitRequestResetResult,
|
VisitRequestResetResult,
|
||||||
VisitRequestView,
|
VisitRequestView,
|
||||||
@@ -96,6 +102,22 @@ export const changePassword = (req: ChangePasswordRequest) =>
|
|||||||
export const listZones = () => request<Zone[]>('/zones');
|
export const listZones = () => request<Zone[]>('/zones');
|
||||||
export const listPurposeCodes = () => request<PurposeCode[]>('/purpose-codes');
|
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 =====
|
// ===== Visit requests =====
|
||||||
export const listVisitRequests = () =>
|
export const listVisitRequests = () =>
|
||||||
request<VisitRequestView[]>('/visit-requests');
|
request<VisitRequestView[]>('/visit-requests');
|
||||||
@@ -124,6 +146,8 @@ export const uploadVisitRequests = (file: File) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const visitRequestTemplateUrl = () => '/api/visit-requests/template';
|
||||||
|
|
||||||
// ===== Approvals =====
|
// ===== Approvals =====
|
||||||
export const approveRequest = (id: number, comment?: string) =>
|
export const approveRequest = (id: number, comment?: string) =>
|
||||||
request<VisitRequestView>(`/approvals/${id}/approve`, jsonInit('POST', { comment }));
|
request<VisitRequestView>(`/approvals/${id}/approve`, jsonInit('POST', { comment }));
|
||||||
@@ -168,7 +192,7 @@ export const deleteBlacklist = (id: number) =>
|
|||||||
|
|
||||||
// ===== Reports =====
|
// ===== Reports =====
|
||||||
export const listReportVisits = (from: string, to: string) =>
|
export const listReportVisits = (from: string, to: string) =>
|
||||||
request<VisitRequestView[]>(`/reports/visits?from=${from}&to=${to}`);
|
request<ReportVisitView[]>(`/reports/visits?from=${from}&to=${to}`);
|
||||||
|
|
||||||
export const reportDownloadUrl = (from: string, to: string) =>
|
export const reportDownloadUrl = (from: string, to: string) =>
|
||||||
`/api/reports/visits.xlsx?from=${from}&to=${to}`;
|
`/api/reports/visits.xlsx?from=${from}&to=${to}`;
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export const DateTimePicker: React.FC<Props> = ({ value, onChange, placeholder }
|
|||||||
placeholderText={placeholder ?? '날짜와 시간을 선택하세요'}
|
placeholderText={placeholder ?? '날짜와 시간을 선택하세요'}
|
||||||
className="dt-input"
|
className="dt-input"
|
||||||
popperClassName="acs-datepicker"
|
popperClassName="acs-datepicker"
|
||||||
|
popperPlacement="bottom-end"
|
||||||
renderCustomHeader={({ date, decreaseMonth, increaseMonth, prevMonthButtonDisabled, nextMonthButtonDisabled }) => (
|
renderCustomHeader={({ date, decreaseMonth, increaseMonth, prevMonthButtonDisabled, nextMonthButtonDisabled }) => (
|
||||||
<div className="dt-picker-header">
|
<div className="dt-picker-header">
|
||||||
<button type="button" className="dt-picker-nav" onClick={decreaseMonth} disabled={prevMonthButtonDisabled} aria-label="이전 달">
|
<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 type="button" className="dt-picker-nav" onClick={increaseMonth} disabled={nextMonthButtonDisabled} aria-label="다음 달">
|
||||||
›
|
›
|
||||||
</button>
|
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ function getDisplayStatus(request: VisitRequestView | null, access: AccessRecord
|
|||||||
return { label: '재실중', className: 'green' };
|
return { label: '재실중', className: 'green' };
|
||||||
}
|
}
|
||||||
if (access?.checkOutAt) {
|
if (access?.checkOutAt) {
|
||||||
return { label: '퇴장', className: 'gray' };
|
return { label: '퇴실', className: 'gray' };
|
||||||
}
|
}
|
||||||
if (!request) {
|
if (!request) {
|
||||||
return { label: '-', className: 'gray' };
|
return { label: '-', className: 'gray' };
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ export const DashboardPage: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadDashboard();
|
loadDashboard();
|
||||||
|
const t = setInterval(loadDashboard, 3000);
|
||||||
|
return () => clearInterval(t);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const deleteCancelled = async (target: VisitRequestView) => {
|
const deleteCancelled = async (target: VisitRequestView) => {
|
||||||
@@ -113,7 +115,7 @@ export const DashboardPage: React.FC = () => {
|
|||||||
{insideIds.has(r.id) ? (
|
{insideIds.has(r.id) ? (
|
||||||
<span className="badge badge-green">재실중</span>
|
<span className="badge badge-green">재실중</span>
|
||||||
) : exitedIds.has(r.id) ? (
|
) : exitedIds.has(r.id) ? (
|
||||||
<span className="badge badge-gray">퇴장</span>
|
<span className="badge badge-gray">퇴실</span>
|
||||||
) : (
|
) : (
|
||||||
<span className={`badge badge-${STATUS_CLASS[r.status]}`}>{STATUS_LABEL[r.status]}</span>
|
<span className={`badge badge-${STATUS_CLASS[r.status]}`}>{STATUS_LABEL[r.status]}</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
387
frontend/src/pages/PublicVisitApplicationPage.tsx
Normal file
387
frontend/src/pages/PublicVisitApplicationPage.tsx
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
confirmVisitorVerification,
|
||||||
|
createVisitorApplication,
|
||||||
|
listPurposeCodes,
|
||||||
|
startVisitorVerification,
|
||||||
|
} from '../api';
|
||||||
|
import { DateTimePicker } from '../components/DateTimePicker';
|
||||||
|
import { PurposeCode, VisitorVerificationMethod } from '../types';
|
||||||
|
import bokBadge from '../assets/bok-badge.png';
|
||||||
|
|
||||||
|
const SERVER_ROOM_OPTIONS = ['4층전산실', '5층전산실'];
|
||||||
|
const ROOM_OPTIONS = ['4층종합상황실', '4층CMT실', '3층사무실', '기타'];
|
||||||
|
const FALLBACK_PURPOSE_CODES: PurposeCode[] = [
|
||||||
|
{ id: 1, code: 'INSPECTION', name: '점검', sortOrder: 10, active: true, customAllowed: false },
|
||||||
|
{ id: 2, code: 'WORK', name: '작업', sortOrder: 20, active: true, customAllowed: false },
|
||||||
|
{ id: 3, code: 'TOUR', name: '견학', sortOrder: 30, active: true, customAllowed: false },
|
||||||
|
{ id: 4, code: 'MEETING', name: '회의', sortOrder: 40, active: true, customAllowed: false },
|
||||||
|
{ id: 5, code: 'CLEANING', name: '청소', sortOrder: 50, active: true, customAllowed: false },
|
||||||
|
{ id: 6, code: 'ETC', name: '기타', sortOrder: 900, active: true, customAllowed: true },
|
||||||
|
];
|
||||||
|
const DEFAULT_WATCHER1 = { name: '류관순', team: 'IT전략국', contact: '313' };
|
||||||
|
const AFFILIATION_OPTIONS = [
|
||||||
|
'IT센터관리팀', 'IT서비스팀', '네트워크팀', '클라우드팀', 'RTGS시스템팀',
|
||||||
|
'금융IT인프라팀', '정보인프라팀', 'AI플랫폼팀', '보안운영팀', '보안관제반',
|
||||||
|
'IT리스크팀', 'IT기획팀', '정보기획팀', 'IT전략국',
|
||||||
|
];
|
||||||
|
|
||||||
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
const normalizePhoneText = (value: string): string =>
|
||||||
|
value
|
||||||
|
.replace(/[0-9]/g, (char) => String(char.charCodeAt(0) - 0xff10))
|
||||||
|
.replace(/[-ー–—]/g, '-')
|
||||||
|
.replace(/\s+/g, '');
|
||||||
|
|
||||||
|
const formatPhoneLike = (value: string): string => {
|
||||||
|
const compact = normalizePhoneText(value);
|
||||||
|
const digits = compact.replace(/\D/g, '');
|
||||||
|
if (!/^[\d-]*$/.test(compact)) return compact;
|
||||||
|
if (digits.length === 11) return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7)}`;
|
||||||
|
if (digits.length === 10) return `${digits.slice(0, 3)}-${digits.slice(3, 6)}-${digits.slice(6)}`;
|
||||||
|
return compact;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitizePhoneInput = (value: string): string =>
|
||||||
|
normalizePhoneText(value).replace(/[^\d-]/g, '').slice(0, 20);
|
||||||
|
|
||||||
|
const sameDate = (a: Date, b: Date): boolean =>
|
||||||
|
a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
||||||
|
|
||||||
|
const phoneDigits = (value: string): string => normalizePhoneText(value).replace(/\D/g, '');
|
||||||
|
|
||||||
|
export const PublicVisitApplicationPage: React.FC = () => {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
visitorName: '',
|
||||||
|
company: '',
|
||||||
|
contact: '',
|
||||||
|
email: '',
|
||||||
|
vehicleNo: '',
|
||||||
|
serverRooms: [] as string[],
|
||||||
|
room: '',
|
||||||
|
roomEtc: '',
|
||||||
|
purpose: '',
|
||||||
|
purposeEtc: '',
|
||||||
|
workName: '',
|
||||||
|
controlName: '',
|
||||||
|
controlTeam: '',
|
||||||
|
controlContact: '',
|
||||||
|
watcher1Name: DEFAULT_WATCHER1.name,
|
||||||
|
watcher1Team: DEFAULT_WATCHER1.team,
|
||||||
|
watcher1Contact: DEFAULT_WATCHER1.contact,
|
||||||
|
watcher2Name: '',
|
||||||
|
watcher2Team: '',
|
||||||
|
watcher2Contact: '',
|
||||||
|
visitFrom: '',
|
||||||
|
visitTo: '',
|
||||||
|
});
|
||||||
|
const [purposeCodes, setPurposeCodes] = useState<PurposeCode[]>(FALLBACK_PURPOSE_CODES);
|
||||||
|
const [verificationMethod, setVerificationMethod] = useState<VisitorVerificationMethod>('PHONE');
|
||||||
|
const [verificationId, setVerificationId] = useState('');
|
||||||
|
const [verificationCode, setVerificationCode] = useState('');
|
||||||
|
const [verificationToken, setVerificationToken] = useState('');
|
||||||
|
const [verificationHint, setVerificationHint] = useState('');
|
||||||
|
const [consent, setConsent] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const contactInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
listPurposeCodes().then(setPurposeCodes).catch(() => setPurposeCodes(FALLBACK_PURPOSE_CODES));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectedPurpose = purposeCodes.find((p) => p.code === form.purpose);
|
||||||
|
|
||||||
|
const update = (k: keyof typeof form) => (
|
||||||
|
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
|
||||||
|
) => setForm({ ...form, [k]: e.target.value });
|
||||||
|
|
||||||
|
const updateContact = (k: 'contact' | 'controlContact' | 'watcher2Contact') => (
|
||||||
|
e: React.ChangeEvent<HTMLInputElement>,
|
||||||
|
) => {
|
||||||
|
const next = sanitizePhoneInput(e.target.value);
|
||||||
|
setForm({ ...form, [k]: next });
|
||||||
|
if (k === 'contact' && verificationMethod === 'PHONE') {
|
||||||
|
setVerificationToken('');
|
||||||
|
setVerificationId('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearContactVerification = () => {
|
||||||
|
if (verificationMethod === 'PHONE') {
|
||||||
|
setVerificationToken('');
|
||||||
|
setVerificationId('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentVisitorContact = (): string => sanitizePhoneInput(contactInputRef.current?.value ?? form.contact);
|
||||||
|
|
||||||
|
const formatVisitorContactInput = () => {
|
||||||
|
if (!contactInputRef.current) return;
|
||||||
|
contactInputRef.current.value = formatPhoneLike(contactInputRef.current.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleServerRoom = (room: string) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
serverRooms: e.target.checked ? [...f.serverRooms, room] : f.serverRooms.filter((r) => r !== room),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const validate = (): string | null => {
|
||||||
|
if (!form.visitorName.trim()) return '방문자 이름을 입력하세요.';
|
||||||
|
if (!form.company.trim()) return '회사/소속을 입력하세요.';
|
||||||
|
if (!currentVisitorContact()) return '연락처를 입력하세요.';
|
||||||
|
if (form.email.trim() && !EMAIL_RE.test(form.email.trim())) return '이메일 형식을 확인하세요.';
|
||||||
|
if (verificationMethod === 'EMAIL' && !form.email.trim()) return '이메일 인증을 선택한 경우 이메일이 필요합니다.';
|
||||||
|
if (!verificationToken) return '본인확인을 완료하세요.';
|
||||||
|
if (form.serverRooms.length === 0 && !form.room) return '방문 구역을 선택하세요.';
|
||||||
|
if (form.room === '기타' && !form.roomEtc.trim()) return '기타 구역을 입력하세요.';
|
||||||
|
if (!form.purpose) return '방문 목적을 선택하세요.';
|
||||||
|
if (selectedPurpose?.customAllowed && !form.purposeEtc.trim()) return '기타 방문 목적을 입력하세요.';
|
||||||
|
if (!form.controlName.trim()) return '출입통제담당자 이름을 입력하세요.';
|
||||||
|
if (!form.controlTeam.trim()) return '출입통제담당자 소속을 선택하세요.';
|
||||||
|
if (!form.visitFrom) return '방문 일시를 선택하세요.';
|
||||||
|
if (!form.visitTo) return '퇴실 예정일시를 선택하세요.';
|
||||||
|
const from = new Date(form.visitFrom);
|
||||||
|
const to = new Date(form.visitTo);
|
||||||
|
if (from > to) return '퇴실 예정일시는 방문 일시 이후여야 합니다.';
|
||||||
|
if (!sameDate(from, to)) return '퇴실 예정일이 다음 날이면 날짜별로 나누어 신청하세요.';
|
||||||
|
if (!consent) return '개인정보 수집 및 이용에 동의해야 신청할 수 있습니다.';
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestVerification = async () => {
|
||||||
|
setError(null);
|
||||||
|
setVerificationToken('');
|
||||||
|
const currentContact = currentVisitorContact();
|
||||||
|
formatVisitorContactInput();
|
||||||
|
const target = verificationMethod === 'PHONE' ? phoneDigits(currentContact) : form.email.trim();
|
||||||
|
if (!target) {
|
||||||
|
setError(verificationMethod === 'PHONE' ? '휴대폰번호를 입력하세요.' : '이메일을 입력하세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (verificationMethod === 'PHONE' && target.length < 10) {
|
||||||
|
setError(`휴대폰번호를 확인하세요. 현재 숫자 ${target.length}자리입니다.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (verificationMethod === 'EMAIL' && !EMAIL_RE.test(form.email.trim())) {
|
||||||
|
setError('이메일 형식을 확인하세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const result = await startVisitorVerification(verificationMethod, target);
|
||||||
|
setVerificationId(result.verificationId);
|
||||||
|
setVerificationHint(result.devCode ? `리허설 인증번호: ${result.devCode}` : '인증번호를 발송했습니다.');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '인증번호 요청에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmVerification = async () => {
|
||||||
|
setError(null);
|
||||||
|
if (!verificationId) {
|
||||||
|
setError('인증번호를 먼저 요청하세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const result = await confirmVisitorVerification(verificationId, verificationCode);
|
||||||
|
setVerificationToken(result.verificationToken);
|
||||||
|
setVerificationHint('본인확인이 완료되었습니다.');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '인증번호 확인에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const message = validate();
|
||||||
|
if (message) {
|
||||||
|
setError(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await createVisitorApplication({
|
||||||
|
visitorName: form.visitorName.trim(),
|
||||||
|
company: form.company.trim(),
|
||||||
|
contact: formatPhoneLike(currentVisitorContact()),
|
||||||
|
email: form.email.trim() || undefined,
|
||||||
|
vehicleNo: form.vehicleNo.trim() || undefined,
|
||||||
|
zoneName: form.serverRooms[0] || undefined,
|
||||||
|
roomZone: form.room === '기타' ? form.roomEtc.trim() : form.room || undefined,
|
||||||
|
purpose: selectedPurpose?.customAllowed ? form.purposeEtc.trim() : selectedPurpose?.name ?? form.purpose,
|
||||||
|
purposeCode: selectedPurpose?.code ?? form.purpose,
|
||||||
|
purposeDetail: selectedPurpose?.customAllowed ? form.purposeEtc.trim() : undefined,
|
||||||
|
workName: form.workName.trim() || undefined,
|
||||||
|
controlName: form.controlName.trim() || undefined,
|
||||||
|
controlTeam: form.controlTeam.trim() || undefined,
|
||||||
|
controlContact: formatPhoneLike(form.controlContact.trim()) || undefined,
|
||||||
|
watcher1Name: form.watcher1Name,
|
||||||
|
watcher1Team: form.watcher1Team,
|
||||||
|
watcher1Contact: form.watcher1Contact,
|
||||||
|
watcher2Name: form.watcher2Name.trim() || undefined,
|
||||||
|
watcher2Team: form.watcher2Team.trim() || undefined,
|
||||||
|
watcher2Contact: formatPhoneLike(form.watcher2Contact.trim()) || undefined,
|
||||||
|
visitFrom: form.visitFrom,
|
||||||
|
visitTo: form.visitTo,
|
||||||
|
verificationMethod,
|
||||||
|
verificationToken,
|
||||||
|
});
|
||||||
|
setDone(true);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '방문신청 접수에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
return (
|
||||||
|
<main className="public-visit-shell">
|
||||||
|
<section className="public-visit-card public-visit-complete">
|
||||||
|
<img src={bokBadge} alt="" className="public-visit-badge" />
|
||||||
|
<h1>방문신청이 접수되었습니다.</h1>
|
||||||
|
<p>담당자가 신청 내용을 확인한 뒤 출입신청으로 등록합니다.</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="public-visit-shell">
|
||||||
|
<form className="public-visit-card visit-form" onSubmit={onSubmit} noValidate>
|
||||||
|
<div className="public-visit-head">
|
||||||
|
<img src={bokBadge} alt="" className="public-visit-badge" />
|
||||||
|
<div>
|
||||||
|
<h1>방문신청</h1>
|
||||||
|
<p>IT센터 출입 사전 신청</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset className="form-group span-2">
|
||||||
|
<legend>방문자</legend>
|
||||||
|
<div className="group-grid">
|
||||||
|
<label className="field"><span>방문자 이름 <b className="required">*</b></span><input value={form.visitorName} onChange={update('visitorName')} autoFocus /></label>
|
||||||
|
<label className="field"><span>회사/소속 <b className="required">*</b></span><input value={form.company} onChange={update('company')} /></label>
|
||||||
|
<label className="field">
|
||||||
|
<span>연락처 <b className="required">*</b></span>
|
||||||
|
<input
|
||||||
|
ref={contactInputRef}
|
||||||
|
className="phone-input"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="off"
|
||||||
|
defaultValue={form.contact}
|
||||||
|
onChange={clearContactVerification}
|
||||||
|
onBlur={(e) => { e.currentTarget.value = formatPhoneLike(e.currentTarget.value); }}
|
||||||
|
placeholder="010-0000-0000"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field"><span>이메일</span><input type="email" value={form.email} onChange={update('email')} placeholder="name@example.com" /></label>
|
||||||
|
<label className="field"><span>차량번호</span><input value={form.vehicleNo} onChange={update('vehicleNo')} /></label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset className="form-group span-2">
|
||||||
|
<legend>본인확인</legend>
|
||||||
|
<div className="public-verification-grid">
|
||||||
|
<label className="radio-inline"><input type="radio" checked={verificationMethod === 'PHONE'} onChange={() => setVerificationMethod('PHONE')} /> 휴대폰 인증</label>
|
||||||
|
<label className="radio-inline"><input type="radio" checked={verificationMethod === 'EMAIL'} onChange={() => setVerificationMethod('EMAIL')} /> 이메일 인증</label>
|
||||||
|
<button type="button" className="btn-ghost" onClick={requestVerification} disabled={busy || Boolean(verificationToken)}>인증번호 요청</button>
|
||||||
|
<input value={verificationCode} onChange={(e) => setVerificationCode(e.target.value.replace(/\D/g, '').slice(0, 6))} placeholder="인증번호 6자리" />
|
||||||
|
<button type="button" className="btn-ghost" onClick={confirmVerification} disabled={busy || Boolean(verificationToken)}>확인</button>
|
||||||
|
{verificationHint && <span className="verification-hint">{verificationHint}</span>}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset className="form-group span-2">
|
||||||
|
<legend>방문 내용</legend>
|
||||||
|
<div className="group-grid">
|
||||||
|
<div className="field">
|
||||||
|
<span>출입 전산실</span>
|
||||||
|
<div className="checkbox-row">
|
||||||
|
{SERVER_ROOM_OPTIONS.map((z) => (
|
||||||
|
<label key={z} className="checkbox-inline">
|
||||||
|
<input type="checkbox" checked={form.serverRooms.includes(z)} onChange={toggleServerRoom(z)} />
|
||||||
|
<span>{z}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="field"><span>추가 구역</span><select value={form.room} onChange={update('room')}><option value="">선택하세요</option>{ROOM_OPTIONS.map((r) => <option key={r} value={r}>{r}</option>)}</select></label>
|
||||||
|
{form.room === '기타' && <label className="field"><span>기타 구역 <b className="required">*</b></span><input value={form.roomEtc} onChange={update('roomEtc')} /></label>}
|
||||||
|
<label className="field"><span>방문 목적 <b className="required">*</b></span><select value={form.purpose} onChange={update('purpose')}><option value="">선택하세요</option>{purposeCodes.map((p) => <option key={p.code} value={p.code}>{p.name}</option>)}</select></label>
|
||||||
|
{selectedPurpose?.customAllowed && <label className="field"><span>기타 목적 <b className="required">*</b></span><input value={form.purposeEtc} onChange={update('purposeEtc')} /></label>}
|
||||||
|
<label className="field"><span>작업명</span><input value={form.workName} onChange={update('workName')} /></label>
|
||||||
|
<label className="field"><span>방문 일시 <b className="required">*</b></span><DateTimePicker value={form.visitFrom} onChange={(v) => setForm((f) => ({ ...f, visitFrom: v }))} placeholder="방문 일시 선택" /></label>
|
||||||
|
<label className="field"><span>퇴실 예정일시 <b className="required">*</b></span><DateTimePicker value={form.visitTo} onChange={(v) => setForm((f) => ({ ...f, visitTo: v }))} placeholder="퇴실 예정일시 선택" /></label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset className="form-group span-2">
|
||||||
|
<legend>출입통제담당자(당행직원)</legend>
|
||||||
|
<div className="group-grid">
|
||||||
|
<label className="field"><span>이름 <b className="required">*</b></span><input value={form.controlName} onChange={update('controlName')} /></label>
|
||||||
|
<label className="field">
|
||||||
|
<span>소속 <b className="required">*</b></span>
|
||||||
|
<select value={form.controlTeam} onChange={update('controlTeam')}>
|
||||||
|
<option value="">선택하세요</option>
|
||||||
|
{AFFILIATION_OPTIONS.map((team) => <option key={team} value={team}>{team}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="field"><span>연락처</span><input className="phone-input" type="text" inputMode="tel" value={form.controlContact} onChange={updateContact('controlContact')} placeholder="내선번호/휴대폰번호" /></label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset className="form-group span-2">
|
||||||
|
<legend>현장감시자</legend>
|
||||||
|
<div className="subsection-title">현장감시자1 <em className="hint-inline">: IT센터 사무보조원 자동 지정</em></div>
|
||||||
|
<div className="group-grid">
|
||||||
|
<label className="field"><span>이름</span><input value={form.watcher1Name} readOnly /></label>
|
||||||
|
<label className="field"><span>소속</span><input value={form.watcher1Team} readOnly /></label>
|
||||||
|
<label className="field"><span>연락처</span><input className="phone-input" value={form.watcher1Contact} readOnly /></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="subsection-title">현장감시자2 <em className="hint-inline">: 센터내 상주직원을 알고 있는 경우만 입력</em></div>
|
||||||
|
<div className="group-grid">
|
||||||
|
<label className="field"><span>이름</span><input value={form.watcher2Name} onChange={update('watcher2Name')} /></label>
|
||||||
|
<label className="field">
|
||||||
|
<span>소속</span>
|
||||||
|
<select value={form.watcher2Team} onChange={update('watcher2Team')}>
|
||||||
|
<option value="">선택하세요</option>
|
||||||
|
{AFFILIATION_OPTIONS.map((team) => <option key={team} value={team}>{team}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="field"><span>연락처</span><input className="phone-input" type="text" inputMode="tel" value={form.watcher2Contact} onChange={updateContact('watcher2Contact')} placeholder="내선번호/휴대폰번호" /></label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div className="span-2 consent-box">
|
||||||
|
<label className="consent-label">
|
||||||
|
<input type="checkbox" checked={consent} onChange={(e) => setConsent(e.target.checked)} />
|
||||||
|
<span>
|
||||||
|
<b>[방문자 개인정보 수집·이용 동의 <span className="required">*</span>]</b><br />
|
||||||
|
수집 항목: 이름, 연락처, 이메일, 차량번호<br />
|
||||||
|
이용 목적: IT센터 방문신청 접수 및 출입자 관리<br />
|
||||||
|
<span className="consent-retention-warning">보유 기간: 출입 완료 후 개인정보는 삭제처리</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-actions span-2">
|
||||||
|
{error && <span className="form-error" role="alert">{error}</span>}
|
||||||
|
<button type="submit" className="btn-primary" disabled={busy}>{busy ? '처리 중...' : '방문신청'}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { listReportVisits, reportDownloadUrl } from '../api';
|
import { listReportVisits, listTodayAccess, reportDownloadUrl } from '../api';
|
||||||
import { DatePickerField } from '../components/DatePickerField';
|
import { DatePickerField } from '../components/DatePickerField';
|
||||||
import { VisitRequestView } from '../types';
|
import { AccessRecord, ReportVisitView } from '../types';
|
||||||
import { STATUS_CLASS, STATUS_LABEL, formatDateTime } from '../status';
|
import { STATUS_CLASS, STATUS_LABEL, formatDateTime } from '../status';
|
||||||
|
|
||||||
const pad = (n: number) => String(n).padStart(2, '0');
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
@@ -20,22 +20,48 @@ function firstOfMonthISO(): string {
|
|||||||
export const ReportPage: React.FC = () => {
|
export const ReportPage: React.FC = () => {
|
||||||
const [from, setFrom] = useState(firstOfMonthISO());
|
const [from, setFrom] = useState(firstOfMonthISO());
|
||||||
const [to, setTo] = useState(todayISO());
|
const [to, setTo] = useState(todayISO());
|
||||||
const [items, setItems] = useState<VisitRequestView[]>([]);
|
const [items, setItems] = useState<ReportVisitView[]>([]);
|
||||||
|
const [accessRecords, setAccessRecords] = useState<AccessRecord[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const load = () => {
|
const load = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
listReportVisits(from, to)
|
Promise.all([
|
||||||
.then(setItems)
|
listReportVisits(from, to),
|
||||||
|
listTodayAccess().catch(() => [] as AccessRecord[]),
|
||||||
|
])
|
||||||
|
.then(([visits, records]) => {
|
||||||
|
setItems(visits);
|
||||||
|
setAccessRecords(records);
|
||||||
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
setItems([]);
|
setItems([]);
|
||||||
|
setAccessRecords([]);
|
||||||
setError(e instanceof Error ? e.message : '보고서 조회에 실패했습니다.');
|
setError(e instanceof Error ? e.message : '보고서 조회에 실패했습니다.');
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const accessById = new Map(accessRecords.map((r) => [r.visitRequestId, r]));
|
||||||
|
const statusBadge = (r: ReportVisitView) => {
|
||||||
|
const access = accessById.get(r.id);
|
||||||
|
if (r.reportStatusLabel) {
|
||||||
|
return {
|
||||||
|
className: r.reportStatusLabel === '재실중' ? 'green' : r.reportStatusLabel === '퇴실' ? 'gray' : STATUS_CLASS[r.status],
|
||||||
|
label: r.reportStatusLabel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (access?.inside) {
|
||||||
|
return { className: 'green', label: '재실중' };
|
||||||
|
}
|
||||||
|
if (access?.checkOutAt) {
|
||||||
|
return { className: 'gray', label: '퇴실' };
|
||||||
|
}
|
||||||
|
return { className: STATUS_CLASS[r.status], label: STATUS_LABEL[r.status] };
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
// Load the default month range once; explicit 조회 handles later date changes.
|
// Load the default month range once; explicit 조회 handles later date changes.
|
||||||
@@ -113,7 +139,12 @@ export const ReportPage: React.FC = () => {
|
|||||||
<td>{r.workName || '-'}</td>
|
<td>{r.workName || '-'}</td>
|
||||||
<td>{formatDateTime(r.visitFrom)}</td>
|
<td>{formatDateTime(r.visitFrom)}</td>
|
||||||
<td>{formatDateTime(r.visitTo)}</td>
|
<td>{formatDateTime(r.visitTo)}</td>
|
||||||
<td><span className={`badge badge-${STATUS_CLASS[r.status]}`}>{STATUS_LABEL[r.status]}</span></td>
|
<td>
|
||||||
|
{(() => {
|
||||||
|
const badge = statusBadge(r);
|
||||||
|
return <span className={`badge badge-${badge.className}`}>{badge.label}</span>;
|
||||||
|
})()}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 { DateTimePicker } from '../components/DateTimePicker';
|
||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
import { PurposeCode, Watcher1Settings } from '../types';
|
import { PurposeCode, VisitorApplicationView, Watcher1Settings } from '../types';
|
||||||
|
|
||||||
// 코드 시트 목록을 콤보/체크박스에 반영.
|
// 코드 시트 목록을 콤보/체크박스에 반영.
|
||||||
// 전산실: 체크박스(다중). 선택한 개수만큼 신청/QR이 생성된다.
|
// 전산실: 체크박스(다중). 선택한 개수만큼 신청/QR이 생성된다.
|
||||||
@@ -46,15 +46,24 @@ const isLaterCalendarDate = (later: Date, earlier: Date): boolean => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatPhoneLike = (value: string): string => {
|
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) {
|
if (digits.length === 11) {
|
||||||
return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7)}`;
|
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 = () => {
|
export const VisitRequestFormPage: React.FC = () => {
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
|
sourceApplicationId: undefined as number | undefined,
|
||||||
visitorName: '',
|
visitorName: '',
|
||||||
company: '',
|
company: '',
|
||||||
contact: '',
|
contact: '',
|
||||||
@@ -77,6 +86,10 @@ export const VisitRequestFormPage: React.FC = () => {
|
|||||||
const [consent, setConsent] = useState(false);
|
const [consent, setConsent] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
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 navigate = useNavigate();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
@@ -93,7 +106,7 @@ export const VisitRequestFormPage: React.FC = () => {
|
|||||||
|
|
||||||
const updateFormattedContact = (k: 'contact' | 'watcher2Contact') => (
|
const updateFormattedContact = (k: 'contact' | 'watcher2Contact') => (
|
||||||
e: React.ChangeEvent<HTMLInputElement>,
|
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>) =>
|
const toggleServerRoom = (room: string) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
setForm((f) => ({
|
setForm((f) => ({
|
||||||
@@ -103,6 +116,47 @@ export const VisitRequestFormPage: React.FC = () => {
|
|||||||
: f.serverRooms.filter((r) => r !== room),
|
: f.serverRooms.filter((r) => r !== room),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const loadVisitorApplications = async () => {
|
||||||
|
setImportLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setImportCandidates(await listVisitorApplications(importQuery.trim() || undefined));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '방문신청 목록을 불러오지 못했습니다.');
|
||||||
|
} finally {
|
||||||
|
setImportLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyVisitorApplication = (item: VisitorApplicationView) => {
|
||||||
|
const serverRooms = item.zoneName && SERVER_ROOM_OPTIONS.includes(item.zoneName) ? [item.zoneName] : [];
|
||||||
|
const etcRoom = ROOM_OPTIONS[ROOM_OPTIONS.length - 1];
|
||||||
|
const roomValue = item.roomZone && ROOM_OPTIONS.includes(item.roomZone) ? item.roomZone : item.roomZone ? etcRoom : '';
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
sourceApplicationId: item.id,
|
||||||
|
visitorName: item.visitorName,
|
||||||
|
company: item.company ?? '',
|
||||||
|
contact: item.contact ?? '',
|
||||||
|
email: item.email ?? '',
|
||||||
|
vehicleNo: item.vehicleNo ?? '',
|
||||||
|
serverRooms,
|
||||||
|
room: roomValue,
|
||||||
|
roomEtc: roomValue === etcRoom ? item.roomZone ?? '' : '',
|
||||||
|
purpose: item.purposeCode ?? '',
|
||||||
|
purposeEtc: item.purposeDetail ?? '',
|
||||||
|
workName: item.workName ?? '',
|
||||||
|
watcher2Name: item.watcher2Name ?? '',
|
||||||
|
watcher2Team: item.watcher2Team ?? '',
|
||||||
|
watcher2Contact: item.watcher2Contact ?? '',
|
||||||
|
visitFrom: item.visitFrom.slice(0, 16),
|
||||||
|
visitTo: item.visitTo.slice(0, 16),
|
||||||
|
}));
|
||||||
|
setConsent(true);
|
||||||
|
setShowImport(false);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
/** Returns the first Korean validation error, or null if valid. */
|
/** Returns the first Korean validation error, or null if valid. */
|
||||||
const validate = (): string | null => {
|
const validate = (): string | null => {
|
||||||
if (!form.visitorName.trim()) return '방문자 이름을 입력하세요.';
|
if (!form.visitorName.trim()) return '방문자 이름을 입력하세요.';
|
||||||
@@ -139,6 +193,7 @@ export const VisitRequestFormPage: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const roomZone = form.room === '기타' ? form.roomEtc.trim() : form.room;
|
const roomZone = form.room === '기타' ? form.roomEtc.trim() : form.room;
|
||||||
await createVisitRequest({
|
await createVisitRequest({
|
||||||
|
sourceApplicationId: form.sourceApplicationId,
|
||||||
visitorName: form.visitorName.trim(),
|
visitorName: form.visitorName.trim(),
|
||||||
company: form.company.trim() || undefined,
|
company: form.company.trim() || undefined,
|
||||||
contact: formatPhoneLike(form.contact.trim()),
|
contact: formatPhoneLike(form.contact.trim()),
|
||||||
@@ -168,7 +223,46 @@ export const VisitRequestFormPage: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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 */}
|
{/* noValidate: use our Korean messages instead of the browser's native popups */}
|
||||||
<form className="card form-grid visit-form" onSubmit={onSubmit} noValidate>
|
<form className="card form-grid visit-form" onSubmit={onSubmit} noValidate>
|
||||||
@@ -185,7 +279,7 @@ export const VisitRequestFormPage: React.FC = () => {
|
|||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>연락처 <b className="required">*</b></span>
|
<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>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>이메일</span>
|
<span>이메일</span>
|
||||||
@@ -278,7 +372,7 @@ export const VisitRequestFormPage: React.FC = () => {
|
|||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>연락처</span>
|
<span>연락처</span>
|
||||||
<input value="" placeholder="내선번호/휴대폰번호" readOnly />
|
<input className="phone-input" value="" placeholder="내선번호/휴대폰번호" readOnly />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@@ -297,7 +391,7 @@ export const VisitRequestFormPage: React.FC = () => {
|
|||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>연락처</span>
|
<span>연락처</span>
|
||||||
<input value={watcher1.contact} readOnly />
|
<input className="phone-input" value={watcher1.contact} readOnly />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -316,7 +410,7 @@ export const VisitRequestFormPage: React.FC = () => {
|
|||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>연락처</span>
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { cancelVisitRequest, deleteVisitRequest, listVisitRequests, uploadVisitRequests } from '../api';
|
import {
|
||||||
import { VisitRequestView } from '../types';
|
cancelVisitRequest,
|
||||||
|
deleteVisitRequest,
|
||||||
|
listTodayAccess,
|
||||||
|
listVisitRequests,
|
||||||
|
uploadVisitRequests,
|
||||||
|
visitRequestTemplateUrl,
|
||||||
|
} from '../api';
|
||||||
|
import { AccessRecord, VisitRequestView } from '../types';
|
||||||
import { STATUS_CLASS, STATUS_LABEL, formatVisitRange } from '../status';
|
import { STATUS_CLASS, STATUS_LABEL, formatVisitRange } from '../status';
|
||||||
import { Dialog } from '../components/Dialog';
|
import { Dialog } from '../components/Dialog';
|
||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
|
|
||||||
export const VisitRequestListPage: React.FC = () => {
|
export const VisitRequestListPage: React.FC = () => {
|
||||||
const [items, setItems] = useState<VisitRequestView[]>([]);
|
const [items, setItems] = useState<VisitRequestView[]>([]);
|
||||||
|
const [accessRecords, setAccessRecords] = useState<AccessRecord[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [notice, setNotice] = useState<string | null>(null);
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -18,13 +26,35 @@ export const VisitRequestListPage: React.FC = () => {
|
|||||||
|
|
||||||
const load = () => {
|
const load = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
listVisitRequests()
|
Promise.all([
|
||||||
.then(setItems)
|
listVisitRequests(),
|
||||||
|
listTodayAccess().catch(() => [] as AccessRecord[]),
|
||||||
|
])
|
||||||
|
.then(([requests, records]) => {
|
||||||
|
setItems(requests);
|
||||||
|
setAccessRecords(records);
|
||||||
|
})
|
||||||
.catch((e) => setError(e instanceof Error ? e.message : '조회 실패'))
|
.catch((e) => setError(e instanceof Error ? e.message : '조회 실패'))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(load, []);
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
const t = setInterval(load, 3000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const accessById = new Map(accessRecords.map((r) => [r.visitRequestId, r]));
|
||||||
|
const statusBadge = (r: VisitRequestView) => {
|
||||||
|
const access = accessById.get(r.id);
|
||||||
|
if (access?.inside) {
|
||||||
|
return { className: 'green', label: '재실중' };
|
||||||
|
}
|
||||||
|
if (access?.checkOutAt) {
|
||||||
|
return { className: 'gray', label: '퇴실' };
|
||||||
|
}
|
||||||
|
return { className: STATUS_CLASS[r.status], label: STATUS_LABEL[r.status] };
|
||||||
|
};
|
||||||
|
|
||||||
const confirmCancel = async () => {
|
const confirmCancel = async () => {
|
||||||
const id = cancelingId;
|
const id = cancelingId;
|
||||||
@@ -74,7 +104,8 @@ export const VisitRequestListPage: React.FC = () => {
|
|||||||
<div className="page-head">
|
<div className="page-head">
|
||||||
<h2>출입 신청 목록</h2>
|
<h2>출입 신청 목록</h2>
|
||||||
<div className="head-actions">
|
<div className="head-actions">
|
||||||
<button className="btn-ghost" onClick={() => fileRef.current?.click()}>엑셀 업로드</button>
|
<a className="btn-ghost" href={visitRequestTemplateUrl()}>양식 다운로드</a>
|
||||||
|
<button className="btn-ghost" onClick={() => fileRef.current?.click()}>작성 파일 업로드</button>
|
||||||
<input ref={fileRef} type="file" accept=".xlsx" hidden onChange={onUpload} />
|
<input ref={fileRef} type="file" accept=".xlsx" hidden onChange={onUpload} />
|
||||||
<Link className="btn-primary" to="/visit-requests/new">+ 출입 신청</Link>
|
<Link className="btn-primary" to="/visit-requests/new">+ 출입 신청</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -105,7 +136,12 @@ export const VisitRequestListPage: React.FC = () => {
|
|||||||
<td>{r.purpose || '-'}</td>
|
<td>{r.purpose || '-'}</td>
|
||||||
<td>{r.workName || '-'}</td>
|
<td>{r.workName || '-'}</td>
|
||||||
<td>{formatVisitRange(r.visitFrom, r.visitTo)}</td>
|
<td>{formatVisitRange(r.visitFrom, r.visitTo)}</td>
|
||||||
<td><span className={`badge badge-${STATUS_CLASS[r.status]}`}>{STATUS_LABEL[r.status]}</span></td>
|
<td>
|
||||||
|
{(() => {
|
||||||
|
const badge = statusBadge(r);
|
||||||
|
return <span className={`badge badge-${badge.className}`}>{badge.label}</span>;
|
||||||
|
})()}
|
||||||
|
</td>
|
||||||
<td className="row-actions">
|
<td className="row-actions">
|
||||||
{r.status === 'APPROVED' && (
|
{r.status === 'APPROVED' && (
|
||||||
<button className="btn-link" onClick={() => navigate(`/badge/${r.id}`)}>출입증</button>
|
<button className="btn-link" onClick={() => navigate(`/badge/${r.id}`)}>출입증</button>
|
||||||
|
|||||||
@@ -121,8 +121,24 @@ a { color: inherit; text-decoration: none; }
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
letter-spacing: 0;
|
||||||
background: #fff;
|
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); }
|
.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 */
|
/* 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-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-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-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) */
|
/* 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; }
|
.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-amber { background: #fef3c7; color: #b45309; }
|
||||||
.badge-red { background: #fee2e2; color: #b91c1c; }
|
.badge-red { background: #fee2e2; color: #b91c1c; }
|
||||||
.badge-gray { background: #e2e8f0; color: #475569; }
|
.badge-gray { background: #e2e8f0; color: #475569; }
|
||||||
|
.badge-blue { background: #dbeafe; color: #1d4ed8; }
|
||||||
|
|
||||||
/* ===== Alerts ===== */
|
/* ===== Alerts ===== */
|
||||||
.alert { padding: 12px 14px; border-radius: 8px; margin-bottom: 16px; font-size: 14px; }
|
.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); }
|
.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 ===== */
|
/* ===== Access console ===== */
|
||||||
.console-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items: start; }
|
.console-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items: start; }
|
||||||
.inline-form { display: flex; gap: 8px; }
|
.inline-form { display: flex; gap: 8px; }
|
||||||
@@ -492,6 +647,10 @@ button:disabled { opacity: .55; cursor: not-allowed; }
|
|||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.stat-grid { grid-template-columns: repeat(2, 1fr); }
|
.stat-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
.form-grid { grid-template-columns: 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; }
|
.group-grid { grid-template-columns: 1fr; }
|
||||||
.detail-grid,
|
.detail-grid,
|
||||||
.detail-item {
|
.detail-item {
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ export type VisitStatus =
|
|||||||
| 'EXPIRED';
|
| 'EXPIRED';
|
||||||
|
|
||||||
export interface VisitRequestCreate {
|
export interface VisitRequestCreate {
|
||||||
|
sourceApplicationId?: number;
|
||||||
visitorName: string;
|
visitorName: string;
|
||||||
company?: string;
|
company?: string;
|
||||||
contact: string;
|
contact: string;
|
||||||
@@ -127,6 +128,61 @@ export interface VisitRequestCreate {
|
|||||||
visitTo: string;
|
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 {
|
export interface VisitRequestView {
|
||||||
id: number;
|
id: number;
|
||||||
visitorName: string;
|
visitorName: string;
|
||||||
@@ -158,6 +214,13 @@ export interface VisitRequestView {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReportVisitView extends VisitRequestView {
|
||||||
|
checkInAt?: string;
|
||||||
|
checkOutAt?: string;
|
||||||
|
inside?: boolean;
|
||||||
|
reportStatusLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PublicPass {
|
export interface PublicPass {
|
||||||
visitorName: string;
|
visitorName: string;
|
||||||
company?: string;
|
company?: string;
|
||||||
|
|||||||
43
migrations/008_visitor_applications.sql
Normal file
43
migrations/008_visitor_applications.sql
Normal 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);
|
||||||
7
migrations/009_visitor_application_staff.sql
Normal file
7
migrations/009_visitor_application_staff.sql
Normal 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);
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
"test": "npm run test:node",
|
"test": "npm run test:node",
|
||||||
"test:node": "npm run build:server && node --test --test-isolation=none dist/server/**/*.test.js",
|
"test:node": "npm run build:server && node --test --test-isolation=none dist/server/**/*.test.js",
|
||||||
|
"deploy:check": "node scripts/deployment-check.mjs",
|
||||||
"db:check": "node scripts/check-db.mjs",
|
"db:check": "node scripts/check-db.mjs",
|
||||||
"minio:check": "node scripts/check-minio.mjs",
|
"minio:check": "node scripts/check-minio.mjs",
|
||||||
"dev": "tsx watch server/index.ts",
|
"dev": "tsx watch server/index.ts",
|
||||||
|
|||||||
186
scripts/deployment-check.mjs
Normal file
186
scripts/deployment-check.mjs
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const DEFAULT_BASE = 'https://acs.apps.bokdev.in';
|
||||||
|
const projectRoot = process.cwd();
|
||||||
|
|
||||||
|
function argValue(name, fallback) {
|
||||||
|
const prefix = `--${name}=`;
|
||||||
|
const inline = process.argv.find((arg) => arg.startsWith(prefix));
|
||||||
|
if (inline) return inline.slice(prefix.length);
|
||||||
|
const index = process.argv.indexOf(`--${name}`);
|
||||||
|
if (index >= 0 && process.argv[index + 1]) return process.argv[index + 1];
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ok(message) {
|
||||||
|
console.log(`[ok] ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(message) {
|
||||||
|
console.error(`[fail] ${message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function warn(message) {
|
||||||
|
console.warn(`[warn] ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLocal(relativePath) {
|
||||||
|
return readFileSync(path.join(projectRoot, relativePath), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function git(args) {
|
||||||
|
return execFileSync('git', args, {
|
||||||
|
cwd: projectRoot,
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
}).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkJson(base, route) {
|
||||||
|
const url = new URL(route, base).toString();
|
||||||
|
try {
|
||||||
|
const response = await fetch(url);
|
||||||
|
const body = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
fail(`${route} returned HTTP ${response.status}: ${body.slice(0, 160)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(body);
|
||||||
|
if (parsed?.ok !== true) {
|
||||||
|
fail(`${route} response did not include ok=true: ${body.slice(0, 160)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ok(`${route} ${response.status}`);
|
||||||
|
} catch (error) {
|
||||||
|
fail(`${route} check failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cookieHeaderFrom(response) {
|
||||||
|
if (typeof response.headers.getSetCookie === 'function') {
|
||||||
|
return response.headers.getSetCookie().map((cookie) => cookie.split(';')[0]).join('; ');
|
||||||
|
}
|
||||||
|
const cookie = response.headers.get('set-cookie');
|
||||||
|
return cookie ? cookie.split(',').map((part) => part.split(';')[0]).join('; ') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(base, username, password) {
|
||||||
|
if (!username || !password) return '';
|
||||||
|
|
||||||
|
const url = new URL('/api/auth/login', base).toString();
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
const body = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
fail(`/api/auth/login returned HTTP ${response.status}: ${body.slice(0, 160)}`);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
ok('/api/auth/login 200');
|
||||||
|
return cookieHeaderFrom(response);
|
||||||
|
} catch (error) {
|
||||||
|
fail(`/api/auth/login check failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkTemplate(base, cookie) {
|
||||||
|
const route = '/api/visit-requests/template';
|
||||||
|
const url = new URL(route, base).toString();
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, cookie ? { headers: { cookie } } : undefined);
|
||||||
|
const contentType = response.headers.get('content-type') ?? '';
|
||||||
|
const disposition = response.headers.get('content-disposition') ?? '';
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.text();
|
||||||
|
fail(`${route} returned HTTP ${response.status}: ${body.slice(0, 200)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!contentType.includes('spreadsheet') && !disposition.includes('.xlsx')) {
|
||||||
|
fail(`${route} did not look like an xlsx download: content-type=${contentType || '-'}, content-disposition=${disposition || '-'}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ok(`${route} xlsx download`);
|
||||||
|
} catch (error) {
|
||||||
|
fail(`${route} check failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkLocalTemplate() {
|
||||||
|
const templatePath = 'docs/form_sample.xlsx';
|
||||||
|
if (!existsSync(path.join(projectRoot, templatePath))) {
|
||||||
|
fail(`${templatePath} is missing locally`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ok(`${templatePath} exists locally`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
git(['ls-files', '--error-unmatch', templatePath]);
|
||||||
|
ok(`${templatePath} is tracked by git`);
|
||||||
|
} catch {
|
||||||
|
fail(`${templatePath} is not tracked by git`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkDockerPackaging() {
|
||||||
|
const dockerignore = readLocal('.dockerignore');
|
||||||
|
const dockerfile = readLocal('Dockerfile');
|
||||||
|
|
||||||
|
if (!dockerignore.includes('!docs/form_sample.xlsx')) {
|
||||||
|
fail('.dockerignore does not allow docs/form_sample.xlsx into the build context');
|
||||||
|
} else {
|
||||||
|
ok('.dockerignore allows docs/form_sample.xlsx');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dockerfile.includes('COPY docs/form_sample.xlsx ./docs/form_sample.xlsx')) {
|
||||||
|
fail('Dockerfile does not copy docs/form_sample.xlsx into the runtime image');
|
||||||
|
} else {
|
||||||
|
ok('Dockerfile copies docs/form_sample.xlsx into the runtime image');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkGitState() {
|
||||||
|
try {
|
||||||
|
const head = git(['rev-parse', '--short', 'HEAD']);
|
||||||
|
ok(`local HEAD ${head}`);
|
||||||
|
|
||||||
|
const upstream = git(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']);
|
||||||
|
const aheadBehind = git(['rev-list', '--left-right', '--count', `${upstream}...HEAD`]).split(/\s+/);
|
||||||
|
const behind = Number(aheadBehind[0] ?? 0);
|
||||||
|
const ahead = Number(aheadBehind[1] ?? 0);
|
||||||
|
if (ahead || behind) {
|
||||||
|
warn(`branch differs from ${upstream}: ahead ${ahead}, behind ${behind}`);
|
||||||
|
} else {
|
||||||
|
ok(`branch matches ${upstream}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
warn(`git state check skipped: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = argValue('base', DEFAULT_BASE).replace(/\/+$/, '/');
|
||||||
|
const username = argValue('username', process.env.ACS_CHECK_USER ?? '');
|
||||||
|
const password = argValue('password', process.env.ACS_CHECK_PASSWORD ?? '');
|
||||||
|
console.log(`[acs-deploy-check] base=${base}`);
|
||||||
|
|
||||||
|
checkLocalTemplate();
|
||||||
|
checkDockerPackaging();
|
||||||
|
checkGitState();
|
||||||
|
await checkJson(base, '/healthz');
|
||||||
|
await checkJson(base, '/db');
|
||||||
|
const cookie = await login(base, username, password);
|
||||||
|
await checkTemplate(base, cookie);
|
||||||
|
|
||||||
|
if (process.exitCode) {
|
||||||
|
console.error('[acs-deploy-check] failed');
|
||||||
|
process.exit(process.exitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[acs-deploy-check] passed');
|
||||||
@@ -3,7 +3,9 @@ import { Router } from 'express';
|
|||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import ExcelJS from 'exceljs';
|
import ExcelJS from 'exceljs';
|
||||||
import QRCode from 'qrcode';
|
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';
|
import { ok } from '../http/apiResponse.js';
|
||||||
import { ApiError } from '../http/errors.js';
|
import { ApiError } from '../http/errors.js';
|
||||||
import { env } from '../config/env.js';
|
import { env } from '../config/env.js';
|
||||||
@@ -61,7 +63,14 @@ interface AccessRow {
|
|||||||
check_out_at: string | Date | null;
|
check_out_at: string | Date | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ReportVisitRow extends VisitRow {
|
||||||
|
check_in_at: string | Date | null;
|
||||||
|
check_out_at: string | Date | null;
|
||||||
|
inside: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 3 * 1024 * 1024 } });
|
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 3 * 1024 * 1024 } });
|
||||||
|
const visitRequestTemplatePath = path.resolve(process.cwd(), 'docs', 'form_sample.xlsx');
|
||||||
|
|
||||||
function asyncRoute(handler: (req: Request, res: Response, next: NextFunction) => Promise<void>) {
|
function asyncRoute(handler: (req: Request, res: Response, next: NextFunction) => Promise<void>) {
|
||||||
return (req: Request, res: Response, next: NextFunction) => {
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
@@ -107,6 +116,24 @@ function toVisit(row: VisitRow) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function visitStatusLabel(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'DRAFT': return '임시저장';
|
||||||
|
case 'PENDING': return '승인대기';
|
||||||
|
case 'APPROVED': return '승인완료';
|
||||||
|
case 'REJECTED': return '반려';
|
||||||
|
case 'CANCELLED': return '취소';
|
||||||
|
case 'EXPIRED': return '만료';
|
||||||
|
default: return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportStatusLabel(row: Pick<ReportVisitRow, 'status' | 'inside' | 'check_out_at'>): string {
|
||||||
|
if (row.inside) return '재실중';
|
||||||
|
if (row.check_out_at) return '퇴실';
|
||||||
|
return visitStatusLabel(row.status);
|
||||||
|
}
|
||||||
|
|
||||||
function required(value: unknown, label: string): string {
|
function required(value: unknown, label: string): string {
|
||||||
const text = String(value ?? '').trim();
|
const text = String(value ?? '').trim();
|
||||||
if (!text) throw new ApiError(400, `${label}을(를) 입력하세요.`);
|
if (!text) throw new ApiError(400, `${label}을(를) 입력하세요.`);
|
||||||
@@ -359,7 +386,14 @@ function parseVisitDate(row: ExcelJS.Row, index: number, label: string): string
|
|||||||
.replace(/-+/g, '-')
|
.replace(/-+/g, '-')
|
||||||
.replace(/-$/, '');
|
.replace(/-$/, '');
|
||||||
const match = normalized.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
|
const match = normalized.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
|
||||||
if (!match) throw new ApiError(400, `${label} 형식이 올바르지 않습니다. 예: 2026-07-08 또는 2026.7.8`);
|
if (!match) {
|
||||||
|
const koreanMonthDay = normalized.match(/^(\d{1,2})\uC6D4(\d{1,2})\uC77C/);
|
||||||
|
if (koreanMonthDay) {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
return `${year}-${pad2(Number(koreanMonthDay[1]))}-${pad2(Number(koreanMonthDay[2]))}`;
|
||||||
|
}
|
||||||
|
throw new ApiError(400, `${label} 형식이 올바르지 않습니다. 예: 2026-07-08 또는 2026.7.8`);
|
||||||
|
}
|
||||||
return `${match[1]}-${pad2(Number(match[2]))}-${pad2(Number(match[3]))}`;
|
return `${match[1]}-${pad2(Number(match[2]))}-${pad2(Number(match[3]))}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,6 +433,41 @@ function isVisitUploadRowEmpty(row: ExcelJS.Row): boolean {
|
|||||||
return [2, 4, 5, 7, 9].every((index) => !cellText(row, index));
|
return [2, 4, 5, 7, 9].every((index) => !cellText(row, index));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function compactCellText(row: ExcelJS.Row, index: number): string {
|
||||||
|
return cellText(row, index).replace(/\s+/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVisitUploadNonDataRow(row: ExcelJS.Row): boolean {
|
||||||
|
const sequence = compactCellText(row, 1);
|
||||||
|
const purpose = compactCellText(row, 2);
|
||||||
|
const date = compactCellText(row, 5);
|
||||||
|
const name = compactCellText(row, 7);
|
||||||
|
|
||||||
|
return (
|
||||||
|
sequence.includes('\uC21C\uBC88') ||
|
||||||
|
purpose.includes('\uCD9C\uC785\uAC1C\uC694') ||
|
||||||
|
date.includes('\uCD9C\uC785\uAC1C\uC694') ||
|
||||||
|
date.includes('\uCD9C\uC785\uC77C\uC790') ||
|
||||||
|
name.includes('\uCD9C\uC785\uC790\uC815\uBCF4') ||
|
||||||
|
name.includes('\uC774\uB984')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findVisitUploadStartRow(sheet: ExcelJS.Worksheet): number {
|
||||||
|
for (let rowNumber = 1; rowNumber <= sheet.rowCount; rowNumber += 1) {
|
||||||
|
const row = sheet.getRow(rowNumber);
|
||||||
|
const dateHeader = compactCellText(row, 5);
|
||||||
|
const nameHeader = compactCellText(row, 7);
|
||||||
|
if (
|
||||||
|
dateHeader.includes('\uCD9C\uC785\uC77C\uC790') &&
|
||||||
|
nameHeader.includes('\uC774\uB984')
|
||||||
|
) {
|
||||||
|
return rowNumber + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
async function workbookFromUpload(file?: Express.Multer.File): Promise<ExcelJS.Workbook> {
|
async function workbookFromUpload(file?: Express.Multer.File): Promise<ExcelJS.Workbook> {
|
||||||
if (!file) throw new ApiError(400, '업로드할 파일을 선택하세요.');
|
if (!file) throw new ApiError(400, '업로드할 파일을 선택하세요.');
|
||||||
const workbook = new ExcelJS.Workbook();
|
const workbook = new ExcelJS.Workbook();
|
||||||
@@ -411,10 +480,12 @@ async function importVisitWorkbook(dbQuery: QueryFn, actor: UserRow, file?: Expr
|
|||||||
const sheet = workbook.getWorksheet('방문자명단') ?? workbook.worksheets[0];
|
const sheet = workbook.getWorksheet('방문자명단') ?? workbook.worksheets[0];
|
||||||
if (!sheet) throw new ApiError(400, '엑셀 시트를 찾을 수 없습니다.');
|
if (!sheet) throw new ApiError(400, '엑셀 시트를 찾을 수 없습니다.');
|
||||||
const result = { totalRows: 0, successCount: 0, errors: [] as string[], success: false };
|
const result = { totalRows: 0, successCount: 0, errors: [] as string[], success: false };
|
||||||
|
const startRow = findVisitUploadStartRow(sheet);
|
||||||
|
|
||||||
for (let rowNumber = 3; rowNumber <= sheet.rowCount; rowNumber += 1) {
|
for (let rowNumber = startRow; rowNumber <= sheet.rowCount; rowNumber += 1) {
|
||||||
const row = sheet.getRow(rowNumber);
|
const row = sheet.getRow(rowNumber);
|
||||||
if (isVisitUploadRowEmpty(row)) continue;
|
if (isVisitUploadRowEmpty(row)) continue;
|
||||||
|
if (isVisitUploadNonDataRow(row)) continue;
|
||||||
result.totalRows += 1;
|
result.totalRows += 1;
|
||||||
try {
|
try {
|
||||||
const date = parseVisitDate(row, 5, '출입일자');
|
const date = parseVisitDate(row, 5, '출입일자');
|
||||||
@@ -526,6 +597,87 @@ async function createOneVisit(dbQuery: QueryFn, actor: UserRow, body: Record<str
|
|||||||
return toVisit((await visitById(dbQuery, Number(created.rows[0].id)))!);
|
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 {
|
export function createBusinessRouter(deps: BusinessRouterDeps = { query }): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const dbQuery = deps.query;
|
const dbQuery = deps.query;
|
||||||
@@ -541,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) => {
|
router.get('/visit-requests', asyncRoute(async (req, res) => {
|
||||||
await requireCurrentUser(dbQuery, req);
|
await requireCurrentUser(dbQuery, req);
|
||||||
ok(res, await listVisitRows(dbQuery));
|
ok(res, await listVisitRows(dbQuery));
|
||||||
@@ -551,6 +867,14 @@ export function createBusinessRouter(deps: BusinessRouterDeps = { query }): Rout
|
|||||||
ok(res, await listVisitRows(dbQuery, "WHERE vr.status = 'PENDING'"));
|
ok(res, await listVisitRows(dbQuery, "WHERE vr.status = 'PENDING'"));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
router.get('/visit-requests/template', asyncRoute(async (req, res) => {
|
||||||
|
await requireCurrentUser(dbQuery, req);
|
||||||
|
if (!existsSync(visitRequestTemplatePath)) {
|
||||||
|
throw new ApiError(404, '엑셀 업로드 양식을 찾을 수 없습니다.');
|
||||||
|
}
|
||||||
|
res.download(visitRequestTemplatePath, 'visit-request-template.xlsx');
|
||||||
|
}));
|
||||||
|
|
||||||
router.get('/visit-requests/:id', asyncRoute(async (req, res) => {
|
router.get('/visit-requests/:id', asyncRoute(async (req, res) => {
|
||||||
await requireCurrentUser(dbQuery, req);
|
await requireCurrentUser(dbQuery, req);
|
||||||
const visit = await visitById(dbQuery, Number(req.params.id));
|
const visit = await visitById(dbQuery, Number(req.params.id));
|
||||||
@@ -573,6 +897,19 @@ export function createBusinessRouter(deps: BusinessRouterDeps = { query }): Rout
|
|||||||
created.push(visit);
|
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);
|
ok(res, created);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -879,17 +1216,129 @@ export function createBusinessRouter(deps: BusinessRouterDeps = { query }): Rout
|
|||||||
|
|
||||||
router.get('/reports/visits', asyncRoute(async (req, res) => {
|
router.get('/reports/visits', asyncRoute(async (req, res) => {
|
||||||
await requireCurrentUser(dbQuery, req);
|
await requireCurrentUser(dbQuery, req);
|
||||||
ok(res, await listVisitRows(dbQuery, 'WHERE vr.visit_from::date BETWEEN $1::date AND $2::date', [req.query.from, req.query.to]));
|
const result = await dbQuery<ReportVisitRow>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
vr.id,
|
||||||
|
v.name AS visitor_name,
|
||||||
|
v.company,
|
||||||
|
v.contact,
|
||||||
|
v.email,
|
||||||
|
v.vehicle_no,
|
||||||
|
h.id AS host_id,
|
||||||
|
h.full_name AS host_name,
|
||||||
|
h.department AS host_department,
|
||||||
|
vr.zone_name,
|
||||||
|
vr.purpose,
|
||||||
|
vr.purpose_code,
|
||||||
|
vr.purpose_detail,
|
||||||
|
vr.work_name,
|
||||||
|
vr.control_name,
|
||||||
|
vr.control_team,
|
||||||
|
vr.control_contact,
|
||||||
|
vr.watcher1_name,
|
||||||
|
vr.watcher1_team,
|
||||||
|
vr.watcher1_contact,
|
||||||
|
vr.watcher2_name,
|
||||||
|
vr.watcher2_team,
|
||||||
|
vr.watcher2_contact,
|
||||||
|
vr.visit_from,
|
||||||
|
vr.visit_to,
|
||||||
|
vr.status,
|
||||||
|
vr.qr_token,
|
||||||
|
vr.created_at,
|
||||||
|
acc.check_in_at,
|
||||||
|
acc.check_out_at,
|
||||||
|
COALESCE(acc.check_in_at > COALESCE(acc.check_out_at, 'epoch'::timestamp), FALSE) AS inside
|
||||||
|
FROM visit_requests vr
|
||||||
|
JOIN visitors v ON v.id = vr.visitor_id
|
||||||
|
JOIN users h ON h.id = vr.host_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT
|
||||||
|
max(ae.event_at) FILTER (WHERE ae.direction = 'IN') AS check_in_at,
|
||||||
|
max(ae.event_at) FILTER (WHERE ae.direction = 'OUT') AS check_out_at
|
||||||
|
FROM access_events ae
|
||||||
|
WHERE ae.visit_request_id = vr.id
|
||||||
|
) acc ON TRUE
|
||||||
|
WHERE vr.visit_from::date BETWEEN $1::date AND $2::date
|
||||||
|
ORDER BY vr.created_at DESC
|
||||||
|
`,
|
||||||
|
[req.query.from, req.query.to],
|
||||||
|
);
|
||||||
|
ok(res, result.rows.map((row) => ({
|
||||||
|
...toVisit(row),
|
||||||
|
checkInAt: toIso(row.check_in_at),
|
||||||
|
checkOutAt: toIso(row.check_out_at),
|
||||||
|
inside: Boolean(row.inside),
|
||||||
|
reportStatusLabel: reportStatusLabel(row),
|
||||||
|
})));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
router.get('/reports/visits.xlsx', asyncRoute(async (req, res) => {
|
router.get('/reports/visits.xlsx', asyncRoute(async (req, res) => {
|
||||||
await requireCurrentUser(dbQuery, req);
|
await requireCurrentUser(dbQuery, req);
|
||||||
const visits = await listVisitRows(dbQuery, 'WHERE vr.visit_from::date BETWEEN $1::date AND $2::date', [req.query.from, req.query.to]);
|
const visits = await dbQuery<ReportVisitRow>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
vr.id,
|
||||||
|
v.name AS visitor_name,
|
||||||
|
v.company,
|
||||||
|
v.contact,
|
||||||
|
v.email,
|
||||||
|
v.vehicle_no,
|
||||||
|
h.id AS host_id,
|
||||||
|
h.full_name AS host_name,
|
||||||
|
h.department AS host_department,
|
||||||
|
vr.zone_name,
|
||||||
|
vr.purpose,
|
||||||
|
vr.purpose_code,
|
||||||
|
vr.purpose_detail,
|
||||||
|
vr.work_name,
|
||||||
|
vr.control_name,
|
||||||
|
vr.control_team,
|
||||||
|
vr.control_contact,
|
||||||
|
vr.watcher1_name,
|
||||||
|
vr.watcher1_team,
|
||||||
|
vr.watcher1_contact,
|
||||||
|
vr.watcher2_name,
|
||||||
|
vr.watcher2_team,
|
||||||
|
vr.watcher2_contact,
|
||||||
|
vr.visit_from,
|
||||||
|
vr.visit_to,
|
||||||
|
vr.status,
|
||||||
|
vr.qr_token,
|
||||||
|
vr.created_at,
|
||||||
|
acc.check_in_at,
|
||||||
|
acc.check_out_at,
|
||||||
|
COALESCE(acc.check_in_at > COALESCE(acc.check_out_at, 'epoch'::timestamp), FALSE) AS inside
|
||||||
|
FROM visit_requests vr
|
||||||
|
JOIN visitors v ON v.id = vr.visitor_id
|
||||||
|
JOIN users h ON h.id = vr.host_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT
|
||||||
|
max(ae.event_at) FILTER (WHERE ae.direction = 'IN') AS check_in_at,
|
||||||
|
max(ae.event_at) FILTER (WHERE ae.direction = 'OUT') AS check_out_at
|
||||||
|
FROM access_events ae
|
||||||
|
WHERE ae.visit_request_id = vr.id
|
||||||
|
) acc ON TRUE
|
||||||
|
WHERE vr.visit_from::date BETWEEN $1::date AND $2::date
|
||||||
|
ORDER BY vr.created_at DESC
|
||||||
|
`,
|
||||||
|
[req.query.from, req.query.to],
|
||||||
|
);
|
||||||
const workbook = new ExcelJS.Workbook();
|
const workbook = new ExcelJS.Workbook();
|
||||||
const sheet = workbook.addWorksheet('visits');
|
const sheet = workbook.addWorksheet('visits');
|
||||||
sheet.addRow(['방문자', '회사', '구역', '목적', '작업명', '상태', '출입일시', '퇴실일시']);
|
sheet.addRow(['방문자', '회사', '구역', '목적', '작업명', '상태', '출입일시', '퇴실일시']);
|
||||||
for (const visit of visits) {
|
for (const visit of visits.rows) {
|
||||||
sheet.addRow([visit.visitorName, visit.company ?? '', visit.zoneName ?? '', visit.purpose, visit.workName ?? '', visit.status, visit.visitFrom, visit.visitTo]);
|
sheet.addRow([
|
||||||
|
visit.visitor_name,
|
||||||
|
visit.company ?? '',
|
||||||
|
visit.zone_name ?? '',
|
||||||
|
visit.purpose,
|
||||||
|
visit.work_name ?? '',
|
||||||
|
reportStatusLabel(visit),
|
||||||
|
toIso(visit.visit_from),
|
||||||
|
toIso(visit.check_out_at) ?? toIso(visit.visit_to),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
sheet.getRow(1).font = { bold: true };
|
sheet.getRow(1).font = { bold: true };
|
||||||
const buffer = await workbook.xlsx.writeBuffer();
|
const buffer = await workbook.xlsx.writeBuffer();
|
||||||
|
|||||||
Reference in New Issue
Block a user