From da0d35ae7f40ff1fb259c1316d07004a5214bc9f Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 3 Jul 2026 16:52:11 +0900 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=94=BC=EB=93=9C=EB=B0=B1=20=EB=B0=98=EC=98=81=20(=EC=B6=9C?= =?UTF-8?q?=EC=9E=85=EC=A6=9D/=EB=B3=B4=EA=B3=A0=EC=84=9C/=EC=83=81?= =?UTF-8?q?=EB=8B=A8=EB=B0=94/=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C/?= =?UTF-8?q?=EC=97=91=EC=85=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../itcenter/acs/service/ReportService.java | 53 +++++++++++++++---- frontend/src/components/DatePickerField.tsx | 43 +++++++++++++++ frontend/src/components/Layout.tsx | 3 +- frontend/src/pages/DashboardPage.tsx | 15 +++++- frontend/src/pages/DeliveryOutboxPage.tsx | 2 +- frontend/src/pages/ReportPage.tsx | 25 +++++---- frontend/src/styles/common.css | 2 +- 7 files changed, 117 insertions(+), 26 deletions(-) create mode 100644 frontend/src/components/DatePickerField.tsx diff --git a/backend/src/main/java/com/itcenter/acs/service/ReportService.java b/backend/src/main/java/com/itcenter/acs/service/ReportService.java index a6dde09..402bedd 100644 --- a/backend/src/main/java/com/itcenter/acs/service/ReportService.java +++ b/backend/src/main/java/com/itcenter/acs/service/ReportService.java @@ -58,29 +58,35 @@ public class ReportService { Sheet sheet = wb.createSheet("출입기록"); 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); for (int i = 0; i < headers.length; i++) { Cell c = head.createCell(i); c.setCellValue(headers[i]); c.setCellStyle(headerStyle); + widths[i] = displayWidth(headers[i]); } int r = 1; for (VisitRequest vr : rows) { Row row = sheet.createRow(r++); - row.createCell(0).setCellValue(vr.getVisitor().getName()); - row.createCell(1).setCellValue(nv(vr.getVisitor().getCompany())); - row.createCell(2).setCellValue(nv(vr.getVisitor().getContact())); - row.createCell(3).setCellValue(nv(vr.getZoneName())); - row.createCell(4).setCellValue(vr.getHost().getFullName()); - row.createCell(5).setCellValue(nv(vr.getPurpose())); - row.createCell(6).setCellValue(fmt(vr.getVisitFrom())); - row.createCell(7).setCellValue(fmt(vr.getVisitTo())); - row.createCell(8).setCellValue(STATUS_KO.getOrDefault(vr.getStatus(), vr.getStatus().name())); + put(row, 0, vr.getVisitor().getName(), widths); + put(row, 1, nv(vr.getVisitor().getCompany()), widths); + put(row, 2, nv(vr.getVisitor().getContact()), widths); + put(row, 3, nv(vr.getZoneName()), widths); + put(row, 4, vr.getHost().getFullName(), widths); + put(row, 5, nv(vr.getPurpose()), widths); + put(row, 6, fmt(vr.getVisitFrom()), widths); + put(row, 7, fmt(vr.getVisitTo()), widths); + 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++) { - sheet.autoSizeColumn(i); + int chars = Math.min(widths[i] + 2, 60); + sheet.setColumnWidth(i, chars * 256); } 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) { CellStyle style = wb.createCellStyle(); Font font = wb.createFont(); diff --git a/frontend/src/components/DatePickerField.tsx b/frontend/src/components/DatePickerField.tsx new file mode 100644 index 0000000..9807624 --- /dev/null +++ b/frontend/src/components/DatePickerField.tsx @@ -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 cannot force — it follows the OS locale) while the + * bound value stays "YYYY-MM-DD" for the API. + */ +export const DatePickerField: React.FC = ({ value, onChange, placeholder }) => { + const ref = useRef(null); + + return ( + d && onChange(toISODate(d))} + dateFormat="yyyy.MM.dd" + dateFormatCalendar="yyyy.M월" + locale="ko" + placeholderText={placeholder ?? '날짜를 선택하세요'} + className="dt-input" + popperClassName="acs-datepicker" + /> + ); +}; diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 6720d75..68933a3 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -32,14 +32,13 @@ export const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => 출입신청 {hasRole('ADMIN') && 승인대기} {hasRole('HOST', 'SECURITY', 'ADMIN') && 출입콘솔} - {hasRole('SECURITY', 'ADMIN') && 리포트} + {hasRole('SECURITY', 'ADMIN') && 보고서} {hasRole('ADMIN') && 블랙리스트} {hasRole('ADMIN') && 발송내역} {hasRole('ADMIN') && 감사로그}
- {user?.fullName} {user?.roles.map((r) => ( {ROLE_LABEL[r] ?? r} diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index d4f1a67..aa56754 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -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([]); const [stats, setStats] = useState(null); + const [insideIds, setInsideIds] = useState>(new Set()); const [error, setError] = useState(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 = () => { {r.company || '-'} {r.zoneName || '-'} {formatDateTime(r.visitFrom)} - {STATUS_LABEL[r.status]} + + {insideIds.has(r.id) ? ( + 재실중 + ) : ( + {STATUS_LABEL[r.status]} + )} + ))} diff --git a/frontend/src/pages/DeliveryOutboxPage.tsx b/frontend/src/pages/DeliveryOutboxPage.tsx index 71eedf0..9549e46 100644 --- a/frontend/src/pages/DeliveryOutboxPage.tsx +++ b/frontend/src/pages/DeliveryOutboxPage.tsx @@ -45,7 +45,7 @@ export const DeliveryOutboxPage: React.FC = () => { - +
{error &&
{error}
} diff --git a/frontend/src/pages/ReportPage.tsx b/frontend/src/pages/ReportPage.tsx index 344e209..246f4fb 100644 --- a/frontend/src/pages/ReportPage.tsx +++ b/frontend/src/pages/ReportPage.tsx @@ -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 (
-

방문 리포트

+

출입관리 보고서

-

기간을 선택하고 엑셀(.xlsx) 파일로 내려받습니다. (방문 시작일 기준)

+

기간을 선택하고 [엑셀 다운로드] 버튼을 클릭하면, 엑셀(.xlsx) 파일로 내려받습니다. (방문 시작일 기준)

diff --git a/frontend/src/styles/common.css b/frontend/src/styles/common.css index 136a7f5..92e2c79 100644 --- a/frontend/src/styles/common.css +++ b/frontend/src/styles/common.css @@ -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; }