fix: handle visit request Excel form upload

This commit is contained in:
unknown
2026-07-27 15:08:36 +09:00
parent eaecfae330
commit 6c511bf355
8 changed files with 231 additions and 16 deletions

BIN
docs/form_sample.xlsx Normal file

Binary file not shown.

View File

@@ -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<VisitRequestView>(`/approvals/${id}/approve`, jsonInit('POST', { comment }));
@@ -168,7 +171,7 @@ export const deleteBlacklist = (id: number) =>
// ===== Reports =====
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) =>
`/api/reports/visits.xlsx?from=${from}&to=${to}`;

View File

@@ -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' };

View File

@@ -115,7 +115,7 @@ export const DashboardPage: React.FC = () => {
{insideIds.has(r.id) ? (
<span className="badge badge-green"></span>
) : 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>
)}

View File

@@ -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<VisitRequestView[]>([]);
const [items, setItems] = useState<ReportVisitView[]>([]);
const [accessRecords, setAccessRecords] = useState<AccessRecord[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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] };
};

View File

@@ -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 = () => {
<div className="page-head">
<h2> </h2>
<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} />
<Link className="btn-primary" to="/visit-requests/new">+ </Link>
</div>

View File

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

View File

@@ -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<void>) {
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 {
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<ExcelJS.Workbook> {
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<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) => {
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 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();