diff --git a/docs/form_sample.xlsx b/docs/form_sample.xlsx new file mode 100644 index 0000000..265b117 Binary files /dev/null and b/docs/form_sample.xlsx differ 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();