fix(ui): 테스트 피드백 반영 (출입증/보고서/상단바/대시보드/엑셀)

1. 출입증 카드 하단 안내문을 한 줄로(.badge-foot font-size 11px + nowrap).
2. 보고서 화면: 명칭 '리포트→보고서'·'방문 리포트→출입관리 보고서', 안내문 보강,
   날짜 입력을 YYYY.MM.DD 표시 커스텀 피커(DatePickerField)로 교체,
   기본값 시작일=이번 달 1일·종료일=오늘.
3. 보고서 엑셀 열 너비를 내용 기준(한글 2폭)으로 계산해 설정 → 셀 잘림 해소
   (POI autoSizeColumn의 CJK 과소측정 문제 회피).
4. 상단바에서 사용자 이름 표시 제거(역할 태그만 유지) → '발송내역' 메뉴 잘림 해소,
   발송내역 화면 [새로고침]→[조회].
5. 대시보드 '최근 출입 신청'에서 현재 재실 중인 방문자는 상태를 '재실중'으로 표시
   (기존 listInside API 재활용, 백엔드 무변경).

검증: 프론트 tsc+vite 빌드 통과, 백엔드 build+test(9건) 통과, 생성 xlsx의 열 너비가
내용에 맞게 설정됨(연락처15·출입일시18·상태10 등) 확인.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
unknown
2026-07-03 16:52:11 +09:00
parent d8ba443b35
commit da0d35ae7f
7 changed files with 117 additions and 26 deletions

View File

@@ -0,0 +1,43 @@
import React, { useRef } from 'react';
import DatePicker, { registerLocale } from 'react-datepicker';
import { ko } from 'date-fns/locale';
import 'react-datepicker/dist/react-datepicker.css';
registerLocale('ko', ko);
interface Props {
/** date string in "YYYY-MM-DD" (the format the back-end expects). */
value: string;
onChange: (value: string) => void;
placeholder?: string;
}
const pad = (n: number) => String(n).padStart(2, '0');
/** Date → "YYYY-MM-DD" (local). */
function toISODate(d: Date): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
/**
* Korean-localized date-only picker. Displays the value as YYYY.MM.DD (which the
* native <input type="date"> cannot force — it follows the OS locale) while the
* bound value stays "YYYY-MM-DD" for the API.
*/
export const DatePickerField: React.FC<Props> = ({ value, onChange, placeholder }) => {
const ref = useRef<DatePicker>(null);
return (
<DatePicker
ref={ref}
selected={value ? new Date(`${value}T00:00:00`) : null}
onChange={(d: Date | null) => d && onChange(toISODate(d))}
dateFormat="yyyy.MM.dd"
dateFormatCalendar="yyyy.M월"
locale="ko"
placeholderText={placeholder ?? '날짜를 선택하세요'}
className="dt-input"
popperClassName="acs-datepicker"
/>
);
};

View File

