From 6c511bf355fe712d14b2457b277b3bfddf581fd0 Mon Sep 17 00:00:00 2001 From: unknown <0420301@BOKNET.INTRA> Date: Mon, 27 Jul 2026 15:08:36 +0900 Subject: [PATCH] fix: handle visit request Excel form upload --- docs/form_sample.xlsx | Bin 0 -> 17448 bytes frontend/src/api.ts | 5 +- .../components/VisitRequestDetailDialog.tsx | 2 +- frontend/src/pages/DashboardPage.tsx | 2 +- frontend/src/pages/ReportPage.tsx | 14 +- frontend/src/pages/VisitRequestListPage.tsx | 14 +- frontend/src/types.ts | 7 + server/routes/business.ts | 203 +++++++++++++++++- 8 files changed, 231 insertions(+), 16 deletions(-) create mode 100644 docs/form_sample.xlsx diff --git a/docs/form_sample.xlsx b/docs/form_sample.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..265b117f1c8d25fd5c2b6eb4ac07e88f24b6324c GIT binary patch literal 17448 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 IfnQhu2ajoT`Tzg` literal 0 HcmV?d00001 diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 41a2351..651d52f 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -15,6 +15,7 @@ import { LoginRequest, PassDelivery, PurposeCode, + ReportVisitView, Role, SmsDiagnosticsResult, StatsSummary, @@ -124,6 +125,8 @@ export const uploadVisitRequests = (file: File) => { }); }; +export const visitRequestTemplateUrl = () => '/api/visit-requests/template'; + // ===== Approvals ===== export const approveRequest = (id: number, comment?: string) => request(`/approvals/${id}/approve`, jsonInit('POST', { comment })); @@ -168,7 +171,7 @@ export const deleteBlacklist = (id: number) => // ===== Reports ===== export const listReportVisits = (from: string, to: string) => - request(`/reports/visits?from=${from}&to=${to}`); + request(`/reports/visits?from=${from}&to=${to}`); export const reportDownloadUrl = (from: string, to: string) => `/api/reports/visits.xlsx?from=${from}&to=${to}`; diff --git a/frontend/src/components/VisitRequestDetailDialog.tsx b/frontend/src/components/VisitRequestDetailDialog.tsx index 52cf4f8..be6e33a 100644 --- a/frontend/src/components/VisitRequestDetailDialog.tsx +++ b/frontend/src/components/VisitRequestDetailDialog.tsx @@ -153,7 +153,7 @@ function getDisplayStatus(request: VisitRequestView | null, access: AccessRecord return { label: '재실중', className: 'green' }; } if (access?.checkOutAt) { - return { label: '퇴장', className: 'gray' }; + return { label: '퇴실', className: 'gray' }; } if (!request) { return { label: '-', className: 'gray' }; diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index fb5c5e0..e3639ce 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -115,7 +115,7 @@ export const DashboardPage: React.FC = () => { {insideIds.has(r.id) ? ( 재실중 ) : exitedIds.has(r.id) ? ( - 퇴장 + 퇴실 ) : ( {STATUS_LABEL[r.status]} )} diff --git a/frontend/src/pages/ReportPage.tsx b/frontend/src/pages/ReportPage.tsx index 27c6e7b..72db626 100644 --- a/frontend/src/pages/ReportPage.tsx +++ b/frontend/src/pages/ReportPage.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from 'react'; import { listReportVisits, listTodayAccess, reportDownloadUrl } from '../api'; import { DatePickerField } from '../components/DatePickerField'; -import { AccessRecord, VisitRequestView } from '../types'; +import { AccessRecord, ReportVisitView } from '../types'; import { STATUS_CLASS, STATUS_LABEL, formatDateTime } from '../status'; const pad = (n: number) => String(n).padStart(2, '0'); @@ -20,7 +20,7 @@ function firstOfMonthISO(): string { export const ReportPage: React.FC = () => { const [from, setFrom] = useState(firstOfMonthISO()); const [to, setTo] = useState(todayISO()); - const [items, setItems] = useState([]); + const [items, setItems] = useState([]); const [accessRecords, setAccessRecords] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -45,13 +45,19 @@ export const ReportPage: React.FC = () => { }; const accessById = new Map(accessRecords.map((r) => [r.visitRequestId, r])); - const statusBadge = (r: VisitRequestView) => { + 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: 'gray', label: '퇴실' }; } return { className: STATUS_CLASS[r.status], label: STATUS_LABEL[r.status] }; }; diff --git a/frontend/src/pages/VisitRequestListPage.tsx b/frontend/src/pages/VisitRequestListPage.tsx index e26d8e5..b26f2a5 100644 --- a/frontend/src/pages/VisitRequestListPage.tsx +++ b/frontend/src/pages/VisitRequestListPage.tsx @@ -1,6 +1,13 @@ import React, { useEffect, useRef, useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; -import { cancelVisitRequest, deleteVisitRequest, listTodayAccess, listVisitRequests, uploadVisitRequests } from '../api'; +import { + cancelVisitRequest, + deleteVisitRequest, + listTodayAccess, + listVisitRequests, + uploadVisitRequests, + visitRequestTemplateUrl, +} from '../api'; import { AccessRecord, VisitRequestView } from '../types'; import { STATUS_CLASS, STATUS_LABEL, formatVisitRange } from '../status'; import { Dialog } from '../components/Dialog'; @@ -44,7 +51,7 @@ export const VisitRequestListPage: React.FC = () => { return { className: 'green', label: '재실중' }; } if (access?.checkOutAt) { - return { className: 'gray', label: '퇴장' }; + return { className: 'gray', label: '퇴실' }; } return { className: STATUS_CLASS[r.status], label: STATUS_LABEL[r.status] }; }; @@ -97,7 +104,8 @@ export const VisitRequestListPage: React.FC = () => {

