From ecf60aac1dc8b74fffacfbe59673a094d8351851 Mon Sep 17 00:00:00 2001 From: zio Date: Mon, 13 Jul 2026 05:16:35 +0900 Subject: [PATCH] feat(web): tabulator data table + breadcrumb/404 navigation + message-template admin - KxDataTable: in-house tabulator features (sort/filter/pagination/CSV UTF-8 BOM) on canonical .kx-table; applied to exhibitors, lead scoring, visitor registration - Breadcrumb in shell content top (menu-IA-aligned trails, detail->list return), NotFoundPage wired to router catch-all with role-aware home return - MessageAdminPage (/admin/messages): V48 sys_message CRUD + {key} placeholder render preview, wired to shell menu / breadcrumb / MDI labels / admin dashboard - i18n: datatable/breadcrumb/notFound/msg namespaces, ko-en-zh-ja parity kept Co-Authored-By: Claude Fable 5 --- src/frontend/src/App.tsx | 6 +- src/frontend/src/components/KxDataTable.tsx | 373 ++++++++++++++++++ src/frontend/src/components/data-table.css | 229 +++++++++++ .../src/components/layout/AppShell.tsx | 5 + .../src/components/layout/Breadcrumb.tsx | 54 +++ .../src/components/layout/breadcrumb.css | 60 +++ .../src/components/layout/breadcrumbConfig.ts | 125 ++++++ .../src/components/layout/mdiLabels.ts | 1 + src/frontend/src/components/ui/icons.tsx | 10 + src/frontend/src/i18n/locales/en.json | 89 ++++- src/frontend/src/i18n/locales/ja.json | 89 ++++- src/frontend/src/i18n/locales/ko.json | 89 ++++- src/frontend/src/i18n/locales/zh.json | 89 ++++- .../src/screens/admin/AdminDashboardPage.tsx | 1 + .../src/screens/admin/MessageAdminPage.tsx | 373 ++++++++++++++++++ .../src/screens/admin/messageAdminApi.ts | 61 +++ .../src/screens/common/NotFoundPage.tsx | 46 +++ src/frontend/src/screens/common/not-found.css | 60 +++ .../src/screens/exhibitors/ExhibitorsPage.tsx | 140 +++---- .../src/screens/visitor/LeadScoringPage.tsx | 106 ++--- .../VisitorRegistrationDashboardPage.tsx | 100 +++-- 21 files changed, 1899 insertions(+), 207 deletions(-) create mode 100644 src/frontend/src/components/KxDataTable.tsx create mode 100644 src/frontend/src/components/data-table.css create mode 100644 src/frontend/src/components/layout/Breadcrumb.tsx create mode 100644 src/frontend/src/components/layout/breadcrumb.css create mode 100644 src/frontend/src/components/layout/breadcrumbConfig.ts create mode 100644 src/frontend/src/screens/admin/MessageAdminPage.tsx create mode 100644 src/frontend/src/screens/admin/messageAdminApi.ts create mode 100644 src/frontend/src/screens/common/NotFoundPage.tsx create mode 100644 src/frontend/src/screens/common/not-found.css diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index e45f1d7..1426f3c 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -72,6 +72,7 @@ import { SysUserAdminPage } from './screens/admin/SysUserAdminPage'; import { RoleAdminPage } from './screens/admin/RoleAdminPage'; import { CommonCodeAdminPage } from './screens/admin/CommonCodeAdminPage'; import { MenuAdminPage } from './screens/admin/MenuAdminPage'; +import { MessageAdminPage } from './screens/admin/MessageAdminPage'; import { PublicGatewayPage, VisitorTrackPage, @@ -85,6 +86,7 @@ import { PublicInquiryPage, PublicTicketPage, } from './screens/public'; +import { NotFoundPage } from './screens/common/NotFoundPage'; import { AppShell } from './components/layout/AppShell'; /** SCR-22 → SCR-23 작성 화면 진입 배선. */ @@ -259,6 +261,7 @@ export function App() { } /> } /> } /> + } /> } /> @@ -292,7 +295,8 @@ export function App() { } /> - } /> + {/* 404 — 미매칭 경로(공개·인증 공통, 셸 비의존 전폭 카드 · 4.4) */} + } /> ); } diff --git a/src/frontend/src/components/KxDataTable.tsx b/src/frontend/src/components/KxDataTable.tsx new file mode 100644 index 0000000..549ad09 --- /dev/null +++ b/src/frontend/src/components/KxDataTable.tsx @@ -0,0 +1,373 @@ +/* + * KxDataTable — 재사용 데이터테이블 (Nifty tables/tabulator 계보 · 소유자 지시 1.3 / task #32). + * + * 정본 `.kx-table`(screens/shared.css) 스타일 위에 인터랙션 레이어를 얹은 자체 구현. + * 외부 라이브러리(tabulator-tables 등) 미사용 — 번들·다크·i18n·접근성 정합을 위해 직접 구현. + * + * 기능: 컬럼 헤더 클릭 정렬(asc/desc/해제)·텍스트 필터(검색)·페이지네이션(10/25/50)· + * CSV 내보내기(UTF-8 BOM · 한글 엑셀 호환 · CRLF · 셀 이스케이프). + * + * 데이터 소싱은 호출측 책임 — rows 는 이미 (서버/외부필터로) 확정된 배열을 넘긴다. + * 외부 세그먼트/칩 필터는 toolbarStart 로 주입(검색/정렬/페이지는 이 컴포넌트가 담당). + * + * 사용 예: + * r.name }, + * { key:'amount', header:t('...'), numeric:true, value:(r)=>r.amount, + * render:(r)=>{r.amount.toLocaleString()} }]} + * rows={rows} rowKey={(r)=>r.id} onRowClick={setSel} csvFileName="exhibitors" /> + */ +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 './data-table.css'; + +export type KxCellValue = string | number | null | undefined; + +export interface KxColumn { + /** 안정 키(정렬·React key). */ + key: string; + /** 헤더 라벨(이미 i18n 해소된 문자열). */ + header: string; + /** 정렬·검색·CSV 에 쓰는 원시 값 접근자. 없으면 그 컬럼은 정렬/검색/CSV 제외. */ + value?: (row: T) => KxCellValue; + /** 셀 렌더(미지정 시 value 문자열 표시). */ + render?: (row: T) => ReactNode; + /** 숫자열 — 우정렬 tabular-nums(.kx-num) + 수치 비교 정렬. */ + numeric?: boolean; + /** 정렬 가능 여부(기본: value 가 있으면 true). */ + sortable?: boolean; + /** 검색 대상 여부(기본: value 가 있으면 true). */ + searchable?: boolean; + /** CSV 포함 여부(기본 true). */ + csv?: boolean; + /** 셀(td) 추가 클래스. */ + tdClassName?: string; + /** 헤더(th) 추가 클래스. */ + thClassName?: string; +} + +type SortDir = 'asc' | 'desc'; +interface SortState { + key: string; + dir: SortDir; +} + +export interface KxDataTableProps { + columns: KxColumn[]; + rows: T[]; + rowKey: (row: T) => string; + onRowClick?: (row: T) => void; + isRowSelected?: (row: T) => boolean; + rowClassName?: (row: T) => string | undefined; + /** 제브라 스트라이프(기본 true). */ + zebra?: boolean; + /** 검색 입력 노출(기본 true). */ + searchable?: boolean; + searchPlaceholder?: string; + /** 페이지 크기 선택지(기본 [10,25,50]). */ + pageSizeOptions?: number[]; + initialPageSize?: number; + /** 지정 시 CSV 내보내기 버튼 노출 — 다운로드 파일명(확장자 제외). */ + csvFileName?: string; + /** 툴바 좌측(외부 필터 등) · 우측 추가 슬롯. */ + toolbarStart?: ReactNode; + toolbarEnd?: ReactNode; + ariaLabel?: string; + emptyTitle?: string; + emptyDescription?: string; + className?: string; +} + +/** CSV 셀 이스케이프(쉼표·따옴표·줄바꿈 방어). */ +function csvCell(v: KxCellValue): string { + const s = String(v ?? ''); + return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; +} + +/** 값 → 검색 문자열(소문자). */ +function searchText(v: KxCellValue): string { + return String(v ?? '').toLowerCase(); +} + +/** 두 셀 값 비교(numeric 이면 수치, 아니면 로케일 문자열). null/undefined 는 뒤로. */ +function compare(a: KxCellValue, b: KxCellValue, numeric: boolean): number { + const an = a === null || a === undefined || a === ''; + const bn = b === null || b === undefined || b === ''; + if (an && bn) return 0; + if (an) return 1; + if (bn) return -1; + if (numeric) return Number(a) - Number(b); + return String(a).localeCompare(String(b), undefined, { numeric: true }); +} + +export function KxDataTable({ + columns, + rows, + rowKey, + onRowClick, + isRowSelected, + rowClassName, + zebra = true, + searchable = true, + searchPlaceholder, + pageSizeOptions = [10, 25, 50], + initialPageSize, + csvFileName, + toolbarStart, + toolbarEnd, + ariaLabel, + emptyTitle, + emptyDescription, + className = '', +}: KxDataTableProps) { + const { t } = useTranslation(); + const [query, setQuery] = useState(''); + const [sort, setSort] = useState(null); + const [pageSize, setPageSize] = useState(initialPageSize ?? pageSizeOptions[0] ?? 10); + const [page, setPage] = useState(0); + + const colOf = (key: string) => columns.find((c) => c.key === key); + const canSort = (c: KxColumn) => (c.sortable ?? !!c.value) && !!c.value; + const canSearch = (c: KxColumn) => (c.searchable ?? !!c.value) && !!c.value; + + // ── 필터(검색) ── + const filtered = useMemo(() => { + const kw = query.trim().toLowerCase(); + if (!kw) return rows; + const searchCols = columns.filter(canSearch); + return rows.filter((r) => + searchCols.some((c) => searchText(c.value!(r)).includes(kw)), + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rows, query, columns]); + + // ── 정렬 ── + const sorted = useMemo(() => { + if (!sort) return filtered; + const col = colOf(sort.key); + if (!col?.value) return filtered; + const dir = sort.dir === 'asc' ? 1 : -1; + // 안정 정렬: 원본 인덱스 tie-break. + return filtered + .map((r, i) => [r, i] as const) + .sort((x, y) => { + const c = compare(col.value!(x[0]), col.value!(y[0]), !!col.numeric) * dir; + return c !== 0 ? c : x[1] - y[1]; + }) + .map(([r]) => r); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filtered, sort, columns]); + + // ── 페이지네이션(page 는 렌더 시 유효 범위로 클램프) ── + const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize)); + const safePage = Math.min(page, totalPages - 1); + const start = safePage * pageSize; + const pageRows = sorted.slice(start, start + pageSize); + + const onSearch = (v: string) => { + setQuery(v); + setPage(0); + }; + const onPageSize = (v: number) => { + setPageSize(v); + setPage(0); + }; + const toggleSort = (key: string) => { + setPage(0); + setSort((prev) => { + if (!prev || prev.key !== key) return { key, dir: 'asc' }; + if (prev.dir === 'asc') return { key, dir: 'desc' }; + return null; // desc → 해제 + }); + }; + + const exportCsv = () => { + const csvCols = columns.filter((c) => c.value && c.csv !== false); + const header = csvCols.map((c) => csvCell(c.header)).join(','); + const lines = sorted.map((r) => csvCols.map((c) => csvCell(c.value!(r))).join(',')); + // UTF-8 BOM() + CRLF — 한글 엑셀 호환. + const csv = '' + [header, ...lines].join('\r\n'); + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${csvFileName ?? 'export'}.csv`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + const showToolbar = searchable || !!csvFileName || !!toolbarStart || !!toolbarEnd; + + return ( +
+ {showToolbar && ( +
+ {toolbarStart} + {searchable && ( + + )} +
+ {toolbarEnd} + {csvFileName && ( + + )} +
+
+ )} + +
+ + + + {columns.map((c) => { + const sortable = canSort(c); + const active = sort?.key === c.key; + const dir = active ? sort!.dir : undefined; + const ariaSort = active ? (dir === 'asc' ? 'ascending' : 'descending') : 'none'; + return ( + + ); + })} + + + + {pageRows.map((r) => { + const selected = isRowSelected?.(r); + const extra = rowClassName?.(r) ?? ''; + return ( + onRowClick(r) : undefined} + onKeyDown={ + onRowClick + ? (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onRowClick(r); + } + } + : undefined + } + > + {columns.map((c) => ( + + ))} + + ); + })} + {pageRows.length === 0 && ( + + + + )} + +
+ {sortable ? ( + + ) : ( + c.header + )} +
+ {c.render ? c.render(r) : (c.value ? String(c.value(r) ?? '-') : null)} +
+ +
+
+ +
+ + {t('datatable.count', { shown: pageRows.length, total: sorted.length })} + +
+ +
+ + + {t('datatable.pageInfo', { page: safePage + 1, total: totalPages })} + + +
+
+
+
+ ); +} diff --git a/src/frontend/src/components/data-table.css b/src/frontend/src/components/data-table.css new file mode 100644 index 0000000..97a6072 --- /dev/null +++ b/src/frontend/src/components/data-table.css @@ -0,0 +1,229 @@ +/* + * KxDataTable 스타일 — Nifty tabulator 계보. 정본 `.kx-table`(screens/shared.css) 위에 + * 툴바(검색·CSV·외부필터)·정렬 헤더 버튼·푸터(페이지 크기·페이저)만 얹는다. + * 색·간격·폰트는 kx 토큰만 사용(하드코딩 hex 금지) · 다크/라이트 양 테마 · 선 SVG 아이콘. + */ +@import '../screens/shared.css'; + +.kx-dt { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +/* ── 툴바(검색 · 외부 필터 · CSV) ── */ +.kx-dt__toolbar { + display: flex; + align-items: center; + gap: var(--space-3); + flex-wrap: wrap; +} +.kx-dt__toolbar-end { + margin-left: auto; + display: flex; + align-items: center; + gap: var(--space-2); +} + +.kx-dt__search { + position: relative; + display: inline-flex; + align-items: center; + min-width: 200px; + max-width: 320px; + flex: 1 1 220px; +} +.kx-dt__search-ic { + position: absolute; + left: 10px; + color: var(--color-neutral-500); + pointer-events: none; +} +.kx-dt__search-input { + width: 100%; + height: 36px; + padding: 0 var(--space-3) 0 34px; + border: var(--border-card); + border-radius: var(--radius-sm); + background: var(--color-white); + font-size: var(--fs-body); + color: var(--color-neutral-900); +} +.kx-dt__search-input::placeholder { + color: var(--color-neutral-500); +} +.kx-dt__search-input:focus-visible { + outline: none; + border-color: var(--color-primary-600); + box-shadow: 0 0 0 3px var(--color-primary-050); +} + +/* CSV 내보내기 버튼(secondary 아웃라인 톤) */ +.kx-dt__csv { + display: inline-flex; + align-items: center; + gap: 6px; + height: 36px; + padding: 0 var(--space-3); + border: var(--border-card); + border-radius: var(--radius-sm); + background: var(--color-white); + color: var(--color-neutral-700); + font-size: var(--fs-body); + font-weight: var(--fw-semibold); + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; + white-space: nowrap; +} +.kx-dt__csv:hover:not(:disabled) { + background: var(--color-neutral-050); + border-color: var(--color-neutral-500); + color: var(--color-neutral-900); +} +.kx-dt__csv:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.kx-dt__csv:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--color-primary-050); +} + +/* ── 정렬 헤더 버튼 ── */ +.kx-dt__sort-btn { + display: inline-flex; + align-items: center; + gap: 6px; + width: 100%; + padding: 0; + border: none; + background: transparent; + font: inherit; + letter-spacing: inherit; + color: inherit; + text-align: left; + cursor: pointer; +} +.kx-dt__sort-btn--num { + justify-content: flex-end; +} +.kx-dt__sort-btn:hover { + color: var(--color-primary-700); +} +.kx-dt__sort-btn:focus-visible { + outline: none; + color: var(--color-primary-700); + box-shadow: 0 0 0 3px var(--color-primary-050); + border-radius: var(--radius-sm); +} +.kx-dt__sort-ic { + flex: none; + color: var(--color-neutral-500); +} +/* 방향에 따라 활성 셰브런만 강조(기본은 둘 다 흐리게) */ +.kx-dt__sort-btn .kx-sort-up, +.kx-dt__sort-btn .kx-sort-down { + opacity: 0.32; + transition: opacity 0.12s ease; +} +.kx-dt__sort-btn[data-dir='asc'] .kx-sort-up, +.kx-dt__sort-btn[data-dir='desc'] .kx-sort-down { + opacity: 1; +} +.kx-dt__sort-btn[data-dir='asc'] .kx-dt__sort-ic, +.kx-dt__sort-btn[data-dir='desc'] .kx-dt__sort-ic { + color: var(--color-primary-600); +} + +/* 클릭 가능 행 */ +.kx-dt__row--click { + cursor: pointer; +} +.kx-table tbody tr.is-selected { + background: var(--color-primary-100); +} +.kx-dt__empty-cell { + padding: 0 !important; +} +.kx-dt__empty-cell:hover { + background: transparent; +} + +/* ── 푸터(건수 · 페이지 크기 · 페이저) ── */ +.kx-dt__footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + flex-wrap: wrap; +} +.kx-dt__count { + font-size: var(--fs-caption); + color: var(--color-neutral-500); +} +.kx-dt__pager { + display: flex; + align-items: center; + gap: var(--space-4); + flex-wrap: wrap; +} +.kx-dt__pagesize { + display: inline-flex; + align-items: center; + gap: var(--space-2); +} +.kx-dt__pagesize-label { + font-size: var(--fs-caption); + color: var(--color-neutral-500); +} +.kx-dt__pagesize-select { + height: 32px; + padding: 0 var(--space-2); +} +.kx-dt__pagenav { + display: inline-flex; + align-items: center; + gap: var(--space-2); +} +.kx-dt__page-btn { + height: 32px; + padding: 0 var(--space-3); + border: var(--border-card); + border-radius: var(--radius-sm); + background: var(--color-white); + color: var(--color-neutral-700); + font-size: var(--fs-caption); + font-weight: var(--fw-semibold); + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease; +} +.kx-dt__page-btn:hover:not(:disabled) { + background: var(--color-neutral-050); + border-color: var(--color-neutral-500); +} +.kx-dt__page-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} +.kx-dt__page-btn:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--color-primary-050); +} +.kx-dt__pageinfo { + font-size: var(--fs-caption); + color: var(--color-neutral-700); + min-width: 96px; + text-align: center; +} + +@media (max-width: 640px) { + .kx-dt__toolbar-end { + margin-left: 0; + } + .kx-dt__search { + max-width: none; + } + .kx-dt__footer { + justify-content: flex-start; + } +} diff --git a/src/frontend/src/components/layout/AppShell.tsx b/src/frontend/src/components/layout/AppShell.tsx index bc7cd92..484a08a 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 { Breadcrumb } from './Breadcrumb'; import { ShellFooter } from './ShellFooter'; import { SettingsCustomizer } from './SettingsCustomizer'; import { labelForPath } from './mdiLabels'; @@ -197,6 +198,7 @@ const GROUPS: NavGroupDef[] = [ { key: 'admin-settings', label: '시스템설정', labelKey: 'shell.menu.items.settings', Icon: IconSettings, to: '/admin/settings' }, { key: 'admin-mail-config', label: '메일 설정', labelKey: 'shell.menu.items.mailConfig', Icon: IconBell, to: '/admin/mail-config' }, { key: 'admin-notify-config', label: '알림 설정', labelKey: 'shell.menu.items.notifyConfig', Icon: IconBell, to: '/admin/notify-config' }, + { key: 'admin-messages', label: '메시지 관리', labelKey: 'shell.menu.items.msgTemplates', Icon: IconDocument, to: '/admin/messages' }, // 마스터·기타 { key: 'sec-masteretc', label: '마스터·기타', labelKey: 'shell.menu.sections.masterEtc', section: true }, { key: 'admin-tenants', label: '테넌트', labelKey: 'shell.menu.items.tenants', Icon: IconExhibitors, to: '/admin/tenants' }, @@ -503,6 +505,9 @@ export function AppShell() { + {/* 브레드크럼 — 콘텐츠 영역 상단(홈>섹션>현재, 조각 클릭 이동 · 4.4). */} + +
diff --git a/src/frontend/src/components/layout/Breadcrumb.tsx b/src/frontend/src/components/layout/Breadcrumb.tsx new file mode 100644 index 0000000..1d1ab07 --- /dev/null +++ b/src/frontend/src/components/layout/Breadcrumb.tsx @@ -0,0 +1,54 @@ +/* + * 브레드크럼 (소유자 지시 4.4) — 인증 셸 콘텐츠 영역 상단의 탐색 경로. + * `홈 > 섹션 > 현재` 를 표시하고 각 조각(홈·부모 목록/대시보드)은 클릭 이동한다. + * 셸 구조는 변경하지 않고 AppShell 의 MdiTabBar 와 content 사이(콘텐츠 상단)에 삽입된다. + * 홈/루트에서는 렌더하지 않는다(중복 회피). 라벨은 4개 로케일 i18n. + */ +import { useLocation, NavLink } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { IconChevronRight } from '../ui/icons'; +import { crumbTrail, type Crumb } from './breadcrumbConfig'; +import './breadcrumb.css'; + +export function Breadcrumb() { + const { pathname } = useLocation(); + const { t } = useTranslation(); + + // 홈/루트는 브레드크럼 불필요. + if (pathname === '/home' || pathname === '/') return null; + + const trail = crumbTrail(pathname); + const crumbs: Crumb[] = [{ labelKey: 'shell.menu.home', to: '/home' }, ...trail]; + + const label = (c: Crumb) => + c.labelKey ? t(c.labelKey, { defaultValue: c.label ?? '' }) : c.label ?? ''; + + return ( + + ); +} diff --git a/src/frontend/src/components/layout/breadcrumb.css b/src/frontend/src/components/layout/breadcrumb.css new file mode 100644 index 0000000..0beeb7a --- /dev/null +++ b/src/frontend/src/components/layout/breadcrumb.css @@ -0,0 +1,60 @@ +/* + * 브레드크럼 바 — 콘텐츠 영역 상단(MdiTabBar 와 content 사이). kx 토큰만 · 선 SVG 구분자. + * 셸 컬럼 레이아웃의 flex-none 바로 자리한다(기존 셸 구조 불변). + */ +.kx-breadcrumb { + flex-shrink: 0; + padding: var(--space-2) var(--gutter, var(--space-5)); + background: var(--color-neutral-050); + border-bottom: var(--border-card); +} +.kx-breadcrumb__list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 2px; + font-size: var(--fs-caption); + line-height: var(--lh-caption); +} +.kx-breadcrumb__item { + display: inline-flex; + align-items: center; + gap: 2px; + min-width: 0; +} +.kx-breadcrumb__sep { + color: var(--color-neutral-200); + flex: none; + margin: 0 2px; +} +.kx-breadcrumb__link { + color: var(--color-neutral-500); + text-decoration: none; + padding: 2px 4px; + border-radius: var(--radius-sm); + white-space: nowrap; + transition: color 0.12s ease, background 0.12s ease; +} +.kx-breadcrumb__link:hover { + color: var(--color-primary-700); + text-decoration: underline; +} +.kx-breadcrumb__link:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--color-primary-050); +} +.kx-breadcrumb__cur { + color: var(--color-neutral-500); + padding: 2px 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 320px; +} +.kx-breadcrumb__cur.is-current { + color: var(--color-neutral-900); + font-weight: var(--fw-semibold); +} diff --git a/src/frontend/src/components/layout/breadcrumbConfig.ts b/src/frontend/src/components/layout/breadcrumbConfig.ts new file mode 100644 index 0000000..e57a0ce --- /dev/null +++ b/src/frontend/src/components/layout/breadcrumbConfig.ts @@ -0,0 +1,125 @@ +/* + * 브레드크럼 경로 구성 — 라우트 → 크럼 트레일(Home 제외; Breadcrumb 가 Home 을 앞에 붙임). + * + * 메뉴 IA(AppShell GROUPS)와 정합하는 정적 매핑 + 상세/스코프 라우트 동적 패턴. + * 라벨은 i18n 키(shell.menu.groups.* · shell.menu.items.* · breadcrumb.*)로 해소하고, + * 미해소 시 label 폴백(ko)을 쓴다. 그룹 크럼은 라우트가 없는 아코디언 섹션이라 비링크, + * 부모 목록/대시보드 크럼은 to 로 복귀 링크를 보장한다(상세→목록/메인 복귀). + * + * mdiLabels 만 import 하여 순환(AppShell → Breadcrumb → config → AppShell)을 피한다. + */ +import { labelForPath } from './mdiLabels'; + +export interface Crumb { + /** i18n 키(우선). */ + labelKey?: string; + /** 리터럴 라벨(i18n 미해소 폴백). */ + label?: string; + /** 클릭 이동 경로(없으면 비링크 텍스트 — 그룹 섹션 등). */ + to?: string; +} + +/** 그룹(대분류) 크럼 — 라우트 없음(아코디언 섹션). */ +const G = (g: string): Crumb => ({ labelKey: `shell.menu.groups.${g}` }); +/** 메뉴 항목 크럼 — to 지정 시 링크. */ +const I = (i: string, to?: string): Crumb => ({ labelKey: `shell.menu.items.${i}`, to }); +/** breadcrumb.* 전용 라벨(상세 화면 등 메뉴에 없는 라벨). */ +const B = (k: string): Crumb => ({ labelKey: `breadcrumb.${k}` }); + +/** 정적(정확 일치) 경로 → 트레일. */ +const STATIC: Record = { + // 전시 운영 + '/exhibitors': [G('ops'), I('exhibitors')], + '/schedule': [G('ops'), I('schedule')], + '/halls': [G('ops'), I('halls')], + '/booth-sales': [G('ops'), I('boothSales')], + '/ops/operations': [G('ops'), I('operations')], + + // 설계·시공 + '/docs': [G('design'), I('documents')], + '/docs/authoring': [G('design'), I('documents', '/docs'), B('docsAuthoring')], + '/auctions': [G('design'), I('auctions')], + '/contractor/dashboard': [G('design'), I('contractor')], + '/logistics': [G('design'), I('logistics')], + + // 관람객·마케팅 + '/visitors': [G('visitor'), I('visitors')], + '/visitors/checkin': [G('visitor'), I('visitors', '/visitors'), I('checkin')], + '/leads': [G('visitor'), I('leads')], + '/campaigns': [G('visitor'), I('campaigns')], + '/sponsorship': [G('visitor'), I('sponsorship')], + '/cms': [G('visitor'), I('cms')], + '/cms/microsite': [G('visitor'), I('cms', '/cms'), B('micrositeBuilder')], + '/cms/i18n': [G('visitor'), I('cms', '/cms'), B('i18nCms')], + + // 정산·분석 + '/settlement': [G('finance'), I('settlement')], + '/analytics': [G('finance'), I('analytics')], + + // 업무 공통 + '/work/worklog': [G('work'), I('worklog')], + '/work/schedule': [G('work'), I('workSchedule')], + '/work/message': [G('work'), I('message')], + '/work/notice': [G('work'), I('notice')], + '/work/meeting': [G('work'), I('meeting')], + '/work/report': [G('work'), I('report')], + '/work/approval': [G('work'), I('approval')], + '/work/opinion': [G('work'), I('opinion')], + '/work/search': [G('work'), I('search')], + '/app-qr': [G('work'), I('appqr')], + + // 개인·알림 + '/notifications': [B('notifications')], + '/me': [B('me')], + + // 시스템관리 (상위 링크 = 관리 대시보드 /admin) + '/admin': [G('system'), I('admin')], + '/admin/login-slides': [G('system'), I('admin', '/admin'), I('slides')], + '/admin/audit': [G('system'), I('admin', '/admin'), I('audit')], + '/admin/settings': [G('system'), I('admin', '/admin'), I('settings')], + '/admin/masterdata/rulesets': [G('system'), I('admin', '/admin'), I('rulesets')], + '/admin/tenants': [G('system'), I('admin', '/admin'), I('tenants')], + '/admin/users': [G('system'), I('admin', '/admin'), I('users')], + '/admin/roles': [G('system'), I('admin', '/admin'), I('roles')], + '/admin/codes': [G('system'), I('admin', '/admin'), I('codes')], + '/admin/menus': [G('system'), I('admin', '/admin'), I('menus')], + '/admin/depts': [G('system'), I('admin', '/admin'), I('depts')], + '/admin/companies': [G('system'), I('admin', '/admin'), I('companies')], + '/admin/programs': [G('system'), I('admin', '/admin'), I('programs')], + '/admin/role-menus': [G('system'), I('admin', '/admin'), I('roleMenus')], + '/admin/auth-policy': [G('system'), I('admin', '/admin'), I('authPolicy')], + '/admin/login-history': [G('system'), I('admin', '/admin'), I('loginHistory')], + '/admin/error-log': [G('system'), I('admin', '/admin'), I('errorLog')], + '/admin/system-health': [G('system'), I('admin', '/admin'), I('systemHealth')], + '/admin/mail-config': [G('system'), I('admin', '/admin'), I('mailConfig')], + '/admin/notify-config': [G('system'), I('admin', '/admin'), I('notifyConfig')], + '/admin/messages': [G('system'), I('admin', '/admin'), I('msgTemplates')], + '/admin/holidays': [G('system'), I('admin', '/admin'), I('holidays')], +}; + +/** 동적 경로 패턴 → 트레일(구체적 규칙 우선). */ +const PATTERNS: { re: RegExp; trail: Crumb[] }[] = [ + { re: /^\/auctions\/[^/]+\/award$/, trail: [G('design'), I('auctions', '/auctions'), B('auctionAward')] }, + { re: /^\/auctions\/[^/]+$/, trail: [G('design'), I('auctions', '/auctions'), B('auctionDetail')] }, + { re: /^\/events\/[^/]+\/dashboard$/, trail: [G('ops'), I('dashboard')] }, + { re: /^\/events\/[^/]+\/booths\/[^/]+\/home$/, trail: [B('exhibitorHome')] }, + { re: /^\/events\/[^/]+\/halls\/[^/]+\/layout$/, trail: [G('design'), I('floorplan')] }, + { re: /^\/events\/[^/]+\/halls\/[^/]+\/comparison$/, trail: [G('design'), B('comparison')] }, + { re: /^\/events\/[^/]+\/booths\/[^/]+\/design$/, trail: [G('design'), B('design')] }, + { re: /^\/events\/[^/]+\/booths\/[^/]+\/utility\/order$/, trail: [G('design'), B('utilityOrder')] }, + { re: /^\/events\/[^/]+\/booths\/[^/]+\/utility$/, trail: [G('design'), B('utility')] }, + { re: /^\/events\/[^/]+\/booths\/[^/]+\/compliance$/, trail: [G('design'), B('compliance')] }, + { re: /^\/events\/[^/]+\/halls\/[^/]+\/approvals$/, trail: [G('ops'), B('approvals')] }, + { re: /^\/events\/[^/]+\/halls\/[^/]+\/booths\/[^/]+\/review$/, trail: [G('ops'), B('reviewDetail')] }, + { re: /^\/events\/[^/]+\/booths\/[^/]+\/gallery$/, trail: [G('design'), B('gallery')] }, +]; + +/** 라우트 → 크럼 트레일(Home 제외). 미등록 경로는 라우트 라벨 1개로 폴백. */ +export function crumbTrail(pathname: string): Crumb[] { + const clean = pathname.split('?')[0].split('#')[0]; + if (STATIC[clean]) return STATIC[clean]; + for (const { re, trail } of PATTERNS) { + if (re.test(clean)) return trail; + } + return [{ label: labelForPath(clean) }]; +} diff --git a/src/frontend/src/components/layout/mdiLabels.ts b/src/frontend/src/components/layout/mdiLabels.ts index a2135fb..1d2bee8 100644 --- a/src/frontend/src/components/layout/mdiLabels.ts +++ b/src/frontend/src/components/layout/mdiLabels.ts @@ -55,6 +55,7 @@ const STATIC_LABELS: Record = { '/admin/roles': '역할 관리', '/admin/codes': '공통 코드', '/admin/menus': '메뉴 관리', + '/admin/messages': '메시지 관리', }; /** 동적 경로 패턴(정규식) → 라벨. 구체적 규칙을 먼저 둔다. */ diff --git a/src/frontend/src/components/ui/icons.tsx b/src/frontend/src/components/ui/icons.tsx index 73eff2a..23c9990 100644 --- a/src/frontend/src/components/ui/icons.tsx +++ b/src/frontend/src/components/ui/icons.tsx @@ -304,6 +304,16 @@ export function IconChevronDown(p: IconProps) { ); } +/** ⇅ 정렬(위/아래 셰브런) — 데이터테이블 헤더. 활성 방향은 CSS 로 강조(.kx-sort-up/.kx-sort-down). */ +export function IconSort(p: IconProps) { + return ( + + + + + ); +} + /** ‹› 좌우 화살촉(비교 슬라이더 그립). */ export function IconCompareArrows(p: IconProps) { return ( diff --git a/src/frontend/src/i18n/locales/en.json b/src/frontend/src/i18n/locales/en.json index ed28b40..86b57ab 100644 --- a/src/frontend/src/i18n/locales/en.json +++ b/src/frontend/src/i18n/locales/en.json @@ -90,7 +90,8 @@ "notifyConfig": "Notify Config", "tenants": "Tenants", "rulesets": "Rulesets", - "slides": "Login Slides" + "slides": "Login Slides", + "msgTemplates": "Messages" }, "sections": { "userOrg": "Users & Org", @@ -1320,7 +1321,8 @@ "modAudit": "Audit log", "modSettings": "System settings", "modRulesets": "Rulesets", - "modTenants": "Tenants" + "modTenants": "Tenants", + "modMessages": "Messages" }, "tenant": { "title": "Tenant Management · Onboarding", @@ -2701,5 +2703,86 @@ "corner": "Corner", "island": "Island" } + }, + "datatable": { + "search": "Search", + "searchAria": "Search table", + "csv": "Export CSV", + "prev": "Prev", + "next": "Next", + "pageSize": "Rows", + "pageInfo": "{{page}} / {{total}}", + "count": "Showing {{shown}} · {{total}} total", + "emptyTitle": "No data to display", + "emptyFiltered": "No results match your search", + "sortAsc": "Sort ascending", + "sortDesc": "Sort descending", + "sortClear": "Clear sort" + }, + "breadcrumb": { + "aria": "Breadcrumb", + "docsAuthoring": "Report Authoring", + "micrositeBuilder": "Microsite Builder", + "i18nCms": "Multilingual CMS", + "notifications": "Notifications", + "me": "My Page", + "auctionDetail": "Auction Detail", + "auctionAward": "Award Comparison", + "exhibitorHome": "Exhibitor Home", + "comparison": "AI Layout Comparison", + "design": "Booth Design Studio", + "utility": "Utility Wiring", + "utilityOrder": "Utility Order", + "compliance": "Compliance Check", + "approvals": "Approval Queue", + "reviewDetail": "Review Detail", + "gallery": "Visualization Gallery" + }, + "notFound": { + "title": "Page not found", + "desc": "The page you requested does not exist or has moved. Please check the address.", + "home": "Go home", + "back": "Go back" + }, + "msg": { + "title": "Message Templates", + "subtitle": "Manage notification, email and notice texts as code/locale templates with {key} placeholders", + "add": "Add template", + "category": "Category", + "allCategories": "All categories", + "locale": "Locale", + "allLocales": "All locales", + "search": "Search", + "searchPlaceholder": "Search code, title, template", + "filterAria": "Message filters", + "loadError": "Failed to load messages", + "emptyTitle": "No messages", + "emptyDesc": "No templates match the filters. Try adding one.", + "code": "Code", + "msgTitle": "Title", + "placeholders": "Placeholders", + "use": "Use", + "manage": "Manage", + "used": "In use", + "unused": "Unused", + "preview": "Preview", + "edit": "Edit", + "delete": "Delete", + "deleteConfirm": "Delete template {{code}} ({{locale}})?", + "saved": "Saved", + "deleted": "Deleted", + "errRequired": "Code, title and template are required", + "useYn": "Use Y/N", + "template": "Template", + "templatePlaceholder": "Use {key} placeholders, e.g. {name}, {eventName}", + "detectedParams": "Detected parameters", + "cancel": "Cancel", + "save": "Save", + "saving": "Saving…", + "close": "Close", + "render": "Render", + "rendering": "Rendering…", + "renderResult": "Result", + "noParams": "This template has no parameters" } -} \ 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 22b084c..1173336 100644 --- a/src/frontend/src/i18n/locales/ja.json +++ b/src/frontend/src/i18n/locales/ja.json @@ -90,7 +90,8 @@ "notifyConfig": "通知設定", "tenants": "テナント", "rulesets": "ルールセット", - "slides": "ログインスライド" + "slides": "ログインスライド", + "msgTemplates": "メッセージ管理" }, "sections": { "userOrg": "ユーザー・組織", @@ -1320,7 +1321,8 @@ "modAudit": "監査ログ", "modSettings": "システム設定", "modRulesets": "ルールセット", - "modTenants": "テナント" + "modTenants": "テナント", + "modMessages": "メッセージ管理" }, "tenant": { "title": "テナント管理·オンボーディング", @@ -2701,5 +2703,86 @@ "corner": "コーナーブース", "island": "アイランドブース" } + }, + "datatable": { + "search": "検索", + "searchAria": "表を検索", + "csv": "CSV エクスポート", + "prev": "前へ", + "next": "次へ", + "pageSize": "表示件数", + "pageInfo": "{{page}} / {{total}} ページ", + "count": "{{shown}} 件表示 · 全 {{total}} 件", + "emptyTitle": "表示するデータがありません", + "emptyFiltered": "検索条件に一致する結果がありません", + "sortAsc": "昇順で並べ替え", + "sortDesc": "降順で並べ替え", + "sortClear": "並べ替え解除" + }, + "breadcrumb": { + "aria": "パンくず", + "docsAuthoring": "申請書類作成", + "micrositeBuilder": "マイクロサイトビルダー", + "i18nCms": "多言語 CMS", + "notifications": "通知センター", + "me": "マイページ", + "auctionDetail": "オークション詳細", + "auctionAward": "落札比較", + "exhibitorHome": "出展社ホーム", + "comparison": "AI レイアウト比較", + "design": "ブース設計スタジオ", + "utility": "配線ビュー", + "utilityOrder": "設備申請", + "compliance": "規定チェック", + "approvals": "審査キュー", + "reviewDetail": "審査詳細", + "gallery": "ビジュアライゼーションギャラリー" + }, + "notFound": { + "title": "ページが見つかりません", + "desc": "リクエストされたページは存在しないか、移動されました。アドレスをご確認ください。", + "home": "ホームへ", + "back": "前のページ" + }, + "msg": { + "title": "メッセージ管理", + "subtitle": "通知・メール・お知らせ文言をコード・ロケール別テンプレートで管理します({key} プレースホルダー置換)", + "add": "テンプレート追加", + "category": "分類", + "allCategories": "すべての分類", + "locale": "ロケール", + "allLocales": "すべてのロケール", + "search": "検索", + "searchPlaceholder": "コード・タイトル・本文を検索", + "filterAria": "メッセージフィルター", + "loadError": "メッセージ一覧を読み込めませんでした", + "emptyTitle": "メッセージがありません", + "emptyDesc": "条件に合うテンプレートがありません。新規追加してください。", + "code": "コード", + "msgTitle": "タイトル", + "placeholders": "プレースホルダー", + "use": "使用", + "manage": "管理", + "used": "使用", + "unused": "未使用", + "preview": "プレビュー", + "edit": "編集", + "delete": "削除", + "deleteConfirm": "テンプレート {{code}}({{locale}})を削除しますか?", + "saved": "保存しました", + "deleted": "削除しました", + "errRequired": "コード・タイトル・本文は必須です", + "useYn": "使用可否", + "template": "本文テンプレート", + "templatePlaceholder": "{name}・{eventName} など {key} プレースホルダーを使用します", + "detectedParams": "検出されたパラメータ", + "cancel": "キャンセル", + "save": "保存", + "saving": "保存中…", + "close": "閉じる", + "render": "レンダリング", + "rendering": "レンダリング中…", + "renderResult": "レンダリング結果", + "noParams": "置換パラメータのないテンプレートです" } -} \ 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 7cdcff2..3789651 100644 --- a/src/frontend/src/i18n/locales/ko.json +++ b/src/frontend/src/i18n/locales/ko.json @@ -90,7 +90,8 @@ "notifyConfig": "알림 설정", "tenants": "테넌트", "rulesets": "룰셋", - "slides": "로그인 슬라이드" + "slides": "로그인 슬라이드", + "msgTemplates": "메시지 관리" }, "sections": { "userOrg": "사용자·조직", @@ -1320,7 +1321,8 @@ "modAudit": "감사로그", "modSettings": "시스템설정", "modRulesets": "룰셋", - "modTenants": "테넌트" + "modTenants": "테넌트", + "modMessages": "메시지 관리" }, "tenant": { "title": "테넌트 관리·온보딩", @@ -2701,5 +2703,86 @@ "corner": "코너부스", "island": "아일랜드부스" } + }, + "datatable": { + "search": "검색", + "searchAria": "표 검색", + "csv": "CSV 내보내기", + "prev": "이전", + "next": "다음", + "pageSize": "표시 개수", + "pageInfo": "{{page}} / {{total}} 페이지", + "count": "{{shown}}건 표시 · 총 {{total}}건", + "emptyTitle": "표시할 데이터가 없습니다", + "emptyFiltered": "검색 조건에 맞는 결과가 없습니다", + "sortAsc": "오름차순 정렬", + "sortDesc": "내림차순 정렬", + "sortClear": "정렬 해제" + }, + "breadcrumb": { + "aria": "경로", + "docsAuthoring": "신고서류 작성", + "micrositeBuilder": "마이크로사이트 빌더", + "i18nCms": "다국어 CMS", + "notifications": "알림센터", + "me": "마이페이지", + "auctionDetail": "옥션 상세", + "auctionAward": "낙찰 비교", + "exhibitorHome": "참가업체 홈", + "comparison": "AI 배치안 비교", + "design": "부스 설계 스튜디오", + "utility": "유틸리티 배선", + "utilityOrder": "유틸리티 신청", + "compliance": "규정 검증", + "approvals": "검수 대기열", + "reviewDetail": "검수 상세", + "gallery": "시각화 갤러리" + }, + "notFound": { + "title": "페이지를 찾을 수 없습니다", + "desc": "요청하신 페이지가 존재하지 않거나 이동되었습니다. 주소를 확인해 주세요.", + "home": "홈으로", + "back": "이전 페이지" + }, + "msg": { + "title": "메시지 관리", + "subtitle": "알림·이메일·통지 문구를 코드·로케일별 템플릿으로 관리합니다 ({key} 플레이스홀더 치환)", + "add": "템플릿 추가", + "category": "분류", + "allCategories": "전체 분류", + "locale": "로케일", + "allLocales": "전체 로케일", + "search": "검색", + "searchPlaceholder": "코드·제목·본문 검색", + "filterAria": "메시지 필터", + "loadError": "메시지 목록을 불러오지 못했습니다", + "emptyTitle": "메시지가 없습니다", + "emptyDesc": "조건에 맞는 템플릿이 없습니다. 새 템플릿을 추가해 보세요.", + "code": "코드", + "msgTitle": "제목", + "placeholders": "플레이스홀더", + "use": "사용", + "manage": "관리", + "used": "사용", + "unused": "미사용", + "preview": "미리보기", + "edit": "수정", + "delete": "삭제", + "deleteConfirm": "{{code}} ({{locale}}) 템플릿을 삭제할까요?", + "saved": "저장되었습니다", + "deleted": "삭제되었습니다", + "errRequired": "코드·제목·본문은 필수입니다", + "useYn": "사용 여부", + "template": "본문 템플릿", + "templatePlaceholder": "{name}·{eventName} 형식의 {key} 플레이스홀더를 사용하세요", + "detectedParams": "감지된 파라미터", + "cancel": "취소", + "save": "저장", + "saving": "저장 중…", + "close": "닫기", + "render": "렌더", + "rendering": "렌더 중…", + "renderResult": "렌더 결과", + "noParams": "치환할 파라미터가 없는 템플릿입니다" } -} \ 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 f587c08..e91b6b9 100644 --- a/src/frontend/src/i18n/locales/zh.json +++ b/src/frontend/src/i18n/locales/zh.json @@ -90,7 +90,8 @@ "notifyConfig": "通知设置", "tenants": "租户", "rulesets": "规则集", - "slides": "登录幻灯片" + "slides": "登录幻灯片", + "msgTemplates": "消息管理" }, "sections": { "userOrg": "用户·组织", @@ -1320,7 +1321,8 @@ "modAudit": "审计日志", "modSettings": "系统设置", "modRulesets": "规则集", - "modTenants": "租户" + "modTenants": "租户", + "modMessages": "消息管理" }, "tenant": { "title": "租户管理·上线", @@ -2701,5 +2703,86 @@ "corner": "转角展位", "island": "岛式展位" } + }, + "datatable": { + "search": "搜索", + "searchAria": "搜索表格", + "csv": "导出 CSV", + "prev": "上一页", + "next": "下一页", + "pageSize": "每页", + "pageInfo": "{{page}} / {{total}} 页", + "count": "显示 {{shown}} 条 · 共 {{total}} 条", + "emptyTitle": "暂无数据", + "emptyFiltered": "没有符合条件的结果", + "sortAsc": "升序排序", + "sortDesc": "降序排序", + "sortClear": "取消排序" + }, + "breadcrumb": { + "aria": "路径", + "docsAuthoring": "申报文件撰写", + "micrositeBuilder": "微网站构建器", + "i18nCms": "多语言 CMS", + "notifications": "通知中心", + "me": "我的页面", + "auctionDetail": "竞价详情", + "auctionAward": "中标比较", + "exhibitorHome": "参展商主页", + "comparison": "AI 布局比较", + "design": "展位设计工作室", + "utility": "配线视图", + "utilityOrder": "配套申请", + "compliance": "合规检查", + "approvals": "审核队列", + "reviewDetail": "审核详情", + "gallery": "可视化画廊" + }, + "notFound": { + "title": "找不到页面", + "desc": "您请求的页面不存在或已移动,请检查网址。", + "home": "返回首页", + "back": "返回上一页" + }, + "msg": { + "title": "消息管理", + "subtitle": "以代码·语言模板管理通知、邮件与告知文案({key} 占位符替换)", + "add": "新增模板", + "category": "分类", + "allCategories": "全部分类", + "locale": "语言", + "allLocales": "全部语言", + "search": "搜索", + "searchPlaceholder": "搜索代码·标题·内容", + "filterAria": "消息筛选", + "loadError": "无法加载消息列表", + "emptyTitle": "暂无消息", + "emptyDesc": "没有符合条件的模板,请新增。", + "code": "代码", + "msgTitle": "标题", + "placeholders": "占位符", + "use": "使用", + "manage": "管理", + "used": "使用", + "unused": "停用", + "preview": "预览", + "edit": "编辑", + "delete": "删除", + "deleteConfirm": "要删除模板 {{code}}({{locale}})吗?", + "saved": "已保存", + "deleted": "已删除", + "errRequired": "代码、标题、内容为必填", + "useYn": "是否使用", + "template": "内容模板", + "templatePlaceholder": "使用 {key} 占位符,如 {name}、{eventName}", + "detectedParams": "检测到的参数", + "cancel": "取消", + "save": "保存", + "saving": "保存中…", + "close": "关闭", + "render": "渲染", + "rendering": "渲染中…", + "renderResult": "渲染结果", + "noParams": "此模板没有可替换参数" } -} \ 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 1b8a7d8..5bf1c6b 100644 --- a/src/frontend/src/screens/admin/AdminDashboardPage.tsx +++ b/src/frontend/src/screens/admin/AdminDashboardPage.tsx @@ -219,6 +219,7 @@ const ADMIN_MODULES: [string, string][] = [ ['admin.modRoles', '/admin/roles'], ['admin.modCodes', '/admin/codes'], ['admin.modMenus', '/admin/menus'], + ['admin.modMessages', '/admin/messages'], ['admin.modAudit', '/admin/audit'], ['admin.modSettings', '/admin/settings'], ['admin.modRulesets', '/admin/masterdata/rulesets'], diff --git a/src/frontend/src/screens/admin/MessageAdminPage.tsx b/src/frontend/src/screens/admin/MessageAdminPage.tsx new file mode 100644 index 0000000..3e6eb9e --- /dev/null +++ b/src/frontend/src/screens/admin/MessageAdminPage.tsx @@ -0,0 +1,373 @@ +/* + * M18 시스템관리 '메시지 관리' (#35 · 소유자 지시 6.6). MessageController 실배선. + * UI: 분류/로케일 필터 + 검색 → 템플릿 테이블 → 편집 모달(upsert) · 렌더 미리보기 모달({key} 파라미터 치환). + * 컨벤션: CommonCodeAdminPage 와 동일(kx-adm · react-query · admin-modules.css 재사용, 신규 CSS 없음). + */ +import { useMemo, useState } from 'react'; +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 { errMessage, useToast } from '../work/workShared'; +import { + MESSAGE_CATEGORIES, + MESSAGE_LOCALES, + extractPlaceholders, + messageApi, + type SysMessage, +} from './messageAdminApi'; +import './admin-modules.css'; + +export function MessageAdminPage() { + const { t } = useTranslation(); + const qc = useQueryClient(); + const { show, node: toast } = useToast(); + + const [category, setCategory] = useState(''); + const [locale, setLocale] = useState(''); + const [keyword, setKeyword] = useState(''); + const [editModal, setEditModal] = useState(undefined); + const [previewModal, setPreviewModal] = useState(undefined); + + const listQ = useQuery({ + queryKey: ['admin-messages', category, locale], + queryFn: () => messageApi.list({ category: category || undefined, locale: locale || undefined }), + retry: false, + }); + + const saveM = useMutation({ + mutationFn: (m: SysMessage) => messageApi.save(m), + onSuccess: () => { + show(t('msg.saved')); + setEditModal(undefined); + qc.invalidateQueries({ queryKey: ['admin-messages'] }); + }, + onError: (e) => show(errMessage(e)), + }); + const delM = useMutation({ + mutationFn: ({ code, loc }: { code: string; loc: string | null }) => + messageApi.remove(code, loc ?? undefined), + onSuccess: () => { + show(t('msg.deleted')); + qc.invalidateQueries({ queryKey: ['admin-messages'] }); + }, + onError: (e) => show(errMessage(e)), + }); + + const rows = listQ.data ?? []; + const categories = useMemo(() => { + const merged = new Set(MESSAGE_CATEGORIES); + rows.forEach((r) => merged.add(r.category)); + return Array.from(merged); + }, [rows]); + + const filtered = useMemo(() => { + const kw = keyword.trim().toLowerCase(); + if (!kw) return rows; + return rows.filter( + (r) => + r.msgCode.toLowerCase().includes(kw) || + r.title.toLowerCase().includes(kw) || + r.template.toLowerCase().includes(kw), + ); + }, [rows, keyword]); + + return ( +
+
+
+

{t('msg.title')}

+

{t('msg.subtitle')}

+
+ +
+ +
+
+ + + +
+
+ + {listQ.isLoading && } + {listQ.isError && !listQ.isLoading && ( + listQ.refetch()} /> + )} + + {!listQ.isLoading && !listQ.isError && ( + filtered.length === 0 ? ( + + ) : ( +
+
+ + + + + + + + + + + + + + {filtered.map((m) => { + const params = extractPlaceholders(m.template); + return ( + + + + + + + + + + ); + })} + +
{t('msg.code')}{t('msg.locale')}{t('msg.category')}{t('msg.msgTitle')}{t('msg.placeholders')}{t('msg.use')}{t('msg.manage')}
{m.msgCode}{m.locale ?? 'ko'}{m.category}{m.title} + {params.length > 0 ? params.map((p) => `{${p}}`).join(' ') : '—'} + + + {m.useYn === 'N' ? t('msg.unused') : t('msg.used')} + + +
+ + + +
+
+
+
+ ) + )} + + {editModal !== undefined && ( + setEditModal(undefined)} + onSubmit={(m) => saveM.mutate(m)} + /> + )} + {previewModal !== undefined && ( + setPreviewModal(undefined)} /> + )} + {toast} +
+ ); +} + +function EditModal({ + msg, + categories, + saving, + onClose, + onSubmit, +}: { + msg: SysMessage | null; + categories: string[]; + saving: boolean; + onClose: () => void; + onSubmit: (m: SysMessage) => void; +}) { + const { t } = useTranslation(); + const isNew = msg === null; + const [msgCode, setMsgCode] = useState(msg?.msgCode ?? ''); + const [loc, setLoc] = useState(msg?.locale ?? 'ko'); + const [category, setCategory] = useState(msg?.category ?? categories[0] ?? 'ACCOUNT'); + const [title, setTitle] = useState(msg?.title ?? ''); + const [template, setTemplate] = useState(msg?.template ?? ''); + const [useYn, setUseYn] = useState(msg?.useYn ?? 'Y'); + const [error, setError] = useState(null); + + const params = extractPlaceholders(template); + + function submit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + if (!msgCode.trim() || !title.trim() || !template.trim()) { + setError(t('msg.errRequired')); + return; + } + onSubmit({ + msgCode: msgCode.trim().toUpperCase(), + locale: loc, + category: category.trim().toUpperCase(), + title: title.trim(), + template, + useYn, + updatedAt: null, + }); + } + + return ( +
+
+
+

{isNew ? t('msg.add') : t('msg.edit')}

+
+ + + + + +