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; }