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 <noreply@anthropic.com>
This commit is contained in:
parent
e67db2e773
commit
ecf60aac1d
@ -72,6 +72,7 @@ import { SysUserAdminPage } from './screens/admin/SysUserAdminPage';
|
|||||||
import { RoleAdminPage } from './screens/admin/RoleAdminPage';
|
import { RoleAdminPage } from './screens/admin/RoleAdminPage';
|
||||||
import { CommonCodeAdminPage } from './screens/admin/CommonCodeAdminPage';
|
import { CommonCodeAdminPage } from './screens/admin/CommonCodeAdminPage';
|
||||||
import { MenuAdminPage } from './screens/admin/MenuAdminPage';
|
import { MenuAdminPage } from './screens/admin/MenuAdminPage';
|
||||||
|
import { MessageAdminPage } from './screens/admin/MessageAdminPage';
|
||||||
import {
|
import {
|
||||||
PublicGatewayPage,
|
PublicGatewayPage,
|
||||||
VisitorTrackPage,
|
VisitorTrackPage,
|
||||||
@ -85,6 +86,7 @@ import {
|
|||||||
PublicInquiryPage,
|
PublicInquiryPage,
|
||||||
PublicTicketPage,
|
PublicTicketPage,
|
||||||
} from './screens/public';
|
} from './screens/public';
|
||||||
|
import { NotFoundPage } from './screens/common/NotFoundPage';
|
||||||
import { AppShell } from './components/layout/AppShell';
|
import { AppShell } from './components/layout/AppShell';
|
||||||
|
|
||||||
/** SCR-22 → SCR-23 작성 화면 진입 배선. */
|
/** SCR-22 → SCR-23 작성 화면 진입 배선. */
|
||||||
@ -259,6 +261,7 @@ export function App() {
|
|||||||
<Route path="/admin/system-health" element={<AdminGuard><SystemHealthPage /></AdminGuard>} />
|
<Route path="/admin/system-health" element={<AdminGuard><SystemHealthPage /></AdminGuard>} />
|
||||||
<Route path="/admin/mail-config" element={<AdminGuard><MailConfigPage /></AdminGuard>} />
|
<Route path="/admin/mail-config" element={<AdminGuard><MailConfigPage /></AdminGuard>} />
|
||||||
<Route path="/admin/notify-config" element={<AdminGuard><NotifyConfigPage /></AdminGuard>} />
|
<Route path="/admin/notify-config" element={<AdminGuard><NotifyConfigPage /></AdminGuard>} />
|
||||||
|
<Route path="/admin/messages" element={<AdminGuard><MessageAdminPage /></AdminGuard>} />
|
||||||
<Route path="/admin/holidays" element={<AdminGuard><HolidayAdminPage /></AdminGuard>} />
|
<Route path="/admin/holidays" element={<AdminGuard><HolidayAdminPage /></AdminGuard>} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
@ -292,7 +295,8 @@ export function App() {
|
|||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="/" element={<RootGate />} />
|
<Route path="/" element={<RootGate />} />
|
||||||
<Route path="*" element={<RootGate />} />
|
{/* 404 — 미매칭 경로(공개·인증 공통, 셸 비의존 전폭 카드 · 4.4) */}
|
||||||
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
373
src/frontend/src/components/KxDataTable.tsx
Normal file
373
src/frontend/src/components/KxDataTable.tsx
Normal file
@ -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 로 주입(검색/정렬/페이지는 이 컴포넌트가 담당).
|
||||||
|
*
|
||||||
|
* 사용 예:
|
||||||
|
* <KxDataTable
|
||||||
|
* columns={[{ key:'name', header:t('...'), value:(r)=>r.name },
|
||||||
|
* { key:'amount', header:t('...'), numeric:true, value:(r)=>r.amount,
|
||||||
|
* render:(r)=><span className="tnum">{r.amount.toLocaleString()}</span> }]}
|
||||||
|
* 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<T> {
|
||||||
|
/** 안정 키(정렬·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<T> {
|
||||||
|
columns: KxColumn<T>[];
|
||||||
|
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<T>({
|
||||||
|
columns,
|
||||||
|
rows,
|
||||||
|
rowKey,
|
||||||
|
onRowClick,
|
||||||
|
isRowSelected,
|
||||||
|
rowClassName,
|
||||||
|
zebra = true,
|
||||||
|
searchable = true,
|
||||||
|
searchPlaceholder,
|
||||||
|
pageSizeOptions = [10, 25, 50],
|
||||||
|
initialPageSize,
|
||||||
|
csvFileName,
|
||||||
|
toolbarStart,
|
||||||
|
toolbarEnd,
|
||||||
|
ariaLabel,
|
||||||
|
emptyTitle,
|
||||||
|
emptyDescription,
|
||||||
|
className = '',
|
||||||
|
}: KxDataTableProps<T>) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [sort, setSort] = useState<SortState | null>(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<T>) => (c.sortable ?? !!c.value) && !!c.value;
|
||||||
|
const canSearch = (c: KxColumn<T>) => (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 (
|
||||||
|
<div className={`kx-dt ${className}`}>
|
||||||
|
{showToolbar && (
|
||||||
|
<div className="kx-dt__toolbar">
|
||||||
|
{toolbarStart}
|
||||||
|
{searchable && (
|
||||||
|
<label className="kx-dt__search">
|
||||||
|
<IconSearch size={16} className="kx-dt__search-ic" />
|
||||||
|
<input
|
||||||
|
className="kx-dt__search-input"
|
||||||
|
type="search"
|
||||||
|
value={query}
|
||||||
|
placeholder={searchPlaceholder ?? t('datatable.search')}
|
||||||
|
onChange={(e) => onSearch(e.target.value)}
|
||||||
|
aria-label={searchPlaceholder ?? t('datatable.searchAria')}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div className="kx-dt__toolbar-end">
|
||||||
|
{toolbarEnd}
|
||||||
|
{csvFileName && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="kx-dt__csv"
|
||||||
|
onClick={exportCsv}
|
||||||
|
disabled={sorted.length === 0}
|
||||||
|
>
|
||||||
|
<IconDownload size={16} />
|
||||||
|
{t('datatable.csv')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="kx-table-scroll">
|
||||||
|
<table
|
||||||
|
className={`kx-table ${zebra ? 'kx-table--zebra' : ''}`}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{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 (
|
||||||
|
<th
|
||||||
|
key={c.key}
|
||||||
|
className={`${c.numeric ? 'kx-num' : ''} ${c.thClassName ?? ''}`}
|
||||||
|
aria-sort={sortable ? (ariaSort as 'ascending' | 'descending' | 'none') : undefined}
|
||||||
|
scope="col"
|
||||||
|
>
|
||||||
|
{sortable ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`kx-dt__sort-btn ${c.numeric ? 'kx-dt__sort-btn--num' : ''}`}
|
||||||
|
data-dir={dir ?? 'none'}
|
||||||
|
onClick={() => toggleSort(c.key)}
|
||||||
|
title={
|
||||||
|
dir === 'asc'
|
||||||
|
? t('datatable.sortDesc')
|
||||||
|
: dir === 'desc'
|
||||||
|
? t('datatable.sortClear')
|
||||||
|
: t('datatable.sortAsc')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="kx-dt__sort-label">{c.header}</span>
|
||||||
|
<IconSort size={14} className="kx-dt__sort-ic" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
c.header
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{pageRows.map((r) => {
|
||||||
|
const selected = isRowSelected?.(r);
|
||||||
|
const extra = rowClassName?.(r) ?? '';
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={rowKey(r)}
|
||||||
|
className={`${onRowClick ? 'kx-dt__row--click' : ''} ${selected ? 'is-selected' : ''} ${extra}`}
|
||||||
|
aria-selected={isRowSelected ? !!selected : undefined}
|
||||||
|
tabIndex={onRowClick ? 0 : undefined}
|
||||||
|
onClick={onRowClick ? () => onRowClick(r) : undefined}
|
||||||
|
onKeyDown={
|
||||||
|
onRowClick
|
||||||
|
? (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onRowClick(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{columns.map((c) => (
|
||||||
|
<td key={c.key} className={`${c.numeric ? 'kx-num tnum' : ''} ${c.tdClassName ?? ''}`}>
|
||||||
|
{c.render ? c.render(r) : (c.value ? String(c.value(r) ?? '-') : null)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{pageRows.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={columns.length} className="kx-dt__empty-cell">
|
||||||
|
<EmptyState
|
||||||
|
title={emptyTitle ?? t('datatable.emptyTitle')}
|
||||||
|
description={emptyDescription ?? (query ? t('datatable.emptyFiltered') : undefined)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="kx-dt__footer">
|
||||||
|
<span className="kx-dt__count tnum">
|
||||||
|
{t('datatable.count', { shown: pageRows.length, total: sorted.length })}
|
||||||
|
</span>
|
||||||
|
<div className="kx-dt__pager">
|
||||||
|
<label className="kx-dt__pagesize">
|
||||||
|
<span className="kx-dt__pagesize-label">{t('datatable.pageSize')}</span>
|
||||||
|
<select
|
||||||
|
className="kx-select kx-dt__pagesize-select"
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => onPageSize(Number(e.target.value))}
|
||||||
|
aria-label={t('datatable.pageSize')}
|
||||||
|
>
|
||||||
|
{pageSizeOptions.map((n) => (
|
||||||
|
<option key={n} value={n}>
|
||||||
|
{n}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="kx-dt__pagenav">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="kx-dt__page-btn"
|
||||||
|
disabled={safePage <= 0}
|
||||||
|
onClick={() => setPage(Math.max(0, safePage - 1))}
|
||||||
|
>
|
||||||
|
{t('datatable.prev')}
|
||||||
|
</button>
|
||||||
|
<span className="kx-dt__pageinfo tnum">
|
||||||
|
{t('datatable.pageInfo', { page: safePage + 1, total: totalPages })}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="kx-dt__page-btn"
|
||||||
|
disabled={safePage + 1 >= totalPages}
|
||||||
|
onClick={() => setPage(safePage + 1)}
|
||||||
|
>
|
||||||
|
{t('datatable.next')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
229
src/frontend/src/components/data-table.css
Normal file
229
src/frontend/src/components/data-table.css
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@ import { useMdiStore } from '../../store/mdiStore';
|
|||||||
import { useUiStore, isMobileViewport } from '../../store/uiStore';
|
import { useUiStore, isMobileViewport } from '../../store/uiStore';
|
||||||
import { useIsAdmin } from '../../screens/admin/AdminGuard';
|
import { useIsAdmin } from '../../screens/admin/AdminGuard';
|
||||||
import { MdiTabBar } from './MdiTabBar';
|
import { MdiTabBar } from './MdiTabBar';
|
||||||
|
import { Breadcrumb } from './Breadcrumb';
|
||||||
import { ShellFooter } from './ShellFooter';
|
import { ShellFooter } from './ShellFooter';
|
||||||
import { SettingsCustomizer } from './SettingsCustomizer';
|
import { SettingsCustomizer } from './SettingsCustomizer';
|
||||||
import { labelForPath } from './mdiLabels';
|
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-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-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-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: 'sec-masteretc', label: '마스터·기타', labelKey: 'shell.menu.sections.masterEtc', section: true },
|
||||||
{ key: 'admin-tenants', label: '테넌트', labelKey: 'shell.menu.items.tenants', Icon: IconExhibitors, to: '/admin/tenants' },
|
{ key: 'admin-tenants', label: '테넌트', labelKey: 'shell.menu.items.tenants', Icon: IconExhibitors, to: '/admin/tenants' },
|
||||||
@ -503,6 +505,9 @@ export function AppShell() {
|
|||||||
|
|
||||||
<MdiTabBar />
|
<MdiTabBar />
|
||||||
|
|
||||||
|
{/* 브레드크럼 — 콘텐츠 영역 상단(홈>섹션>현재, 조각 클릭 이동 · 4.4). */}
|
||||||
|
<Breadcrumb />
|
||||||
|
|
||||||
<main className="kx-shell__content" id="kx-main-content">
|
<main className="kx-shell__content" id="kx-main-content">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
54
src/frontend/src/components/layout/Breadcrumb.tsx
Normal file
54
src/frontend/src/components/layout/Breadcrumb.tsx
Normal file
@ -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 (
|
||||||
|
<nav className="kx-breadcrumb" aria-label={t('breadcrumb.aria')}>
|
||||||
|
<ol className="kx-breadcrumb__list">
|
||||||
|
{crumbs.map((c, i) => {
|
||||||
|
const last = i === crumbs.length - 1;
|
||||||
|
return (
|
||||||
|
<li key={`${c.labelKey ?? c.label ?? i}-${i}`} className="kx-breadcrumb__item">
|
||||||
|
{i > 0 && (
|
||||||
|
<IconChevronRight size={14} className="kx-breadcrumb__sep" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
{c.to && !last ? (
|
||||||
|
<NavLink to={c.to} className="kx-breadcrumb__link">
|
||||||
|
{label(c)}
|
||||||
|
</NavLink>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={`kx-breadcrumb__cur ${last ? 'is-current' : ''}`}
|
||||||
|
aria-current={last ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
{label(c)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
src/frontend/src/components/layout/breadcrumb.css
Normal file
60
src/frontend/src/components/layout/breadcrumb.css
Normal file
@ -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);
|
||||||
|
}
|
||||||
125
src/frontend/src/components/layout/breadcrumbConfig.ts
Normal file
125
src/frontend/src/components/layout/breadcrumbConfig.ts
Normal file
@ -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<string, Crumb[]> = {
|
||||||
|
// 전시 운영
|
||||||
|
'/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) }];
|
||||||
|
}
|
||||||
@ -55,6 +55,7 @@ const STATIC_LABELS: Record<string, string> = {
|
|||||||
'/admin/roles': '역할 관리',
|
'/admin/roles': '역할 관리',
|
||||||
'/admin/codes': '공통 코드',
|
'/admin/codes': '공통 코드',
|
||||||
'/admin/menus': '메뉴 관리',
|
'/admin/menus': '메뉴 관리',
|
||||||
|
'/admin/messages': '메시지 관리',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 동적 경로 패턴(정규식) → 라벨. 구체적 규칙을 먼저 둔다. */
|
/** 동적 경로 패턴(정규식) → 라벨. 구체적 규칙을 먼저 둔다. */
|
||||||
|
|||||||
@ -304,6 +304,16 @@ export function IconChevronDown(p: IconProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** ⇅ 정렬(위/아래 셰브런) — 데이터테이블 헤더. 활성 방향은 CSS 로 강조(.kx-sort-up/.kx-sort-down). */
|
||||||
|
export function IconSort(p: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg {...p}>
|
||||||
|
<path className="kx-sort-up" d="M8 10L12 6L16 10" {...SRJ} />
|
||||||
|
<path className="kx-sort-down" d="M8 14L12 18L16 14" {...SRJ} />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** ‹› 좌우 화살촉(비교 슬라이더 그립). */
|
/** ‹› 좌우 화살촉(비교 슬라이더 그립). */
|
||||||
export function IconCompareArrows(p: IconProps) {
|
export function IconCompareArrows(p: IconProps) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -90,7 +90,8 @@
|
|||||||
"notifyConfig": "Notify Config",
|
"notifyConfig": "Notify Config",
|
||||||
"tenants": "Tenants",
|
"tenants": "Tenants",
|
||||||
"rulesets": "Rulesets",
|
"rulesets": "Rulesets",
|
||||||
"slides": "Login Slides"
|
"slides": "Login Slides",
|
||||||
|
"msgTemplates": "Messages"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"userOrg": "Users & Org",
|
"userOrg": "Users & Org",
|
||||||
@ -1320,7 +1321,8 @@
|
|||||||
"modAudit": "Audit log",
|
"modAudit": "Audit log",
|
||||||
"modSettings": "System settings",
|
"modSettings": "System settings",
|
||||||
"modRulesets": "Rulesets",
|
"modRulesets": "Rulesets",
|
||||||
"modTenants": "Tenants"
|
"modTenants": "Tenants",
|
||||||
|
"modMessages": "Messages"
|
||||||
},
|
},
|
||||||
"tenant": {
|
"tenant": {
|
||||||
"title": "Tenant Management · Onboarding",
|
"title": "Tenant Management · Onboarding",
|
||||||
@ -2701,5 +2703,86 @@
|
|||||||
"corner": "Corner",
|
"corner": "Corner",
|
||||||
"island": "Island"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -90,7 +90,8 @@
|
|||||||
"notifyConfig": "通知設定",
|
"notifyConfig": "通知設定",
|
||||||
"tenants": "テナント",
|
"tenants": "テナント",
|
||||||
"rulesets": "ルールセット",
|
"rulesets": "ルールセット",
|
||||||
"slides": "ログインスライド"
|
"slides": "ログインスライド",
|
||||||
|
"msgTemplates": "メッセージ管理"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"userOrg": "ユーザー・組織",
|
"userOrg": "ユーザー・組織",
|
||||||
@ -1320,7 +1321,8 @@
|
|||||||
"modAudit": "監査ログ",
|
"modAudit": "監査ログ",
|
||||||
"modSettings": "システム設定",
|
"modSettings": "システム設定",
|
||||||
"modRulesets": "ルールセット",
|
"modRulesets": "ルールセット",
|
||||||
"modTenants": "テナント"
|
"modTenants": "テナント",
|
||||||
|
"modMessages": "メッセージ管理"
|
||||||
},
|
},
|
||||||
"tenant": {
|
"tenant": {
|
||||||
"title": "テナント管理·オンボーディング",
|
"title": "テナント管理·オンボーディング",
|
||||||
@ -2701,5 +2703,86 @@
|
|||||||
"corner": "コーナーブース",
|
"corner": "コーナーブース",
|
||||||
"island": "アイランドブース"
|
"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": "置換パラメータのないテンプレートです"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -90,7 +90,8 @@
|
|||||||
"notifyConfig": "알림 설정",
|
"notifyConfig": "알림 설정",
|
||||||
"tenants": "테넌트",
|
"tenants": "테넌트",
|
||||||
"rulesets": "룰셋",
|
"rulesets": "룰셋",
|
||||||
"slides": "로그인 슬라이드"
|
"slides": "로그인 슬라이드",
|
||||||
|
"msgTemplates": "메시지 관리"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"userOrg": "사용자·조직",
|
"userOrg": "사용자·조직",
|
||||||
@ -1320,7 +1321,8 @@
|
|||||||
"modAudit": "감사로그",
|
"modAudit": "감사로그",
|
||||||
"modSettings": "시스템설정",
|
"modSettings": "시스템설정",
|
||||||
"modRulesets": "룰셋",
|
"modRulesets": "룰셋",
|
||||||
"modTenants": "테넌트"
|
"modTenants": "테넌트",
|
||||||
|
"modMessages": "메시지 관리"
|
||||||
},
|
},
|
||||||
"tenant": {
|
"tenant": {
|
||||||
"title": "테넌트 관리·온보딩",
|
"title": "테넌트 관리·온보딩",
|
||||||
@ -2701,5 +2703,86 @@
|
|||||||
"corner": "코너부스",
|
"corner": "코너부스",
|
||||||
"island": "아일랜드부스"
|
"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": "치환할 파라미터가 없는 템플릿입니다"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -90,7 +90,8 @@
|
|||||||
"notifyConfig": "通知设置",
|
"notifyConfig": "通知设置",
|
||||||
"tenants": "租户",
|
"tenants": "租户",
|
||||||
"rulesets": "规则集",
|
"rulesets": "规则集",
|
||||||
"slides": "登录幻灯片"
|
"slides": "登录幻灯片",
|
||||||
|
"msgTemplates": "消息管理"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"userOrg": "用户·组织",
|
"userOrg": "用户·组织",
|
||||||
@ -1320,7 +1321,8 @@
|
|||||||
"modAudit": "审计日志",
|
"modAudit": "审计日志",
|
||||||
"modSettings": "系统设置",
|
"modSettings": "系统设置",
|
||||||
"modRulesets": "规则集",
|
"modRulesets": "规则集",
|
||||||
"modTenants": "租户"
|
"modTenants": "租户",
|
||||||
|
"modMessages": "消息管理"
|
||||||
},
|
},
|
||||||
"tenant": {
|
"tenant": {
|
||||||
"title": "租户管理·上线",
|
"title": "租户管理·上线",
|
||||||
@ -2701,5 +2703,86 @@
|
|||||||
"corner": "转角展位",
|
"corner": "转角展位",
|
||||||
"island": "岛式展位"
|
"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": "此模板没有可替换参数"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -219,6 +219,7 @@ const ADMIN_MODULES: [string, string][] = [
|
|||||||
['admin.modRoles', '/admin/roles'],
|
['admin.modRoles', '/admin/roles'],
|
||||||
['admin.modCodes', '/admin/codes'],
|
['admin.modCodes', '/admin/codes'],
|
||||||
['admin.modMenus', '/admin/menus'],
|
['admin.modMenus', '/admin/menus'],
|
||||||
|
['admin.modMessages', '/admin/messages'],
|
||||||
['admin.modAudit', '/admin/audit'],
|
['admin.modAudit', '/admin/audit'],
|
||||||
['admin.modSettings', '/admin/settings'],
|
['admin.modSettings', '/admin/settings'],
|
||||||
['admin.modRulesets', '/admin/masterdata/rulesets'],
|
['admin.modRulesets', '/admin/masterdata/rulesets'],
|
||||||
|
|||||||
373
src/frontend/src/screens/admin/MessageAdminPage.tsx
Normal file
373
src/frontend/src/screens/admin/MessageAdminPage.tsx
Normal file
@ -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<SysMessage | null | undefined>(undefined);
|
||||||
|
const [previewModal, setPreviewModal] = useState<SysMessage | undefined>(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<string>(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 (
|
||||||
|
<div className="kx-page kx-adm">
|
||||||
|
<header className="kx-adm__head">
|
||||||
|
<div>
|
||||||
|
<h1 className="kx-adm__title">{t('msg.title')}</h1>
|
||||||
|
<p className="kx-adm__subtitle">{t('msg.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<Button leadingIcon={<IconPlus size={16} />} onClick={() => setEditModal(null)}>
|
||||||
|
{t('msg.add')}
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="kx-card kx-adm-filter" aria-label={t('msg.filterAria')}>
|
||||||
|
<div className="kx-adm-filter__grid">
|
||||||
|
<label className="kx-adm-field">
|
||||||
|
<span className="kx-adm-field__label">{t('msg.category')}</span>
|
||||||
|
<select className="kx-adm-field__select" value={category} onChange={(e) => setCategory(e.target.value)}>
|
||||||
|
<option value="">{t('msg.allCategories')}</option>
|
||||||
|
{categories.map((c) => (
|
||||||
|
<option key={c} value={c}>{c}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="kx-adm-field">
|
||||||
|
<span className="kx-adm-field__label">{t('msg.locale')}</span>
|
||||||
|
<select className="kx-adm-field__select" value={locale} onChange={(e) => setLocale(e.target.value)}>
|
||||||
|
<option value="">{t('msg.allLocales')}</option>
|
||||||
|
{MESSAGE_LOCALES.map((l) => (
|
||||||
|
<option key={l} value={l}>{l}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="kx-adm-field">
|
||||||
|
<span className="kx-adm-field__label">{t('msg.search')}</span>
|
||||||
|
<input
|
||||||
|
className="kx-adm-field__input"
|
||||||
|
placeholder={t('msg.searchPlaceholder')}
|
||||||
|
value={keyword}
|
||||||
|
onChange={(e) => setKeyword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{listQ.isLoading && <Skeleton height={320} radius={12} />}
|
||||||
|
{listQ.isError && !listQ.isLoading && (
|
||||||
|
<ErrorState message={t('msg.loadError')} onRetry={() => listQ.refetch()} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!listQ.isLoading && !listQ.isError && (
|
||||||
|
filtered.length === 0 ? (
|
||||||
|
<EmptyState title={t('msg.emptyTitle')} description={t('msg.emptyDesc')} />
|
||||||
|
) : (
|
||||||
|
<section className="kx-card" aria-label={t('msg.title')}>
|
||||||
|
<div className="kx-table-scroll">
|
||||||
|
<table className="kx-table kx-table--zebra kx-adm-tbl">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t('msg.code')}</th>
|
||||||
|
<th>{t('msg.locale')}</th>
|
||||||
|
<th>{t('msg.category')}</th>
|
||||||
|
<th>{t('msg.msgTitle')}</th>
|
||||||
|
<th>{t('msg.placeholders')}</th>
|
||||||
|
<th>{t('msg.use')}</th>
|
||||||
|
<th style={{ textAlign: 'right' }}>{t('msg.manage')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.map((m) => {
|
||||||
|
const params = extractPlaceholders(m.template);
|
||||||
|
return (
|
||||||
|
<tr key={`${m.msgCode}:${m.locale}`}>
|
||||||
|
<td className="kx-adm-mono">{m.msgCode}</td>
|
||||||
|
<td className="kx-adm-mono">{m.locale ?? 'ko'}</td>
|
||||||
|
<td className="kx-adm-muted">{m.category}</td>
|
||||||
|
<td className="kx-adm-tbl__strong">{m.title}</td>
|
||||||
|
<td className="kx-adm-muted kx-adm-mono">
|
||||||
|
{params.length > 0 ? params.map((p) => `{${p}}`).join(' ') : '—'}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={`kx-adm-pill kx-adm-pill--${m.useYn === 'N' ? 'neutral' : 'success'}`}>
|
||||||
|
{m.useYn === 'N' ? t('msg.unused') : t('msg.used')}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="kx-adm-tbl__actions">
|
||||||
|
<Button variant="ghost" onClick={() => setPreviewModal(m)}>{t('msg.preview')}</Button>
|
||||||
|
<Button variant="ghost" onClick={() => setEditModal(m)}>{t('msg.edit')}</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="kx-adm-del"
|
||||||
|
disabled={delM.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(t('msg.deleteConfirm', { code: m.msgCode, locale: m.locale ?? 'ko' })))
|
||||||
|
delM.mutate({ code: m.msgCode, loc: m.locale });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('msg.delete')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editModal !== undefined && (
|
||||||
|
<EditModal
|
||||||
|
msg={editModal}
|
||||||
|
categories={categories}
|
||||||
|
saving={saveM.isPending}
|
||||||
|
onClose={() => setEditModal(undefined)}
|
||||||
|
onSubmit={(m) => saveM.mutate(m)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{previewModal !== undefined && (
|
||||||
|
<PreviewModal msg={previewModal} onClose={() => setPreviewModal(undefined)} />
|
||||||
|
)}
|
||||||
|
{toast}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string | null>(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 (
|
||||||
|
<div className="kx-adm-modal" role="dialog" aria-modal="true" aria-labelledby="msg-modal-title">
|
||||||
|
<div className="kx-adm-modal__backdrop" onClick={onClose} />
|
||||||
|
<form className="kx-adm-modal__panel" onSubmit={submit}>
|
||||||
|
<h2 id="msg-modal-title" className="kx-adm-modal__title">{isNew ? t('msg.add') : t('msg.edit')}</h2>
|
||||||
|
<div className="kx-adm-modal__grid">
|
||||||
|
<label className="kx-adm-field">
|
||||||
|
<span className="kx-adm-field__label">{t('msg.code')}</span>
|
||||||
|
<input
|
||||||
|
className="kx-adm-field__input kx-adm-mono"
|
||||||
|
value={msgCode}
|
||||||
|
disabled={!isNew}
|
||||||
|
placeholder="AUCTION_INVITE"
|
||||||
|
onChange={(e) => setMsgCode(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="kx-adm-field">
|
||||||
|
<span className="kx-adm-field__label">{t('msg.locale')}</span>
|
||||||
|
<select className="kx-adm-field__select" value={loc} disabled={!isNew} onChange={(e) => setLoc(e.target.value)}>
|
||||||
|
{MESSAGE_LOCALES.map((l) => (
|
||||||
|
<option key={l} value={l}>{l}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="kx-adm-field">
|
||||||
|
<span className="kx-adm-field__label">{t('msg.category')}</span>
|
||||||
|
<input
|
||||||
|
className="kx-adm-field__input kx-adm-mono"
|
||||||
|
value={category}
|
||||||
|
list="msg-category-options"
|
||||||
|
onChange={(e) => setCategory(e.target.value)}
|
||||||
|
/>
|
||||||
|
<datalist id="msg-category-options">
|
||||||
|
{categories.map((c) => (
|
||||||
|
<option key={c} value={c} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
</label>
|
||||||
|
<label className="kx-adm-field">
|
||||||
|
<span className="kx-adm-field__label">{t('msg.useYn')}</span>
|
||||||
|
<select className="kx-adm-field__select" value={useYn ?? 'Y'} onChange={(e) => setUseYn(e.target.value)}>
|
||||||
|
<option value="Y">{t('msg.used')}</option>
|
||||||
|
<option value="N">{t('msg.unused')}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="kx-adm-field kx-adm-field--full">
|
||||||
|
<span className="kx-adm-field__label">{t('msg.msgTitle')}</span>
|
||||||
|
<input className="kx-adm-field__input" value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="kx-adm-field kx-adm-field--full">
|
||||||
|
<span className="kx-adm-field__label">{t('msg.template')}</span>
|
||||||
|
<textarea
|
||||||
|
className="kx-adm-field__input"
|
||||||
|
rows={5}
|
||||||
|
value={template}
|
||||||
|
placeholder={t('msg.templatePlaceholder')}
|
||||||
|
onChange={(e) => setTemplate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{params.length > 0 && (
|
||||||
|
<p className="kx-adm-muted kx-adm-field--full">
|
||||||
|
{t('msg.detectedParams')}: <span className="kx-adm-mono">{params.map((p) => `{${p}}`).join(' ')}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error && <p className="kx-adm-modal__error" role="alert">{error}</p>}
|
||||||
|
<div className="kx-adm-modal__actions">
|
||||||
|
<Button variant="ghost" type="button" onClick={onClose}>{t('msg.cancel')}</Button>
|
||||||
|
<Button type="submit" disabled={saving}>{saving ? t('msg.saving') : t('msg.save')}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreviewModal({ msg, onClose }: { msg: SysMessage; onClose: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const params = extractPlaceholders(msg.template);
|
||||||
|
const [values, setValues] = useState<Record<string, string>>({});
|
||||||
|
const renderM = useMutation({
|
||||||
|
mutationFn: () => messageApi.render(msg.msgCode, msg.locale, values),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="kx-adm-modal" role="dialog" aria-modal="true" aria-labelledby="msg-preview-title">
|
||||||
|
<div className="kx-adm-modal__backdrop" onClick={onClose} />
|
||||||
|
<div className="kx-adm-modal__panel">
|
||||||
|
<h2 id="msg-preview-title" className="kx-adm-modal__title">{t('msg.preview')}</h2>
|
||||||
|
<p className="kx-adm-modal__desc">
|
||||||
|
<span className="kx-adm-mono">{msg.msgCode}</span> · {msg.locale ?? 'ko'} · {msg.category}
|
||||||
|
</p>
|
||||||
|
<div className="kx-adm-modal__grid">
|
||||||
|
<label className="kx-adm-field kx-adm-field--full">
|
||||||
|
<span className="kx-adm-field__label">{t('msg.template')}</span>
|
||||||
|
<textarea className="kx-adm-field__input kx-adm-mono" rows={4} value={msg.template} readOnly />
|
||||||
|
</label>
|
||||||
|
{params.map((p) => (
|
||||||
|
<label key={p} className="kx-adm-field">
|
||||||
|
<span className="kx-adm-field__label kx-adm-mono">{`{${p}}`}</span>
|
||||||
|
<input
|
||||||
|
className="kx-adm-field__input"
|
||||||
|
value={values[p] ?? ''}
|
||||||
|
onChange={(e) => setValues((v) => ({ ...v, [p]: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{params.length === 0 && <p className="kx-adm-muted kx-adm-field--full">{t('msg.noParams')}</p>}
|
||||||
|
{renderM.data && (
|
||||||
|
<div className="kx-adm-field--full">
|
||||||
|
<span className="kx-adm-field__label">{t('msg.renderResult')}</span>
|
||||||
|
<p className="kx-card" style={{ padding: 12, whiteSpace: 'pre-wrap' }}>{renderM.data.text}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{renderM.isError && (
|
||||||
|
<p className="kx-adm-modal__error kx-adm-field--full" role="alert">{errMessage(renderM.error)}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="kx-adm-modal__actions">
|
||||||
|
<Button variant="ghost" type="button" onClick={onClose}>{t('msg.close')}</Button>
|
||||||
|
<Button type="button" disabled={renderM.isPending} onClick={() => renderM.mutate()}>
|
||||||
|
{renderM.isPending ? t('msg.rendering') : t('msg.render')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
src/frontend/src/screens/admin/messageAdminApi.ts
Normal file
61
src/frontend/src/screens/admin/messageAdminApi.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
/*
|
||||||
|
* M18 시스템관리 '메시지 관리' 실배선 API 계약 (#35 · V48 sys_message).
|
||||||
|
* 정본: MessageController(com.zioinfo.kintex.system.message) —
|
||||||
|
* - GET /api/admin/messages?category=&locale= 목록 [ADMIN]
|
||||||
|
* - GET /api/admin/messages/{code} 코드별 로케일 [ADMIN]
|
||||||
|
* - POST /api/admin/messages 생성/수정 upsert [ADMIN]
|
||||||
|
* - DELETE /api/admin/messages/{code}?locale= 삭제(locale 옵션)[ADMIN]
|
||||||
|
* - POST /api/messages/{code}/render?locale= {params} 치환 미리보기 [인증]
|
||||||
|
* 템플릿은 {name}·{eventName} 등 중괄호 플레이스홀더 — 실값(PII·시크릿) 미저장.
|
||||||
|
*/
|
||||||
|
import { api } from '../../api/client';
|
||||||
|
|
||||||
|
export interface SysMessage {
|
||||||
|
msgCode: string;
|
||||||
|
locale: string | null;
|
||||||
|
category: string;
|
||||||
|
title: string;
|
||||||
|
template: string;
|
||||||
|
useYn: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderResult {
|
||||||
|
code: string;
|
||||||
|
locale: string | null;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const messageApi = {
|
||||||
|
list: (p: { category?: string; locale?: string } = {}) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (p.category) qs.set('category', p.category);
|
||||||
|
if (p.locale) qs.set('locale', p.locale);
|
||||||
|
const q = qs.toString();
|
||||||
|
return api.get<SysMessage[]>(`/api/admin/messages${q ? `?${q}` : ''}`);
|
||||||
|
},
|
||||||
|
byCode: (code: string) => api.get<SysMessage[]>(`/api/admin/messages/${encodeURIComponent(code)}`),
|
||||||
|
save: (msg: SysMessage) => api.post<void>('/api/admin/messages', msg),
|
||||||
|
remove: (code: string, locale?: string) =>
|
||||||
|
api.del<void>(
|
||||||
|
`/api/admin/messages/${encodeURIComponent(code)}${locale ? `?locale=${encodeURIComponent(locale)}` : ''}`,
|
||||||
|
),
|
||||||
|
render: (code: string, locale: string | null, params: Record<string, string>) =>
|
||||||
|
api.post<RenderResult>(
|
||||||
|
`/api/messages/${encodeURIComponent(code)}/render${locale ? `?locale=${encodeURIComponent(locale)}` : ''}`,
|
||||||
|
{ params },
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** V48 시드 분류 정본 — 백엔드는 자유 문자열이나 UI 는 이 목록 + 목록에서 발견된 값을 병합해 노출. */
|
||||||
|
export const MESSAGE_CATEGORIES = ['AUCTION', 'SETTLEMENT', 'ACCOUNT', 'VISITOR', 'MARKETING'] as const;
|
||||||
|
export const MESSAGE_LOCALES = ['ko', 'en', 'zh', 'ja'] as const;
|
||||||
|
|
||||||
|
/** template 의 {key} 플레이스홀더 추출(중복 제거·등장 순서 유지). */
|
||||||
|
export function extractPlaceholders(template: string): string[] {
|
||||||
|
const found: string[] = [];
|
||||||
|
for (const m of template.matchAll(/\{(\w+)\}/g)) {
|
||||||
|
if (!found.includes(m[1])) found.push(m[1]);
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
46
src/frontend/src/screens/common/NotFoundPage.tsx
Normal file
46
src/frontend/src/screens/common/NotFoundPage.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
/*
|
||||||
|
* 404 — 페이지를 찾을 수 없음 (소유자 지시 4.4). 라우터 catch-all(`*`).
|
||||||
|
* 공개(미인증)·인증 영역 모두 커버 — 셸에 의존하지 않는 전폭 중앙 카드(Nifty 톤).
|
||||||
|
* 홈 복귀 버튼은 인증 시 역할별 랜딩, 미인증 시 공개 관문(/)으로 이동한다.
|
||||||
|
* 선 SVG 아이콘만 사용(이모지 금지).
|
||||||
|
*/
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
import { landingPathFor } from '../../lib/roleTrack';
|
||||||
|
import { Button } from '../../components/ui/Button';
|
||||||
|
import { IconArrowLeft, IconHome, IconSearch } from '../../components/ui/icons';
|
||||||
|
import './not-found.css';
|
||||||
|
|
||||||
|
export function NotFoundPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const workspaces = useAuthStore((s) => s.workspaces);
|
||||||
|
|
||||||
|
const homePath = isAuthenticated ? landingPathFor(user, workspaces) : '/';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="kx-nf">
|
||||||
|
<div className="kx-nf__card kx-card" role="alert" aria-labelledby="kx-nf-title">
|
||||||
|
<div className="kx-nf__icon" aria-hidden="true">
|
||||||
|
<IconSearch size={40} />
|
||||||
|
</div>
|
||||||
|
<p className="kx-nf__code">404</p>
|
||||||
|
<h1 id="kx-nf-title" className="kx-nf__title">
|
||||||
|
{t('notFound.title')}
|
||||||
|
</h1>
|
||||||
|
<p className="kx-nf__desc">{t('notFound.desc')}</p>
|
||||||
|
<div className="kx-nf__actions">
|
||||||
|
<Button variant="primary" leadingIcon={<IconHome size={16} />} onClick={() => navigate(homePath)}>
|
||||||
|
{t('notFound.home')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" leadingIcon={<IconArrowLeft size={16} />} onClick={() => navigate(-1)}>
|
||||||
|
{t('notFound.back')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
src/frontend/src/screens/common/not-found.css
Normal file
60
src/frontend/src/screens/common/not-found.css
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
/*
|
||||||
|
* 404 화면 — 전폭 중앙 정렬 카드(Nifty 톤). kx 토큰만 · 다크/라이트 · 선 SVG.
|
||||||
|
*/
|
||||||
|
@import '../shared.css';
|
||||||
|
|
||||||
|
.kx-nf {
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-6);
|
||||||
|
background: var(--color-neutral-050);
|
||||||
|
}
|
||||||
|
.kx-nf__card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 460px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-8) var(--space-5);
|
||||||
|
}
|
||||||
|
.kx-nf__icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-primary-050);
|
||||||
|
color: var(--color-primary-600);
|
||||||
|
}
|
||||||
|
.kx-nf__code {
|
||||||
|
font-size: var(--fs-display);
|
||||||
|
line-height: var(--lh-display);
|
||||||
|
font-weight: var(--fw-bold);
|
||||||
|
color: var(--color-neutral-500);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.kx-nf__title {
|
||||||
|
font-size: var(--fs-h1);
|
||||||
|
line-height: var(--lh-h1);
|
||||||
|
font-weight: var(--fw-bold);
|
||||||
|
color: var(--color-neutral-900);
|
||||||
|
}
|
||||||
|
.kx-nf__desc {
|
||||||
|
font-size: var(--fs-body);
|
||||||
|
line-height: var(--lh-body);
|
||||||
|
color: var(--color-neutral-500);
|
||||||
|
max-width: 340px;
|
||||||
|
}
|
||||||
|
.kx-nf__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
@ -11,6 +11,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
|
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
|
||||||
import { Offcanvas } from '../../components/ui/Offcanvas';
|
import { Offcanvas } from '../../components/ui/Offcanvas';
|
||||||
|
import { KxDataTable, type KxColumn } from '../../components/KxDataTable';
|
||||||
import { IconUsers, IconExhibitors, IconCheckCircle, IconSettlement } from '../../components/ui/icons';
|
import { IconUsers, IconExhibitors, IconCheckCircle, IconSettlement } from '../../components/ui/icons';
|
||||||
import { useResolvedEventId } from '../../hooks/useResolvedEventId';
|
import { useResolvedEventId } from '../../hooks/useResolvedEventId';
|
||||||
import { exhibitorsApi } from './exhibitorsApi';
|
import { exhibitorsApi } from './exhibitorsApi';
|
||||||
@ -60,7 +61,6 @@ function fmtArea(a: number | null): string {
|
|||||||
export function ExhibitorsPage() {
|
export function ExhibitorsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const eventId = useResolvedEventId();
|
const eventId = useResolvedEventId();
|
||||||
const [keyword, setKeyword] = useState('');
|
|
||||||
const [saleFilter, setSaleFilter] = useState<SaleFilter>('');
|
const [saleFilter, setSaleFilter] = useState<SaleFilter>('');
|
||||||
const [selected, setSelected] = useState<ExhibitorRow | null>(null);
|
const [selected, setSelected] = useState<ExhibitorRow | null>(null);
|
||||||
|
|
||||||
@ -71,18 +71,42 @@ export function ExhibitorsPage() {
|
|||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 세그먼트(판매상태) 필터만 여기서 — 검색·정렬·페이지·CSV 는 KxDataTable 담당.
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
const all = q.data?.exhibitors ?? [];
|
const all = q.data?.exhibitors ?? [];
|
||||||
const kw = keyword.trim().toLowerCase();
|
return saleFilter ? all.filter((r) => r.saleStatus === saleFilter) : all;
|
||||||
return all.filter((r) => {
|
}, [q.data, saleFilter]);
|
||||||
if (saleFilter && r.saleStatus !== saleFilter) return false;
|
|
||||||
if (kw) {
|
const columns = useMemo<KxColumn<ExhibitorRow>[]>(
|
||||||
const hay = `${r.companyName ?? ''} ${r.boothNo ?? ''}`.toLowerCase();
|
() => [
|
||||||
if (!hay.includes(kw)) return false;
|
{ key: 'company', header: t('exhibitorsPage.colCompany'), value: (r) => r.companyName,
|
||||||
}
|
render: (r) => <span className="kx-exh__company">{r.companyName}</span> },
|
||||||
return true;
|
{ key: 'booth', header: t('exhibitorsPage.colBooth'), value: (r) => r.boothNo ?? '',
|
||||||
});
|
render: (r) => <span className="tnum">{r.boothNo ?? '-'}</span> },
|
||||||
}, [q.data, keyword, saleFilter]);
|
{ key: 'type', header: t('exhibitorsPage.colType'),
|
||||||
|
value: (r) => (r.boothType ? t(`exhibitorsPage.boothType.${r.boothType}`, { defaultValue: r.boothType }) : ''),
|
||||||
|
render: (r) => (r.boothType ? t(`exhibitorsPage.boothType.${r.boothType}`, { defaultValue: r.boothType }) : '-') },
|
||||||
|
{ key: 'area', header: t('exhibitorsPage.colArea'), numeric: true, value: (r) => r.areaM2 ?? null,
|
||||||
|
render: (r) => fmtArea(r.areaM2) },
|
||||||
|
{ key: 'sale', header: t('exhibitorsPage.colSale'), value: (r) => saleLabel(t, r.saleStatus),
|
||||||
|
render: (r) => <Pill tone={saleTone(r.saleStatus)} label={saleLabel(t, r.saleStatus)} /> },
|
||||||
|
{ key: 'design', header: t('exhibitorsPage.colDesign'), value: (r) => designLabel(t, r.designStatus),
|
||||||
|
render: (r) => <Pill tone={designTone(r.designStatus)} label={designLabel(t, r.designStatus)} /> },
|
||||||
|
{ key: 'settlement', header: t('exhibitorsPage.colSettlement'), value: (r) => invoiceLabel(t, r.invoiceStatus),
|
||||||
|
render: (r) => (
|
||||||
|
<>
|
||||||
|
<Pill tone={invoiceTone(r.invoiceStatus)} label={invoiceLabel(t, r.invoiceStatus)} />
|
||||||
|
{r.outstanding > 0 && (
|
||||||
|
<span className="kx-exh__due tnum">{t('exhibitorsPage.dueShort', { won: fmtWon(r.outstanding) })}</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) },
|
||||||
|
{ key: 'contact', header: t('exhibitorsPage.colContact'),
|
||||||
|
value: (r) => r.contactName ?? r.contactPhone ?? '',
|
||||||
|
render: (r) => r.contactName ?? r.contactPhone ?? <span className="kx-exh__muted">{t('exhibitorsPage.contactNone')}</span> },
|
||||||
|
],
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
if (!eventId) {
|
if (!eventId) {
|
||||||
return (
|
return (
|
||||||
@ -122,32 +146,6 @@ export function ExhibitorsPage() {
|
|||||||
|
|
||||||
{/* 목록 카드 */}
|
{/* 목록 카드 */}
|
||||||
<section className="kx-card kx-exh__list-card" aria-label={t('exhibitorsPage.listTitle')}>
|
<section className="kx-card kx-exh__list-card" aria-label={t('exhibitorsPage.listTitle')}>
|
||||||
<div className="kx-card__head kx-exh__toolbar">
|
|
||||||
<div className="kx-exh__search">
|
|
||||||
<input
|
|
||||||
className="kx-input kx-exh__search-input"
|
|
||||||
type="search"
|
|
||||||
placeholder={t('exhibitorsPage.searchPlaceholder')}
|
|
||||||
value={keyword}
|
|
||||||
onChange={(e) => setKeyword(e.target.value)}
|
|
||||||
aria-label={t('exhibitorsPage.searchPlaceholder')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="kx-seg" role="tablist" aria-label={t('exhibitorsPage.saleFilterLabel')}>
|
|
||||||
{SALE_FILTERS.map((v) => (
|
|
||||||
<button
|
|
||||||
key={v || 'all'}
|
|
||||||
role="tab"
|
|
||||||
aria-selected={saleFilter === v}
|
|
||||||
className={`kx-seg__btn ${saleFilter === v ? 'is-active' : ''}`}
|
|
||||||
onClick={() => setSaleFilter(v)}
|
|
||||||
>
|
|
||||||
{v === '' ? t('exhibitorsPage.filterAll') : saleLabel(t, v)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{q.isLoading ? (
|
{q.isLoading ? (
|
||||||
<div style={{ display: 'grid', gap: 8 }} aria-hidden="true">
|
<div style={{ display: 'grid', gap: 8 }} aria-hidden="true">
|
||||||
{[0, 1, 2, 3, 4].map((i) => (
|
{[0, 1, 2, 3, 4].map((i) => (
|
||||||
@ -156,50 +154,34 @@ export function ExhibitorsPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : q.isError ? (
|
) : q.isError ? (
|
||||||
<ErrorState message={t('exhibitorsPage.loadError')} onRetry={() => q.refetch()} />
|
<ErrorState message={t('exhibitorsPage.loadError')} onRetry={() => q.refetch()} />
|
||||||
) : rows.length === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
title={t('exhibitorsPage.emptyTitle')}
|
|
||||||
description={t('exhibitorsPage.emptyDesc')}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="kx-table-scroll">
|
<KxDataTable
|
||||||
<table className="kx-table kx-table--zebra">
|
columns={columns}
|
||||||
<thead>
|
rows={rows}
|
||||||
<tr>
|
rowKey={(r) => r.boothId}
|
||||||
<th>{t('exhibitorsPage.colCompany')}</th>
|
onRowClick={setSelected}
|
||||||
<th>{t('exhibitorsPage.colBooth')}</th>
|
isRowSelected={(r) => r.boothId === selected?.boothId}
|
||||||
<th>{t('exhibitorsPage.colType')}</th>
|
searchPlaceholder={t('exhibitorsPage.searchPlaceholder')}
|
||||||
<th className="kx-num">{t('exhibitorsPage.colArea')}</th>
|
csvFileName={`exhibitors_${eventId}`}
|
||||||
<th>{t('exhibitorsPage.colSale')}</th>
|
ariaLabel={t('exhibitorsPage.listTitle')}
|
||||||
<th>{t('exhibitorsPage.colDesign')}</th>
|
emptyTitle={t('exhibitorsPage.emptyTitle')}
|
||||||
<th>{t('exhibitorsPage.colSettlement')}</th>
|
emptyDescription={t('exhibitorsPage.emptyDesc')}
|
||||||
<th>{t('exhibitorsPage.colContact')}</th>
|
toolbarStart={
|
||||||
</tr>
|
<div className="kx-seg" role="tablist" aria-label={t('exhibitorsPage.saleFilterLabel')}>
|
||||||
</thead>
|
{SALE_FILTERS.map((v) => (
|
||||||
<tbody>
|
<button
|
||||||
{rows.map((r) => (
|
key={v || 'all'}
|
||||||
<tr key={r.boothId} className="kx-exh__row" onClick={() => setSelected(r)}>
|
role="tab"
|
||||||
<td className="kx-exh__company">{r.companyName}</td>
|
aria-selected={saleFilter === v}
|
||||||
<td className="tnum">{r.boothNo ?? '-'}</td>
|
className={`kx-seg__btn ${saleFilter === v ? 'is-active' : ''}`}
|
||||||
<td>{r.boothType ? t(`exhibitorsPage.boothType.${r.boothType}`, { defaultValue: r.boothType }) : '-'}</td>
|
onClick={() => setSaleFilter(v)}
|
||||||
<td className="kx-num tnum">{fmtArea(r.areaM2)}</td>
|
>
|
||||||
<td><Pill tone={saleTone(r.saleStatus)} label={saleLabel(t, r.saleStatus)} /></td>
|
{v === '' ? t('exhibitorsPage.filterAll') : saleLabel(t, v)}
|
||||||
<td><Pill tone={designTone(r.designStatus)} label={designLabel(t, r.designStatus)} /></td>
|
</button>
|
||||||
<td>
|
|
||||||
<Pill tone={invoiceTone(r.invoiceStatus)} label={invoiceLabel(t, r.invoiceStatus)} />
|
|
||||||
{r.outstanding > 0 && (
|
|
||||||
<span className="kx-exh__due tnum">{t('exhibitorsPage.dueShort', { won: fmtWon(r.outstanding) })}</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>{r.contactName ?? r.contactPhone ?? <span className="kx-exh__muted">{t('exhibitorsPage.contactNone')}</span>}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
}
|
||||||
</div>
|
/>
|
||||||
)}
|
|
||||||
{!q.isLoading && !q.isError && rows.length > 0 && (
|
|
||||||
<p className="kx-exh__count">{t('exhibitorsPage.count', { n: rows.length })}</p>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { AiLabel } from '../../components/ui/Badge';
|
import { AiLabel } from '../../components/ui/Badge';
|
||||||
import { Button } from '../../components/ui/Button';
|
import { Button } from '../../components/ui/Button';
|
||||||
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
|
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
|
||||||
|
import { KxDataTable, type KxColumn } from '../../components/KxDataTable';
|
||||||
import { IconClock, IconDownload, IconSpark } from '../../components/ui/icons';
|
import { IconClock, IconDownload, IconSpark } from '../../components/ui/icons';
|
||||||
import { ApiRequestError } from '../../api/client';
|
import { ApiRequestError } from '../../api/client';
|
||||||
import { useResolvedEventId } from '../../hooks/useResolvedEventId';
|
import { useResolvedEventId } from '../../hooks/useResolvedEventId';
|
||||||
@ -77,6 +78,22 @@ export function LeadScoringPage() {
|
|||||||
);
|
);
|
||||||
const selected = leads.find((l) => l.id === selectedId) ?? rows[0] ?? leads[0] ?? null;
|
const selected = leads.find((l) => l.id === selectedId) ?? rows[0] ?? leads[0] ?? null;
|
||||||
|
|
||||||
|
const columns = useMemo<KxColumn<LeadItemDto>[]>(
|
||||||
|
() => [
|
||||||
|
{ key: 'name', header: '이름', value: (l) => l.nameMasked,
|
||||||
|
render: (l) => <span className="kx-vis__lead-name">{l.nameMasked}</span> },
|
||||||
|
{ key: 'company', header: '소속', value: (l) => l.company ?? '', render: (l) => l.company ?? '-' },
|
||||||
|
{ key: 'product', header: '관심 제품', value: (l) => l.product ?? '', render: (l) => l.product ?? '-' },
|
||||||
|
{ key: 'interest', header: '관심도', numeric: true, value: (l) => l.interest,
|
||||||
|
render: (l) => <Stars value={l.interest} /> },
|
||||||
|
{ key: 'score', header: 'AI 스코어', numeric: true, value: (l) => l.score,
|
||||||
|
render: (l) => <ScoreGauge score={l.score} /> },
|
||||||
|
{ key: 'collectedAt', header: '수집시각', value: (l) => l.collectedAt ?? '',
|
||||||
|
render: (l) => <span className="tnum">{l.collectedAt ?? '-'}</span> },
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const hotCount = leads.filter((l) => l.score >= 80).length;
|
const hotCount = leads.filter((l) => l.score >= 80).length;
|
||||||
const kpis = [
|
const kpis = [
|
||||||
{ label: '총 리드', value: total.toLocaleString(), sub: undefined as string | undefined, ai: false },
|
{ label: '총 리드', value: total.toLocaleString(), sub: undefined as string | undefined, ai: false },
|
||||||
@ -147,69 +164,32 @@ export function LeadScoringPage() {
|
|||||||
<div className="kx-vis__lead-split">
|
<div className="kx-vis__lead-split">
|
||||||
{/* 리드 테이블 */}
|
{/* 리드 테이블 */}
|
||||||
<section className="kx-card kx-vis__lead-table-card" aria-label="리드 목록">
|
<section className="kx-card kx-vis__lead-table-card" aria-label="리드 목록">
|
||||||
<div className="kx-vis__filters" role="group" aria-label="리드 필터">
|
<KxDataTable
|
||||||
<span className="kx-vis__filters-label">필터</span>
|
columns={columns}
|
||||||
<button
|
rows={rows}
|
||||||
type="button"
|
rowKey={(l) => l.id}
|
||||||
className={`kx-chip ${hotOnly ? 'is-active' : ''}`}
|
onRowClick={(l) => setSelectedId(l.id)}
|
||||||
aria-pressed={hotOnly}
|
isRowSelected={(l) => l.id === selected?.id}
|
||||||
onClick={() => setHotOnly((v) => !v)}
|
rowClassName={() => 'kx-vis__lead-row'}
|
||||||
>
|
zebra={false}
|
||||||
스코어 80+ {hotOnly && <span aria-hidden="true">×</span>}
|
searchPlaceholder="이름·소속·제품 검색"
|
||||||
</button>
|
ariaLabel="리드 목록"
|
||||||
<span className="kx-vis__filters-count tnum">{rows.length}건</span>
|
emptyTitle="수집된 리드가 없습니다"
|
||||||
</div>
|
emptyDescription="부스 배지 QR 스캔이 수집되면 표시됩니다."
|
||||||
<div className="kx-table-scroll">
|
toolbarStart={
|
||||||
<table className="kx-table">
|
<div className="kx-vis__filters" role="group" aria-label="리드 필터">
|
||||||
<thead>
|
<span className="kx-vis__filters-label">필터</span>
|
||||||
<tr>
|
<button
|
||||||
<th scope="col">이름</th>
|
type="button"
|
||||||
<th scope="col">소속</th>
|
className={`kx-chip ${hotOnly ? 'is-active' : ''}`}
|
||||||
<th scope="col">관심 제품</th>
|
aria-pressed={hotOnly}
|
||||||
<th scope="col">관심도</th>
|
onClick={() => setHotOnly((v) => !v)}
|
||||||
<th scope="col">AI 스코어</th>
|
>
|
||||||
<th scope="col">수집시각</th>
|
스코어 80+ {hotOnly && <span aria-hidden="true">×</span>}
|
||||||
</tr>
|
</button>
|
||||||
</thead>
|
</div>
|
||||||
<tbody>
|
}
|
||||||
{rows.map((l) => {
|
/>
|
||||||
const isSel = l.id === selected?.id;
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
key={l.id}
|
|
||||||
className={`kx-vis__lead-row ${isSel ? 'is-selected' : ''}`}
|
|
||||||
onClick={() => setSelectedId(l.id)}
|
|
||||||
tabIndex={0}
|
|
||||||
aria-selected={isSel}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
setSelectedId(l.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<td className="kx-vis__lead-name">{l.nameMasked}</td>
|
|
||||||
<td>{l.company ?? '-'}</td>
|
|
||||||
<td>{l.product ?? '-'}</td>
|
|
||||||
<td><Stars value={l.interest} /></td>
|
|
||||||
<td><ScoreGauge score={l.score} /></td>
|
|
||||||
<td className="tnum">{l.collectedAt ?? '-'}</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{rows.length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={6}>
|
|
||||||
<EmptyState title="수집된 리드가 없습니다" description="부스 배지 QR 스캔이 수집되면 표시됩니다." />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div className="kx-vis__table-foot">
|
|
||||||
<span className="kx-vis__count">총 {total.toLocaleString()}건 중 {rows.length}건 표시</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* 리드 상세·팔로업 */}
|
{/* 리드 상세·팔로업 */}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
Area,
|
Area,
|
||||||
@ -14,6 +15,7 @@ import {
|
|||||||
import { AiLabel } from '../../components/ui/Badge';
|
import { AiLabel } from '../../components/ui/Badge';
|
||||||
import { Button } from '../../components/ui/Button';
|
import { Button } from '../../components/ui/Button';
|
||||||
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
|
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
|
||||||
|
import { KxDataTable, type KxColumn } from '../../components/KxDataTable';
|
||||||
import { IconDocument, IconDownload, IconSpark, IconTag, IconTrendUp, IconUsers } from '../../components/ui/icons';
|
import { IconDocument, IconDownload, IconSpark, IconTag, IconTrendUp, IconUsers } from '../../components/ui/icons';
|
||||||
import { ApiRequestError } from '../../api/client';
|
import { ApiRequestError } from '../../api/client';
|
||||||
import { useResolvedEventId } from '../../hooks/useResolvedEventId';
|
import { useResolvedEventId } from '../../hooks/useResolvedEventId';
|
||||||
@ -57,9 +59,34 @@ export function VisitorRegistrationDashboardPage() {
|
|||||||
const summary: VisitorSummaryDto | null = summaryQ.data ?? (degraded ? SAMPLE_SUMMARY : null);
|
const summary: VisitorSummaryDto | null = summaryQ.data ?? (degraded ? SAMPLE_SUMMARY : null);
|
||||||
const rows: VisitorListItemDto[] =
|
const rows: VisitorListItemDto[] =
|
||||||
listQ.data?.items ?? (degraded ? SAMPLE_ROWS : []);
|
listQ.data?.items ?? (degraded ? SAMPLE_ROWS : []);
|
||||||
const total = listQ.data?.total ?? (degraded ? SAMPLE_ROWS.length : 0);
|
|
||||||
const hardError = (summaryQ.isError && !degraded) || (listQ.isError && !degraded);
|
const hardError = (summaryQ.isError && !degraded) || (listQ.isError && !degraded);
|
||||||
|
|
||||||
|
const columns = useMemo<KxColumn<VisitorListItemDto>[]>(
|
||||||
|
() => [
|
||||||
|
{ key: 'name', header: '이름', value: (r) => r.nameMasked,
|
||||||
|
render: (r) => (
|
||||||
|
<span className="kx-vis__person">
|
||||||
|
<span className="kx-vis__avatar" aria-hidden="true">{initialOf(r.nameMasked)}</span>
|
||||||
|
{r.nameMasked}
|
||||||
|
</span>
|
||||||
|
) },
|
||||||
|
{ key: 'type', header: '유형', value: (r) => typeLabelOf(r.type), render: (r) => <TypePill type={r.type} /> },
|
||||||
|
{ key: 'company', header: '소속', value: (r) => r.company ?? '', render: (r) => r.company ?? '-' },
|
||||||
|
{ key: 'registeredAt', header: '등록일', value: (r) => r.registeredAt ?? '',
|
||||||
|
render: (r) => <span className="tnum">{r.registeredAt ?? '-'}</span> },
|
||||||
|
{ key: 'checkin', header: '체크인', value: (r) => checkinLabelOf(r.checkin), render: (r) => <CheckinPill state={r.checkin} /> },
|
||||||
|
{ key: 'badge', header: '배지', value: (r) => (r.badgeIssued ? '발급' : '미발급'),
|
||||||
|
render: (r) => (r.badgeIssued ? (
|
||||||
|
<span className="kx-vis__badge-issued" title="배지 발급 완료">
|
||||||
|
<QrGlyph size={16} /> 발급
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="kx-vis__badge-none">미발급</span>
|
||||||
|
)) },
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="kx-vis">
|
<div className="kx-vis">
|
||||||
<header className="kx-vis__head">
|
<header className="kx-vis__head">
|
||||||
@ -238,57 +265,16 @@ export function VisitorRegistrationDashboardPage() {
|
|||||||
<h2><IconUsers className="kx-title-ic" size={18} />전체 신청자 명단</h2>
|
<h2><IconUsers className="kx-title-ic" size={18} />전체 신청자 명단</h2>
|
||||||
<span className="kx-vis__pii-note">개인정보 보호 · 이름 마스킹 표기</span>
|
<span className="kx-vis__pii-note">개인정보 보호 · 이름 마스킹 표기</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="kx-table-scroll">
|
<KxDataTable
|
||||||
<table className="kx-table kx-table--zebra">
|
columns={columns}
|
||||||
<thead>
|
rows={rows}
|
||||||
<tr>
|
rowKey={(r) => r.id}
|
||||||
<th scope="col">이름</th>
|
searchPlaceholder="이름·소속 검색"
|
||||||
<th scope="col">유형</th>
|
csvFileName={`visitors_${eventId}`}
|
||||||
<th scope="col">소속</th>
|
ariaLabel="전체 신청자 명단"
|
||||||
<th scope="col">등록일</th>
|
emptyTitle="등록자가 없습니다"
|
||||||
<th scope="col">체크인</th>
|
emptyDescription="사전등록이 접수되면 이 곳에 표시됩니다."
|
||||||
<th scope="col">배지</th>
|
/>
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{rows.map((r) => (
|
|
||||||
<tr key={r.id}>
|
|
||||||
<td>
|
|
||||||
<span className="kx-vis__person">
|
|
||||||
<span className="kx-vis__avatar" aria-hidden="true">{initialOf(r.nameMasked)}</span>
|
|
||||||
{r.nameMasked}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td><TypePill type={r.type} /></td>
|
|
||||||
<td>{r.company ?? '-'}</td>
|
|
||||||
<td className="tnum">{r.registeredAt ?? '-'}</td>
|
|
||||||
<td><CheckinPill state={r.checkin} /></td>
|
|
||||||
<td>
|
|
||||||
{r.badgeIssued ? (
|
|
||||||
<span className="kx-vis__badge-issued" title="배지 발급 완료">
|
|
||||||
<QrGlyph size={16} /> 발급
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="kx-vis__badge-none">미발급</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{rows.length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={6}>
|
|
||||||
<EmptyState title="등록자가 없습니다" description="사전등록이 접수되면 이 곳에 표시됩니다." />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div className="kx-vis__table-foot">
|
|
||||||
<span className="kx-vis__count">
|
|
||||||
총 {total.toLocaleString()}명 중 {rows.length}명 표시
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -319,12 +305,22 @@ function initialOf(nameMasked: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const KNOWN_TYPES: RegVisitorType[] = ['visitor', 'buyer', 'vip'];
|
const KNOWN_TYPES: RegVisitorType[] = ['visitor', 'buyer', 'vip'];
|
||||||
|
/** 유형 코드 → 표시 라벨(검색·CSV·정렬 값). */
|
||||||
|
function typeLabelOf(type: string): string {
|
||||||
|
const t = (KNOWN_TYPES as string[]).includes(type) ? (type as RegVisitorType) : 'visitor';
|
||||||
|
return REG_TYPE_LABEL[t];
|
||||||
|
}
|
||||||
function TypePill({ type }: { type: string }) {
|
function TypePill({ type }: { type: string }) {
|
||||||
const t = (KNOWN_TYPES as string[]).includes(type) ? (type as RegVisitorType) : 'visitor';
|
const t = (KNOWN_TYPES as string[]).includes(type) ? (type as RegVisitorType) : 'visitor';
|
||||||
return <span className={`kx-vis__type is-${t}`}>{REG_TYPE_LABEL[t]}</span>;
|
return <span className={`kx-vis__type is-${t}`}>{REG_TYPE_LABEL[t]}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const KNOWN_CHECKIN: CheckinState[] = ['done', 'waiting', 'cancelled'];
|
const KNOWN_CHECKIN: CheckinState[] = ['done', 'waiting', 'cancelled'];
|
||||||
|
/** 체크인 코드 → 표시 라벨(검색·CSV·정렬 값). */
|
||||||
|
function checkinLabelOf(state: string): string {
|
||||||
|
const s = (KNOWN_CHECKIN as string[]).includes(state) ? (state as CheckinState) : 'waiting';
|
||||||
|
return CHECKIN_LABEL[s];
|
||||||
|
}
|
||||||
function CheckinPill({ state }: { state: string }) {
|
function CheckinPill({ state }: { state: string }) {
|
||||||
const s = (KNOWN_CHECKIN as string[]).includes(state) ? (state as CheckinState) : 'waiting';
|
const s = (KNOWN_CHECKIN as string[]).includes(state) ? (state as CheckinState) : 'waiting';
|
||||||
return (
|
return (
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user