@@ -32,14 +32,13 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
<NavLink to="/visit-requests"></NavLink>
{hasRole('ADMIN') && <NavLink to="/approvals"></NavLink>}
{hasRole('HOST', 'SECURITY', 'ADMIN') && <NavLink to="/access"></NavLink>}
{hasRole('SECURITY', 'ADMIN') && <NavLink to="/reports"></NavLink>}
{hasRole('SECURITY', 'ADMIN') && <NavLink to="/reports"></NavLink>}
{hasRole('ADMIN') && <NavLink to="/blacklist"></NavLink>}
{hasRole('ADMIN') && <NavLink to="/deliveries"></NavLink>}
{hasRole('ADMIN') && <NavLink to="/audit"></NavLink>}
</nav>
<div className="user-box">
<span className="user-name">
{user?.fullName}
<span className="role-tags">
{user?.roles.map((r) => (
<span key={r} className="role-tag">{ROLE_LABEL[r] ?? r}</span>

View File

@@ -1,17 +1,22 @@
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { getStatsSummary, listVisitRequests } from '../api';
import { getStatsSummary, listInside, listVisitRequests } from '../api';
import { StatsSummary, VisitRequestView } from '../types';
import { STATUS_CLASS, STATUS_LABEL, formatDateTime } from '../status';
export const DashboardPage: React.FC = () => {
const [items, setItems] = useState<VisitRequestView[]>([]);
const [stats, setStats] = useState<StatsSummary | null>(null);
const [insideIds, setInsideIds] = useState<Set<number>>(new Set());
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
getStatsSummary().then(setStats).catch(() => setStats(null));
// currently-inside visits → show "재실중" instead of the plain approved status
listInside()
.then((rows) => setInsideIds(new Set(rows.map((r) => r.visitRequestId))))
.catch(() => setInsideIds(new Set()));
listVisitRequests()
.then(setItems)
.catch((e) => setError(e instanceof Error ? e.message : '조회 실패'))
@@ -56,7 +61,13 @@ export const DashboardPage: React.FC = () => {
<td>{r.company || '-'}</td>
<td>{r.zoneName || '-'}</td>
<td>{formatDateTime(r.visitFrom)}</td>
<td><span className={`badge badge-${STATUS_CLASS[r.status]}`}>{STATUS_LABEL[r.status]}</span></td>
<td>
{insideIds.has(r.id) ? (
<span className="badge badge-green"></span>
) : (
<span className={`badge badge-${STATUS_CLASS[r.status]}`}>{STATUS_LABEL[r.status]}</span>
)}
</td>
</tr>
))}
</tbody>

View File

@@ -45,7 +45,7 @@ export const DeliveryOutboxPage: React.FC = () => {
<option value="SENT"></option>
<option value="ALL"></option>
</select>
<button className="btn-ghost" onClick={load} disabled={loading}></button>
<button className="btn-ghost" onClick={load} disabled={loading}></button>
</div>
</div>
{error && <div className="alert alert-error">{error}</div>}

View File

@@ -1,17 +1,22 @@
import React, { useState } from 'react';
import { reportDownloadUrl } from '../api';
import { DatePickerField } from '../components/DatePickerField';
const pad = (n: number) => String(n).padStart(2, '0');
/** Today in YYYY-MM-DD (local). */
function todayISO(): string {
return new Date().toISOString().slice(0, 10);
}
function monthAgoISO(): string {
const d = new Date();
d.setMonth(d.getMonth() - 1);
return d.toISOString().slice(0, 10);
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(monthAgoISO());
const [from, setFrom] = useState(firstOfMonthISO());
const [to, setTo] = useState(todayISO());
const onDownload = () => {
@@ -29,18 +34,18 @@ export const ReportPage: React.FC = () => {
return (
<div>
<div className="page-head"><h2> </h2></div>
<div className="page-head"><h2> </h2></div>
<div className="card">
<p className="muted"> (.xlsx) . ( )</p>
<p className="muted"> [ ] , (.xlsx) . ( )</p>
<div className="report-row">
<label className="field">
<span></span>
<input type="date" value={from} onChange={(e) => setFrom(e.target.value)} />
<DatePickerField value={from} onChange={setFrom} />
</label>
<label className="field">
<span></span>
<input type="date" value={to} onChange={(e) => setTo(e.target.value)} />
<DatePickerField value={to} onChange={setTo} />
</label>
<button className="btn-primary" onClick={onDownload}> </button>
</div>

View File

@@ -288,7 +288,7 @@ button:disabled { opacity: .55; cursor: not-allowed; }
.badge-qr { width: 200px; height: 200px; image-rendering: pixelated; }
.badge-meta { text-align: center; margin: 16px 0; font-size: 13px; }
.badge-meta > div { padding: 4px 0; border-bottom: 1px dashed var(--border); }
.badge-foot { font-size: 12px; color: var(--muted); margin-top: 8px; }
.badge-foot { font-size: 11px; color: var(--muted); margin-top: 8px; white-space: nowrap; }
@media print {
.topbar, .no-print { display: none !important; }