diff --git a/src/frontend/public/brand/kintex-ci.jpg b/src/frontend/public/brand/kintex-ci.jpg new file mode 100644 index 0000000..a9ef041 Binary files /dev/null and b/src/frontend/public/brand/kintex-ci.jpg differ diff --git a/src/frontend/public/login-slides/login-slide-01.jpg b/src/frontend/public/login-slides/login-slide-01.jpg new file mode 100644 index 0000000..21020e2 Binary files /dev/null and b/src/frontend/public/login-slides/login-slide-01.jpg differ diff --git a/src/frontend/public/login-slides/login-slide-02.jpg b/src/frontend/public/login-slides/login-slide-02.jpg new file mode 100644 index 0000000..442b2cb Binary files /dev/null and b/src/frontend/public/login-slides/login-slide-02.jpg differ diff --git a/src/frontend/public/login-slides/login-slide-03.jpg b/src/frontend/public/login-slides/login-slide-03.jpg new file mode 100644 index 0000000..6fb0ff0 Binary files /dev/null and b/src/frontend/public/login-slides/login-slide-03.jpg differ diff --git a/src/frontend/public/login-slides/login-slide-04.jpg b/src/frontend/public/login-slides/login-slide-04.jpg new file mode 100644 index 0000000..8fe5330 Binary files /dev/null and b/src/frontend/public/login-slides/login-slide-04.jpg differ diff --git a/src/frontend/public/login-slides/login-slide-05.jpg b/src/frontend/public/login-slides/login-slide-05.jpg new file mode 100644 index 0000000..350a3af Binary files /dev/null and b/src/frontend/public/login-slides/login-slide-05.jpg differ diff --git a/src/frontend/public/login-slides/login-slide-06.jpg b/src/frontend/public/login-slides/login-slide-06.jpg new file mode 100644 index 0000000..0e421d5 Binary files /dev/null and b/src/frontend/public/login-slides/login-slide-06.jpg differ diff --git a/src/frontend/public/login-slides/login-slide-07.jpg b/src/frontend/public/login-slides/login-slide-07.jpg new file mode 100644 index 0000000..fd78ba0 Binary files /dev/null and b/src/frontend/public/login-slides/login-slide-07.jpg differ diff --git a/src/frontend/public/login-slides/login-slide-08.jpg b/src/frontend/public/login-slides/login-slide-08.jpg new file mode 100644 index 0000000..f6429ed Binary files /dev/null and b/src/frontend/public/login-slides/login-slide-08.jpg differ diff --git a/src/frontend/public/login-slides/login-slide-09.jpg b/src/frontend/public/login-slides/login-slide-09.jpg new file mode 100644 index 0000000..3b81bd8 Binary files /dev/null and b/src/frontend/public/login-slides/login-slide-09.jpg differ diff --git a/src/frontend/public/login-slides/login-slide-10.jpg b/src/frontend/public/login-slides/login-slide-10.jpg new file mode 100644 index 0000000..090caf4 Binary files /dev/null and b/src/frontend/public/login-slides/login-slide-10.jpg differ diff --git a/src/frontend/public/media/hero/hero-6.jpg b/src/frontend/public/media/hero/hero-6.jpg new file mode 100644 index 0000000..a4e5e6e Binary files /dev/null and b/src/frontend/public/media/hero/hero-6.jpg differ diff --git a/src/frontend/public/media/hero/hero-7.jpg b/src/frontend/public/media/hero/hero-7.jpg new file mode 100644 index 0000000..c654650 Binary files /dev/null and b/src/frontend/public/media/hero/hero-7.jpg differ diff --git a/src/frontend/public/media/track/track-agency-01.jpg b/src/frontend/public/media/track/track-agency-01.jpg new file mode 100644 index 0000000..88e9f7e Binary files /dev/null and b/src/frontend/public/media/track/track-agency-01.jpg differ diff --git a/src/frontend/public/media/track/track-agency-02.jpg b/src/frontend/public/media/track/track-agency-02.jpg new file mode 100644 index 0000000..c654650 Binary files /dev/null and b/src/frontend/public/media/track/track-agency-02.jpg differ diff --git a/src/frontend/public/media/track/track-agency-03.jpg b/src/frontend/public/media/track/track-agency-03.jpg new file mode 100644 index 0000000..a4e5e6e Binary files /dev/null and b/src/frontend/public/media/track/track-agency-03.jpg differ diff --git a/src/frontend/public/media/track/track-agency-04.jpg b/src/frontend/public/media/track/track-agency-04.jpg new file mode 100644 index 0000000..11672fe Binary files /dev/null and b/src/frontend/public/media/track/track-agency-04.jpg differ diff --git a/src/frontend/public/media/track/track-agency-05.jpg b/src/frontend/public/media/track/track-agency-05.jpg new file mode 100644 index 0000000..a4d9d15 Binary files /dev/null and b/src/frontend/public/media/track/track-agency-05.jpg differ diff --git a/src/frontend/public/media/track/track-business-01.jpg b/src/frontend/public/media/track/track-business-01.jpg new file mode 100644 index 0000000..003208e Binary files /dev/null and b/src/frontend/public/media/track/track-business-01.jpg differ diff --git a/src/frontend/public/media/track/track-business-02.jpg b/src/frontend/public/media/track/track-business-02.jpg new file mode 100644 index 0000000..30e6db0 Binary files /dev/null and b/src/frontend/public/media/track/track-business-02.jpg differ diff --git a/src/frontend/public/media/track/track-business-03.jpg b/src/frontend/public/media/track/track-business-03.jpg new file mode 100644 index 0000000..045c101 Binary files /dev/null and b/src/frontend/public/media/track/track-business-03.jpg differ diff --git a/src/frontend/public/media/track/track-business-04.jpg b/src/frontend/public/media/track/track-business-04.jpg new file mode 100644 index 0000000..b3b24bd Binary files /dev/null and b/src/frontend/public/media/track/track-business-04.jpg differ diff --git a/src/frontend/public/media/track/track-business-05.jpg b/src/frontend/public/media/track/track-business-05.jpg new file mode 100644 index 0000000..85ba975 Binary files /dev/null and b/src/frontend/public/media/track/track-business-05.jpg differ diff --git a/src/frontend/public/media/track/track-visitor-01.jpg b/src/frontend/public/media/track/track-visitor-01.jpg new file mode 100644 index 0000000..0270e60 Binary files /dev/null and b/src/frontend/public/media/track/track-visitor-01.jpg differ diff --git a/src/frontend/public/media/track/track-visitor-02.jpg b/src/frontend/public/media/track/track-visitor-02.jpg new file mode 100644 index 0000000..ee3f4ea Binary files /dev/null and b/src/frontend/public/media/track/track-visitor-02.jpg differ diff --git a/src/frontend/public/media/track/track-visitor-03.jpg b/src/frontend/public/media/track/track-visitor-03.jpg new file mode 100644 index 0000000..387c3b5 Binary files /dev/null and b/src/frontend/public/media/track/track-visitor-03.jpg differ diff --git a/src/frontend/public/media/track/track-visitor-04.jpg b/src/frontend/public/media/track/track-visitor-04.jpg new file mode 100644 index 0000000..9e80c4b Binary files /dev/null and b/src/frontend/public/media/track/track-visitor-04.jpg differ diff --git a/src/frontend/public/media/track/track-visitor-05.jpg b/src/frontend/public/media/track/track-visitor-05.jpg new file mode 100644 index 0000000..d714325 Binary files /dev/null and b/src/frontend/public/media/track/track-visitor-05.jpg differ diff --git a/src/frontend/public/media/venue/kintex-hall-map.jpg b/src/frontend/public/media/venue/kintex-hall-map.jpg new file mode 100644 index 0000000..fcc1b66 Binary files /dev/null and b/src/frontend/public/media/venue/kintex-hall-map.jpg differ diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index b48a045..d83e304 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -58,11 +58,14 @@ import { ContractorBoothDashboardPage } from './screens/contractor/ContractorBoo import { VisitorRegistrationDashboardPage } from './screens/visitor/VisitorRegistrationDashboardPage'; import { LeadScoringPage } from './screens/visitor/LeadScoringPage'; import { CheckinDeskPage } from './screens/visitor/CheckinDeskPage'; +import { VisitorAccountPage } from './screens/visitor/VisitorAccountPage'; +import { KioskCheckinPage } from './screens/visitor/KioskCheckinPage'; import { EdmCampaignPage } from './screens/marketing/EdmCampaignPage'; import { SponsorshipPage } from './screens/marketing/SponsorshipPage'; import { CmsWorkflowPage } from './screens/cms/CmsWorkflowPage'; import { MicrositeBuilderPage } from './screens/cms/MicrositeBuilderPage'; import { MultilingualCmsPage } from './screens/cms/MultilingualCmsPage'; +import { LiveNoticePublisherPage } from './screens/cms/LiveNoticePublisherPage'; import { AuditLogPage } from './screens/admin/AuditLogPage'; import { SystemSettingsPage } from './screens/admin/SystemSettingsPage'; import { RulesetVersionsPage } from './screens/admin/RulesetVersionsPage'; @@ -87,7 +90,13 @@ import { PublicMicrositePage, PublicInquiryPage, PublicTicketPage, + PublicEventsListPage, + PublicExhibitGuidePage, + PublicVisitGuidePage, + PublicTransportPage, + PublicUnsubscribePage, } from './screens/public'; +import { PublicLiveInfoPage } from './screens/public/PublicLiveInfoPage'; import { NotFoundPage } from './screens/common/NotFoundPage'; import { AppShell } from './components/layout/AppShell'; @@ -140,8 +149,8 @@ function RequireAuth({ children }: { children: React.ReactNode }) { } /** - * 루트/미매칭 분기 — 인증=역할별 랜딩 리다이렉트, 미인증=SCR-T0 3-트랙 공개 관문 직접 렌더 - * (design.md §3C-0: 미인증 `/`는 대외 유입 관문. 2026-07-12 소유자 지적 반영). + * 루트/미매칭 분기 — 인증=역할별 랜딩 리다이렉트, 미인증=`/visitor`로 이동 + * (2026-07-23 소유자 지시: 루트 디폴트 → /visitor. 기존 3-트랙 관문(SCR-T0)은 `/gateway`로 유지·접근 가능). * 인증 시 랜딩은 completeLogin 과 동일 규칙(plan_role_routing §2-1) — ops·business=/home(회귀 0), * admin→/admin, agency→/contractor/dashboard, visitor→/visitor. 로그인 직후·`/`·catch-all 일관. */ @@ -150,7 +159,7 @@ function RootGate() { const user = useAuthStore((s) => s.user); const workspaces = useAuthStore((s) => s.workspaces); if (isAuthenticated) return ; - return ; + return ; } export function App() { @@ -270,6 +279,10 @@ export function App() { {/* SCR-30·32 관람객·리드 (M10 — 샘플) · SCR-33·34 마케팅 (M12 — 샘플) */} } /> } /> + {/* F-C6/C4/C5/B4/B5/C7 관람객 마이 (멤버십·세션·명함·결제수단·최근본·알림) */} + } /> + {/* F-C8 셀프 체크인 키오스크 (풀스크린·즉석 배지 인쇄) */} + } /> } /> } /> } /> @@ -277,6 +290,8 @@ export function App() { } /> } /> } /> + {/* F-B6 라이브 공지 발행 콘솔 (조직자) */} + } /> {/* SCR-A5~A9 관리자 모듈 (감사로그·설정·룰셋=실배선, 테넌트=샘플) — AdminGuard 가드 */} } /> } /> @@ -302,6 +317,9 @@ export function App() { } /> + {/* SCR-T0 3-트랙 공개 관문 — 루트 디폴트가 /visitor 로 이동하므로 별도 경로로 접근 유지(2026-07-23) */} + } /> + {/* SCR-T1~T3 트랙 서브홈 — 비로그인 공개, PublicShell+TrackSwitcher */} } /> } /> @@ -309,6 +327,11 @@ export function App() { {/* SCR-P1~P7 공개 홍보 사이트 — 비로그인, 자체 PublicShell */} } /> + {/* GNB 정보 페이지 (2026-07-23 — 헤더 메뉴 데드링크 해소) */} + } /> + } /> + } /> + } /> } /> } /> } /> @@ -316,6 +339,11 @@ export function App() { } /> } /> } /> + {/* F-C2/C3 현장 라이브 정보 — 주차 현황·주차권 + 대면 혼잡 안내 (공개, PublicShell 내부 래핑) */} + } /> + } /> + {/* F-C1 오픈 알림 수신거부 랜딩 (메일 링크·비로그인 공개) */} + } /> {/* 모바일 전용 (390) — 셸 없이 전폭, 조회·현장 */} ( `${layoutBase(eventId, hallId)}${version != null ? `?version=${version}` : ''}`, ), + // 배치안 버전 이력(자동생성 proposal·draft·applied) — 최신 순. + versions: (eventId: string, hallId: string) => + api.get(`${layoutBase(eventId, hallId)}/versions`), save: (eventId: string, hallId: string, body: LayoutSaveRequest) => api.put(layoutBase(eventId, hallId), body), validate: (eventId: string, hallId: string, version?: number) => diff --git a/src/frontend/src/api/types.ts b/src/frontend/src/api/types.ts index 663ac10..cec346a 100644 --- a/src/frontend/src/api/types.ts +++ b/src/frontend/src/api/types.ts @@ -255,6 +255,16 @@ export interface AutoLayoutOption { booths: BoothDto[]; // apply-option 저장 페이로드 — "이 안으로 편집 시작" 실배선 s7RenderJobId: string | null; // 항상 null — 이미지는 버튼 클릭 시 previewS7로만 생성(2026-07-14) aiSummary?: string; // ai=true 시에만: 엔진 metrics 근거 AI 장단점 요약(WISE AI). 없으면 미표시. + version?: number; // 생성 시 status='proposal' 로 영속된 layout 버전(배치 이력). 미영속 시 없음. +} + +// 배치안 버전 이력(GET /layout/versions) — status: draft|applied|proposal +export interface LayoutVersionDto { + version: number; + name: string; + status: string; + boothCount: number; + updatedAt: string; } // 자연어 조건 해석(POST /auto-generate/interpret) diff --git a/src/frontend/src/components/GridPdfButton.tsx b/src/frontend/src/components/GridPdfButton.tsx new file mode 100644 index 0000000..ec387df --- /dev/null +++ b/src/frontend/src/components/GridPdfButton.tsx @@ -0,0 +1,74 @@ +/* + * 범용 그리드 PDF 출력 버튼 — 전 데이터 그리드 공용(소유자 지시: "그리드는 항상 PDF 출력 버튼"). + * 자기완결형(전역 토스트 provider 불요): busy 상태 + 실패 시 인라인 토스트(.kx-toast) 노출. + * KxDataTable 내장 및 커스텀 .kx-table 그리드 어디서나 배치 가능. + * + * Nifty 문자 그대로: .kx-dt__csv 버튼 스타일 재사용(CSV 버튼과 동일 톤), kx 토큰. 하드코딩 0. + */ +import { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { IconDocument } from './ui/icons'; +import { downloadGridPdf, type GridPdfColumn } from './gridPdf'; + +export interface GridPdfButtonProps { + /** 리포트 제목(= 화면 타이틀, 파일명 base). */ + title: string; + subtitle?: string; + columns: GridPdfColumn[]; + /** 현재 표시 중인 행(필터·정렬 반영). 지연 생성 위해 함수도 허용. */ + rows: Record[] | (() => Record[]); + disabled?: boolean; + className?: string; + /** 버튼 라벨(기본 datatable.pdf). */ + label?: string; +} + +export function GridPdfButton({ + title, + subtitle, + columns, + rows, + disabled, + className = '', + label, +}: GridPdfButtonProps) { + const { t } = useTranslation(); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(null); + + const onClick = useCallback(async () => { + if (busy) return; + setBusy(true); + setErr(null); + try { + const data = typeof rows === 'function' ? rows() : rows; + await downloadGridPdf({ title, subtitle, columns, rows: data }); + } catch (e) { + const msg = e instanceof Error ? e.message : t('datatable.pdfError', { defaultValue: 'PDF 출력에 실패했습니다.' }); + setErr(msg); + window.setTimeout(() => setErr(null), 2600); + } finally { + setBusy(false); + } + }, [busy, rows, title, subtitle, columns, t]); + + return ( + <> + + {err && ( +
+ {err} +
+ )} + + ); +} diff --git a/src/frontend/src/components/KxDataTable.tsx b/src/frontend/src/components/KxDataTable.tsx index 549ad09..c13e7df 100644 --- a/src/frontend/src/components/KxDataTable.tsx +++ b/src/frontend/src/components/KxDataTable.tsx @@ -21,6 +21,8 @@ import { useMemo, useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { EmptyState } from './ui/States'; import { IconDownload, IconSearch, IconSort } from './ui/icons'; +import { GridPdfButton } from './GridPdfButton'; +import type { GridPdfColumn } from './gridPdf'; import './data-table.css'; export type KxCellValue = string | number | null | undefined; @@ -71,6 +73,14 @@ export interface KxDataTableProps { initialPageSize?: number; /** 지정 시 CSV 내보내기 버튼 노출 — 다운로드 파일명(확장자 제외). */ csvFileName?: string; + /** + * PDF 출력(JasperReports) 리포트 제목(= 파일명 base). 미지정 시 ariaLabel 로 폴백. + * title 또는 ariaLabel 이 있으면 PDF 버튼 자동 노출(소유자 지시: 모든 그리드 PDF 버튼). + */ + pdfTitle?: string; + pdfSubtitle?: string; + /** PDF 버튼 노출 여부(기본: pdfTitle 또는 ariaLabel 존재 시 true). */ + enablePdf?: boolean; /** 툴바 좌측(외부 필터 등) · 우측 추가 슬롯. */ toolbarStart?: ReactNode; toolbarEnd?: ReactNode; @@ -115,6 +125,9 @@ export function KxDataTable({ pageSizeOptions = [10, 25, 50], initialPageSize, csvFileName, + pdfTitle, + pdfSubtitle, + enablePdf, toolbarStart, toolbarEnd, ariaLabel, @@ -200,7 +213,31 @@ export function KxDataTable({ URL.revokeObjectURL(url); }; - const showToolbar = searchable || !!csvFileName || !!toolbarStart || !!toolbarEnd; + // ── PDF(JasperReports) — CSV 와 동일 컬럼 집합·정렬 결과를 전송 ── + const resolvedPdfTitle = pdfTitle ?? ariaLabel ?? csvFileName ?? ''; + const showPdf = (enablePdf ?? (!!resolvedPdfTitle)) && !!resolvedPdfTitle; + const pdfCols: GridPdfColumn[] = useMemo( + () => + columns + .filter((c) => c.value && c.csv !== false) + .map((c) => ({ + key: c.key, + label: c.header, + align: c.numeric ? ('right' as const) : ('left' as const), + })), + [columns], + ); + const buildPdfRows = () => + sorted.map((r) => { + const o: Record = {}; + for (const c of columns) { + if (c.value && c.csv !== false) o[c.key] = c.value(r); + } + return o; + }); + + const showToolbar = + searchable || !!csvFileName || showPdf || !!toolbarStart || !!toolbarEnd; return (
@@ -233,6 +270,15 @@ export function KxDataTable({ {t('datatable.csv')} )} + {showPdf && ( + + )}
)} diff --git a/src/frontend/src/components/gridPdf.ts b/src/frontend/src/components/gridPdf.ts new file mode 100644 index 0000000..ae5be25 Binary files /dev/null and b/src/frontend/src/components/gridPdf.ts differ diff --git a/src/frontend/src/components/layout/AppShell.tsx b/src/frontend/src/components/layout/AppShell.tsx index 9bbdae8..0709c6e 100644 --- a/src/frontend/src/components/layout/AppShell.tsx +++ b/src/frontend/src/components/layout/AppShell.tsx @@ -9,6 +9,7 @@ import { useMdiStore } from '../../store/mdiStore'; import { useUiStore, isMobileViewport } from '../../store/uiStore'; import { useIsAdmin } from '../../screens/admin/AdminGuard'; import { MdiTabBar } from './MdiTabBar'; +import { TenantSwitcher } from './TenantSwitcher'; import { Breadcrumb } from './Breadcrumb'; import { ShellFooter } from './ShellFooter'; import { SettingsCustomizer } from './SettingsCustomizer'; @@ -136,6 +137,7 @@ const GROUPS: NavGroupDef[] = [ { key: 'campaigns', label: '마케팅', labelKey: 'shell.menu.items.campaigns', Icon: IconBell, to: '/campaigns' }, { key: 'sponsorship', label: '스폰서십', labelKey: 'shell.menu.items.sponsorship', Icon: IconSettlement, to: '/sponsorship' }, { key: 'cms', label: '콘텐츠 CMS', labelKey: 'shell.menu.items.cms', Icon: IconDocument, to: '/cms' }, + { key: 'liveNotices', label: '라이브 공지', labelKey: 'shell.menu.items.liveNotices', Icon: IconBell, to: '/cms/live-notices' }, ], }, { @@ -625,6 +627,7 @@ export function AppShell() {
+ {workspace && } + +
{children}
+ {footer &&
{footer}
} +
+ + ); +} diff --git a/src/frontend/src/components/ui/brand-logo.css b/src/frontend/src/components/ui/brand-logo.css new file mode 100644 index 0000000..da109df --- /dev/null +++ b/src/frontend/src/components/ui/brand-logo.css @@ -0,0 +1,25 @@ +/* + * KINTEX 브랜드 워드마크 — 컨텍스트별 크기 규격 단일 출처(소유자 지시 2026-07-24 "로고 전체 통일·크기 조정"). + * 정식 CI 워드마크 = /brand/kintex-wordmark.jpg. 임의 px 산재 금지 — 반드시 아래 규격 클래스 사용. + * header : 셸/공개 헤더 28px + * auth : 로그인·2FA 32px + * footer : 풋터 22px + * print : 인쇄·키오스크 38px + */ +.kx-brandmark { + width: auto; + display: block; + object-fit: contain; +} +.kx-brandmark--header { + height: 28px; +} +.kx-brandmark--auth { + height: 32px; +} +.kx-brandmark--footer { + height: 22px; +} +.kx-brandmark--print { + height: 38px; +} diff --git a/src/frontend/src/components/ui/modal.css b/src/frontend/src/components/ui/modal.css new file mode 100644 index 0000000..d277cf9 --- /dev/null +++ b/src/frontend/src/components/ui/modal.css @@ -0,0 +1,131 @@ +/* + * 공용 Modal(중앙 다이얼로그) 스타일 — Nifty 모달 패턴(DESIGN_SYSTEM_NIFTY §2.6). + * 전역 디자인 토큰(--color-*·--space-*·--radius-*·--fs-*·--fw-*)만 사용. kx-modal2__* 네임스페이스. + */ +.kx-modal2 { + position: fixed; + inset: 0; + z-index: 70; + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-4); +} + +/* 백드롭 — 페이드 인/아웃 */ +.kx-modal2__backdrop { + position: absolute; + inset: 0; + background: rgba(15, 23, 42, 0.45); + opacity: 0; + transition: opacity 0.2s ease; +} +.kx-modal2.is-open .kx-modal2__backdrop { + opacity: 1; +} + +/* 패널 — 페이드 + 살짝 스케일 인 */ +.kx-modal2__panel { + position: relative; + z-index: 1; + width: 100%; + max-height: calc(100vh - 2 * var(--space-4)); + display: flex; + flex-direction: column; + background: var(--color-white); + border: var(--border-card); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-modal, 0 20px 48px rgba(15, 23, 42, 0.24)); + outline: none; + opacity: 0; + transform: translateY(8px) scale(0.98); + transition: opacity 0.2s ease, transform 0.2s cubic-bezier(0.32, 0.72, 0, 1); + will-change: transform, opacity; +} +.kx-modal2.is-open .kx-modal2__panel { + opacity: 1; + transform: translateY(0) scale(1); +} +.kx-modal2__panel--sm { max-width: 420px; } +.kx-modal2__panel--md { max-width: 560px; } +.kx-modal2__panel--lg { max-width: 820px; } +.kx-modal2__panel--xl { max-width: min(1160px, 96vw); } + +/* 헤더 */ +.kx-modal2__head { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-4); + border-bottom: var(--border-card); + flex: 0 0 auto; +} +.kx-modal2__title { + flex: 1 1 auto; + min-width: 0; + font-size: var(--fs-h3, var(--fs-body)); + font-weight: var(--fw-semibold); + color: var(--color-neutral-900); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.kx-modal2__headextra { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: var(--space-2); +} +.kx-modal2__close { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-neutral-500); + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; +} +.kx-modal2__close:hover { + background: var(--color-neutral-100); + color: var(--color-neutral-900); +} +.kx-modal2__close:focus-visible { + outline: 2px solid var(--color-brand-500, #1f29fc); + outline-offset: 1px; +} + +/* 바디(스크롤) */ +.kx-modal2__body { + flex: 1 1 auto; + overflow-y: auto; + padding: var(--space-5); +} +.kx-modal2__body.is-flush { + padding: 0; +} + +/* 푸터(고정, 액션 우측) */ +.kx-modal2__foot { + flex: 0 0 auto; + display: flex; + justify-content: flex-end; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); + border-top: var(--border-card); +} + +/* 모션 최소화 존중 */ +@media (prefers-reduced-motion: reduce) { + .kx-modal2__backdrop, + .kx-modal2__panel { + transition: none; + } + .kx-modal2__panel { + transform: none; + } +} diff --git a/src/frontend/src/i18n/locales/en.json b/src/frontend/src/i18n/locales/en.json index 7ca96e5..53326b6 100644 --- a/src/frontend/src/i18n/locales/en.json +++ b/src/frontend/src/i18n/locales/en.json @@ -96,7 +96,8 @@ "tenants": "Tenants", "rulesets": "Rulesets", "slides": "Login Slides", - "msgTemplates": "Messages" + "msgTemplates": "Messages", + "liveNotices": "Live Notices" }, "sections": { "userOrg": "Users & Org", @@ -812,6 +813,7 @@ "feedAria": "Notification feed", "feedTitle": "Notifications", "feedEmpty": "No new notifications", + "viewAll": "View all ({{n}})", "aerialAria": "Hall aerial view", "aerialTitle": "Hall aerial view", "aerialShot": "S7 Hall aerial", @@ -1308,7 +1310,11 @@ "sensorNoneDesc": "HVAC · lighting IoT · parking (iparking) integration is shown once sensors (M14) are connected.", "temp": "Temp", "humidity": "Humidity", - "parkingSlots": "{{zone}}: {{occ}}/{{cap}} slots" + "parkingSlots": "{{zone}}: {{occ}}/{{cap}} slots", + "hallPlanZoomAria": "Enlarge {{label}} floor plan", + "hallPlanAlt": "{{label}} floor plan", + "hallPlanTitle": "{{label}} floor plan (CAD measured)", + "hallPlanZoomAlt": "{{label}} floor plan enlarged" }, "admin": { "deniedTitle": "Access denied", @@ -1342,7 +1348,16 @@ "modSettings": "System settings", "modRulesets": "Rulesets", "modTenants": "Tenants", - "modMessages": "Messages" + "modMessages": "Messages", + "heat": { + "smooth": "Smooth", + "moderate": "Moderate", + "busy": "Busy" + }, + "onSite": "On-site {{n}}", + "recentActivity": "Recent activity", + "activityEmptyTitle": "No recent activity", + "activityEmptyDesc": "No administrative activity recorded yet." }, "tenant": { "title": "Tenant Management · Onboarding", @@ -2477,7 +2492,9 @@ "countTo": "5", "suffix": "M+" } - } + }, + "ctaLive": "Live on-site (crowd & parking)", + "ctaAccount": "My account (membership, sessions, cards)" }, "business": { "heroAria": "Business hero", @@ -2758,7 +2775,11 @@ "emptyFiltered": "No results match your search", "sortAsc": "Sort ascending", "sortDesc": "Sort descending", - "sortClear": "Clear sort" + "sortClear": "Clear sort", + "pdf": "PDF", + "pdfTitle": "Export PDF", + "pdfBusy": "Exporting…", + "pdfError": "PDF export failed." }, "breadcrumb": { "aria": "Breadcrumb", @@ -2777,7 +2798,9 @@ "compliance": "Compliance Check", "approvals": "Approval Queue", "reviewDetail": "Review Detail", - "gallery": "Visualization Gallery" + "gallery": "Visualization Gallery", + "kioskCheckin": "Self check-in", + "visitorAccount": "My account" }, "notFound": { "title": "Page not found", @@ -2825,5 +2848,52 @@ "rendering": "Rendering…", "renderResult": "Result", "noParams": "This template has no parameters" + }, + "publicInfo": { + "events": { + "sub": "Search ongoing and upcoming events and pre-register.", + "resultFor": "{{n}} results for ‘{{q}}’" + }, + "exhibit": { + "sub": "Booth application, construction and operation guide for exhibitors.", + "empty": "No exhibitor guide content yet. Please contact us for assistance." + }, + "visit": { + "sub": "Admission, amenities and accessibility information for visitors.", + "empty": "No visitor guide available." + }, + "transport": { + "sub": "Public transit, driving and shuttle guide", + "empty": "No transportation info available." + } + }, + "liveNotice": { + "feedTitle": "Live Updates", + "live": "LIVE", + "pinned": "Pinned", + "cat": { + "urgent": "Urgent", + "program": "Program", + "info": "Info", + "general": "General" + } + }, + "subscribe": { + "title": "Get open alerts", + "sub": "Be the first to know by email when ticket sales open.", + "email": "Email", + "consent": "I agree to the collection and use of my personal data to receive open alerts.", + "submit": "Subscribe", + "submitting": "Subscribing…", + "errPrivacy": "Please agree to the personal data terms to subscribe.", + "errEmail": "Please enter a valid email address.", + "done": "We'll email {{email}} when tickets open.", + "already": "{{email}} is already subscribed to open alerts.", + "unsubNote": "You can unsubscribe anytime via the link at the bottom of the alert email.", + "unsub": { + "title": "Unsubscribe from open alerts", + "noToken": "This unsubscribe link is invalid.", + "browse": "Browse events" + } } -} \ No newline at end of file +} diff --git a/src/frontend/src/i18n/locales/ja.json b/src/frontend/src/i18n/locales/ja.json index 61894ce..eebb777 100644 --- a/src/frontend/src/i18n/locales/ja.json +++ b/src/frontend/src/i18n/locales/ja.json @@ -96,7 +96,8 @@ "tenants": "テナント", "rulesets": "ルールセット", "slides": "ログインスライド", - "msgTemplates": "メッセージ管理" + "msgTemplates": "メッセージ管理", + "liveNotices": "ライブ告知" }, "sections": { "userOrg": "ユーザー・組織", @@ -812,6 +813,7 @@ "feedAria": "通知フィード", "feedTitle": "通知", "feedEmpty": "新しい通知はありません", + "viewAll": "すべて表示 ({{n}})", "aerialAria": "ホール全景俯瞰", "aerialTitle": "ホール全景俯瞰", "aerialShot": "S7 ホール全景", @@ -1308,7 +1310,11 @@ "sensorNoneDesc": "空調(HVAC)·照明 IoT·駐車(iparking)連携はセンサー(M14)連携時に表示されます。", "temp": "温度", "humidity": "湿度", - "parkingSlots": "{{zone}}: {{occ}}/{{cap}}台" + "parkingSlots": "{{zone}}: {{occ}}/{{cap}}台", + "hallPlanZoomAria": "{{label}}平面図を拡大表示", + "hallPlanAlt": "{{label}}平面図", + "hallPlanTitle": "{{label}}平面図(CAD実測)", + "hallPlanZoomAlt": "{{label}}平面図拡大" }, "admin": { "deniedTitle": "アクセス権限がありません", @@ -1342,7 +1348,16 @@ "modSettings": "システム設定", "modRulesets": "ルールセット", "modTenants": "テナント", - "modMessages": "メッセージ管理" + "modMessages": "メッセージ管理", + "heat": { + "smooth": "円滑", + "moderate": "普通", + "busy": "混雑" + }, + "onSite": "現地チェックイン {{n}}名", + "recentActivity": "最近の活動", + "activityEmptyTitle": "最近の活動なし", + "activityEmptyDesc": "記録された管理活動はまだありません。" }, "tenant": { "title": "テナント管理·オンボーディング", @@ -2477,7 +2492,9 @@ "countTo": "500", "suffix": "万+" } - } + }, + "ctaLive": "現地ライブ(混雑・駐車)", + "ctaAccount": "観覧者マイ(会員・セッション・名刺)" }, "business": { "heroAria": "ビジネス案内ヒーロー", @@ -2758,7 +2775,11 @@ "emptyFiltered": "検索条件に一致する結果がありません", "sortAsc": "昇順で並べ替え", "sortDesc": "降順で並べ替え", - "sortClear": "並べ替え解除" + "sortClear": "並べ替え解除", + "pdf": "PDF", + "pdfTitle": "PDF出力", + "pdfBusy": "出力中…", + "pdfError": "PDF出力に失敗しました。" }, "breadcrumb": { "aria": "パンくず", @@ -2777,7 +2798,9 @@ "compliance": "規定チェック", "approvals": "審査キュー", "reviewDetail": "審査詳細", - "gallery": "ビジュアライゼーションギャラリー" + "gallery": "ビジュアライゼーションギャラリー", + "kioskCheckin": "セルフチェックイン", + "visitorAccount": "観覧者マイ" }, "notFound": { "title": "ページが見つかりません", @@ -2825,5 +2848,52 @@ "rendering": "レンダリング中…", "renderResult": "レンダリング結果", "noParams": "置換パラメータのないテンプレートです" + }, + "publicInfo": { + "events": { + "sub": "開催中・予定のイベントを検索して事前登録できます。", + "resultFor": "「{{q}}」の検索結果 {{n}}件" + }, + "exhibit": { + "sub": "出展社向けのブース申請・施工・運営案内です。", + "empty": "参加案内コンテンツはまだありません。お問い合わせください。" + }, + "visit": { + "sub": "入場・施設・アクセシビリティなど観覧に必要な案内です。", + "empty": "観覧案内はありません。" + }, + "transport": { + "sub": "公共交通・自家用車・シャトル案内", + "empty": "交通案内はありません。" + } + }, + "liveNotice": { + "feedTitle": "ライブ告知", + "live": "LIVE", + "pinned": "固定", + "cat": { + "urgent": "緊急", + "program": "プログラム", + "info": "案内", + "general": "一般" + } + }, + "subscribe": { + "title": "オープン通知を受け取る", + "sub": "チケット販売開始時にメールでいち早くお知らせします。", + "email": "メール", + "consent": "オープン通知の受信のための個人情報の収集・利用に同意します。", + "submit": "通知を申し込む", + "submitting": "申込中…", + "errPrivacy": "購読には個人情報の取り扱い同意が必要です。", + "errEmail": "正しいメールアドレスを入力してください。", + "done": "チケット販売開始時に {{email}} へ通知します。", + "already": "{{email}} は既にオープン通知に登録済みです。", + "unsubNote": "通知メール下部の配信停止リンクからいつでも解除できます。", + "unsub": { + "title": "オープン通知の配信停止", + "noToken": "無効な配信停止リンクです。", + "browse": "イベントを見る" + } } -} \ No newline at end of file +} diff --git a/src/frontend/src/i18n/locales/ko.json b/src/frontend/src/i18n/locales/ko.json index 51cb256..623f21c 100644 --- a/src/frontend/src/i18n/locales/ko.json +++ b/src/frontend/src/i18n/locales/ko.json @@ -96,7 +96,8 @@ "tenants": "테넌트", "rulesets": "룰셋", "slides": "로그인 슬라이드", - "msgTemplates": "메시지 관리" + "msgTemplates": "메시지 관리", + "liveNotices": "라이브 공지" }, "sections": { "userOrg": "사용자·조직", @@ -812,6 +813,7 @@ "feedAria": "알림 피드", "feedTitle": "알림", "feedEmpty": "새 알림이 없습니다", + "viewAll": "전체보기 ({{n}})", "aerialAria": "홀 전경 조감", "aerialTitle": "홀 전경 조감", "aerialShot": "S7 홀 전경", @@ -1308,7 +1310,11 @@ "sensorNoneDesc": "공조(HVAC)·조명 IoT·주차(iparking) 연계는 센서(M14) 연동 시 표시됩니다.", "temp": "온도", "humidity": "습도", - "parkingSlots": "{{zone}}: {{occ}}/{{cap}}면" + "parkingSlots": "{{zone}}: {{occ}}/{{cap}}면", + "hallPlanZoomAria": "{{label}} 평면도 크게 보기", + "hallPlanAlt": "{{label}} 평면도", + "hallPlanTitle": "{{label}} 평면도 (CAD 실측)", + "hallPlanZoomAlt": "{{label}} 평면도 확대" }, "admin": { "deniedTitle": "접근 권한이 없습니다", @@ -1342,7 +1348,16 @@ "modSettings": "시스템설정", "modRulesets": "룰셋", "modTenants": "테넌트", - "modMessages": "메시지 관리" + "modMessages": "메시지 관리", + "heat": { + "smooth": "원활", + "moderate": "보통", + "busy": "혼잡" + }, + "onSite": "현장 체크인 {{n}}명", + "recentActivity": "최근 활동", + "activityEmptyTitle": "최근 활동 없음", + "activityEmptyDesc": "기록된 관리 활동이 아직 없습니다." }, "tenant": { "title": "테넌트 관리·온보딩", @@ -2477,7 +2492,9 @@ "countTo": "500", "suffix": "만+" } - } + }, + "ctaLive": "현장 라이브(혼잡·주차)", + "ctaAccount": "관람객 마이(멤버십·세션·명함)" }, "business": { "heroAria": "비즈니스 안내 히어로", @@ -2758,7 +2775,11 @@ "emptyFiltered": "검색 조건에 맞는 결과가 없습니다", "sortAsc": "오름차순 정렬", "sortDesc": "내림차순 정렬", - "sortClear": "정렬 해제" + "sortClear": "정렬 해제", + "pdf": "PDF", + "pdfTitle": "PDF 출력", + "pdfBusy": "출력 중…", + "pdfError": "PDF 출력에 실패했습니다." }, "breadcrumb": { "aria": "경로", @@ -2777,7 +2798,9 @@ "compliance": "규정 검증", "approvals": "검수 대기열", "reviewDetail": "검수 상세", - "gallery": "시각화 갤러리" + "gallery": "시각화 갤러리", + "kioskCheckin": "셀프 체크인", + "visitorAccount": "관람객 마이" }, "notFound": { "title": "페이지를 찾을 수 없습니다", @@ -2825,5 +2848,52 @@ "rendering": "렌더 중…", "renderResult": "렌더 결과", "noParams": "치환할 파라미터가 없는 템플릿입니다" + }, + "publicInfo": { + "events": { + "sub": "진행 중·예정 행사를 검색하고 사전등록하세요.", + "resultFor": "‘{{q}}’ 검색 결과 {{n}}건" + }, + "exhibit": { + "sub": "참가업체를 위한 부스 신청·시공·운영 안내입니다.", + "empty": "등록된 참가안내 콘텐츠가 없습니다. 문의하기로 상담을 요청하세요." + }, + "visit": { + "sub": "입장·편의시설·접근성 등 관람에 필요한 안내입니다.", + "empty": "등록된 관람안내가 없습니다." + }, + "transport": { + "sub": "대중교통·자가용·셔틀 안내", + "empty": "등록된 교통 안내가 없습니다." + } + }, + "liveNotice": { + "feedTitle": "라이브 공지", + "live": "LIVE", + "pinned": "고정", + "cat": { + "urgent": "긴급", + "program": "프로그램", + "info": "안내", + "general": "일반" + } + }, + "subscribe": { + "title": "오픈 알림 받기", + "sub": "티켓 판매가 시작되면 이메일로 가장 먼저 알려드립니다.", + "email": "이메일", + "consent": "오픈 알림 수신을 위한 개인정보 수집·이용에 동의합니다.", + "submit": "알림 신청", + "submitting": "신청 중…", + "errPrivacy": "구독 알림 수신을 위한 개인정보 수집·이용 동의가 필요합니다.", + "errEmail": "올바른 이메일 주소를 입력해 주세요.", + "done": "{{email}} 주소로 티켓 오픈 시 알림을 보내드립니다.", + "already": "{{email}} 주소는 이미 오픈 알림을 신청하셨습니다.", + "unsubNote": "알림 메일 하단의 수신거부 링크로 언제든 해지할 수 있습니다.", + "unsub": { + "title": "오픈 알림 수신거부", + "noToken": "유효하지 않은 수신거부 링크입니다.", + "browse": "행사 둘러보기" + } } -} \ No newline at end of file +} diff --git a/src/frontend/src/i18n/locales/zh.json b/src/frontend/src/i18n/locales/zh.json index 771a682..7716e1e 100644 --- a/src/frontend/src/i18n/locales/zh.json +++ b/src/frontend/src/i18n/locales/zh.json @@ -96,7 +96,8 @@ "tenants": "租户", "rulesets": "规则集", "slides": "登录幻灯片", - "msgTemplates": "消息管理" + "msgTemplates": "消息管理", + "liveNotices": "实时公告" }, "sections": { "userOrg": "用户·组织", @@ -812,6 +813,7 @@ "feedAria": "通知动态", "feedTitle": "通知", "feedEmpty": "暂无新通知", + "viewAll": "查看全部 ({{n}})", "aerialAria": "展厅鸟瞰", "aerialTitle": "展厅鸟瞰", "aerialShot": "S7 展厅鸟瞰", @@ -1308,7 +1310,11 @@ "sensorNoneDesc": "空调(HVAC)·照明 IoT·停车(iparking)联动将在接入传感器(M14)后显示。", "temp": "温度", "humidity": "湿度", - "parkingSlots": "{{zone}}: {{occ}}/{{cap}} 车位" + "parkingSlots": "{{zone}}: {{occ}}/{{cap}} 车位", + "hallPlanZoomAria": "放大查看{{label}}平面图", + "hallPlanAlt": "{{label}}平面图", + "hallPlanTitle": "{{label}}平面图(CAD实测)", + "hallPlanZoomAlt": "{{label}}平面图放大" }, "admin": { "deniedTitle": "无访问权限", @@ -1342,7 +1348,16 @@ "modSettings": "系统设置", "modRulesets": "规则集", "modTenants": "租户", - "modMessages": "消息管理" + "modMessages": "消息管理", + "heat": { + "smooth": "顺畅", + "moderate": "一般", + "busy": "拥挤" + }, + "onSite": "现场签到 {{n}}人", + "recentActivity": "最近活动", + "activityEmptyTitle": "暂无最近活动", + "activityEmptyDesc": "尚无记录的管理活动。" }, "tenant": { "title": "租户管理·上线", @@ -2477,7 +2492,9 @@ "countTo": "500", "suffix": "万+" } - } + }, + "ctaLive": "现场实况(拥挤·停车)", + "ctaAccount": "观众我的(会员·场次·名片)" }, "business": { "heroAria": "商务介绍", @@ -2758,7 +2775,11 @@ "emptyFiltered": "没有符合条件的结果", "sortAsc": "升序排序", "sortDesc": "降序排序", - "sortClear": "取消排序" + "sortClear": "取消排序", + "pdf": "PDF", + "pdfTitle": "导出 PDF", + "pdfBusy": "导出中…", + "pdfError": "PDF 导出失败。" }, "breadcrumb": { "aria": "路径", @@ -2777,7 +2798,9 @@ "compliance": "合规检查", "approvals": "审核队列", "reviewDetail": "审核详情", - "gallery": "可视化画廊" + "gallery": "可视化画廊", + "kioskCheckin": "自助签到", + "visitorAccount": "观众我的" }, "notFound": { "title": "找不到页面", @@ -2825,5 +2848,52 @@ "rendering": "渲染中…", "renderResult": "渲染结果", "noParams": "此模板没有可替换参数" + }, + "publicInfo": { + "events": { + "sub": "搜索进行中及即将举办的活动并预登记。", + "resultFor": "“{{q}}”的搜索结果 {{n}} 条" + }, + "exhibit": { + "sub": "面向参展商的展位申请、施工及运营指南。", + "empty": "暂无参展指南内容。请通过咨询联系我们。" + }, + "visit": { + "sub": "入场、便利设施及无障碍等观展所需信息。", + "empty": "暂无观展指南。" + }, + "transport": { + "sub": "公共交通、自驾及班车指南", + "empty": "暂无交通信息。" + } + }, + "liveNotice": { + "feedTitle": "实时公告", + "live": "直播中", + "pinned": "置顶", + "cat": { + "urgent": "紧急", + "program": "日程", + "info": "通知", + "general": "一般" + } + }, + "subscribe": { + "title": "获取开售通知", + "sub": "门票开售时第一时间通过邮件通知您。", + "email": "邮箱", + "consent": "我同意为接收开售通知而收集和使用个人信息。", + "submit": "订阅通知", + "submitting": "订阅中…", + "errPrivacy": "订阅需同意个人信息条款。", + "errEmail": "请输入有效的邮箱地址。", + "done": "门票开售时将通知 {{email}}。", + "already": "{{email}} 已订阅开售通知。", + "unsubNote": "可随时通过通知邮件底部的退订链接取消。", + "unsub": { + "title": "退订开售通知", + "noToken": "退订链接无效。", + "browse": "浏览活动" + } } -} \ No newline at end of file +} diff --git a/src/frontend/src/screens/admin/AdminDashboardPage.tsx b/src/frontend/src/screens/admin/AdminDashboardPage.tsx index a1cf1a5..80c2fe8 100644 --- a/src/frontend/src/screens/admin/AdminDashboardPage.tsx +++ b/src/frontend/src/screens/admin/AdminDashboardPage.tsx @@ -13,11 +13,13 @@ import { } from 'recharts'; import { CHART } from '../chartColors'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; -import { IconClock, IconOperations } from '../../components/ui/icons'; +import { IconClock, IconList, IconOperations } from '../../components/ui/icons'; import { adminApi } from '../../api/endpoints'; import { ApiRequestError } from '../../api/client'; import { StatusPill } from '../work/workShared'; -import type { AdminDashboardData, AdminStats } from '../../api/types'; +import type { AdminStats } from '../../api/types'; +import type { AdminDashboardExt } from './adminDashboardTypes'; +import { heatClass } from './adminDashboardTypes'; import { ADMIN_KPIS, LIVE_EVENTS, TENANTS, VISITOR_TREND } from './sampleAdmin'; import './admin.css'; @@ -45,7 +47,7 @@ export function AdminDashboardPage() { }); const degraded = isDegradable(q.error); - const data: AdminDashboardData | null = q.data ?? (degraded ? FALLBACK : null); + const data: AdminDashboardExt | null = (q.data as AdminDashboardExt | undefined) ?? (degraded ? FALLBACK : null); const hardError = q.isError && !degraded; const statsDegraded = isDegradable(sq.error); @@ -168,6 +170,9 @@ export function AdminDashboardPage() {

{e.name}

{e.hall ?? t('admin.hallTbd')}

@@ -177,6 +182,12 @@ export function AdminDashboardPage() { {t('admin.occupancy', { n: clamp(e.occupancy) })} +

+ {t('admin.onSite', { + defaultValue: '현장 체크인 {{n}}명', + n: (e.onSiteCount ?? 0).toLocaleString(), + })} +

))} @@ -184,12 +195,55 @@ export function AdminDashboardPage() { )} + + {/* 최근 활동 — audit_log 실 기록(민감정보 미포함). 원천 부재 시 빈 상태(정직). */} +
+
+