출입 신청 목록

- + 양식 다운로드 + + 출입 신청
diff --git a/frontend/src/types.ts b/frontend/src/types.ts index a4ee644..b8a028b 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -158,6 +158,13 @@ export interface VisitRequestView { createdAt: string; } +export interface ReportVisitView extends VisitRequestView { + checkInAt?: string; + checkOutAt?: string; + inside?: boolean; + reportStatusLabel?: string; +} + export interface PublicPass { visitorName: string; company?: string; diff --git a/server/routes/business.ts b/server/routes/business.ts index 6c9da6a..974a0e9 100644 --- a/server/routes/business.ts +++ b/server/routes/business.ts @@ -4,6 +4,8 @@ import multer from 'multer'; import ExcelJS from 'exceljs'; import QRCode from 'qrcode'; import { randomUUID } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; import { ok } from '../http/apiResponse.js'; import { ApiError } from '../http/errors.js'; import { env } from '../config/env.js'; @@ -61,7 +63,14 @@ interface AccessRow { 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 visitRequestTemplatePath = path.resolve(process.cwd(), 'docs', 'form_sample.xlsx'); function asyncRoute(handler: (req: Request, res: Response, next: NextFunction) => Promise) { 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): string { + if (row.inside) return '재실중'; + if (row.check_out_at) return '퇴실'; + return visitStatusLabel(row.status); +} + function required(value: unknown, label: string): string { const text = String(value ?? '').trim(); if (!text) throw new ApiError(400, `${label}을(를) 입력하세요.`); @@ -359,7 +386,14 @@ function parseVisitDate(row: ExcelJS.Row, index: number, label: string): string .replace(/-+/g, '-') .replace(/-$/, ''); 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]))}`; } @@ -399,6 +433,41 @@ function isVisitUploadRowEmpty(row: ExcelJS.Row): boolean { 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 { if (!file) throw new ApiError(400, '업로드할 파일을 선택하세요.'); 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]; if (!sheet) throw new ApiError(400, '엑셀 시트를 찾을 수 없습니다.'); 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); if (isVisitUploadRowEmpty(row)) continue; + if (isVisitUploadNonDataRow(row)) continue; result.totalRows += 1; try { const date = parseVisitDate(row, 5, '출입일자'); @@ -551,6 +622,14 @@ export function createBusinessRouter(deps: BusinessRouterDeps = { query }): Rout 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) => { await requireCurrentUser(dbQuery, req); const visit = await visitById(dbQuery, Number(req.params.id)); @@ -879,17 +958,129 @@ export function createBusinessRouter(deps: BusinessRouterDeps = { query }): Rout router.get('/reports/visits', asyncRoute(async (req, res) => { 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( + ` + 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) => { 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( + ` + 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 sheet = workbook.addWorksheet('visits'); sheet.addRow(['방문자', '회사', '구역', '목적', '작업명', '상태', '출입일시', '퇴실일시']); - for (const visit of visits) { - sheet.addRow([visit.visitorName, visit.company ?? '', visit.zoneName ?? '', visit.purpose, visit.workName ?? '', visit.status, visit.visitFrom, visit.visitTo]); + for (const visit of visits.rows) { + 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 }; const buffer = await workbook.xlsx.writeBuffer();