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:
@@ -58,29 +58,35 @@ public class ReportService {
|
|||||||
Sheet sheet = wb.createSheet("출입기록");
|
Sheet sheet = wb.createSheet("출입기록");
|
||||||
CellStyle headerStyle = headerStyle(wb);
|
CellStyle headerStyle = headerStyle(wb);
|
||||||
|
|
||||||
|
// track the widest displayed content per column (CJK counts double) to size columns
|
||||||
|
int[] widths = new int[headers.length];
|
||||||
Row head = sheet.createRow(0);
|
Row head = sheet.createRow(0);
|
||||||
for (int i = 0; i < headers.length; i++) {
|
for (int i = 0; i < headers.length; i++) {
|
||||||
Cell c = head.createCell(i);
|
Cell c = head.createCell(i);
|
||||||
c.setCellValue(headers[i]);
|
c.setCellValue(headers[i]);
|
||||||
c.setCellStyle(headerStyle);
|
c.setCellStyle(headerStyle);
|
||||||
|
widths[i] = displayWidth(headers[i]);
|
||||||
}
|
}
|
||||||
|
|
||||||
int r = 1;
|
int r = 1;
|
||||||
for (VisitRequest vr : rows) {
|
for (VisitRequest vr : rows) {
|
||||||
Row row = sheet.createRow(r++);
|
Row row = sheet.createRow(r++);
|
||||||
row.createCell(0).setCellValue(vr.getVisitor().getName());
|
put(row, 0, vr.getVisitor().getName(), widths);
|
||||||
row.createCell(1).setCellValue(nv(vr.getVisitor().getCompany()));
|
put(row, 1, nv(vr.getVisitor().getCompany()), widths);
|
||||||
row.createCell(2).setCellValue(nv(vr.getVisitor().getContact()));
|
put(row, 2, nv(vr.getVisitor().getContact()), widths);
|
||||||
row.createCell(3).setCellValue(nv(vr.getZoneName()));
|
put(row, 3, nv(vr.getZoneName()), widths);
|
||||||
row.createCell(4).setCellValue(vr.getHost().getFullName());
|
put(row, 4, vr.getHost().getFullName(), widths);
|
||||||
row.createCell(5).setCellValue(nv(vr.getPurpose()));
|
put(row, 5, nv(vr.getPurpose()), widths);
|
||||||
row.createCell(6).setCellValue(fmt(vr.getVisitFrom()));
|
put(row, 6, fmt(vr.getVisitFrom()), widths);
|
||||||
row.createCell(7).setCellValue(fmt(vr.getVisitTo()));
|
put(row, 7, fmt(vr.getVisitTo()), widths);
|
||||||
row.createCell(8).setCellValue(STATUS_KO.getOrDefault(vr.getStatus(), vr.getStatus().name()));
|
put(row, 8, STATUS_KO.getOrDefault(vr.getStatus(), vr.getStatus().name()), widths);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// autoSizeColumn under-measures CJK text, so set widths from the content
|
||||||
|
// (1 char ≈ 256 units; +2 chars padding; capped so long purposes don't explode).
|
||||||
for (int i = 0; i < headers.length; i++) {
|
for (int i = 0; i < headers.length; i++) {
|
||||||
sheet.autoSizeColumn(i);
|
int chars = Math.min(widths[i] + 2, 60);
|
||||||
|
sheet.setColumnWidth(i, chars * 256);
|
||||||
}
|
}
|
||||||
|
|
||||||
wb.write(out);
|
wb.write(out);
|
||||||
@@ -90,6 +96,33 @@ public class ReportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Writes a string cell and grows the column's tracked display width. */
|
||||||
|
private void put(Row row, int col, String value, int[] widths) {
|
||||||
|
row.createCell(col).setCellValue(value);
|
||||||
|
int w = displayWidth(value);
|
||||||
|
if (w > widths[col]) {
|
||||||
|
widths[col] = w;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Display width where CJK (Hangul/한자/전각) glyphs count as 2 columns, others as 1. */
|
||||||
|
private int displayWidth(String s) {
|
||||||
|
if (s == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
int w = 0;
|
||||||
|
for (int i = 0; i < s.length(); i++) {
|
||||||
|
char c = s.charAt(i);
|
||||||
|
boolean wide = (c >= 0xAC00 && c <= 0xD7A3) // Hangul syllables
|
||||||
|
|| (c >= 0x1100 && c <= 0x11FF) // Hangul Jamo
|
||||||
|
|| (c >= 0x3130 && c <= 0x318F) // Hangul compatibility Jamo
|
||||||
|
|| (c >= 0x4E00 && c <= 0x9FFF) // CJK unified ideographs
|
||||||
|
|| (c >= 0xFF00 && c <= 0xFFEF); // fullwidth forms
|
||||||
|
w += wide ? 2 : 1;
|
||||||
|
}
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
|
||||||
private CellStyle headerStyle(Workbook wb) {
|
private CellStyle headerStyle(Workbook wb) {
|
||||||
CellStyle style = wb.createCellStyle();
|
CellStyle style = wb.createCellStyle();
|
||||||
Font font = wb.createFont();
|
Font font = wb.createFont();
|
||||||
|
|||||||
43
frontend/src/components/DatePickerField.tsx
Normal file
43
frontend/src/components/DatePickerField.tsx
Normal 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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -32,14 +32,13 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
|||||||
<NavLink to="/visit-requests">출입신청</NavLink>
|
<NavLink to="/visit-requests">출입신청</NavLink>
|
||||||
{hasRole('ADMIN') && <NavLink to="/approvals">승인대기</NavLink>}
|
{hasRole('ADMIN') && <NavLink to="/approvals">승인대기</NavLink>}
|
||||||
{hasRole('HOST', 'SECURITY', 'ADMIN') && <NavLink to="/access">출입콘솔</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="/blacklist">블랙리스트</NavLink>}
|
||||||
{hasRole('ADMIN') && <NavLink to="/deliveries">발송내역</NavLink>}
|
{hasRole('ADMIN') && <NavLink to="/deliveries">발송내역</NavLink>}
|
||||||
{hasRole('ADMIN') && <NavLink to="/audit">감사로그</NavLink>}
|
{hasRole('ADMIN') && <NavLink to="/audit">감사로그</NavLink>}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="user-box">
|
<div className="user-box">
|
||||||
<span className="user-name">
|
<span className="user-name">
|
||||||
{user?.fullName}
|
|
||||||
<span className="role-tags">
|
<span className="role-tags">
|
||||||
{user?.roles.map((r) => (
|
{user?.roles.map((r) => (
|
||||||
<span key={r} className="role-tag">{ROLE_LABEL[r] ?? r}</span>
|
<span key={r} className="role-tag">{ROLE_LABEL[r] ?? r}</span>
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { getStatsSummary, listVisitRequests } from '../api';
|
import { getStatsSummary, listInside, listVisitRequests } from '../api';
|
||||||
import { StatsSummary, VisitRequestView } from '../types';
|
import { StatsSummary, VisitRequestView } from '../types';
|
||||||
import { STATUS_CLASS, STATUS_LABEL, formatDateTime } from '../status';
|
import { STATUS_CLASS, STATUS_LABEL, formatDateTime } from '../status';
|
||||||
|
|
||||||
export const DashboardPage: React.FC = () => {
|
export const DashboardPage: React.FC = () => {
|
||||||
const [items, setItems] = useState<VisitRequestView[]>([]);
|
const [items, setItems] = useState<VisitRequestView[]>([]);
|
||||||
const [stats, setStats] = useState<StatsSummary | null>(null);
|
const [stats, setStats] = useState<StatsSummary | null>(null);
|
||||||
|
const [insideIds, setInsideIds] = useState<Set<number>>(new Set());
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getStatsSummary().then(setStats).catch(() => setStats(null));
|
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()
|
listVisitRequests()
|
||||||
.then(setItems)
|
.then(setItems)
|
||||||
.catch((e) => setError(e instanceof Error ? e.message : '조회 실패'))
|
.catch((e) => setError(e instanceof Error ? e.message : '조회 실패'))
|
||||||
@@ -56,7 +61,13 @@ export const DashboardPage: React.FC = () => {
|
|||||||
<td>{r.company || '-'}</td>
|
<td>{r.company || '-'}</td>
|
||||||
<td>{r.zoneName || '-'}</td>
|
<td>{r.zoneName || '-'}</td>
|
||||||
<td>{formatDateTime(r.visitFrom)}</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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export const DeliveryOutboxPage: React.FC = () => {
|
|||||||
<option value="SENT">성공</option>
|
<option value="SENT">성공</option>
|
||||||
<option value="ALL">전체</option>
|
<option value="ALL">전체</option>
|
||||||
</select>
|
</select>
|
||||||
<button className="btn-ghost" onClick={load} disabled={loading}>새로고침</button>
|
<button className="btn-ghost" onClick={load} disabled={loading}>조회</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{error && <div className="alert alert-error">{error}</div>}
|
{error && <div className="alert alert-error">{error}</div>}
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { reportDownloadUrl } from '../api';
|
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 {
|
function todayISO(): string {
|
||||||
return new Date().toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
function monthAgoISO(): string {
|
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
d.setMonth(d.getMonth() - 1);
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||||
return d.toISOString().slice(0, 10);
|
}
|
||||||
|
/** 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 = () => {
|
export const ReportPage: React.FC = () => {
|
||||||
const [from, setFrom] = useState(monthAgoISO());
|
const [from, setFrom] = useState(firstOfMonthISO());
|
||||||
const [to, setTo] = useState(todayISO());
|
const [to, setTo] = useState(todayISO());
|
||||||
|
|
||||||
const onDownload = () => {
|
const onDownload = () => {
|
||||||
@@ -29,18 +34,18 @@ export const ReportPage: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="page-head"><h2>방문 리포트</h2></div>
|
<div className="page-head"><h2>출입관리 보고서</h2></div>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<p className="muted">기간을 선택하고 엑셀(.xlsx) 파일로 내려받습니다. (방문 시작일 기준)</p>
|
<p className="muted">기간을 선택하고 [엑셀 다운로드] 버튼을 클릭하면, 엑셀(.xlsx) 파일로 내려받습니다. (방문 시작일 기준)</p>
|
||||||
<div className="report-row">
|
<div className="report-row">
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>시작일</span>
|
<span>시작일</span>
|
||||||
<input type="date" value={from} onChange={(e) => setFrom(e.target.value)} />
|
<DatePickerField value={from} onChange={setFrom} />
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>종료일</span>
|
<span>종료일</span>
|
||||||
<input type="date" value={to} onChange={(e) => setTo(e.target.value)} />
|
<DatePickerField value={to} onChange={setTo} />
|
||||||
</label>
|
</label>
|
||||||
<button className="btn-primary" onClick={onDownload}>엑셀 다운로드</button>
|
<button className="btn-primary" onClick={onDownload}>엑셀 다운로드</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -288,7 +288,7 @@ button:disabled { opacity: .55; cursor: not-allowed; }
|
|||||||
.badge-qr { width: 200px; height: 200px; image-rendering: pixelated; }
|
.badge-qr { width: 200px; height: 200px; image-rendering: pixelated; }
|
||||||
.badge-meta { text-align: center; margin: 16px 0; font-size: 13px; }
|
.badge-meta { text-align: center; margin: 16px 0; font-size: 13px; }
|
||||||
.badge-meta > div { padding: 4px 0; border-bottom: 1px dashed var(--border); }
|
.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 {
|
@media print {
|
||||||
.topbar, .no-print { display: none !important; }
|
.topbar, .no-print { display: none !important; }
|
||||||
|
|||||||
Reference in New Issue
Block a user