{t('admin.recentActivity', { defaultValue: '최근 활동' })}

+
+ {(data.recentActivity?.length ?? 0) === 0 ? ( + + ) : ( +
    + {data.recentActivity!.map((a, i) => ( +
  • +
  • + ))} +
+ )} +
)} ); } +/** 혼잡 등급 한국어 라벨(i18n defaultValue 폴백). */ +function heatLabel(heat: 'smooth' | 'moderate' | 'busy'): string { + return heat === 'busy' ? '혼잡' : heat === 'moderate' ? '보통' : '원활'; +} + +/** ISO(UTC) → 로컬 'MM-DD HH:mm' 표기. 파싱 실패 시 원문 반환. */ +function formatTs(iso: string): string { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + const p = (n: number) => String(n).padStart(2, '0'); + return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; +} + const TOOLTIP_STYLE = { fontSize: 12, borderRadius: 8, @@ -225,7 +279,7 @@ function isDegradable(error: unknown): boolean { * 샘플 폴백(백엔드 shape 매핑) — 정상 경로는 실 API. NETWORK/NOT_FOUND 시에만 사용. * 근거: 기존 sampleAdmin.ts(집계·센서 미구축 시연 데이터)를 AdminDashboardData 로 강등 변환. */ -const FALLBACK: AdminDashboardData = { +const FALLBACK: AdminDashboardExt = { kpis: ADMIN_KPIS.map((k) => ({ label: k.label, value: k.value, sub: k.delta ?? null })), visitorTrend: VISITOR_TREND.map((p) => ({ hour: p.hour, count: p.today })), liveEvents: LIVE_EVENTS.filter((e) => e.live).map((e) => ({ @@ -234,8 +288,12 @@ const FALLBACK: AdminDashboardData = { hall: e.hallTime, occupancy: e.progress ?? 60, status: 'ongoing', + // 폴백은 오프라인 시연치 — onSiteCount/heat 는 실집계 부재를 정직 반영(0/원활). + onSiteCount: 0, + heat: 'smooth', })), tenants: TENANTS, + recentActivity: [], }; /** 집계 지표 강등 폴백 — NETWORK/NOT_FOUND 시에만(정상 경로는 실 API). degraded 배너와 함께 노출. */ diff --git a/src/frontend/src/screens/admin/AuditLogPage.tsx b/src/frontend/src/screens/admin/AuditLogPage.tsx index 70f2ab2..488eb53 100644 --- a/src/frontend/src/screens/admin/AuditLogPage.tsx +++ b/src/frontend/src/screens/admin/AuditLogPage.tsx @@ -11,8 +11,10 @@ import { Trans, useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { Button } from '../../components/ui/Button'; -import { IconDownload, IconSearch } from '../../components/ui/icons'; +import { IconDownload, IconList, IconSearch } from '../../components/ui/icons'; import { auditApi, KNOWN_AUDIT_ACTIONS, type AuditLogRow } from './adminModulesApi'; +import { GridPdfButton } from '../../components/GridPdfButton'; +import '../../components/data-table.css'; import './admin-modules.css'; const PAGE_SIZES = [20, 50, 100]; @@ -50,6 +52,24 @@ export function AuditLogPage() { const from = total === 0 ? 0 : page * size + 1; const to = Math.min(total, (page + 1) * size); + const pdfColumns = [ + { key: 'time', label: t('audit.colTime') }, + { key: 'user', label: t('audit.colUser') }, + { key: 'action', label: t('audit.colAction') }, + { key: 'target', label: t('audit.colTarget') }, + { key: 'event', label: t('audit.colEvent') }, + { key: 'result', label: t('audit.colResult') }, + ]; + const pdfRows = () => + rows.map((r) => ({ + time: fmtTs(r.createdAt), + user: r.actorName ?? r.actorId ?? '—', + action: r.summary ? `${r.action ?? '—'} · ${r.summary}` : r.action ?? '—', + target: r.targetType ? `${r.targetType}${r.targetId ? ` #${r.targetId}` : ''}` : '—', + event: r.eventId ?? '—', + result: r.result ?? '—', + })); + function applyFilters() { setPage(0); setApplied({ action, actorId, eventId }); @@ -66,7 +86,7 @@ export function AuditLogPage() {
-

{t('audit.title')}

+

{t('audit.title')}

{t('audit.subtitle')}

@@ -144,6 +164,11 @@ export function AuditLogPage() { /> ) : ( <> +
+
+ +
+
diff --git a/src/frontend/src/screens/admin/AuthPolicyPage.tsx b/src/frontend/src/screens/admin/AuthPolicyPage.tsx index 3d9218e..848b660 100644 --- a/src/frontend/src/screens/admin/AuthPolicyPage.tsx +++ b/src/frontend/src/screens/admin/AuthPolicyPage.tsx @@ -6,6 +6,7 @@ import { useEffect, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; +import { IconShieldCheck } from '../../components/ui/icons'; import { ErrorState, Skeleton } from '../../components/ui/States'; import { Button } from '../../components/ui/Button'; import { errMessage, useToast } from '../work/workShared'; @@ -49,7 +50,7 @@ export function AuthPolicyPage() {
-

{t('authPolicy.title')}

+

{t('authPolicy.title')}

{t('authPolicy.subtitle')}

diff --git a/src/frontend/src/screens/admin/CommonCodeAdminPage.tsx b/src/frontend/src/screens/admin/CommonCodeAdminPage.tsx index 336737f..7c7da51 100644 --- a/src/frontend/src/screens/admin/CommonCodeAdminPage.tsx +++ b/src/frontend/src/screens/admin/CommonCodeAdminPage.tsx @@ -10,7 +10,7 @@ import { useTranslation } from 'react-i18next'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { Button } from '../../components/ui/Button'; import { KxDataTable, type KxColumn } from '../../components/KxDataTable'; -import { IconPlus } from '../../components/ui/icons'; +import { IconList, IconPlus } from '../../components/ui/icons'; import { errMessage, useToast } from '../work/workShared'; import { codeApi, type Code, type CodeGroup } from './adminCrudApi'; import './admin-modules.css'; @@ -120,7 +120,7 @@ export function CommonCodeAdminPage() {
-

{t('code.title')}

+

{t('code.title')}

{t('code.subtitle')}

diff --git a/src/frontend/src/screens/admin/CompanyAdminPage.tsx b/src/frontend/src/screens/admin/CompanyAdminPage.tsx index f937db1..d27b338 100644 --- a/src/frontend/src/screens/admin/CompanyAdminPage.tsx +++ b/src/frontend/src/screens/admin/CompanyAdminPage.tsx @@ -9,10 +9,12 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { Button } from '../../components/ui/Button'; -import { IconPlus, IconSearch } from '../../components/ui/icons'; +import { IconExhibitors, IconPlus, IconSearch } from '../../components/ui/icons'; import { errMessage, useToast } from '../work/workShared'; import { companyApi, PARTNER_TYPES, type Company, type CompanySaveBody } from './systemBasicsApi'; import { Pager } from './LoginHistoryPage'; +import { GridPdfButton } from '../../components/GridPdfButton'; +import '../../components/data-table.css'; import './admin-modules.css'; export function CompanyAdminPage() { @@ -48,11 +50,29 @@ export function CompanyAdminPage() { function apply() { setPage(0); setApplied({ keyword, partnerType }); } + const pdfColumns = [ + { key: 'name', label: t('company.colName') }, + { key: 'bizNo', label: t('company.colBizNo') }, + { key: 'type', label: t('company.colType') }, + { key: 'contact', label: t('company.colContact') }, + { key: 'tel', label: t('company.colTel') }, + { key: 'use', label: t('company.colUse') }, + ]; + const pdfRows = () => + rows.map((r) => ({ + name: r.companyName, + bizNo: r.bizNo ?? '-', + type: r.partnerType ? t(`company.pt_${r.partnerType}`) : '-', + contact: r.contactName ?? '-', + tel: r.contactTel ?? '-', + use: r.useYn === 'N' ? t('sysadmin.noUse') : t('sysadmin.use'), + })); + return (
-

{t('company.title')}

+

{t('company.title')}

{t('company.subtitle')}

@@ -87,6 +107,11 @@ export function CompanyAdminPage() { ) : ( <> +
+
+ +
+
diff --git a/src/frontend/src/screens/admin/DeptAdminPage.tsx b/src/frontend/src/screens/admin/DeptAdminPage.tsx index 56881c8..f35135b 100644 --- a/src/frontend/src/screens/admin/DeptAdminPage.tsx +++ b/src/frontend/src/screens/admin/DeptAdminPage.tsx @@ -186,7 +186,7 @@ export function DeptAdminPage() {
-

{t('dept.title')}

+

{t('dept.title')}

{t('dept.subtitle')}

diff --git a/src/frontend/src/screens/admin/ErrorLogPage.tsx b/src/frontend/src/screens/admin/ErrorLogPage.tsx index 25c0a5c..0fd5919 100644 --- a/src/frontend/src/screens/admin/ErrorLogPage.tsx +++ b/src/frontend/src/screens/admin/ErrorLogPage.tsx @@ -8,10 +8,12 @@ import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { Button } from '../../components/ui/Button'; -import { IconSearch } from '../../components/ui/icons'; +import { IconSearch, IconWarning } from '../../components/ui/icons'; import { fmtDateTime } from '../work/workShared'; import { errorLogApi } from './systemBasicsApi'; import { Pager } from './LoginHistoryPage'; +import { GridPdfButton } from '../../components/GridPdfButton'; +import '../../components/data-table.css'; import './admin-modules.css'; export function ErrorLogPage() { @@ -30,11 +32,27 @@ export function ErrorLogPage() { const total = q.data?.total ?? 0; const totalPages = Math.max(1, Math.ceil(total / size)); + const pdfColumns = [ + { key: 'time', label: t('errorLog.colTime') }, + { key: 'code', label: t('errorLog.colCode') }, + { key: 'method', label: t('errorLog.colMethod') }, + { key: 'path', label: t('errorLog.colPath') }, + { key: 'summary', label: t('errorLog.colSummary') }, + ]; + const pdfRows = () => + rows.map((r) => ({ + time: fmtDateTime(r.createdAt), + code: r.errorCode ?? '-', + method: r.method ?? '-', + path: r.path ?? '-', + summary: r.summary ?? '-', + })); + return (
-

{t('errorLog.title')}

+

{t('errorLog.title')}

{t('errorLog.subtitle')}

@@ -63,6 +81,11 @@ export function ErrorLogPage() { ) : ( <> +
+
+ +
+
diff --git a/src/frontend/src/screens/admin/HolidayAdminPage.tsx b/src/frontend/src/screens/admin/HolidayAdminPage.tsx index 9adbae6..b31e053 100644 --- a/src/frontend/src/screens/admin/HolidayAdminPage.tsx +++ b/src/frontend/src/screens/admin/HolidayAdminPage.tsx @@ -10,7 +10,7 @@ import { useTranslation } from 'react-i18next'; import { ErrorState, Skeleton } from '../../components/ui/States'; import { Button } from '../../components/ui/Button'; import { KxDataTable, type KxColumn } from '../../components/KxDataTable'; -import { IconPlus } from '../../components/ui/icons'; +import { IconCalendar, IconPlus } from '../../components/ui/icons'; import { errMessage, useToast } from '../work/workShared'; import { holidayApi, type Holiday, type HolidayCreateBody } from './holidayApi'; import './admin-modules.css'; @@ -108,7 +108,7 @@ export function HolidayAdminPage() {
-

{t('holiday.title')}

+

{t('holiday.title')}

{t('holiday.subtitle')}

diff --git a/src/frontend/src/screens/admin/InfraConfigPage.tsx b/src/frontend/src/screens/admin/InfraConfigPage.tsx index 66d504c..a1922b9 100644 --- a/src/frontend/src/screens/admin/InfraConfigPage.tsx +++ b/src/frontend/src/screens/admin/InfraConfigPage.tsx @@ -6,6 +6,7 @@ import { useEffect, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; +import { IconSettings } from '../../components/ui/icons'; import { ErrorState, Skeleton } from '../../components/ui/States'; import { Button } from '../../components/ui/Button'; import { errMessage, useToast } from '../work/workShared'; @@ -54,7 +55,7 @@ function InfraConfig({ variant }: { variant: Variant }) {
-

{t(`${titleKey}.title`)}

+

{t(`${titleKey}.title`)}

{t(`${titleKey}.subtitle`)}

diff --git a/src/frontend/src/screens/admin/LoginHistoryPage.tsx b/src/frontend/src/screens/admin/LoginHistoryPage.tsx index 1c42ca8..5281ef9 100644 --- a/src/frontend/src/screens/admin/LoginHistoryPage.tsx +++ b/src/frontend/src/screens/admin/LoginHistoryPage.tsx @@ -8,9 +8,11 @@ import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { Button } from '../../components/ui/Button'; -import { IconSearch } from '../../components/ui/icons'; +import { IconClock, IconSearch } from '../../components/ui/icons'; import { fmtDateTime } from '../work/workShared'; import { loginHistoryApi } from './systemBasicsApi'; +import { GridPdfButton } from '../../components/GridPdfButton'; +import '../../components/data-table.css'; import './admin-modules.css'; const RESULTS = ['', 'SUCCESS', 'FAIL', 'LOCKED']; @@ -32,11 +34,29 @@ export function LoginHistoryPage() { const total = q.data?.total ?? 0; const totalPages = Math.max(1, Math.ceil(total / size)); + const pdfColumns = [ + { key: 'time', label: t('loginHistory.colTime') }, + { key: 'result', label: t('loginHistory.colResult') }, + { key: 'user', label: t('loginHistory.colUser') }, + { key: 'email', label: t('loginHistory.colEmail') }, + { key: 'ip', label: t('loginHistory.colIp') }, + { key: 'reason', label: t('loginHistory.colReason') }, + ]; + const pdfRows = () => + rows.map((r) => ({ + time: fmtDateTime(r.createdAt), + result: r.result ?? '-', + user: r.userId ?? '-', + email: r.emailMasked ?? '-', + ip: r.ipMasked ?? '-', + reason: r.failReason ?? '-', + })); + return (
-

{t('loginHistory.title')}

+

{t('loginHistory.title')}

{t('loginHistory.subtitle')}

@@ -76,6 +96,11 @@ export function LoginHistoryPage() { ) : ( <> +
+
+ +
+
diff --git a/src/frontend/src/screens/admin/LoginSlideAdminPage.tsx b/src/frontend/src/screens/admin/LoginSlideAdminPage.tsx index fa0df12..da28387 100644 --- a/src/frontend/src/screens/admin/LoginSlideAdminPage.tsx +++ b/src/frontend/src/screens/admin/LoginSlideAdminPage.tsx @@ -116,7 +116,7 @@ export function LoginSlideAdminPage() {
-

{t('slide.title')}

+

{t('slide.title')}

]} />

diff --git a/src/frontend/src/screens/admin/MenuAdminPage.tsx b/src/frontend/src/screens/admin/MenuAdminPage.tsx index 36815ad..cd69a0f 100644 --- a/src/frontend/src/screens/admin/MenuAdminPage.tsx +++ b/src/frontend/src/screens/admin/MenuAdminPage.tsx @@ -8,9 +8,11 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { Button } from '../../components/ui/Button'; -import { IconPlus } from '../../components/ui/icons'; +import { IconMenu, IconPlus } from '../../components/ui/icons'; import { errMessage, useToast } from '../work/workShared'; import { menuApi, MENU_PRG_TYPES, type MenuNode, type MenuSaveBody } from './adminCrudApi'; +import { GridPdfButton } from '../../components/GridPdfButton'; +import '../../components/data-table.css'; import './admin-modules.css'; interface FlatMenu extends MenuNode { @@ -58,6 +60,24 @@ export function MenuAdminPage() { const flat = useMemo(() => toDepthList(q.data ?? []), [q.data]); + const pdfColumns = [ + { key: 'name', label: t('menu.colName') }, + { key: 'path', label: t('menu.colPath') }, + { key: 'type', label: t('menu.colType') }, + { key: 'role', label: t('menu.colRole') }, + { key: 'sort', label: t('menu.colSort'), align: 'right' as const }, + { key: 'use', label: t('menu.colUse') }, + ]; + const pdfRows = () => + flat.map((m) => ({ + name: `${' '.repeat(m.depth)}${m.menuName}`, + path: m.menuPath ?? '—', + type: m.prgType ?? '—', + role: m.requiredRole ?? t('menu.all'), + sort: m.sortOrder, + use: m.useYn === 'N' ? t('menu.unused') : t('menu.used'), + })); + const saveM = useMutation({ mutationFn: (body: MenuSaveBody) => menuApi.save(body), onSuccess: () => { @@ -80,7 +100,7 @@ export function MenuAdminPage() {
-

{t('menu.title')}

+

{t('menu.title')}

{t('menu.subtitle')}

@@ -96,6 +116,12 @@ export function MenuAdminPage() { {flat.length === 0 ? ( ) : ( + <> +
+
+ +
+
@@ -152,6 +178,7 @@ export function MenuAdminPage() {
+ )} )} diff --git a/src/frontend/src/screens/admin/MessageAdminPage.tsx b/src/frontend/src/screens/admin/MessageAdminPage.tsx index 3e6eb9e..3e09ba7 100644 --- a/src/frontend/src/screens/admin/MessageAdminPage.tsx +++ b/src/frontend/src/screens/admin/MessageAdminPage.tsx @@ -8,7 +8,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { Button } from '../../components/ui/Button'; -import { IconPlus } from '../../components/ui/icons'; +import { IconChat, IconPlus } from '../../components/ui/icons'; import { errMessage, useToast } from '../work/workShared'; import { MESSAGE_CATEGORIES, @@ -17,6 +17,8 @@ import { messageApi, type SysMessage, } from './messageAdminApi'; +import { GridPdfButton } from '../../components/GridPdfButton'; +import '../../components/data-table.css'; import './admin-modules.css'; export function MessageAdminPage() { @@ -73,11 +75,32 @@ export function MessageAdminPage() { ); }, [rows, keyword]); + const pdfColumns = [ + { key: 'code', label: t('msg.code') }, + { key: 'locale', label: t('msg.locale') }, + { key: 'category', label: t('msg.category') }, + { key: 'title', label: t('msg.msgTitle') }, + { key: 'placeholders', label: t('msg.placeholders') }, + { key: 'use', label: t('msg.use') }, + ]; + const pdfRows = () => + filtered.map((m) => { + const params = extractPlaceholders(m.template); + return { + code: m.msgCode, + locale: m.locale ?? 'ko', + category: m.category, + title: m.title, + placeholders: params.length > 0 ? params.map((p) => `{${p}}`).join(' ') : '—', + use: m.useYn === 'N' ? t('msg.unused') : t('msg.used'), + }; + }); + return (
-

{t('msg.title')}

+

{t('msg.title')}

{t('msg.subtitle')}