fix: handle visit request Excel form upload
This commit is contained in:
BIN
docs/form_sample.xlsx
Normal file
BIN
docs/form_sample.xlsx
Normal file
Binary file not shown.
@@ -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}`;
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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] };
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user