157 lines
5.4 KiB
TypeScript
157 lines
5.4 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
import { listReportVisits, listTodayAccess, reportDownloadUrl } from '../api';
|
|
import { DatePickerField } from '../components/DatePickerField';
|
|
import { AccessRecord, ReportVisitView } from '../types';
|
|
import { STATUS_CLASS, STATUS_LABEL, formatDateTime } from '../status';
|
|
|
|
const pad = (n: number) => String(n).padStart(2, '0');
|
|
|
|
/** Today in YYYY-MM-DD (local). */
|
|
function todayISO(): string {
|
|
const d = new Date();
|
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
}
|
|
/** First day of the current month in YYYY-MM-DD (local). */
|
|
function firstOfMonthISO(): string {
|
|
const d = new Date();
|
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-01`;
|
|
}
|
|
|
|
export const ReportPage: React.FC = () => {
|
|
const [from, setFrom] = useState(firstOfMonthISO());
|
|
const [to, setTo] = useState(todayISO());
|
|
const [items, setItems] = useState<ReportVisitView[]>([]);
|
|
const [accessRecords, setAccessRecords] = useState<AccessRecord[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const load = () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
Promise.all([
|
|
listReportVisits(from, to),
|
|
listTodayAccess().catch(() => [] as AccessRecord[]),
|
|
])
|
|
.then(([visits, records]) => {
|
|
setItems(visits);
|
|
setAccessRecords(records);
|
|
})
|
|
.catch((e) => {
|
|
setItems([]);
|
|
setAccessRecords([]);
|
|
setError(e instanceof Error ? e.message : '보고서 조회에 실패했습니다.');
|
|
})
|
|
.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(() => {
|
|
load();
|
|
// Load the default month range once; explicit 조회 handles later date changes.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
const onDownload = () => {
|
|
// Trigger a download in-place via a temporary anchor. Using window.open left
|
|
// a blank tab behind (the .xlsx response has no HTML to render). The anchor's
|
|
// download attribute makes the browser save the file without navigating away.
|
|
// Same-origin, so the session cookie is sent automatically (dev proxy / nginx).
|
|
const a = document.createElement('a');
|
|
a.href = reportDownloadUrl(from, to);
|
|
a.download = `visits_${from}_${to}.xlsx`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<div className="page-head"><h2>출입관리 보고서</h2></div>
|
|
|
|
<div className="card">
|
|
<div className="report-row">
|
|
<label className="field">
|
|
<span>시작일</span>
|
|
<DatePickerField value={from} onChange={setFrom} />
|
|
</label>
|
|
<label className="field">
|
|
<span>종료일</span>
|
|
<DatePickerField value={to} onChange={setTo} />
|
|
</label>
|
|
<button className="btn-ghost" onClick={load} disabled={loading}>조회</button>
|
|
<button className="btn-primary" onClick={onDownload}>엑셀 다운로드</button>
|
|
</div>
|
|
</div>
|
|
|
|
{error && <div className="alert alert-error">{error}</div>}
|
|
|
|
<div className="card">
|
|
<div className="page-head">
|
|
<h3>조회 결과 ({items.length})</h3>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<p className="muted">불러오는 중...</p>
|
|
) : items.length === 0 ? (
|
|
<p className="muted">조회된 출입 신청이 없습니다.</p>
|
|
) : (
|
|
<table className="table">
|
|
<thead>
|
|
<tr>
|
|
<th>방문자</th>
|
|
<th>회사</th>
|
|
<th>연락처</th>
|
|
<th>출입구역</th>
|
|
<th>호스트</th>
|
|
<th>출입목적</th>
|
|
<th>작업명</th>
|
|
<th>출입일시</th>
|
|
<th>퇴실일시</th>
|
|
<th>상태</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{items.map((r) => (
|
|
<tr key={r.id}>
|
|
<td>{r.visitorName}</td>
|
|
<td>{r.company || '-'}</td>
|
|
<td>{r.contact || '-'}</td>
|
|
<td>{r.zoneName || '-'}</td>
|
|
<td>{r.hostName}</td>
|
|
<td>{r.purpose || '-'}</td>
|
|
<td>{r.workName || '-'}</td>
|
|
<td>{formatDateTime(r.visitFrom)}</td>
|
|
<td>{formatDateTime(r.visitTo)}</td>
|
|
<td>
|
|
{(() => {
|
|
const badge = statusBadge(r);
|
|
return <span className={`badge badge-${badge.className}`}>{badge.label}</span>;
|
|
})()}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|