feat(frontend): public live info, visitor account/kiosk, tenant switcher, grid PDF, brand logo

- Public: live notice feed, event subscribe, smart/rotating ticket QR, info pages, unsubscribe
- Visitor: kiosk check-in, account page + API
- Admin/work/analytics/floorplan/auction/cms/ops screen updates
- Components: Modal, BrandLogo, TenantSwitcher, GridPdfButton
- i18n 4 locales, public brand/login-slide/hero/track/venue assets

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zio 2026-07-24 00:41:49 +09:00
parent 92572df511
commit 9faeb8892b
174 changed files with 7007 additions and 380 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 493 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

View File

@ -58,11 +58,14 @@ import { ContractorBoothDashboardPage } from './screens/contractor/ContractorBoo
import { VisitorRegistrationDashboardPage } from './screens/visitor/VisitorRegistrationDashboardPage'; import { VisitorRegistrationDashboardPage } from './screens/visitor/VisitorRegistrationDashboardPage';
import { LeadScoringPage } from './screens/visitor/LeadScoringPage'; import { LeadScoringPage } from './screens/visitor/LeadScoringPage';
import { CheckinDeskPage } from './screens/visitor/CheckinDeskPage'; import { CheckinDeskPage } from './screens/visitor/CheckinDeskPage';
import { VisitorAccountPage } from './screens/visitor/VisitorAccountPage';
import { KioskCheckinPage } from './screens/visitor/KioskCheckinPage';
import { EdmCampaignPage } from './screens/marketing/EdmCampaignPage'; import { EdmCampaignPage } from './screens/marketing/EdmCampaignPage';
import { SponsorshipPage } from './screens/marketing/SponsorshipPage'; import { SponsorshipPage } from './screens/marketing/SponsorshipPage';
import { CmsWorkflowPage } from './screens/cms/CmsWorkflowPage'; import { CmsWorkflowPage } from './screens/cms/CmsWorkflowPage';
import { MicrositeBuilderPage } from './screens/cms/MicrositeBuilderPage'; import { MicrositeBuilderPage } from './screens/cms/MicrositeBuilderPage';
import { MultilingualCmsPage } from './screens/cms/MultilingualCmsPage'; import { MultilingualCmsPage } from './screens/cms/MultilingualCmsPage';
import { LiveNoticePublisherPage } from './screens/cms/LiveNoticePublisherPage';
import { AuditLogPage } from './screens/admin/AuditLogPage'; import { AuditLogPage } from './screens/admin/AuditLogPage';
import { SystemSettingsPage } from './screens/admin/SystemSettingsPage'; import { SystemSettingsPage } from './screens/admin/SystemSettingsPage';
import { RulesetVersionsPage } from './screens/admin/RulesetVersionsPage'; import { RulesetVersionsPage } from './screens/admin/RulesetVersionsPage';
@ -87,7 +90,13 @@ import {
PublicMicrositePage, PublicMicrositePage,
PublicInquiryPage, PublicInquiryPage,
PublicTicketPage, PublicTicketPage,
PublicEventsListPage,
PublicExhibitGuidePage,
PublicVisitGuidePage,
PublicTransportPage,
PublicUnsubscribePage,
} from './screens/public'; } from './screens/public';
import { PublicLiveInfoPage } from './screens/public/PublicLiveInfoPage';
import { NotFoundPage } from './screens/common/NotFoundPage'; import { NotFoundPage } from './screens/common/NotFoundPage';
import { AppShell } from './components/layout/AppShell'; import { AppShell } from './components/layout/AppShell';
@ -140,8 +149,8 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
} }
/** /**
* / = , =SCR-T0 3- * / = , =`/visitor`
* (design.md §3C-0: 미인증 `/` . 2026-07-12 ). * (2026-07-23 지시: 루트 /visitor. 3- (SCR-T0) `/gateway` · ).
* completeLogin (plan_role_routing §2-1) ops·business=/home( 0), * completeLogin (plan_role_routing §2-1) ops·business=/home( 0),
* admin/admin, agency/contractor/dashboard, visitor/visitor. ·`/`·catch-all . * admin/admin, agency/contractor/dashboard, visitor/visitor. ·`/`·catch-all .
*/ */
@ -150,7 +159,7 @@ function RootGate() {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const workspaces = useAuthStore((s) => s.workspaces); const workspaces = useAuthStore((s) => s.workspaces);
if (isAuthenticated) return <Navigate to={landingPathFor(user, workspaces)} replace />; if (isAuthenticated) return <Navigate to={landingPathFor(user, workspaces)} replace />;
return <PublicGatewayPage />; return <Navigate to="/visitor" replace />;
} }
export function App() { export function App() {
@ -270,6 +279,10 @@ export function App() {
{/* SCR-30·32 관람객·리드 (M10 — 샘플) · SCR-33·34 마케팅 (M12 — 샘플) */} {/* SCR-30·32 관람객·리드 (M10 — 샘플) · SCR-33·34 마케팅 (M12 — 샘플) */}
<Route path="/visitors" element={<VisitorRegistrationDashboardPage />} /> <Route path="/visitors" element={<VisitorRegistrationDashboardPage />} />
<Route path="/visitors/checkin" element={<CheckinDeskPage />} /> <Route path="/visitors/checkin" element={<CheckinDeskPage />} />
{/* F-C6/C4/C5/B4/B5/C7 관람객 마이 (멤버십·세션·명함·결제수단·최근본·알림) */}
<Route path="/visitor/account" element={<VisitorAccountPage />} />
{/* F-C8 셀프 체크인 키오스크 (풀스크린·즉석 배지 인쇄) */}
<Route path="/visitors/kiosk" element={<KioskCheckinPage />} />
<Route path="/leads" element={<LeadScoringPage />} /> <Route path="/leads" element={<LeadScoringPage />} />
<Route path="/campaigns" element={<EdmCampaignPage />} /> <Route path="/campaigns" element={<EdmCampaignPage />} />
<Route path="/sponsorship" element={<SponsorshipPage />} /> <Route path="/sponsorship" element={<SponsorshipPage />} />
@ -277,6 +290,8 @@ export function App() {
<Route path="/cms" element={<CmsWorkflowPage />} /> <Route path="/cms" element={<CmsWorkflowPage />} />
<Route path="/cms/microsite" element={<MicrositeBuilderPage />} /> <Route path="/cms/microsite" element={<MicrositeBuilderPage />} />
<Route path="/cms/i18n" element={<MultilingualCmsPage />} /> <Route path="/cms/i18n" element={<MultilingualCmsPage />} />
{/* F-B6 라이브 공지 발행 콘솔 (조직자) */}
<Route path="/cms/live-notices" element={<LiveNoticePublisherPage />} />
{/* SCR-A5~A9 관리자 모듈 (감사로그·설정·룰셋=실배선, 테넌트=샘플) — AdminGuard 가드 */} {/* SCR-A5~A9 관리자 모듈 (감사로그·설정·룰셋=실배선, 테넌트=샘플) — AdminGuard 가드 */}
<Route path="/admin/audit" element={<AdminGuard><AuditLogPage /></AdminGuard>} /> <Route path="/admin/audit" element={<AdminGuard><AuditLogPage /></AdminGuard>} />
<Route path="/admin/settings" element={<AdminGuard><SystemSettingsPage /></AdminGuard>} /> <Route path="/admin/settings" element={<AdminGuard><SystemSettingsPage /></AdminGuard>} />
@ -302,6 +317,9 @@ export function App() {
<Route path="/admin/holidays" element={<AdminGuard><HolidayAdminPage /></AdminGuard>} /> <Route path="/admin/holidays" element={<AdminGuard><HolidayAdminPage /></AdminGuard>} />
</Route> </Route>
{/* SCR-T0 3-트랙 공개 관문 — 루트 디폴트가 /visitor 로 이동하므로 별도 경로로 접근 유지(2026-07-23) */}
<Route path="/gateway" element={<PublicGatewayPage />} />
{/* SCR-T1~T3 트랙 서브홈 — 비로그인 공개, PublicShell+TrackSwitcher */} {/* SCR-T1~T3 트랙 서브홈 — 비로그인 공개, PublicShell+TrackSwitcher */}
<Route path="/visitor" element={<VisitorTrackPage />} /> <Route path="/visitor" element={<VisitorTrackPage />} />
<Route path="/business" element={<BusinessTrackPage />} /> <Route path="/business" element={<BusinessTrackPage />} />
@ -309,6 +327,11 @@ export function App() {
{/* SCR-P1~P7 공개 홍보 사이트 — 비로그인, 자체 PublicShell */} {/* SCR-P1~P7 공개 홍보 사이트 — 비로그인, 자체 PublicShell */}
<Route path="/public" element={<PublicHomePage />} /> <Route path="/public" element={<PublicHomePage />} />
{/* GNB 정보 페이지 (2026-07-23 — 헤더 메뉴 데드링크 해소) */}
<Route path="/public/events" element={<PublicEventsListPage />} />
<Route path="/public/exhibit-guide" element={<PublicExhibitGuidePage />} />
<Route path="/public/visit-guide" element={<PublicVisitGuidePage />} />
<Route path="/public/transport" element={<PublicTransportPage />} />
<Route path="/public/events/:eventId" element={<PublicEventDetailPage />} /> <Route path="/public/events/:eventId" element={<PublicEventDetailPage />} />
<Route path="/public/events/:eventId/floorplan" element={<PublicFloorplanPage />} /> <Route path="/public/events/:eventId/floorplan" element={<PublicFloorplanPage />} />
<Route path="/public/events/:eventId/register" element={<PublicRegistrationPage />} /> <Route path="/public/events/:eventId/register" element={<PublicRegistrationPage />} />
@ -316,6 +339,11 @@ export function App() {
<Route path="/public/exhibit-inquiry" element={<PublicInquiryPage />} /> <Route path="/public/exhibit-inquiry" element={<PublicInquiryPage />} />
<Route path="/tickets/:eventId/purchase" element={<PublicTicketPage />} /> <Route path="/tickets/:eventId/purchase" element={<PublicTicketPage />} />
<Route path="/tickets/lookup" element={<PublicTicketPage />} /> <Route path="/tickets/lookup" element={<PublicTicketPage />} />
{/* F-C2/C3 현장 라이브 정보 — 주차 현황·주차권 + 대면 혼잡 안내 (공개, PublicShell 내부 래핑) */}
<Route path="/live" element={<PublicLiveInfoPage />} />
<Route path="/live/:eventId" element={<PublicLiveInfoPage />} />
{/* F-C1 오픈 알림 수신거부 랜딩 (메일 링크·비로그인 공개) */}
<Route path="/public/subscriptions/unsubscribe" element={<PublicUnsubscribePage />} />
{/* 모바일 전용 (390) — 셸 없이 전폭, 조회·현장 */} {/* 모바일 전용 (390) — 셸 없이 전폭, 조회·현장 */}
<Route <Route

View File

@ -25,6 +25,7 @@ import type {
ForgotPasswordRequest, ForgotPasswordRequest,
KintexPrincipal, KintexPrincipal,
LayoutDto, LayoutDto,
LayoutVersionDto,
LayoutInterpretRequest, LayoutInterpretRequest,
LayoutInterpretResult, LayoutInterpretResult,
LayoutSaveRequest, LayoutSaveRequest,
@ -161,6 +162,9 @@ export const layoutApi = {
api.get<LayoutDto>( api.get<LayoutDto>(
`${layoutBase(eventId, hallId)}${version != null ? `?version=${version}` : ''}`, `${layoutBase(eventId, hallId)}${version != null ? `?version=${version}` : ''}`,
), ),
// 배치안 버전 이력(자동생성 proposal·draft·applied) — 최신 순.
versions: (eventId: string, hallId: string) =>
api.get<LayoutVersionDto[]>(`${layoutBase(eventId, hallId)}/versions`),
save: (eventId: string, hallId: string, body: LayoutSaveRequest) => save: (eventId: string, hallId: string, body: LayoutSaveRequest) =>
api.put<LayoutDto>(layoutBase(eventId, hallId), body), api.put<LayoutDto>(layoutBase(eventId, hallId), body),
validate: (eventId: string, hallId: string, version?: number) => validate: (eventId: string, hallId: string, version?: number) =>

View File

@ -255,6 +255,16 @@ export interface AutoLayoutOption {
booths: BoothDto[]; // apply-option 저장 페이로드 — "이 안으로 편집 시작" 실배선 booths: BoothDto[]; // apply-option 저장 페이로드 — "이 안으로 편집 시작" 실배선
s7RenderJobId: string | null; // 항상 null — 이미지는 버튼 클릭 시 previewS7로만 생성(2026-07-14) s7RenderJobId: string | null; // 항상 null — 이미지는 버튼 클릭 시 previewS7로만 생성(2026-07-14)
aiSummary?: string; // ai=true 시에만: 엔진 metrics 근거 AI 장단점 요약(WISE AI). 없으면 미표시. aiSummary?: string; // ai=true 시에만: 엔진 metrics 근거 AI 장단점 요약(WISE AI). 없으면 미표시.
version?: number; // 생성 시 status='proposal' 로 영속된 layout 버전(배치 이력). 미영속 시 없음.
}
// 배치안 버전 이력(GET /layout/versions) — status: draft|applied|proposal
export interface LayoutVersionDto {
version: number;
name: string;
status: string;
boothCount: number;
updatedAt: string;
} }
// 자연어 조건 해석(POST /auto-generate/interpret) // 자연어 조건 해석(POST /auto-generate/interpret)

View File

@ -0,0 +1,74 @@
/*
* PDF ( : "그리드는 항상 PDF 출력 버튼").
* ( provider ): busy + (.kx-toast) .
* KxDataTable .kx-table .
*
* Nifty 그대로: .kx-dt__csv (CSV ), kx . 0.
*/
import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { IconDocument } from './ui/icons';
import { downloadGridPdf, type GridPdfColumn } from './gridPdf';
export interface GridPdfButtonProps {
/** 리포트 제목(= 화면 타이틀, 파일명 base). */
title: string;
subtitle?: string;
columns: GridPdfColumn[];
/** 현재 표시 중인 행(필터·정렬 반영). 지연 생성 위해 함수도 허용. */
rows: Record<string, unknown>[] | (() => Record<string, unknown>[]);
disabled?: boolean;
className?: string;
/** 버튼 라벨(기본 datatable.pdf). */
label?: string;
}
export function GridPdfButton({
title,
subtitle,
columns,
rows,
disabled,
className = '',
label,
}: GridPdfButtonProps) {
const { t } = useTranslation();
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const onClick = useCallback(async () => {
if (busy) return;
setBusy(true);
setErr(null);
try {
const data = typeof rows === 'function' ? rows() : rows;
await downloadGridPdf({ title, subtitle, columns, rows: data });
} catch (e) {
const msg = e instanceof Error ? e.message : t('datatable.pdfError', { defaultValue: 'PDF 출력에 실패했습니다.' });
setErr(msg);
window.setTimeout(() => setErr(null), 2600);
} finally {
setBusy(false);
}
}, [busy, rows, title, subtitle, columns, t]);
return (
<>
<button
type="button"
className={`kx-dt__csv ${className}`}
onClick={onClick}
disabled={disabled || busy}
title={t('datatable.pdfTitle', { defaultValue: 'PDF 출력' })}
>
<IconDocument size={16} />
{busy ? t('datatable.pdfBusy', { defaultValue: '출력 중…' }) : (label ?? t('datatable.pdf', { defaultValue: 'PDF' }))}
</button>
{err && (
<div className="kx-toast" role="alert">
{err}
</div>
)}
</>
);
}

View File

@ -21,6 +21,8 @@ import { useMemo, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { EmptyState } from './ui/States'; import { EmptyState } from './ui/States';
import { IconDownload, IconSearch, IconSort } from './ui/icons'; import { IconDownload, IconSearch, IconSort } from './ui/icons';
import { GridPdfButton } from './GridPdfButton';
import type { GridPdfColumn } from './gridPdf';
import './data-table.css'; import './data-table.css';
export type KxCellValue = string | number | null | undefined; export type KxCellValue = string | number | null | undefined;
@ -71,6 +73,14 @@ export interface KxDataTableProps<T> {
initialPageSize?: number; initialPageSize?: number;
/** 지정 시 CSV 내보내기 버튼 노출 — 다운로드 파일명(확장자 제외). */ /** 지정 시 CSV 내보내기 버튼 노출 — 다운로드 파일명(확장자 제외). */
csvFileName?: string; csvFileName?: string;
/**
* PDF (JasperReports) (= base). ariaLabel .
* title ariaLabel PDF ( 지시: 모든 PDF ).
*/
pdfTitle?: string;
pdfSubtitle?: string;
/** PDF 버튼 노출 여부(기본: pdfTitle 또는 ariaLabel 존재 시 true). */
enablePdf?: boolean;
/** 툴바 좌측(외부 필터 등) · 우측 추가 슬롯. */ /** 툴바 좌측(외부 필터 등) · 우측 추가 슬롯. */
toolbarStart?: ReactNode; toolbarStart?: ReactNode;
toolbarEnd?: ReactNode; toolbarEnd?: ReactNode;
@ -115,6 +125,9 @@ export function KxDataTable<T>({
pageSizeOptions = [10, 25, 50], pageSizeOptions = [10, 25, 50],
initialPageSize, initialPageSize,
csvFileName, csvFileName,
pdfTitle,
pdfSubtitle,
enablePdf,
toolbarStart, toolbarStart,
toolbarEnd, toolbarEnd,
ariaLabel, ariaLabel,
@ -200,7 +213,31 @@ export function KxDataTable<T>({
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}; };
const showToolbar = searchable || !!csvFileName || !!toolbarStart || !!toolbarEnd; // ── PDF(JasperReports) — CSV 와 동일 컬럼 집합·정렬 결과를 전송 ──
const resolvedPdfTitle = pdfTitle ?? ariaLabel ?? csvFileName ?? '';
const showPdf = (enablePdf ?? (!!resolvedPdfTitle)) && !!resolvedPdfTitle;
const pdfCols: GridPdfColumn[] = useMemo(
() =>
columns
.filter((c) => c.value && c.csv !== false)
.map((c) => ({
key: c.key,
label: c.header,
align: c.numeric ? ('right' as const) : ('left' as const),
})),
[columns],
);
const buildPdfRows = () =>
sorted.map((r) => {
const o: Record<string, unknown> = {};
for (const c of columns) {
if (c.value && c.csv !== false) o[c.key] = c.value(r);
}
return o;
});
const showToolbar =
searchable || !!csvFileName || showPdf || !!toolbarStart || !!toolbarEnd;
return ( return (
<div className={`kx-dt ${className}`}> <div className={`kx-dt ${className}`}>
@ -233,6 +270,15 @@ export function KxDataTable<T>({
{t('datatable.csv')} {t('datatable.csv')}
</button> </button>
)} )}
{showPdf && (
<GridPdfButton
title={resolvedPdfTitle}
subtitle={pdfSubtitle}
columns={pdfCols}
rows={buildPdfRows}
disabled={sorted.length === 0}
/>
)}
</div> </div>
</div> </div>
)} )}

Binary file not shown.

View File

@ -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 { TenantSwitcher } from './TenantSwitcher';
import { Breadcrumb } from './Breadcrumb'; import { Breadcrumb } from './Breadcrumb';
import { ShellFooter } from './ShellFooter'; import { ShellFooter } from './ShellFooter';
import { SettingsCustomizer } from './SettingsCustomizer'; import { SettingsCustomizer } from './SettingsCustomizer';
@ -136,6 +137,7 @@ const GROUPS: NavGroupDef[] = [
{ key: 'campaigns', label: '마케팅', labelKey: 'shell.menu.items.campaigns', Icon: IconBell, to: '/campaigns' }, { key: 'campaigns', label: '마케팅', labelKey: 'shell.menu.items.campaigns', Icon: IconBell, to: '/campaigns' },
{ key: 'sponsorship', label: '스폰서십', labelKey: 'shell.menu.items.sponsorship', Icon: IconSettlement, to: '/sponsorship' }, { key: 'sponsorship', label: '스폰서십', labelKey: 'shell.menu.items.sponsorship', Icon: IconSettlement, to: '/sponsorship' },
{ key: 'cms', label: '콘텐츠 CMS', labelKey: 'shell.menu.items.cms', Icon: IconDocument, to: '/cms' }, { key: 'cms', label: '콘텐츠 CMS', labelKey: 'shell.menu.items.cms', Icon: IconDocument, to: '/cms' },
{ key: 'liveNotices', label: '라이브 공지', labelKey: 'shell.menu.items.liveNotices', Icon: IconBell, to: '/cms/live-notices' },
], ],
}, },
{ {
@ -625,6 +627,7 @@ export function AppShell() {
</div> </div>
</div> </div>
<div className="kx-shell__topbar-right"> <div className="kx-shell__topbar-right">
<TenantSwitcher />
{workspace && <DdayChip dday={workspace.dday} />} {workspace && <DdayChip dday={workspace.dday} />}
<button <button
className="kx-shell__icon-btn" className="kx-shell__icon-btn"

View File

@ -0,0 +1,96 @@
import { useEffect, useState, type ChangeEvent } from 'react';
import { api, getAccessToken, setAccessToken } from '../../api/client';
import { useIsAdmin } from '../../screens/admin/AdminGuard';
/**
* () ( 2 · §1A-3-2).
* tid (POST /api/admin/tenants/{code}/switch),
* (reload). 1 .
*
* 소유권: components/layout client.ts( ) ( ).
* ko(i18n JSON ).
*/
interface TenantRow {
code: string;
name: string;
status: string;
}
interface SwitchResponse {
accessToken: string;
expiresIn: number;
tenant: string;
}
/** 현재 세션 토큰의 tid 클레임(대문자). 파싱 실패·부재 시 기준 테넌트 KINTEX. */
function currentTid(): string {
const tok = getAccessToken();
if (!tok) return 'KINTEX';
try {
const seg = tok.split('.')[1];
const json = atob(seg.replace(/-/g, '+').replace(/_/g, '/'));
const payload = JSON.parse(json) as { tid?: string };
return String(payload.tid ?? 'KINTEX').toUpperCase();
} catch {
return 'KINTEX';
}
}
export function TenantSwitcher() {
const isAdmin = useIsAdmin();
const [tenants, setTenants] = useState<TenantRow[]>([]);
const [current] = useState<string>(() => currentTid());
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!isAdmin) return;
let cancelled = false;
api
.get<TenantRow[]>('/api/admin/tenants')
.then((rows) => {
if (!cancelled) setTenants(rows ?? []);
})
.catch(() => {
/* 목록 조회 실패 시 스위처 미표시 — 조용히 무시 */
});
return () => {
cancelled = true;
};
}, [isAdmin]);
// 관리자가 아니거나 전환할 테넌트가 1개뿐이면 노출하지 않는다(클러터 방지).
if (!isAdmin || tenants.length < 2) return null;
const onChange = async (e: ChangeEvent<HTMLSelectElement>) => {
const code = e.target.value;
if (!code || code.toUpperCase() === current || busy) return;
setBusy(true);
try {
const res = await api.post<SwitchResponse>(
`/api/admin/tenants/${encodeURIComponent(code)}/switch`,
);
setAccessToken(res.accessToken);
window.location.reload(); // 전 화면을 새 테넌트 컨텍스트로 재조회
} catch {
setBusy(false); // 실패 시 선택 원복
}
};
return (
<label className="kx-tenant-switcher" title="전시관(테넌트) 전환">
<span className="kx-tenant-switcher__label"></span>
<select
className="kx-tenant-switcher__select"
value={current}
onChange={onChange}
disabled={busy}
aria-label="전시관 전환"
>
{tenants.map((t) => (
<option key={t.code} value={t.code}>
{t.name} ({t.code})
</option>
))}
</select>
</label>
);
}

View File

@ -684,3 +684,34 @@
color: var(--color-error); color: var(--color-error);
border-color: var(--color-error); border-color: var(--color-error);
} }
/* 테넌트(전시관) 스위처 — 플랫폼 관리자 전용 헤더 드롭다운(멀티테넌시 2단계). */
.kx-tenant-switcher {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 0 var(--space-2);
height: 32px;
border: var(--border-card);
border-radius: var(--radius-sm);
background: var(--color-surface);
}
.kx-tenant-switcher__label {
font-size: var(--fs-nano);
font-weight: var(--fw-semibold);
color: var(--color-neutral-500);
white-space: nowrap;
}
.kx-tenant-switcher__select {
border: none;
background: transparent;
font-size: var(--fs-micro);
font-weight: var(--fw-semibold);
color: var(--color-neutral-800);
cursor: pointer;
max-width: 180px;
}
.kx-tenant-switcher__select:disabled {
opacity: 0.6;
cursor: progress;
}

View File

@ -45,12 +45,15 @@ const STATIC: Record<string, Crumb[]> = {
// 관람객·마케팅 // 관람객·마케팅
'/visitors': [G('visitor'), I('visitors')], '/visitors': [G('visitor'), I('visitors')],
'/visitors/checkin': [G('visitor'), I('visitors', '/visitors'), I('checkin')], '/visitors/checkin': [G('visitor'), I('visitors', '/visitors'), I('checkin')],
'/visitors/kiosk': [G('visitor'), I('visitors', '/visitors'), B('kioskCheckin')],
'/visitor/account': [G('visitor'), B('visitorAccount')],
'/leads': [G('visitor'), I('leads')], '/leads': [G('visitor'), I('leads')],
'/campaigns': [G('visitor'), I('campaigns')], '/campaigns': [G('visitor'), I('campaigns')],
'/sponsorship': [G('visitor'), I('sponsorship')], '/sponsorship': [G('visitor'), I('sponsorship')],
'/cms': [G('visitor'), I('cms')], '/cms': [G('visitor'), I('cms')],
'/cms/microsite': [G('visitor'), I('cms', '/cms'), B('micrositeBuilder')], '/cms/microsite': [G('visitor'), I('cms', '/cms'), B('micrositeBuilder')],
'/cms/i18n': [G('visitor'), I('cms', '/cms'), B('i18nCms')], '/cms/i18n': [G('visitor'), I('cms', '/cms'), B('i18nCms')],
'/cms/live-notices': [G('visitor'), { labelKey: 'shell.menu.items.liveNotices', label: '라이브 공지' }],
// 정산·분석 // 정산·분석
'/settlement': [G('finance'), I('settlement')], '/settlement': [G('finance'), I('settlement')],

View File

@ -24,12 +24,15 @@ const STATIC_LABELS: Record<string, string> = {
'/auctions': '공사 옥션', '/auctions': '공사 옥션',
'/visitors': '관람객', '/visitors': '관람객',
'/visitors/checkin': '체크인 데스크', '/visitors/checkin': '체크인 데스크',
'/visitors/kiosk': '셀프 체크인 키오스크',
'/visitor/account': '관람객 마이',
'/leads': '리드', '/leads': '리드',
'/campaigns': '마케팅', '/campaigns': '마케팅',
'/sponsorship': '스폰서십', '/sponsorship': '스폰서십',
'/cms': '콘텐츠 CMS', '/cms': '콘텐츠 CMS',
'/cms/microsite': '마이크로사이트 빌더', '/cms/microsite': '마이크로사이트 빌더',
'/cms/i18n': '다국어 CMS', '/cms/i18n': '다국어 CMS',
'/cms/live-notices': '라이브 공지',
// 업무 공통 // 업무 공통
'/work/worklog': '업무일지', '/work/worklog': '업무일지',
'/work/schedule': '일정', '/work/schedule': '일정',

View File

@ -0,0 +1,23 @@
import './brand-logo.css';
/** 브랜드 워드마크 노출 컨텍스트 → 크기 규격(brand-logo.css 단일 출처). */
export type BrandLogoSize = 'header' | 'auth' | 'footer' | 'print';
/** 정식 CI 워드마크 경로(단일 출처). 구 자산(kintex-ci.jpg)·텍스트 워드마크는 이 컴포넌트로 대체한다. */
export const KINTEX_WORDMARK = '/brand/kintex-wordmark.jpg';
interface BrandLogoProps {
/** 노출 컨텍스트 — 크기 규격 결정(header 28 · auth 32 · footer 22 · print 38). */
size?: BrandLogoSize;
className?: string;
alt?: string;
}
/**
* KINTEX . (kx-brandmark--*) px .
* AppShell( ) ( ) .
*/
export function BrandLogo({ size = 'header', className, alt = 'KINTEX' }: BrandLogoProps) {
const cls = `kx-brandmark kx-brandmark--${size}${className ? ` ${className}` : ''}`;
return <img className={cls} src={KINTEX_WORDMARK} alt={alt} decoding="async" />;
}

View File

@ -0,0 +1,131 @@
/*
* Modal( ) Nifty (docs/DESIGN_SYSTEM_NIFTY.md §2.6):
* (· ) + (radius-lg) + (· X) / / ( ).
*
* (Offcanvas )
* - Esc · (Tab ) · ,
* - body · / (reduced-motion )
* - role=dialog · aria-modal · aria-label(title) · kx
*
* :
* <Modal open={open} onClose={close} title="평면도" size="xl" footer={<Button/>}></Modal>
*/
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { IconClose } from './icons';
import './modal.css';
export type ModalSize = 'sm' | 'md' | 'lg' | 'xl';
export interface ModalProps {
open: boolean;
onClose: () => void;
title: string;
/** 패널 폭 규격. 기본 'md'(560). xl 은 평면도 등 대형 뷰어. */
size?: ModalSize;
children: ReactNode;
/** 제목 옆 부가 액션 슬롯(선택). */
headerExtra?: ReactNode;
/** 하단 고정 푸터(액션 버튼, 선택). */
footer?: ReactNode;
/** 바디 여백 제거(이미지·캔버스 전면 표시용). */
flushBody?: boolean;
}
const EXIT_MS = 200;
export function Modal({ open, onClose, title, size = 'md', children, headerExtra, footer, flushBody }: ModalProps) {
const [mounted, setMounted] = useState(open);
const [visible, setVisible] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
const prevFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (open) {
prevFocusRef.current = document.activeElement as HTMLElement | null;
setMounted(true);
const id = requestAnimationFrame(() => setVisible(true));
return () => cancelAnimationFrame(id);
}
setVisible(false);
const timer = window.setTimeout(() => setMounted(false), EXIT_MS);
return () => window.clearTimeout(timer);
}, [open]);
useEffect(() => {
if (visible) {
panelRef.current?.focus();
} else if (prevFocusRef.current) {
prevFocusRef.current.focus?.();
}
}, [visible]);
useEffect(() => {
if (!mounted) return;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = prev;
};
}, [mounted]);
const onKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Escape') {
e.stopPropagation();
onClose();
return;
}
if (e.key !== 'Tab') return;
const panel = panelRef.current;
if (!panel) return;
const focusables = panel.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])',
);
if (focusables.length === 0) {
e.preventDefault();
panel.focus();
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = document.activeElement;
if (e.shiftKey) {
if (active === first || active === panel) {
e.preventDefault();
last.focus();
}
} else if (active === last) {
e.preventDefault();
first.focus();
}
},
[onClose],
);
if (!mounted) return null;
return (
<div className={`kx-modal2${visible ? ' is-open' : ''}`}>
<div className="kx-modal2__backdrop" onClick={onClose} aria-hidden="true" />
<div
ref={panelRef}
className={`kx-modal2__panel kx-modal2__panel--${size}`}
role="dialog"
aria-modal="true"
aria-label={title}
tabIndex={-1}
onKeyDown={onKeyDown}
>
<header className="kx-modal2__head">
<h2 className="kx-modal2__title">{title}</h2>
{headerExtra && <div className="kx-modal2__headextra">{headerExtra}</div>}
<button type="button" className="kx-modal2__close" aria-label={`${title} 닫기`} onClick={onClose}>
<IconClose size={20} />
</button>
</header>
<div className={`kx-modal2__body${flushBody ? ' is-flush' : ''}`}>{children}</div>
{footer && <footer className="kx-modal2__foot">{footer}</footer>}
</div>
</div>
);
}

View File

@ -0,0 +1,25 @@
/*
* KINTEX 브랜드 워드마크 컨텍스트별 크기 규격 단일 출처(소유자 지시 2026-07-24 "로고 전체 통일·크기 조정").
* 정식 CI 워드마크 = /brand/kintex-wordmark.jpg. 임의 px 산재 금지 반드시 아래 규격 클래스 사용.
* header : /공개 헤더 28px
* auth : 로그인·2FA 32px
* footer : 풋터 22px
* print : 인쇄·키오스크 38px
*/
.kx-brandmark {
width: auto;
display: block;
object-fit: contain;
}
.kx-brandmark--header {
height: 28px;
}
.kx-brandmark--auth {
height: 32px;
}
.kx-brandmark--footer {
height: 22px;
}
.kx-brandmark--print {
height: 38px;
}

View File

@ -0,0 +1,131 @@
/*
* 공용 Modal(중앙 다이얼로그) 스타일 Nifty 모달 패턴(DESIGN_SYSTEM_NIFTY §2.6).
* 전역 디자인 토큰(--color-*·--space-*·--radius-*·--fs-*·--fw-*) 사용. kx-modal2__* 네임스페이스.
*/
.kx-modal2 {
position: fixed;
inset: 0;
z-index: 70;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-4);
}
/* 백드롭 — 페이드 인/아웃 */
.kx-modal2__backdrop {
position: absolute;
inset: 0;
background: rgba(15, 23, 42, 0.45);
opacity: 0;
transition: opacity 0.2s ease;
}
.kx-modal2.is-open .kx-modal2__backdrop {
opacity: 1;
}
/* 패널 — 페이드 + 살짝 스케일 인 */
.kx-modal2__panel {
position: relative;
z-index: 1;
width: 100%;
max-height: calc(100vh - 2 * var(--space-4));
display: flex;
flex-direction: column;
background: var(--color-white);
border: var(--border-card);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-modal, 0 20px 48px rgba(15, 23, 42, 0.24));
outline: none;
opacity: 0;
transform: translateY(8px) scale(0.98);
transition: opacity 0.2s ease, transform 0.2s cubic-bezier(0.32, 0.72, 0, 1);
will-change: transform, opacity;
}
.kx-modal2.is-open .kx-modal2__panel {
opacity: 1;
transform: translateY(0) scale(1);
}
.kx-modal2__panel--sm { max-width: 420px; }
.kx-modal2__panel--md { max-width: 560px; }
.kx-modal2__panel--lg { max-width: 820px; }
.kx-modal2__panel--xl { max-width: min(1160px, 96vw); }
/* 헤더 */
.kx-modal2__head {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-4);
border-bottom: var(--border-card);
flex: 0 0 auto;
}
.kx-modal2__title {
flex: 1 1 auto;
min-width: 0;
font-size: var(--fs-h3, var(--fs-body));
font-weight: var(--fw-semibold);
color: var(--color-neutral-900);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kx-modal2__headextra {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: var(--space-2);
}
.kx-modal2__close {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-neutral-500);
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.kx-modal2__close:hover {
background: var(--color-neutral-100);
color: var(--color-neutral-900);
}
.kx-modal2__close:focus-visible {
outline: 2px solid var(--color-brand-500, #1f29fc);
outline-offset: 1px;
}
/* 바디(스크롤) */
.kx-modal2__body {
flex: 1 1 auto;
overflow-y: auto;
padding: var(--space-5);
}
.kx-modal2__body.is-flush {
padding: 0;
}
/* 푸터(고정, 액션 우측) */
.kx-modal2__foot {
flex: 0 0 auto;
display: flex;
justify-content: flex-end;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-top: var(--border-card);
}
/* 모션 최소화 존중 */
@media (prefers-reduced-motion: reduce) {
.kx-modal2__backdrop,
.kx-modal2__panel {
transition: none;
}
.kx-modal2__panel {
transform: none;
}
}

View File

@ -96,7 +96,8 @@
"tenants": "Tenants", "tenants": "Tenants",
"rulesets": "Rulesets", "rulesets": "Rulesets",
"slides": "Login Slides", "slides": "Login Slides",
"msgTemplates": "Messages" "msgTemplates": "Messages",
"liveNotices": "Live Notices"
}, },
"sections": { "sections": {
"userOrg": "Users & Org", "userOrg": "Users & Org",
@ -812,6 +813,7 @@
"feedAria": "Notification feed", "feedAria": "Notification feed",
"feedTitle": "Notifications", "feedTitle": "Notifications",
"feedEmpty": "No new notifications", "feedEmpty": "No new notifications",
"viewAll": "View all ({{n}})",
"aerialAria": "Hall aerial view", "aerialAria": "Hall aerial view",
"aerialTitle": "Hall aerial view", "aerialTitle": "Hall aerial view",
"aerialShot": "S7 Hall aerial", "aerialShot": "S7 Hall aerial",
@ -1308,7 +1310,11 @@
"sensorNoneDesc": "HVAC · lighting IoT · parking (iparking) integration is shown once sensors (M14) are connected.", "sensorNoneDesc": "HVAC · lighting IoT · parking (iparking) integration is shown once sensors (M14) are connected.",
"temp": "Temp", "temp": "Temp",
"humidity": "Humidity", "humidity": "Humidity",
"parkingSlots": "{{zone}}: {{occ}}/{{cap}} slots" "parkingSlots": "{{zone}}: {{occ}}/{{cap}} slots",
"hallPlanZoomAria": "Enlarge {{label}} floor plan",
"hallPlanAlt": "{{label}} floor plan",
"hallPlanTitle": "{{label}} floor plan (CAD measured)",
"hallPlanZoomAlt": "{{label}} floor plan enlarged"
}, },
"admin": { "admin": {
"deniedTitle": "Access denied", "deniedTitle": "Access denied",
@ -1342,7 +1348,16 @@
"modSettings": "System settings", "modSettings": "System settings",
"modRulesets": "Rulesets", "modRulesets": "Rulesets",
"modTenants": "Tenants", "modTenants": "Tenants",
"modMessages": "Messages" "modMessages": "Messages",
"heat": {
"smooth": "Smooth",
"moderate": "Moderate",
"busy": "Busy"
},
"onSite": "On-site {{n}}",
"recentActivity": "Recent activity",
"activityEmptyTitle": "No recent activity",
"activityEmptyDesc": "No administrative activity recorded yet."
}, },
"tenant": { "tenant": {
"title": "Tenant Management · Onboarding", "title": "Tenant Management · Onboarding",
@ -2477,7 +2492,9 @@
"countTo": "5", "countTo": "5",
"suffix": "M+" "suffix": "M+"
} }
} },
"ctaLive": "Live on-site (crowd & parking)",
"ctaAccount": "My account (membership, sessions, cards)"
}, },
"business": { "business": {
"heroAria": "Business hero", "heroAria": "Business hero",
@ -2758,7 +2775,11 @@
"emptyFiltered": "No results match your search", "emptyFiltered": "No results match your search",
"sortAsc": "Sort ascending", "sortAsc": "Sort ascending",
"sortDesc": "Sort descending", "sortDesc": "Sort descending",
"sortClear": "Clear sort" "sortClear": "Clear sort",
"pdf": "PDF",
"pdfTitle": "Export PDF",
"pdfBusy": "Exporting…",
"pdfError": "PDF export failed."
}, },
"breadcrumb": { "breadcrumb": {
"aria": "Breadcrumb", "aria": "Breadcrumb",
@ -2777,7 +2798,9 @@
"compliance": "Compliance Check", "compliance": "Compliance Check",
"approvals": "Approval Queue", "approvals": "Approval Queue",
"reviewDetail": "Review Detail", "reviewDetail": "Review Detail",
"gallery": "Visualization Gallery" "gallery": "Visualization Gallery",
"kioskCheckin": "Self check-in",
"visitorAccount": "My account"
}, },
"notFound": { "notFound": {
"title": "Page not found", "title": "Page not found",
@ -2825,5 +2848,52 @@
"rendering": "Rendering…", "rendering": "Rendering…",
"renderResult": "Result", "renderResult": "Result",
"noParams": "This template has no parameters" "noParams": "This template has no parameters"
},
"publicInfo": {
"events": {
"sub": "Search ongoing and upcoming events and pre-register.",
"resultFor": "{{n}} results for {{q}}"
},
"exhibit": {
"sub": "Booth application, construction and operation guide for exhibitors.",
"empty": "No exhibitor guide content yet. Please contact us for assistance."
},
"visit": {
"sub": "Admission, amenities and accessibility information for visitors.",
"empty": "No visitor guide available."
},
"transport": {
"sub": "Public transit, driving and shuttle guide",
"empty": "No transportation info available."
}
},
"liveNotice": {
"feedTitle": "Live Updates",
"live": "LIVE",
"pinned": "Pinned",
"cat": {
"urgent": "Urgent",
"program": "Program",
"info": "Info",
"general": "General"
}
},
"subscribe": {
"title": "Get open alerts",
"sub": "Be the first to know by email when ticket sales open.",
"email": "Email",
"consent": "I agree to the collection and use of my personal data to receive open alerts.",
"submit": "Subscribe",
"submitting": "Subscribing…",
"errPrivacy": "Please agree to the personal data terms to subscribe.",
"errEmail": "Please enter a valid email address.",
"done": "We'll email {{email}} when tickets open.",
"already": "{{email}} is already subscribed to open alerts.",
"unsubNote": "You can unsubscribe anytime via the link at the bottom of the alert email.",
"unsub": {
"title": "Unsubscribe from open alerts",
"noToken": "This unsubscribe link is invalid.",
"browse": "Browse events"
}
} }
} }

View File

@ -96,7 +96,8 @@
"tenants": "テナント", "tenants": "テナント",
"rulesets": "ルールセット", "rulesets": "ルールセット",
"slides": "ログインスライド", "slides": "ログインスライド",
"msgTemplates": "メッセージ管理" "msgTemplates": "メッセージ管理",
"liveNotices": "ライブ告知"
}, },
"sections": { "sections": {
"userOrg": "ユーザー・組織", "userOrg": "ユーザー・組織",
@ -812,6 +813,7 @@
"feedAria": "通知フィード", "feedAria": "通知フィード",
"feedTitle": "通知", "feedTitle": "通知",
"feedEmpty": "新しい通知はありません", "feedEmpty": "新しい通知はありません",
"viewAll": "すべて表示 ({{n}})",
"aerialAria": "ホール全景俯瞰", "aerialAria": "ホール全景俯瞰",
"aerialTitle": "ホール全景俯瞰", "aerialTitle": "ホール全景俯瞰",
"aerialShot": "S7 ホール全景", "aerialShot": "S7 ホール全景",
@ -1308,7 +1310,11 @@
"sensorNoneDesc": "空調(HVAC)·照明 IoT·駐車(iparking)連携はセンサー(M14)連携時に表示されます。", "sensorNoneDesc": "空調(HVAC)·照明 IoT·駐車(iparking)連携はセンサー(M14)連携時に表示されます。",
"temp": "温度", "temp": "温度",
"humidity": "湿度", "humidity": "湿度",
"parkingSlots": "{{zone}}: {{occ}}/{{cap}}台" "parkingSlots": "{{zone}}: {{occ}}/{{cap}}台",
"hallPlanZoomAria": "{{label}}平面図を拡大表示",
"hallPlanAlt": "{{label}}平面図",
"hallPlanTitle": "{{label}}平面図CAD実測",
"hallPlanZoomAlt": "{{label}}平面図拡大"
}, },
"admin": { "admin": {
"deniedTitle": "アクセス権限がありません", "deniedTitle": "アクセス権限がありません",
@ -1342,7 +1348,16 @@
"modSettings": "システム設定", "modSettings": "システム設定",
"modRulesets": "ルールセット", "modRulesets": "ルールセット",
"modTenants": "テナント", "modTenants": "テナント",
"modMessages": "メッセージ管理" "modMessages": "メッセージ管理",
"heat": {
"smooth": "円滑",
"moderate": "普通",
"busy": "混雑"
},
"onSite": "現地チェックイン {{n}}名",
"recentActivity": "最近の活動",
"activityEmptyTitle": "最近の活動なし",
"activityEmptyDesc": "記録された管理活動はまだありません。"
}, },
"tenant": { "tenant": {
"title": "テナント管理·オンボーディング", "title": "テナント管理·オンボーディング",
@ -2477,7 +2492,9 @@
"countTo": "500", "countTo": "500",
"suffix": "万+" "suffix": "万+"
} }
} },
"ctaLive": "現地ライブ(混雑・駐車)",
"ctaAccount": "観覧者マイ(会員・セッション・名刺)"
}, },
"business": { "business": {
"heroAria": "ビジネス案内ヒーロー", "heroAria": "ビジネス案内ヒーロー",
@ -2758,7 +2775,11 @@
"emptyFiltered": "検索条件に一致する結果がありません", "emptyFiltered": "検索条件に一致する結果がありません",
"sortAsc": "昇順で並べ替え", "sortAsc": "昇順で並べ替え",
"sortDesc": "降順で並べ替え", "sortDesc": "降順で並べ替え",
"sortClear": "並べ替え解除" "sortClear": "並べ替え解除",
"pdf": "PDF",
"pdfTitle": "PDF出力",
"pdfBusy": "出力中…",
"pdfError": "PDF出力に失敗しました。"
}, },
"breadcrumb": { "breadcrumb": {
"aria": "パンくず", "aria": "パンくず",
@ -2777,7 +2798,9 @@
"compliance": "規定チェック", "compliance": "規定チェック",
"approvals": "審査キュー", "approvals": "審査キュー",
"reviewDetail": "審査詳細", "reviewDetail": "審査詳細",
"gallery": "ビジュアライゼーションギャラリー" "gallery": "ビジュアライゼーションギャラリー",
"kioskCheckin": "セルフチェックイン",
"visitorAccount": "観覧者マイ"
}, },
"notFound": { "notFound": {
"title": "ページが見つかりません", "title": "ページが見つかりません",
@ -2825,5 +2848,52 @@
"rendering": "レンダリング中…", "rendering": "レンダリング中…",
"renderResult": "レンダリング結果", "renderResult": "レンダリング結果",
"noParams": "置換パラメータのないテンプレートです" "noParams": "置換パラメータのないテンプレートです"
},
"publicInfo": {
"events": {
"sub": "開催中・予定のイベントを検索して事前登録できます。",
"resultFor": "「{{q}}」の検索結果 {{n}}件"
},
"exhibit": {
"sub": "出展社向けのブース申請・施工・運営案内です。",
"empty": "参加案内コンテンツはまだありません。お問い合わせください。"
},
"visit": {
"sub": "入場・施設・アクセシビリティなど観覧に必要な案内です。",
"empty": "観覧案内はありません。"
},
"transport": {
"sub": "公共交通・自家用車・シャトル案内",
"empty": "交通案内はありません。"
}
},
"liveNotice": {
"feedTitle": "ライブ告知",
"live": "LIVE",
"pinned": "固定",
"cat": {
"urgent": "緊急",
"program": "プログラム",
"info": "案内",
"general": "一般"
}
},
"subscribe": {
"title": "オープン通知を受け取る",
"sub": "チケット販売開始時にメールでいち早くお知らせします。",
"email": "メール",
"consent": "オープン通知の受信のための個人情報の収集・利用に同意します。",
"submit": "通知を申し込む",
"submitting": "申込中…",
"errPrivacy": "購読には個人情報の取り扱い同意が必要です。",
"errEmail": "正しいメールアドレスを入力してください。",
"done": "チケット販売開始時に {{email}} へ通知します。",
"already": "{{email}} は既にオープン通知に登録済みです。",
"unsubNote": "通知メール下部の配信停止リンクからいつでも解除できます。",
"unsub": {
"title": "オープン通知の配信停止",
"noToken": "無効な配信停止リンクです。",
"browse": "イベントを見る"
}
} }
} }

View File

@ -96,7 +96,8 @@
"tenants": "테넌트", "tenants": "테넌트",
"rulesets": "룰셋", "rulesets": "룰셋",
"slides": "로그인 슬라이드", "slides": "로그인 슬라이드",
"msgTemplates": "메시지 관리" "msgTemplates": "메시지 관리",
"liveNotices": "라이브 공지"
}, },
"sections": { "sections": {
"userOrg": "사용자·조직", "userOrg": "사용자·조직",
@ -812,6 +813,7 @@
"feedAria": "알림 피드", "feedAria": "알림 피드",
"feedTitle": "알림", "feedTitle": "알림",
"feedEmpty": "새 알림이 없습니다", "feedEmpty": "새 알림이 없습니다",
"viewAll": "전체보기 ({{n}})",
"aerialAria": "홀 전경 조감", "aerialAria": "홀 전경 조감",
"aerialTitle": "홀 전경 조감", "aerialTitle": "홀 전경 조감",
"aerialShot": "S7 홀 전경", "aerialShot": "S7 홀 전경",
@ -1308,7 +1310,11 @@
"sensorNoneDesc": "공조(HVAC)·조명 IoT·주차(iparking) 연계는 센서(M14) 연동 시 표시됩니다.", "sensorNoneDesc": "공조(HVAC)·조명 IoT·주차(iparking) 연계는 센서(M14) 연동 시 표시됩니다.",
"temp": "온도", "temp": "온도",
"humidity": "습도", "humidity": "습도",
"parkingSlots": "{{zone}}: {{occ}}/{{cap}}면" "parkingSlots": "{{zone}}: {{occ}}/{{cap}}면",
"hallPlanZoomAria": "{{label}} 평면도 크게 보기",
"hallPlanAlt": "{{label}} 평면도",
"hallPlanTitle": "{{label}} 평면도 (CAD 실측)",
"hallPlanZoomAlt": "{{label}} 평면도 확대"
}, },
"admin": { "admin": {
"deniedTitle": "접근 권한이 없습니다", "deniedTitle": "접근 권한이 없습니다",
@ -1342,7 +1348,16 @@
"modSettings": "시스템설정", "modSettings": "시스템설정",
"modRulesets": "룰셋", "modRulesets": "룰셋",
"modTenants": "테넌트", "modTenants": "테넌트",
"modMessages": "메시지 관리" "modMessages": "메시지 관리",
"heat": {
"smooth": "원활",
"moderate": "보통",
"busy": "혼잡"
},
"onSite": "현장 체크인 {{n}}명",
"recentActivity": "최근 활동",
"activityEmptyTitle": "최근 활동 없음",
"activityEmptyDesc": "기록된 관리 활동이 아직 없습니다."
}, },
"tenant": { "tenant": {
"title": "테넌트 관리·온보딩", "title": "테넌트 관리·온보딩",
@ -2477,7 +2492,9 @@
"countTo": "500", "countTo": "500",
"suffix": "만+" "suffix": "만+"
} }
} },
"ctaLive": "현장 라이브(혼잡·주차)",
"ctaAccount": "관람객 마이(멤버십·세션·명함)"
}, },
"business": { "business": {
"heroAria": "비즈니스 안내 히어로", "heroAria": "비즈니스 안내 히어로",
@ -2758,7 +2775,11 @@
"emptyFiltered": "검색 조건에 맞는 결과가 없습니다", "emptyFiltered": "검색 조건에 맞는 결과가 없습니다",
"sortAsc": "오름차순 정렬", "sortAsc": "오름차순 정렬",
"sortDesc": "내림차순 정렬", "sortDesc": "내림차순 정렬",
"sortClear": "정렬 해제" "sortClear": "정렬 해제",
"pdf": "PDF",
"pdfTitle": "PDF 출력",
"pdfBusy": "출력 중…",
"pdfError": "PDF 출력에 실패했습니다."
}, },
"breadcrumb": { "breadcrumb": {
"aria": "경로", "aria": "경로",
@ -2777,7 +2798,9 @@
"compliance": "규정 검증", "compliance": "규정 검증",
"approvals": "검수 대기열", "approvals": "검수 대기열",
"reviewDetail": "검수 상세", "reviewDetail": "검수 상세",
"gallery": "시각화 갤러리" "gallery": "시각화 갤러리",
"kioskCheckin": "셀프 체크인",
"visitorAccount": "관람객 마이"
}, },
"notFound": { "notFound": {
"title": "페이지를 찾을 수 없습니다", "title": "페이지를 찾을 수 없습니다",
@ -2825,5 +2848,52 @@
"rendering": "렌더 중…", "rendering": "렌더 중…",
"renderResult": "렌더 결과", "renderResult": "렌더 결과",
"noParams": "치환할 파라미터가 없는 템플릿입니다" "noParams": "치환할 파라미터가 없는 템플릿입니다"
},
"publicInfo": {
"events": {
"sub": "진행 중·예정 행사를 검색하고 사전등록하세요.",
"resultFor": "{{q}} 검색 결과 {{n}}건"
},
"exhibit": {
"sub": "참가업체를 위한 부스 신청·시공·운영 안내입니다.",
"empty": "등록된 참가안내 콘텐츠가 없습니다. 문의하기로 상담을 요청하세요."
},
"visit": {
"sub": "입장·편의시설·접근성 등 관람에 필요한 안내입니다.",
"empty": "등록된 관람안내가 없습니다."
},
"transport": {
"sub": "대중교통·자가용·셔틀 안내",
"empty": "등록된 교통 안내가 없습니다."
}
},
"liveNotice": {
"feedTitle": "라이브 공지",
"live": "LIVE",
"pinned": "고정",
"cat": {
"urgent": "긴급",
"program": "프로그램",
"info": "안내",
"general": "일반"
}
},
"subscribe": {
"title": "오픈 알림 받기",
"sub": "티켓 판매가 시작되면 이메일로 가장 먼저 알려드립니다.",
"email": "이메일",
"consent": "오픈 알림 수신을 위한 개인정보 수집·이용에 동의합니다.",
"submit": "알림 신청",
"submitting": "신청 중…",
"errPrivacy": "구독 알림 수신을 위한 개인정보 수집·이용 동의가 필요합니다.",
"errEmail": "올바른 이메일 주소를 입력해 주세요.",
"done": "{{email}} 주소로 티켓 오픈 시 알림을 보내드립니다.",
"already": "{{email}} 주소는 이미 오픈 알림을 신청하셨습니다.",
"unsubNote": "알림 메일 하단의 수신거부 링크로 언제든 해지할 수 있습니다.",
"unsub": {
"title": "오픈 알림 수신거부",
"noToken": "유효하지 않은 수신거부 링크입니다.",
"browse": "행사 둘러보기"
}
} }
} }

View File

@ -96,7 +96,8 @@
"tenants": "租户", "tenants": "租户",
"rulesets": "规则集", "rulesets": "规则集",
"slides": "登录幻灯片", "slides": "登录幻灯片",
"msgTemplates": "消息管理" "msgTemplates": "消息管理",
"liveNotices": "实时公告"
}, },
"sections": { "sections": {
"userOrg": "用户·组织", "userOrg": "用户·组织",
@ -812,6 +813,7 @@
"feedAria": "通知动态", "feedAria": "通知动态",
"feedTitle": "通知", "feedTitle": "通知",
"feedEmpty": "暂无新通知", "feedEmpty": "暂无新通知",
"viewAll": "查看全部 ({{n}})",
"aerialAria": "展厅鸟瞰", "aerialAria": "展厅鸟瞰",
"aerialTitle": "展厅鸟瞰", "aerialTitle": "展厅鸟瞰",
"aerialShot": "S7 展厅鸟瞰", "aerialShot": "S7 展厅鸟瞰",
@ -1308,7 +1310,11 @@
"sensorNoneDesc": "空调(HVAC)·照明 IoT·停车(iparking)联动将在接入传感器(M14)后显示。", "sensorNoneDesc": "空调(HVAC)·照明 IoT·停车(iparking)联动将在接入传感器(M14)后显示。",
"temp": "温度", "temp": "温度",
"humidity": "湿度", "humidity": "湿度",
"parkingSlots": "{{zone}}: {{occ}}/{{cap}} 车位" "parkingSlots": "{{zone}}: {{occ}}/{{cap}} 车位",
"hallPlanZoomAria": "放大查看{{label}}平面图",
"hallPlanAlt": "{{label}}平面图",
"hallPlanTitle": "{{label}}平面图CAD实测",
"hallPlanZoomAlt": "{{label}}平面图放大"
}, },
"admin": { "admin": {
"deniedTitle": "无访问权限", "deniedTitle": "无访问权限",
@ -1342,7 +1348,16 @@
"modSettings": "系统设置", "modSettings": "系统设置",
"modRulesets": "规则集", "modRulesets": "规则集",
"modTenants": "租户", "modTenants": "租户",
"modMessages": "消息管理" "modMessages": "消息管理",
"heat": {
"smooth": "顺畅",
"moderate": "一般",
"busy": "拥挤"
},
"onSite": "现场签到 {{n}}人",
"recentActivity": "最近活动",
"activityEmptyTitle": "暂无最近活动",
"activityEmptyDesc": "尚无记录的管理活动。"
}, },
"tenant": { "tenant": {
"title": "租户管理·上线", "title": "租户管理·上线",
@ -2477,7 +2492,9 @@
"countTo": "500", "countTo": "500",
"suffix": "万+" "suffix": "万+"
} }
} },
"ctaLive": "现场实况(拥挤·停车)",
"ctaAccount": "观众我的(会员·场次·名片)"
}, },
"business": { "business": {
"heroAria": "商务介绍", "heroAria": "商务介绍",
@ -2758,7 +2775,11 @@
"emptyFiltered": "没有符合条件的结果", "emptyFiltered": "没有符合条件的结果",
"sortAsc": "升序排序", "sortAsc": "升序排序",
"sortDesc": "降序排序", "sortDesc": "降序排序",
"sortClear": "取消排序" "sortClear": "取消排序",
"pdf": "PDF",
"pdfTitle": "导出 PDF",
"pdfBusy": "导出中…",
"pdfError": "PDF 导出失败。"
}, },
"breadcrumb": { "breadcrumb": {
"aria": "路径", "aria": "路径",
@ -2777,7 +2798,9 @@
"compliance": "合规检查", "compliance": "合规检查",
"approvals": "审核队列", "approvals": "审核队列",
"reviewDetail": "审核详情", "reviewDetail": "审核详情",
"gallery": "可视化画廊" "gallery": "可视化画廊",
"kioskCheckin": "自助签到",
"visitorAccount": "观众我的"
}, },
"notFound": { "notFound": {
"title": "找不到页面", "title": "找不到页面",
@ -2825,5 +2848,52 @@
"rendering": "渲染中…", "rendering": "渲染中…",
"renderResult": "渲染结果", "renderResult": "渲染结果",
"noParams": "此模板没有可替换参数" "noParams": "此模板没有可替换参数"
},
"publicInfo": {
"events": {
"sub": "搜索进行中及即将举办的活动并预登记。",
"resultFor": "“{{q}}”的搜索结果 {{n}} 条"
},
"exhibit": {
"sub": "面向参展商的展位申请、施工及运营指南。",
"empty": "暂无参展指南内容。请通过咨询联系我们。"
},
"visit": {
"sub": "入场、便利设施及无障碍等观展所需信息。",
"empty": "暂无观展指南。"
},
"transport": {
"sub": "公共交通、自驾及班车指南",
"empty": "暂无交通信息。"
}
},
"liveNotice": {
"feedTitle": "实时公告",
"live": "直播中",
"pinned": "置顶",
"cat": {
"urgent": "紧急",
"program": "日程",
"info": "通知",
"general": "一般"
}
},
"subscribe": {
"title": "获取开售通知",
"sub": "门票开售时第一时间通过邮件通知您。",
"email": "邮箱",
"consent": "我同意为接收开售通知而收集和使用个人信息。",
"submit": "订阅通知",
"submitting": "订阅中…",
"errPrivacy": "订阅需同意个人信息条款。",
"errEmail": "请输入有效的邮箱地址。",
"done": "门票开售时将通知 {{email}}。",
"already": "{{email}} 已订阅开售通知。",
"unsubNote": "可随时通过通知邮件底部的退订链接取消。",
"unsub": {
"title": "退订开售通知",
"noToken": "退订链接无效。",
"browse": "浏览活动"
}
} }
} }

View File

@ -13,11 +13,13 @@ import {
} from 'recharts'; } from 'recharts';
import { CHART } from '../chartColors'; import { CHART } from '../chartColors';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { IconClock, IconOperations } from '../../components/ui/icons'; import { IconClock, IconList, IconOperations } from '../../components/ui/icons';
import { adminApi } from '../../api/endpoints'; import { adminApi } from '../../api/endpoints';
import { ApiRequestError } from '../../api/client'; import { ApiRequestError } from '../../api/client';
import { StatusPill } from '../work/workShared'; import { StatusPill } from '../work/workShared';
import type { AdminDashboardData, AdminStats } from '../../api/types'; import type { AdminStats } from '../../api/types';
import type { AdminDashboardExt } from './adminDashboardTypes';
import { heatClass } from './adminDashboardTypes';
import { ADMIN_KPIS, LIVE_EVENTS, TENANTS, VISITOR_TREND } from './sampleAdmin'; import { ADMIN_KPIS, LIVE_EVENTS, TENANTS, VISITOR_TREND } from './sampleAdmin';
import './admin.css'; import './admin.css';
@ -45,7 +47,7 @@ export function AdminDashboardPage() {
}); });
const degraded = isDegradable(q.error); const degraded = isDegradable(q.error);
const data: AdminDashboardData | null = q.data ?? (degraded ? FALLBACK : null); const data: AdminDashboardExt | null = (q.data as AdminDashboardExt | undefined) ?? (degraded ? FALLBACK : null);
const hardError = q.isError && !degraded; const hardError = q.isError && !degraded;
const statsDegraded = isDegradable(sq.error); const statsDegraded = isDegradable(sq.error);
@ -168,6 +170,9 @@ export function AdminDashboardPage() {
<div className="kx-livecard__top"> <div className="kx-livecard__top">
<span className="kx-livecard__dot is-live" aria-hidden="true" /> <span className="kx-livecard__dot is-live" aria-hidden="true" />
<StatusPill value={e.status} /> <StatusPill value={e.status} />
<span className={`kx-heatpill is-${heatClass(e.heat)}`}>
{t(`admin.heat.${heatClass(e.heat)}`, { defaultValue: heatLabel(heatClass(e.heat)) })}
</span>
</div> </div>
<h3 className="kx-livecard__title">{e.name}</h3> <h3 className="kx-livecard__title">{e.name}</h3>
<p className="kx-livecard__meta">{e.hall ?? t('admin.hallTbd')}</p> <p className="kx-livecard__meta">{e.hall ?? t('admin.hallTbd')}</p>
@ -177,6 +182,12 @@ export function AdminDashboardPage() {
</div> </div>
<span className="kx-livecard__occ-val tnum">{t('admin.occupancy', { n: clamp(e.occupancy) })}</span> <span className="kx-livecard__occ-val tnum">{t('admin.occupancy', { n: clamp(e.occupancy) })}</span>
</div> </div>
<p className="kx-livecard__onsite tnum">
{t('admin.onSite', {
defaultValue: '현장 체크인 {{n}}명',
n: (e.onSiteCount ?? 0).toLocaleString(),
})}
</p>
</div> </div>
</article> </article>
))} ))}
@ -184,12 +195,55 @@ export function AdminDashboardPage() {
)} )}
</aside> </aside>
</div> </div>
{/* 최근 활동 — audit_log 실 기록(민감정보 미포함). 원천 부재 시 빈 상태(정직). */}
<section className="kx-card kx-admin__activity" aria-label={t('admin.recentActivity', { defaultValue: '최근 활동' })}>
<div className="kx-card__head">
<h2><IconList className="kx-title-ic" size={18} />{t('admin.recentActivity', { defaultValue: '최근 활동' })}</h2>
</div>
{(data.recentActivity?.length ?? 0) === 0 ? (
<EmptyState
title={t('admin.activityEmptyTitle', { defaultValue: '최근 활동 없음' })}
description={t('admin.activityEmptyDesc', { defaultValue: '기록된 관리 활동이 아직 없습니다.' })}
/>
) : (
<ul className="kx-admin__activity-list">
{data.recentActivity!.map((a, i) => (
<li key={`${a.action}-${a.createdAt}-${i}`} className="kx-actrow">
<span className={`kx-actrow__dot is-${a.result === 'SUCCESS' ? 'ok' : 'warn'}`} aria-hidden="true" />
<div className="kx-actrow__body">
<p className="kx-actrow__line">
<span className="kx-actrow__action">{a.action}</span>
{a.targetType && <span className="kx-actrow__target">{a.targetType}</span>}
{a.actorName && <span className="kx-actrow__actor">{a.actorName}</span>}
</p>
{a.summary && <p className="kx-actrow__summary">{a.summary}</p>}
</div>
<time className="kx-actrow__time tnum">{formatTs(a.createdAt)}</time>
</li>
))}
</ul>
)}
</section>
</> </>
)} )}
</div> </div>
); );
} }
/** 혼잡 등급 한국어 라벨(i18n defaultValue 폴백). */
function heatLabel(heat: 'smooth' | 'moderate' | 'busy'): string {
return heat === 'busy' ? '혼잡' : heat === 'moderate' ? '보통' : '원활';
}
/** ISO(UTC) → 로컬 'MM-DD HH:mm' 표기. 파싱 실패 시 원문 반환. */
function formatTs(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const p = (n: number) => String(n).padStart(2, '0');
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
const TOOLTIP_STYLE = { const TOOLTIP_STYLE = {
fontSize: 12, fontSize: 12,
borderRadius: 8, borderRadius: 8,
@ -225,7 +279,7 @@ function isDegradable(error: unknown): boolean {
* ( shape ) API. NETWORK/NOT_FOUND . * ( shape ) API. NETWORK/NOT_FOUND .
* 근거: 기존 sampleAdmin.ts(· ) AdminDashboardData . * 근거: 기존 sampleAdmin.ts(· ) AdminDashboardData .
*/ */
const FALLBACK: AdminDashboardData = { const FALLBACK: AdminDashboardExt = {
kpis: ADMIN_KPIS.map((k) => ({ label: k.label, value: k.value, sub: k.delta ?? null })), kpis: ADMIN_KPIS.map((k) => ({ label: k.label, value: k.value, sub: k.delta ?? null })),
visitorTrend: VISITOR_TREND.map((p) => ({ hour: p.hour, count: p.today })), visitorTrend: VISITOR_TREND.map((p) => ({ hour: p.hour, count: p.today })),
liveEvents: LIVE_EVENTS.filter((e) => e.live).map((e) => ({ liveEvents: LIVE_EVENTS.filter((e) => e.live).map((e) => ({
@ -234,8 +288,12 @@ const FALLBACK: AdminDashboardData = {
hall: e.hallTime, hall: e.hallTime,
occupancy: e.progress ?? 60, occupancy: e.progress ?? 60,
status: 'ongoing', status: 'ongoing',
// 폴백은 오프라인 시연치 — onSiteCount/heat 는 실집계 부재를 정직 반영(0/원활).
onSiteCount: 0,
heat: 'smooth',
})), })),
tenants: TENANTS, tenants: TENANTS,
recentActivity: [],
}; };
/** 집계 지표 강등 폴백 — NETWORK/NOT_FOUND 시에만(정상 경로는 실 API). degraded 배너와 함께 노출. */ /** 집계 지표 강등 폴백 — NETWORK/NOT_FOUND 시에만(정상 경로는 실 API). degraded 배너와 함께 노출. */

View File

@ -11,8 +11,10 @@ import { Trans, 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 { Button } from '../../components/ui/Button'; import { Button } from '../../components/ui/Button';
import { IconDownload, IconSearch } from '../../components/ui/icons'; import { IconDownload, IconList, IconSearch } from '../../components/ui/icons';
import { auditApi, KNOWN_AUDIT_ACTIONS, type AuditLogRow } from './adminModulesApi'; import { auditApi, KNOWN_AUDIT_ACTIONS, type AuditLogRow } from './adminModulesApi';
import { GridPdfButton } from '../../components/GridPdfButton';
import '../../components/data-table.css';
import './admin-modules.css'; import './admin-modules.css';
const PAGE_SIZES = [20, 50, 100]; const PAGE_SIZES = [20, 50, 100];
@ -50,6 +52,24 @@ export function AuditLogPage() {
const from = total === 0 ? 0 : page * size + 1; const from = total === 0 ? 0 : page * size + 1;
const to = Math.min(total, (page + 1) * size); const to = Math.min(total, (page + 1) * size);
const pdfColumns = [
{ key: 'time', label: t('audit.colTime') },
{ key: 'user', label: t('audit.colUser') },
{ key: 'action', label: t('audit.colAction') },
{ key: 'target', label: t('audit.colTarget') },
{ key: 'event', label: t('audit.colEvent') },
{ key: 'result', label: t('audit.colResult') },
];
const pdfRows = () =>
rows.map((r) => ({
time: fmtTs(r.createdAt),
user: r.actorName ?? r.actorId ?? '—',
action: r.summary ? `${r.action ?? '—'} · ${r.summary}` : r.action ?? '—',
target: r.targetType ? `${r.targetType}${r.targetId ? ` #${r.targetId}` : ''}` : '—',
event: r.eventId ?? '—',
result: r.result ?? '—',
}));
function applyFilters() { function applyFilters() {
setPage(0); setPage(0);
setApplied({ action, actorId, eventId }); setApplied({ action, actorId, eventId });
@ -66,7 +86,7 @@ export function AuditLogPage() {
<div className="kx-page kx-adm"> <div className="kx-page kx-adm">
<header className="kx-adm__head"> <header className="kx-adm__head">
<div> <div>
<h1 className="kx-adm__title">{t('audit.title')}</h1> <h1 className="kx-adm__title"><IconList className="kx-title-ic" size={20} />{t('audit.title')}</h1>
<p className="kx-adm__subtitle"> <p className="kx-adm__subtitle">
{t('audit.subtitle')} {t('audit.subtitle')}
</p> </p>
@ -144,6 +164,11 @@ export function AuditLogPage() {
/> />
) : ( ) : (
<> <>
<div className="kx-dt__toolbar">
<div className="kx-dt__toolbar-end">
<GridPdfButton title={t('audit.title')} columns={pdfColumns} rows={pdfRows} disabled={rows.length === 0} />
</div>
</div>
<div className="kx-table-scroll"> <div className="kx-table-scroll">
<table className="kx-table kx-table--zebra kx-adm-audit"> <table className="kx-table kx-table--zebra kx-adm-audit">
<thead> <thead>

View File

@ -6,6 +6,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { IconShieldCheck } from '../../components/ui/icons';
import { ErrorState, Skeleton } from '../../components/ui/States'; import { ErrorState, Skeleton } from '../../components/ui/States';
import { Button } from '../../components/ui/Button'; import { Button } from '../../components/ui/Button';
import { errMessage, useToast } from '../work/workShared'; import { errMessage, useToast } from '../work/workShared';
@ -49,7 +50,7 @@ export function AuthPolicyPage() {
<div className="kx-page kx-adm"> <div className="kx-page kx-adm">
<header className="kx-adm__head"> <header className="kx-adm__head">
<div> <div>
<h1 className="kx-adm__title">{t('authPolicy.title')}</h1> <h1 className="kx-adm__title"><IconShieldCheck className="kx-title-ic" size={20} />{t('authPolicy.title')}</h1>
<p className="kx-adm__subtitle">{t('authPolicy.subtitle')}</p> <p className="kx-adm__subtitle">{t('authPolicy.subtitle')}</p>
</div> </div>
</header> </header>

View File

@ -10,7 +10,7 @@ import { useTranslation } from 'react-i18next';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { Button } from '../../components/ui/Button'; import { Button } from '../../components/ui/Button';
import { KxDataTable, type KxColumn } from '../../components/KxDataTable'; import { KxDataTable, type KxColumn } from '../../components/KxDataTable';
import { IconPlus } from '../../components/ui/icons'; import { IconList, IconPlus } from '../../components/ui/icons';
import { errMessage, useToast } from '../work/workShared'; import { errMessage, useToast } from '../work/workShared';
import { codeApi, type Code, type CodeGroup } from './adminCrudApi'; import { codeApi, type Code, type CodeGroup } from './adminCrudApi';
import './admin-modules.css'; import './admin-modules.css';
@ -120,7 +120,7 @@ export function CommonCodeAdminPage() {
<div className="kx-page kx-adm"> <div className="kx-page kx-adm">
<header className="kx-adm__head"> <header className="kx-adm__head">
<div> <div>
<h1 className="kx-adm__title">{t('code.title')}</h1> <h1 className="kx-adm__title"><IconList className="kx-title-ic" size={20} />{t('code.title')}</h1>
<p className="kx-adm__subtitle">{t('code.subtitle')}</p> <p className="kx-adm__subtitle">{t('code.subtitle')}</p>
</div> </div>
<Button leadingIcon={<IconPlus size={16} />} onClick={() => setGroupModal(null)}>{t('code.addGroup')}</Button> <Button leadingIcon={<IconPlus size={16} />} onClick={() => setGroupModal(null)}>{t('code.addGroup')}</Button>

View File

@ -9,10 +9,12 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { Button } from '../../components/ui/Button'; import { Button } from '../../components/ui/Button';
import { IconPlus, IconSearch } from '../../components/ui/icons'; import { IconExhibitors, IconPlus, IconSearch } from '../../components/ui/icons';
import { errMessage, useToast } from '../work/workShared'; import { errMessage, useToast } from '../work/workShared';
import { companyApi, PARTNER_TYPES, type Company, type CompanySaveBody } from './systemBasicsApi'; import { companyApi, PARTNER_TYPES, type Company, type CompanySaveBody } from './systemBasicsApi';
import { Pager } from './LoginHistoryPage'; import { Pager } from './LoginHistoryPage';
import { GridPdfButton } from '../../components/GridPdfButton';
import '../../components/data-table.css';
import './admin-modules.css'; import './admin-modules.css';
export function CompanyAdminPage() { export function CompanyAdminPage() {
@ -48,11 +50,29 @@ export function CompanyAdminPage() {
function apply() { setPage(0); setApplied({ keyword, partnerType }); } function apply() { setPage(0); setApplied({ keyword, partnerType }); }
const pdfColumns = [
{ key: 'name', label: t('company.colName') },
{ key: 'bizNo', label: t('company.colBizNo') },
{ key: 'type', label: t('company.colType') },
{ key: 'contact', label: t('company.colContact') },
{ key: 'tel', label: t('company.colTel') },
{ key: 'use', label: t('company.colUse') },
];
const pdfRows = () =>
rows.map((r) => ({
name: r.companyName,
bizNo: r.bizNo ?? '-',
type: r.partnerType ? t(`company.pt_${r.partnerType}`) : '-',
contact: r.contactName ?? '-',
tel: r.contactTel ?? '-',
use: r.useYn === 'N' ? t('sysadmin.noUse') : t('sysadmin.use'),
}));
return ( return (
<div className="kx-page kx-adm"> <div className="kx-page kx-adm">
<header className="kx-adm__head"> <header className="kx-adm__head">
<div> <div>
<h1 className="kx-adm__title">{t('company.title')}</h1> <h1 className="kx-adm__title"><IconExhibitors className="kx-title-ic" size={20} />{t('company.title')}</h1>
<p className="kx-adm__subtitle">{t('company.subtitle')}</p> <p className="kx-adm__subtitle">{t('company.subtitle')}</p>
</div> </div>
<Button leadingIcon={<IconPlus size={16} />} onClick={() => setEditing(null)}>{t('company.add')}</Button> <Button leadingIcon={<IconPlus size={16} />} onClick={() => setEditing(null)}>{t('company.add')}</Button>
@ -87,6 +107,11 @@ export function CompanyAdminPage() {
<EmptyState title={t('company.emptyTitle')} description={t('company.emptyDesc')} /> <EmptyState title={t('company.emptyTitle')} description={t('company.emptyDesc')} />
) : ( ) : (
<> <>
<div className="kx-dt__toolbar">
<div className="kx-dt__toolbar-end">
<GridPdfButton title={t('company.title')} columns={pdfColumns} rows={pdfRows} disabled={rows.length === 0} />
</div>
</div>
<div className="kx-table-scroll"> <div className="kx-table-scroll">
<table className="kx-table kx-table--zebra"> <table className="kx-table kx-table--zebra">
<thead> <thead>

View File

@ -186,7 +186,7 @@ export function DeptAdminPage() {
<div className="kx-page kx-adm"> <div className="kx-page kx-adm">
<header className="kx-adm__head"> <header className="kx-adm__head">
<div> <div>
<h1 className="kx-adm__title">{t('dept.title')}</h1> <h1 className="kx-adm__title"><IconUsers className="kx-title-ic" size={20} />{t('dept.title')}</h1>
<p className="kx-adm__subtitle">{t('dept.subtitle')}</p> <p className="kx-adm__subtitle">{t('dept.subtitle')}</p>
</div> </div>
<div className="kx-dept-headActions"> <div className="kx-dept-headActions">

View File

@ -8,10 +8,12 @@ import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { Button } from '../../components/ui/Button'; import { Button } from '../../components/ui/Button';
import { IconSearch } from '../../components/ui/icons'; import { IconSearch, IconWarning } from '../../components/ui/icons';
import { fmtDateTime } from '../work/workShared'; import { fmtDateTime } from '../work/workShared';
import { errorLogApi } from './systemBasicsApi'; import { errorLogApi } from './systemBasicsApi';
import { Pager } from './LoginHistoryPage'; import { Pager } from './LoginHistoryPage';
import { GridPdfButton } from '../../components/GridPdfButton';
import '../../components/data-table.css';
import './admin-modules.css'; import './admin-modules.css';
export function ErrorLogPage() { export function ErrorLogPage() {
@ -30,11 +32,27 @@ export function ErrorLogPage() {
const total = q.data?.total ?? 0; const total = q.data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / size)); const totalPages = Math.max(1, Math.ceil(total / size));
const pdfColumns = [
{ key: 'time', label: t('errorLog.colTime') },
{ key: 'code', label: t('errorLog.colCode') },
{ key: 'method', label: t('errorLog.colMethod') },
{ key: 'path', label: t('errorLog.colPath') },
{ key: 'summary', label: t('errorLog.colSummary') },
];
const pdfRows = () =>
rows.map((r) => ({
time: fmtDateTime(r.createdAt),
code: r.errorCode ?? '-',
method: r.method ?? '-',
path: r.path ?? '-',
summary: r.summary ?? '-',
}));
return ( return (
<div className="kx-page kx-adm"> <div className="kx-page kx-adm">
<header className="kx-adm__head"> <header className="kx-adm__head">
<div> <div>
<h1 className="kx-adm__title">{t('errorLog.title')}</h1> <h1 className="kx-adm__title"><IconWarning className="kx-title-ic" size={20} />{t('errorLog.title')}</h1>
<p className="kx-adm__subtitle">{t('errorLog.subtitle')}</p> <p className="kx-adm__subtitle">{t('errorLog.subtitle')}</p>
</div> </div>
</header> </header>
@ -63,6 +81,11 @@ export function ErrorLogPage() {
<EmptyState title={t('errorLog.emptyTitle')} description={t('errorLog.emptyDesc')} /> <EmptyState title={t('errorLog.emptyTitle')} description={t('errorLog.emptyDesc')} />
) : ( ) : (
<> <>
<div className="kx-dt__toolbar">
<div className="kx-dt__toolbar-end">
<GridPdfButton title={t('errorLog.title')} columns={pdfColumns} rows={pdfRows} disabled={rows.length === 0} />
</div>
</div>
<div className="kx-table-scroll"> <div className="kx-table-scroll">
<table className="kx-table kx-table--zebra"> <table className="kx-table kx-table--zebra">
<thead> <thead>

View File

@ -10,7 +10,7 @@ import { useTranslation } from 'react-i18next';
import { ErrorState, Skeleton } from '../../components/ui/States'; import { ErrorState, Skeleton } from '../../components/ui/States';
import { Button } from '../../components/ui/Button'; import { Button } from '../../components/ui/Button';
import { KxDataTable, type KxColumn } from '../../components/KxDataTable'; import { KxDataTable, type KxColumn } from '../../components/KxDataTable';
import { IconPlus } from '../../components/ui/icons'; import { IconCalendar, IconPlus } from '../../components/ui/icons';
import { errMessage, useToast } from '../work/workShared'; import { errMessage, useToast } from '../work/workShared';
import { holidayApi, type Holiday, type HolidayCreateBody } from './holidayApi'; import { holidayApi, type Holiday, type HolidayCreateBody } from './holidayApi';
import './admin-modules.css'; import './admin-modules.css';
@ -108,7 +108,7 @@ export function HolidayAdminPage() {
<div className="kx-page kx-adm"> <div className="kx-page kx-adm">
<header className="kx-adm__head"> <header className="kx-adm__head">
<div> <div>
<h1 className="kx-adm__title">{t('holiday.title')}</h1> <h1 className="kx-adm__title"><IconCalendar className="kx-title-ic" size={20} />{t('holiday.title')}</h1>
<p className="kx-adm__subtitle">{t('holiday.subtitle')}</p> <p className="kx-adm__subtitle">{t('holiday.subtitle')}</p>
</div> </div>
<div className="kx-adm-tbl__actions"> <div className="kx-adm-tbl__actions">

View File

@ -6,6 +6,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { IconSettings } from '../../components/ui/icons';
import { ErrorState, Skeleton } from '../../components/ui/States'; import { ErrorState, Skeleton } from '../../components/ui/States';
import { Button } from '../../components/ui/Button'; import { Button } from '../../components/ui/Button';
import { errMessage, useToast } from '../work/workShared'; import { errMessage, useToast } from '../work/workShared';
@ -54,7 +55,7 @@ function InfraConfig({ variant }: { variant: Variant }) {
<div className="kx-page kx-adm"> <div className="kx-page kx-adm">
<header className="kx-adm__head"> <header className="kx-adm__head">
<div> <div>
<h1 className="kx-adm__title">{t(`${titleKey}.title`)}</h1> <h1 className="kx-adm__title"><IconSettings className="kx-title-ic" size={20} />{t(`${titleKey}.title`)}</h1>
<p className="kx-adm__subtitle">{t(`${titleKey}.subtitle`)}</p> <p className="kx-adm__subtitle">{t(`${titleKey}.subtitle`)}</p>
</div> </div>
</header> </header>

View File

@ -8,9 +8,11 @@ import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { Button } from '../../components/ui/Button'; import { Button } from '../../components/ui/Button';
import { IconSearch } from '../../components/ui/icons'; import { IconClock, IconSearch } from '../../components/ui/icons';
import { fmtDateTime } from '../work/workShared'; import { fmtDateTime } from '../work/workShared';
import { loginHistoryApi } from './systemBasicsApi'; import { loginHistoryApi } from './systemBasicsApi';
import { GridPdfButton } from '../../components/GridPdfButton';
import '../../components/data-table.css';
import './admin-modules.css'; import './admin-modules.css';
const RESULTS = ['', 'SUCCESS', 'FAIL', 'LOCKED']; const RESULTS = ['', 'SUCCESS', 'FAIL', 'LOCKED'];
@ -32,11 +34,29 @@ export function LoginHistoryPage() {
const total = q.data?.total ?? 0; const total = q.data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / size)); const totalPages = Math.max(1, Math.ceil(total / size));
const pdfColumns = [
{ key: 'time', label: t('loginHistory.colTime') },
{ key: 'result', label: t('loginHistory.colResult') },
{ key: 'user', label: t('loginHistory.colUser') },
{ key: 'email', label: t('loginHistory.colEmail') },
{ key: 'ip', label: t('loginHistory.colIp') },
{ key: 'reason', label: t('loginHistory.colReason') },
];
const pdfRows = () =>
rows.map((r) => ({
time: fmtDateTime(r.createdAt),
result: r.result ?? '-',
user: r.userId ?? '-',
email: r.emailMasked ?? '-',
ip: r.ipMasked ?? '-',
reason: r.failReason ?? '-',
}));
return ( return (
<div className="kx-page kx-adm"> <div className="kx-page kx-adm">
<header className="kx-adm__head"> <header className="kx-adm__head">
<div> <div>
<h1 className="kx-adm__title">{t('loginHistory.title')}</h1> <h1 className="kx-adm__title"><IconClock className="kx-title-ic" size={20} />{t('loginHistory.title')}</h1>
<p className="kx-adm__subtitle">{t('loginHistory.subtitle')}</p> <p className="kx-adm__subtitle">{t('loginHistory.subtitle')}</p>
</div> </div>
</header> </header>
@ -76,6 +96,11 @@ export function LoginHistoryPage() {
<EmptyState title={t('loginHistory.emptyTitle')} description={t('loginHistory.emptyDesc')} /> <EmptyState title={t('loginHistory.emptyTitle')} description={t('loginHistory.emptyDesc')} />
) : ( ) : (
<> <>
<div className="kx-dt__toolbar">
<div className="kx-dt__toolbar-end">
<GridPdfButton title={t('loginHistory.title')} columns={pdfColumns} rows={pdfRows} disabled={rows.length === 0} />
</div>
</div>
<div className="kx-table-scroll"> <div className="kx-table-scroll">
<table className="kx-table kx-table--zebra"> <table className="kx-table kx-table--zebra">
<thead> <thead>

View File

@ -116,7 +116,7 @@ export function LoginSlideAdminPage() {
<div className="kx-admin"> <div className="kx-admin">
<header className="kx-admin__head"> <header className="kx-admin__head">
<div> <div>
<h1 className="kx-admin__title">{t('slide.title')}</h1> <h1 className="kx-admin__title"><IconImage className="kx-title-ic" size={20} />{t('slide.title')}</h1>
<p className="kx-admin__subtitle"> <p className="kx-admin__subtitle">
<Trans i18nKey="slide.subtitle" components={[<strong key="s" />]} /> <Trans i18nKey="slide.subtitle" components={[<strong key="s" />]} />
</p> </p>

View File

@ -8,9 +8,11 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { Button } from '../../components/ui/Button'; import { Button } from '../../components/ui/Button';
import { IconPlus } from '../../components/ui/icons'; import { IconMenu, IconPlus } from '../../components/ui/icons';
import { errMessage, useToast } from '../work/workShared'; import { errMessage, useToast } from '../work/workShared';
import { menuApi, MENU_PRG_TYPES, type MenuNode, type MenuSaveBody } from './adminCrudApi'; import { menuApi, MENU_PRG_TYPES, type MenuNode, type MenuSaveBody } from './adminCrudApi';
import { GridPdfButton } from '../../components/GridPdfButton';
import '../../components/data-table.css';
import './admin-modules.css'; import './admin-modules.css';
interface FlatMenu extends MenuNode { interface FlatMenu extends MenuNode {
@ -58,6 +60,24 @@ export function MenuAdminPage() {
const flat = useMemo(() => toDepthList(q.data ?? []), [q.data]); const flat = useMemo(() => toDepthList(q.data ?? []), [q.data]);
const pdfColumns = [
{ key: 'name', label: t('menu.colName') },
{ key: 'path', label: t('menu.colPath') },
{ key: 'type', label: t('menu.colType') },
{ key: 'role', label: t('menu.colRole') },
{ key: 'sort', label: t('menu.colSort'), align: 'right' as const },
{ key: 'use', label: t('menu.colUse') },
];
const pdfRows = () =>
flat.map((m) => ({
name: `${' '.repeat(m.depth)}${m.menuName}`,
path: m.menuPath ?? '—',
type: m.prgType ?? '—',
role: m.requiredRole ?? t('menu.all'),
sort: m.sortOrder,
use: m.useYn === 'N' ? t('menu.unused') : t('menu.used'),
}));
const saveM = useMutation({ const saveM = useMutation({
mutationFn: (body: MenuSaveBody) => menuApi.save(body), mutationFn: (body: MenuSaveBody) => menuApi.save(body),
onSuccess: () => { onSuccess: () => {
@ -80,7 +100,7 @@ export function MenuAdminPage() {
<div className="kx-page kx-adm"> <div className="kx-page kx-adm">
<header className="kx-adm__head"> <header className="kx-adm__head">
<div> <div>
<h1 className="kx-adm__title">{t('menu.title')}</h1> <h1 className="kx-adm__title"><IconMenu className="kx-title-ic" size={20} />{t('menu.title')}</h1>
<p className="kx-adm__subtitle">{t('menu.subtitle')}</p> <p className="kx-adm__subtitle">{t('menu.subtitle')}</p>
</div> </div>
<Button leadingIcon={<IconPlus size={16} />} onClick={() => setEditing(null)}>{t('menu.addMenu')}</Button> <Button leadingIcon={<IconPlus size={16} />} onClick={() => setEditing(null)}>{t('menu.addMenu')}</Button>
@ -96,6 +116,12 @@ export function MenuAdminPage() {
{flat.length === 0 ? ( {flat.length === 0 ? (
<EmptyState title={t('menu.emptyTitle')} description={t('menu.emptyDesc')} /> <EmptyState title={t('menu.emptyTitle')} description={t('menu.emptyDesc')} />
) : ( ) : (
<>
<div className="kx-dt__toolbar">
<div className="kx-dt__toolbar-end">
<GridPdfButton title={t('menu.title')} columns={pdfColumns} rows={pdfRows} disabled={flat.length === 0} />
</div>
</div>
<div className="kx-table-scroll"> <div className="kx-table-scroll">
<table className="kx-table kx-table--zebra kx-adm-tbl"> <table className="kx-table kx-table--zebra kx-adm-tbl">
<thead> <thead>
@ -152,6 +178,7 @@ export function MenuAdminPage() {
</tbody> </tbody>
</table> </table>
</div> </div>
</>
)} )}
</section> </section>
)} )}

View File

@ -8,7 +8,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { Button } from '../../components/ui/Button'; import { Button } from '../../components/ui/Button';
import { IconPlus } from '../../components/ui/icons'; import { IconChat, IconPlus } from '../../components/ui/icons';
import { errMessage, useToast } from '../work/workShared'; import { errMessage, useToast } from '../work/workShared';
import { import {
MESSAGE_CATEGORIES, MESSAGE_CATEGORIES,
@ -17,6 +17,8 @@ import {
messageApi, messageApi,
type SysMessage, type SysMessage,
} from './messageAdminApi'; } from './messageAdminApi';
import { GridPdfButton } from '../../components/GridPdfButton';
import '../../components/data-table.css';
import './admin-modules.css'; import './admin-modules.css';
export function MessageAdminPage() { export function MessageAdminPage() {
@ -73,11 +75,32 @@ export function MessageAdminPage() {
); );
}, [rows, keyword]); }, [rows, keyword]);
const pdfColumns = [
{ key: 'code', label: t('msg.code') },
{ key: 'locale', label: t('msg.locale') },
{ key: 'category', label: t('msg.category') },
{ key: 'title', label: t('msg.msgTitle') },
{ key: 'placeholders', label: t('msg.placeholders') },
{ key: 'use', label: t('msg.use') },
];
const pdfRows = () =>
filtered.map((m) => {
const params = extractPlaceholders(m.template);
return {
code: m.msgCode,
locale: m.locale ?? 'ko',
category: m.category,
title: m.title,
placeholders: params.length > 0 ? params.map((p) => `{${p}}`).join(' ') : '—',
use: m.useYn === 'N' ? t('msg.unused') : t('msg.used'),
};
});
return ( return (
<div className="kx-page kx-adm"> <div className="kx-page kx-adm">
<header className="kx-adm__head"> <header className="kx-adm__head">
<div> <div>
<h1 className="kx-adm__title">{t('msg.title')}</h1> <h1 className="kx-adm__title"><IconChat className="kx-title-ic" size={20} />{t('msg.title')}</h1>
<p className="kx-adm__subtitle">{t('msg.subtitle')}</p> <p className="kx-adm__subtitle">{t('msg.subtitle')}</p>
</div> </div>
<Button leadingIcon={<IconPlus size={16} />} onClick={() => setEditModal(null)}> <Button leadingIcon={<IconPlus size={16} />} onClick={() => setEditModal(null)}>
@ -127,6 +150,11 @@ export function MessageAdminPage() {
<EmptyState title={t('msg.emptyTitle')} description={t('msg.emptyDesc')} /> <EmptyState title={t('msg.emptyTitle')} description={t('msg.emptyDesc')} />
) : ( ) : (
<section className="kx-card" aria-label={t('msg.title')}> <section className="kx-card" aria-label={t('msg.title')}>
<div className="kx-dt__toolbar">
<div className="kx-dt__toolbar-end">
<GridPdfButton title={t('msg.title')} columns={pdfColumns} rows={pdfRows} disabled={filtered.length === 0} />
</div>
</div>
<div className="kx-table-scroll"> <div className="kx-table-scroll">
<table className="kx-table kx-table--zebra kx-adm-tbl"> <table className="kx-table kx-table--zebra kx-adm-tbl">
<thead> <thead>

View File

@ -8,7 +8,7 @@ import { useTranslation } from 'react-i18next';
import { ErrorState, Skeleton } from '../../components/ui/States'; import { ErrorState, Skeleton } from '../../components/ui/States';
import { Button } from '../../components/ui/Button'; import { Button } from '../../components/ui/Button';
import { KxDataTable, type KxColumn } from '../../components/KxDataTable'; import { KxDataTable, type KxColumn } from '../../components/KxDataTable';
import { IconPlus, IconSearch } from '../../components/ui/icons'; import { IconList, IconPlus, IconSearch } from '../../components/ui/icons';
import { errMessage, useToast } from '../work/workShared'; import { errMessage, useToast } from '../work/workShared';
import { programApi, PROGRAM_TYPES, type Program, type ProgramSaveBody } from './systemBasicsApi'; import { programApi, PROGRAM_TYPES, type Program, type ProgramSaveBody } from './systemBasicsApi';
import './admin-modules.css'; import './admin-modules.css';
@ -68,7 +68,7 @@ export function ProgramAdminPage() {
<div className="kx-page kx-adm"> <div className="kx-page kx-adm">
<header className="kx-adm__head"> <header className="kx-adm__head">
<div> <div>
<h1 className="kx-adm__title">{t('program.title')}</h1> <h1 className="kx-adm__title"><IconList className="kx-title-ic" size={20} />{t('program.title')}</h1>
<p className="kx-adm__subtitle">{t('program.subtitle')}</p> <p className="kx-adm__subtitle">{t('program.subtitle')}</p>
</div> </div>
<Button leadingIcon={<IconPlus size={16} />} onClick={() => setEditing(null)}>{t('program.add')}</Button> <Button leadingIcon={<IconPlus size={16} />} onClick={() => setEditing(null)}>{t('program.add')}</Button>

View File

@ -9,7 +9,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { Button } from '../../components/ui/Button'; import { Button } from '../../components/ui/Button';
import { IconPlus } from '../../components/ui/icons'; import { IconPlus, IconShieldCheck } from '../../components/ui/icons';
import { errMessage, useToast } from '../work/workShared'; import { errMessage, useToast } from '../work/workShared';
import { roleApi, type Role } from './adminCrudApi'; import { roleApi, type Role } from './adminCrudApi';
import './admin-modules.css'; import './admin-modules.css';
@ -68,7 +68,7 @@ export function RoleAdminPage() {
<div className="kx-page kx-adm"> <div className="kx-page kx-adm">
<header className="kx-adm__head"> <header className="kx-adm__head">
<div> <div>
<h1 className="kx-adm__title">{t('role.title')}</h1> <h1 className="kx-adm__title"><IconShieldCheck className="kx-title-ic" size={20} />{t('role.title')}</h1>
<p className="kx-adm__subtitle"> <p className="kx-adm__subtitle">
{t('role.subtitle')} {t('role.subtitle')}
</p> </p>

View File

@ -6,11 +6,14 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { IconMenu } from '../../components/ui/icons';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { Button } from '../../components/ui/Button'; import { Button } from '../../components/ui/Button';
import { errMessage, useToast } from '../work/workShared'; import { errMessage, useToast } from '../work/workShared';
import { roleApi } from './adminCrudApi'; import { roleApi } from './adminCrudApi';
import { roleMenuApi, type RoleMenu } from './systemBasicsApi'; import { roleMenuApi, type RoleMenu } from './systemBasicsApi';
import { GridPdfButton } from '../../components/GridPdfButton';
import '../../components/data-table.css';
import './admin-modules.css'; import './admin-modules.css';
export function RoleMenuAdminPage() { export function RoleMenuAdminPage() {
@ -61,11 +64,28 @@ export function RoleMenuAdminPage() {
const menus = menusQ.data ?? []; const menus = menusQ.data ?? [];
const pdfColumns = [
{ key: 'menu', label: t('roleMenu.colMenu') },
{ key: 'access', label: t('roleMenu.colAccess'), align: 'center' as const },
{ key: 'read', label: t('roleMenu.colRead'), align: 'center' as const },
{ key: 'write', label: t('roleMenu.colWrite'), align: 'center' as const },
];
const pdfRows = () =>
menus.map((m) => {
const st = draft[m.menuId] ?? { readYn: 'N', writeYn: 'N', mapped: false };
return {
menu: m.menuName,
access: st.mapped ? 'Y' : '—',
read: st.mapped && st.readYn === 'Y' ? 'Y' : '—',
write: st.mapped && st.writeYn === 'Y' ? 'Y' : '—',
};
});
return ( return (
<div className="kx-page kx-adm"> <div className="kx-page kx-adm">
<header className="kx-adm__head"> <header className="kx-adm__head">
<div> <div>
<h1 className="kx-adm__title">{t('roleMenu.title')}</h1> <h1 className="kx-adm__title"><IconMenu className="kx-title-ic" size={20} />{t('roleMenu.title')}</h1>
<p className="kx-adm__subtitle">{t('roleMenu.subtitle')}</p> <p className="kx-adm__subtitle">{t('roleMenu.subtitle')}</p>
</div> </div>
<Button disabled={!selected || saveM.isPending} onClick={() => saveM.mutate()}> <Button disabled={!selected || saveM.isPending} onClick={() => saveM.mutate()}>
@ -103,6 +123,12 @@ export function RoleMenuAdminPage() {
menus.length === 0 ? ( menus.length === 0 ? (
<EmptyState title={t('roleMenu.noMenus')} /> <EmptyState title={t('roleMenu.noMenus')} />
) : ( ) : (
<>
<div className="kx-dt__toolbar">
<div className="kx-dt__toolbar-end">
<GridPdfButton title={t('roleMenu.title')} columns={pdfColumns} rows={pdfRows} disabled={menus.length === 0} />
</div>
</div>
<div className="kx-table-scroll"> <div className="kx-table-scroll">
<table className="kx-table kx-table--zebra"> <table className="kx-table kx-table--zebra">
<thead> <thead>
@ -136,6 +162,7 @@ export function RoleMenuAdminPage() {
</tbody> </tbody>
</table> </table>
</div> </div>
</>
) )
)} )}
</section> </section>

View File

@ -6,6 +6,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { IconFlag } from '../../components/ui/icons';
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 { KxDataTable, type KxColumn } from '../../components/KxDataTable'; import { KxDataTable, type KxColumn } from '../../components/KxDataTable';
@ -69,7 +70,7 @@ export function RulesetVersionsPage() {
<div className="kx-page kx-adm"> <div className="kx-page kx-adm">
<header className="kx-adm__head"> <header className="kx-adm__head">
<div> <div>
<h1 className="kx-adm__title">{t('ruleset.title')}</h1> <h1 className="kx-adm__title"><IconFlag className="kx-title-ic" size={20} />{t('ruleset.title')}</h1>
<p className="kx-adm__subtitle">{t('ruleset.subtitle')}</p> <p className="kx-adm__subtitle">{t('ruleset.subtitle')}</p>
</div> </div>
</header> </header>

View File

@ -9,7 +9,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Trans, useTranslation } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { Button } from '../../components/ui/Button'; import { Button } from '../../components/ui/Button';
import { IconPlus, IconSearch } from '../../components/ui/icons'; import { IconPlus, IconSearch, IconUsers } from '../../components/ui/icons';
import { errMessage, fmtDateTime, useToast } from '../work/workShared'; import { errMessage, fmtDateTime, useToast } from '../work/workShared';
import { import {
sysUserApi, sysUserApi,
@ -19,6 +19,8 @@ import {
type UserSaveBody, type UserSaveBody,
type UserSearchParams, type UserSearchParams,
} from './adminCrudApi'; } from './adminCrudApi';
import { GridPdfButton } from '../../components/GridPdfButton';
import '../../components/data-table.css';
import './admin-modules.css'; import './admin-modules.css';
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
@ -72,6 +74,26 @@ export function SysUserAdminPage() {
const total = q.data?.total ?? 0; const total = q.data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const pdfColumns = [
{ key: 'user', label: t('sysuser.colUser') },
{ key: 'email', label: t('sysuser.colEmail') },
{ key: 'role', label: t('sysuser.colRole') },
{ key: 'dept', label: t('sysuser.colDept') },
{ key: 'status', label: t('sysuser.colStatus') },
{ key: 'otp', label: 'OTP' },
{ key: 'lastLogin', label: t('sysuser.colLastLogin') },
];
const pdfRows = () =>
rows.map((u) => ({
user: u.displayName ?? '—',
email: u.email ?? '—',
role: (u.roleCode ?? 'USER').toUpperCase(),
dept: u.deptId ?? '—',
status: (u.status ?? 'ACTIVE').toUpperCase(),
otp: u.otpEnabled ? t('sysuser.otpSet') : t('sysuser.otpUnset'),
lastLogin: u.lastLoginAt ? fmtDateTime(u.lastLoginAt) : '—',
}));
function applyFilters() { function applyFilters() {
setPage(0); setPage(0);
setApplied({ setApplied({
@ -92,7 +114,7 @@ export function SysUserAdminPage() {
<div className="kx-page kx-adm"> <div className="kx-page kx-adm">
<header className="kx-adm__head"> <header className="kx-adm__head">
<div> <div>
<h1 className="kx-adm__title">{t('sysuser.title')}</h1> <h1 className="kx-adm__title"><IconUsers className="kx-title-ic" size={20} />{t('sysuser.title')}</h1>
<p className="kx-adm__subtitle"> <p className="kx-adm__subtitle">
{t('sysuser.subtitle')} {t('sysuser.subtitle')}
</p> </p>
@ -153,6 +175,11 @@ export function SysUserAdminPage() {
<EmptyState title={t('sysuser.emptyTitle')} description={t('sysuser.emptyDesc')} /> <EmptyState title={t('sysuser.emptyTitle')} description={t('sysuser.emptyDesc')} />
) : ( ) : (
<> <>
<div className="kx-dt__toolbar">
<div className="kx-dt__toolbar-end">
<GridPdfButton title={t('sysuser.title')} columns={pdfColumns} rows={pdfRows} disabled={rows.length === 0} />
</div>
</div>
<div className="kx-table-scroll"> <div className="kx-table-scroll">
<table className="kx-table kx-table--zebra kx-adm-tbl"> <table className="kx-table kx-table--zebra kx-adm-tbl">
<thead> <thead>

View File

@ -5,6 +5,7 @@
*/ */
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { IconBolt } from '../../components/ui/icons';
import { ErrorState, Skeleton } from '../../components/ui/States'; import { ErrorState, Skeleton } from '../../components/ui/States';
import { Button } from '../../components/ui/Button'; import { Button } from '../../components/ui/Button';
import { systemHealthApi, type SystemHealth } from './systemBasicsApi'; import { systemHealthApi, type SystemHealth } from './systemBasicsApi';
@ -24,7 +25,7 @@ export function SystemHealthPage() {
<div className="kx-page kx-adm"> <div className="kx-page kx-adm">
<header className="kx-adm__head"> <header className="kx-adm__head">
<div> <div>
<h1 className="kx-adm__title">{t('sysHealth.title')}</h1> <h1 className="kx-adm__title"><IconBolt className="kx-title-ic" size={20} />{t('sysHealth.title')}</h1>
<p className="kx-adm__subtitle">{t('sysHealth.subtitle')}</p> <p className="kx-adm__subtitle">{t('sysHealth.subtitle')}</p>
</div> </div>
<Button variant="ghost" onClick={() => q.refetch()}>{t('sysHealth.refresh')}</Button> <Button variant="ghost" onClick={() => q.refetch()}>{t('sysHealth.refresh')}</Button>

View File

@ -10,7 +10,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { Button } from '../../components/ui/Button'; import { Button } from '../../components/ui/Button';
import { IconPlus } from '../../components/ui/icons'; import { IconPlus, IconSettings } from '../../components/ui/icons';
import { errMessage, useToast } from '../work/workShared'; import { errMessage, useToast } from '../work/workShared';
import { settingApi, type SettingRow, type SettingSaveBody } from './adminModulesApi'; import { settingApi, type SettingRow, type SettingSaveBody } from './adminModulesApi';
import './admin-modules.css'; import './admin-modules.css';
@ -48,7 +48,7 @@ export function SystemSettingsPage() {
<div className="kx-page kx-adm"> <div className="kx-page kx-adm">
<header className="kx-adm__head"> <header className="kx-adm__head">
<div> <div>
<h1 className="kx-adm__title">{t('settings.title')}</h1> <h1 className="kx-adm__title"><IconSettings className="kx-title-ic" size={20} />{t('settings.title')}</h1>
<p className="kx-adm__subtitle"> <p className="kx-adm__subtitle">
{t('settings.subtitle')} {t('settings.subtitle')}
</p> </p>

View File

@ -12,7 +12,7 @@ import type { TFunction } from 'i18next';
import { Button } from '../../components/ui/Button'; import { Button } from '../../components/ui/Button';
import { ErrorState, Skeleton } from '../../components/ui/States'; import { ErrorState, Skeleton } from '../../components/ui/States';
import { KxDataTable, type KxColumn } from '../../components/KxDataTable'; import { KxDataTable, type KxColumn } from '../../components/KxDataTable';
import { IconCheck, IconPlus } from '../../components/ui/icons'; import { IconCheck, IconGrid, IconPlus } from '../../components/ui/icons';
import { errMessage, useToast } from '../work/workShared'; import { errMessage, useToast } from '../work/workShared';
import { tenantApi, TENANT_STATUSES, type Tenant, type TenantCreateBody } from './adminCrudApi'; import { tenantApi, TENANT_STATUSES, type Tenant, type TenantCreateBody } from './adminCrudApi';
import './admin-modules.css'; import './admin-modules.css';
@ -67,7 +67,7 @@ export function TenantAdminPage() {
<div className="kx-page kx-adm"> <div className="kx-page kx-adm">
<header className="kx-adm__head"> <header className="kx-adm__head">
<div> <div>
<h1 className="kx-adm__title">{t('tenant.title')}</h1> <h1 className="kx-adm__title"><IconGrid className="kx-title-ic" size={20} />{t('tenant.title')}</h1>
<p className="kx-adm__subtitle">{t('tenant.subtitle')}</p> <p className="kx-adm__subtitle">{t('tenant.subtitle')}</p>
</div> </div>
<Button leadingIcon={<IconPlus size={16} />} onClick={() => setModalOpen(true)}> <Button leadingIcon={<IconPlus size={16} />} onClick={() => setModalOpen(true)}>

View File

@ -67,8 +67,8 @@
font-weight: var(--fw-bold); font-weight: var(--fw-bold);
white-space: nowrap; white-space: nowrap;
} }
.kx-adm-pill--success { color: var(--color-success); background: #e6f4ee; } .kx-adm-pill--success { color: var(--color-success); background: var(--color-success-bg); }
.kx-adm-pill--error { color: var(--color-error); background: #fef3f2; } .kx-adm-pill--error { color: var(--color-error); background: var(--color-error-bg); }
.kx-adm-pill--warn { color: var(--color-violation-warn-text); background: var(--color-violation-warn-bg); } .kx-adm-pill--warn { color: var(--color-violation-warn-text); background: var(--color-violation-warn-bg); }
.kx-adm-pill--neutral { color: var(--color-neutral-500); background: var(--color-neutral-100); } .kx-adm-pill--neutral { color: var(--color-neutral-500); background: var(--color-neutral-100); }
@ -227,7 +227,7 @@
font-size: var(--fs-nano); font-size: var(--fs-nano);
font-weight: var(--fw-bold); font-weight: var(--fw-bold);
color: var(--color-warning); color: var(--color-warning);
background: #fff4e5; background: var(--color-warning-bg);
padding: 1px 6px; padding: 1px 6px;
border-radius: var(--radius-pill); border-radius: var(--radius-pill);
} }
@ -450,7 +450,7 @@
font-size: var(--fs-caption); font-size: var(--fs-caption);
font-weight: var(--fw-bold); font-weight: var(--fw-bold);
} }
.kx-adm-rolechip--admin { color: var(--color-error); background: #fef3f2; } .kx-adm-rolechip--admin { color: var(--color-error); background: var(--color-error-bg); }
.kx-adm-rolechip--manager { color: var(--color-primary-700); background: var(--color-primary-100); } .kx-adm-rolechip--manager { color: var(--color-primary-700); background: var(--color-primary-100); }
.kx-adm-rolechip--user { color: var(--color-neutral-500); background: var(--color-neutral-100); } .kx-adm-rolechip--user { color: var(--color-neutral-500); background: var(--color-neutral-100); }
@ -500,7 +500,7 @@
margin-top: var(--space-3); margin-top: var(--space-3);
padding: var(--space-2) var(--space-3); padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: #fef3f2; background: var(--color-error-bg);
color: var(--color-error); color: var(--color-error);
font-size: var(--fs-caption); font-size: var(--fs-caption);
} }

View File

@ -345,3 +345,104 @@
flex-wrap: wrap; flex-wrap: wrap;
gap: var(--space-1); gap: var(--space-1);
} }
/* ── 라이브 행사 혼잡 등급 pill(M14 파생) + 현장 체크인(M10) ── */
.kx-heatpill {
display: inline-flex;
align-items: center;
padding: 1px 8px;
border-radius: 999px;
font-size: var(--fs-micro);
font-weight: var(--fw-semibold);
line-height: 1.6;
}
.kx-heatpill.is-smooth {
color: var(--color-success);
background: var(--color-success-bg);
}
.kx-heatpill.is-moderate {
color: var(--color-warning);
background: var(--color-warning-bg);
}
.kx-heatpill.is-busy {
color: var(--color-error);
background: var(--color-error-bg);
}
.kx-livecard__onsite {
margin-top: 4px;
font-size: var(--fs-caption);
color: var(--color-neutral-700);
}
/* ── 최근 활동 카드(audit_log 실 기록) ── */
.kx-admin__activity {
margin-top: var(--space-4);
}
.kx-admin__activity-list {
display: flex;
flex-direction: column;
gap: var(--space-1);
margin: 0;
padding: 0;
list-style: none;
}
.kx-actrow {
display: flex;
align-items: flex-start;
gap: var(--space-2);
padding: var(--space-2) var(--space-1);
border-bottom: 1px solid var(--color-neutral-100);
}
.kx-actrow:last-child {
border-bottom: none;
}
.kx-actrow__dot {
width: 8px;
height: 8px;
margin-top: 6px;
flex-shrink: 0;
border-radius: 50%;
}
.kx-actrow__dot.is-ok {
background: var(--color-success);
}
.kx-actrow__dot.is-warn {
background: var(--color-error);
}
.kx-actrow__body {
flex: 1;
min-width: 0;
}
.kx-actrow__line {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 6px;
}
.kx-actrow__action {
font-size: var(--fs-caption);
font-weight: var(--fw-semibold);
color: var(--color-neutral-900);
}
.kx-actrow__target {
font-size: var(--fs-micro);
color: var(--color-neutral-500);
}
.kx-actrow__actor {
font-size: var(--fs-micro);
color: var(--color-neutral-500);
}
.kx-actrow__summary {
margin-top: 2px;
font-size: var(--fs-micro);
color: var(--color-neutral-500);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kx-actrow__time {
flex-shrink: 0;
font-size: var(--fs-micro);
color: var(--color-neutral-500);
white-space: nowrap;
}

View File

@ -0,0 +1,35 @@
/*
* SCR-16 .
* api/types.ts( ) AdminDashboardDto
* ( M10 ·M14 , audit_log) screens/admin .
* q.data ( shape ).
*/
import type { AdminDashboardData, AdminLiveEvent } from '../../api/types';
/** 라이브 행사 + M10 체크인 실인원(onSiteCount) · M14 혼잡 등급(heat). 구버전 응답 호환 위해 optional. */
export interface AdminLiveEventExt extends AdminLiveEvent {
onSiteCount?: number;
heat?: 'smooth' | 'moderate' | 'busy' | string;
}
/** 최근 관리 활동 — audit_log 실 기록. */
export interface AdminActivity {
action: string;
actorName: string | null;
targetType: string | null;
summary: string | null;
result: string;
createdAt: string;
}
/** 대시보드 응답 확장 — liveEvents 확장 + recentActivity. */
export interface AdminDashboardExt extends Omit<AdminDashboardData, 'liveEvents'> {
liveEvents: AdminLiveEventExt[];
recentActivity?: AdminActivity[];
}
/** 혼잡 등급 → CSS 상태 클래스(ops 화면과 동일 어휘). 미지정/미지원은 smooth. */
export function heatClass(heat: string | undefined): 'smooth' | 'moderate' | 'busy' {
if (heat === 'busy' || heat === 'moderate' || heat === 'smooth') return heat;
return 'smooth';
}

View File

@ -5,9 +5,11 @@ import { Button } from '../../components/ui/Button';
import { AiLabel } from '../../components/ui/Badge'; import { AiLabel } from '../../components/ui/Badge';
import { EmptyState } from '../../components/ui/States'; import { EmptyState } from '../../components/ui/States';
import { IconAiBot } from '../../components/ui/icons'; import { IconAiBot } from '../../components/ui/icons';
import { GridPdfButton } from '../../components/GridPdfButton';
import { ApiRequestError } from '../../api/client'; import { ApiRequestError } from '../../api/client';
import { aiConsoleApi } from './aiConsoleApi'; import { aiConsoleApi } from './aiConsoleApi';
import type { NlQueryResult, RulesChatResult } from './aiConsoleApi'; import type { NlQueryResult, RulesChatResult } from './aiConsoleApi';
import '../../components/data-table.css';
import './aiConsole.css'; import './aiConsole.css';
/* /*
@ -113,6 +115,16 @@ function NlQueryTab() {
> >
{showSql ? t('ai.nlquery.hideSql') : t('ai.nlquery.showSql')} {showSql ? t('ai.nlquery.hideSql') : t('ai.nlquery.showSql')}
</button> </button>
<GridPdfButton
title={t('ai.console.tabNlquery')}
columns={result.columns.map((c) => ({ key: c, label: c }))}
rows={result.rows.map((row) => {
const o: Record<string, unknown> = {};
for (const c of result.columns) o[c] = cell(row[c]);
return o;
})}
disabled={result.rowCount === 0}
/>
</div> </div>
{showSql && ( {showSql && (

View File

@ -33,6 +33,8 @@ import {
} from './sampleAnalytics'; } from './sampleAnalytics';
import { GlobalOverviewSection } from './GlobalOverviewSection'; import { GlobalOverviewSection } from './GlobalOverviewSection';
import { AiConsolePanel } from './AiConsolePanel'; import { AiConsolePanel } from './AiConsolePanel';
import { GridPdfButton } from '../../components/GridPdfButton';
import '../../components/data-table.css';
import './analytics.css'; import './analytics.css';
/* /*
@ -59,6 +61,27 @@ export function AnalyticsDashboardPage() {
const data: AnalyticsData | null = q.data ?? (degraded ? fallback(perspective) : null); const data: AnalyticsData | null = q.data ?? (degraded ? fallback(perspective) : null);
const hardError = q.isError && !degraded; const hardError = q.isError && !degraded;
const perfPdfColumns = isOperator
? [
{ key: 'item', label: t('analytics.colItem') },
{ key: 'amount', label: t('analytics.colAmount'), align: 'right' as const },
{ key: 'grade', label: t('analytics.colGrade') },
]
: [
{ key: 'company', label: t('analytics.colCompany') },
{ key: 'leads', label: t('analytics.colLeads'), align: 'right' as const },
{ key: 'conversion', label: t('analytics.colConversion'), align: 'right' as const },
];
const perfPdfRows = () =>
isOperator
? (data?.pnl ?? []).map((r) => ({ item: r.item, amount: formatWon(r.amount), grade: r.grade }))
: (data?.exhibitorPerf ?? []).map((r) => ({
company: r.companyName,
leads: r.leads.toLocaleString(),
conversion: `${r.conversionPercent.toFixed(1)}%`,
}));
const perfEmpty = isOperator ? (data?.pnl.length ?? 0) === 0 : (data?.exhibitorPerf.length ?? 0) === 0;
return ( return (
<div className="kx-bi"> <div className="kx-bi">
<header className="kx-bi__head"> <header className="kx-bi__head">
@ -233,6 +256,12 @@ export function AnalyticsDashboardPage() {
<div className="kx-card__head"> <div className="kx-card__head">
<h2><IconSettlement className="kx-title-ic" size={18} />{isOperator ? t('analytics.pnlTitle') : t('analytics.exhibitorPerfTitle')}</h2> <h2><IconSettlement className="kx-title-ic" size={18} />{isOperator ? t('analytics.pnlTitle') : t('analytics.exhibitorPerfTitle')}</h2>
{degraded && <span className="kx-bi__degraded">{t('analytics.sampleTag')}</span>} {degraded && <span className="kx-bi__degraded">{t('analytics.sampleTag')}</span>}
<GridPdfButton
title={isOperator ? t('analytics.pnlTitle') : t('analytics.exhibitorPerfTitle')}
columns={perfPdfColumns}
rows={perfPdfRows}
disabled={perfEmpty}
/>
</div> </div>
<div className="kx-table-scroll"> <div className="kx-table-scroll">
{isOperator ? ( {isOperator ? (

View File

@ -42,7 +42,7 @@
.kx-aicon__error { .kx-aicon__error {
padding: var(--space-3); padding: var(--space-3);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: #FEF3F2; background: var(--color-error-bg);
border: 1px solid #FDA29B; border: 1px solid #FDA29B;
color: #B42318; color: #B42318;
font-size: var(--fs-body); font-size: var(--fs-body);
@ -64,8 +64,8 @@
.kx-aicon__prov { .kx-aicon__prov {
padding: 2px 8px; padding: 2px 8px;
border-radius: var(--radius-pill); border-radius: var(--radius-pill);
background: var(--color-neutral-100, #F2F4F7); background: var(--color-neutral-100, var(--color-neutral-100));
color: var(--color-neutral-700, #344054); color: var(--color-neutral-700, var(--color-neutral-700));
} }
.kx-aicon__count { .kx-aicon__count {
color: var(--color-neutral-500); color: var(--color-neutral-500);
@ -105,7 +105,7 @@
.kx-aicon__answer { .kx-aicon__answer {
padding: var(--space-3); padding: var(--space-3);
background: var(--color-neutral-50, #F9FAFB); background: var(--color-neutral-50, var(--color-neutral-050));
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-size: var(--fs-body); font-size: var(--fs-body);
color: var(--color-neutral-900); color: var(--color-neutral-900);
@ -119,7 +119,7 @@
.kx-aicon__cites-h { .kx-aicon__cites-h {
display: block; display: block;
font-weight: var(--fw-semibold); font-weight: var(--fw-semibold);
color: var(--color-neutral-700, #344054); color: var(--color-neutral-700, var(--color-neutral-700));
margin-bottom: var(--space-1, 4px); margin-bottom: var(--space-1, 4px);
} }
.kx-aicon__cites ul { .kx-aicon__cites ul {
@ -131,7 +131,7 @@
gap: var(--space-1, 4px); gap: var(--space-1, 4px);
} }
.kx-aicon__cites li { .kx-aicon__cites li {
color: var(--color-neutral-700, #344054); color: var(--color-neutral-700, var(--color-neutral-700));
} }
.kx-aicon__code { .kx-aicon__code {
display: inline-block; display: inline-block;

View File

@ -143,7 +143,7 @@ export function AppQrCodePage() {
<section className="kx-appqr__hero"> <section className="kx-appqr__hero">
<div className="kx-appqr__hero-text"> <div className="kx-appqr__hero-text">
<span className="kx-appqr__eyebrow">{t('appqr.eyebrow')}</span> <span className="kx-appqr__eyebrow">{t('appqr.eyebrow')}</span>
<h1 className="kx-appqr__title">{t('appqr.title')}</h1> <h1 className="kx-appqr__title"><IconGrid className="kx-title-ic" size={20} />{t('appqr.title')}</h1>
<p className="kx-appqr__subtitle">{t('appqr.subtitle')}</p> <p className="kx-appqr__subtitle">{t('appqr.subtitle')}</p>
<p className="kx-appqr__intro">{t('appqr.intro')}</p> <p className="kx-appqr__intro">{t('appqr.intro')}</p>

View File

@ -7,6 +7,8 @@ import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { IconPlus, IconSearch } from '../../components/ui/icons'; import { IconPlus, IconSearch } from '../../components/ui/icons';
import { approvalApi, type ApprovalBox, type ApprovalListItem, type ApprovalStatus } from './approvalApi'; import { approvalApi, type ApprovalBox, type ApprovalListItem, type ApprovalStatus } from './approvalApi';
import { ApprovalComposePanel } from './ApprovalComposePanel'; import { ApprovalComposePanel } from './ApprovalComposePanel';
import { GridPdfButton } from '../../components/GridPdfButton';
import '../../components/data-table.css';
import './approval-queue.css'; import './approval-queue.css';
/* /*
@ -63,6 +65,24 @@ export function ManagerApprovalQueuePage() {
const statusLabel = (s: ApprovalStatus) => t(`approval.status.${s}`); const statusLabel = (s: ApprovalStatus) => t(`approval.status.${s}`);
const pdfColumns = [
{ key: 'title', label: t('approval.colTitle') },
{ key: 'docType', label: t('approval.colDocType') },
{ key: 'drafter', label: t('approval.colDrafter') },
{ key: 'submitted', label: t('approval.colSubmitted') },
{ key: 'step', label: t('approval.colStep') },
{ key: 'status', label: t('approval.colStatus') },
];
const pdfRows = () =>
rows.map((r) => ({
title: r.refId ? `${r.title} · ${r.refId}` : r.title,
docType: t(`approval.docTypes.${r.docType}`, { defaultValue: r.docType }),
drafter: r.drafterName ?? r.drafterId,
submitted: (r.submittedAt ?? '').slice(0, 10) || '-',
step: r.totalSteps > 0 ? `${r.curStep}/${r.totalSteps}` : '-',
status: statusLabel(r.status),
}));
const openCompose = () => { const openCompose = () => {
setNotice(null); setNotice(null);
setSp({ compose: '1' }); setSp({ compose: '1' });
@ -172,6 +192,7 @@ export function ManagerApprovalQueuePage() {
<div className="kx-aq__main-head"> <div className="kx-aq__main-head">
<h2>{t(`approval.tab.${tab}`)}</h2> <h2>{t(`approval.tab.${tab}`)}</h2>
<span className="kx-aq__count tnum">{rows.length}</span> <span className="kx-aq__count tnum">{rows.length}</span>
<GridPdfButton title={t('approval.boxTitle')} columns={pdfColumns} rows={pdfRows} disabled={rows.length === 0} />
</div> </div>
<div className="kx-aq__table-wrap"> <div className="kx-aq__table-wrap">
{listQ.isLoading ? ( {listQ.isLoading ? (

View File

@ -5,15 +5,7 @@ import { useTranslation } from 'react-i18next';
import { Button } from '../../components/ui/Button'; import { Button } from '../../components/ui/Button';
import { AiLabel } from '../../components/ui/Badge'; import { AiLabel } from '../../components/ui/Badge';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { import { IconApproval, IconChevronRight, IconClose, IconDownload, IconExpand, IconMinus, IconPlus, IconSpark } from '../../components/ui/icons';
IconChevronRight,
IconClose,
IconDownload,
IconExpand,
IconMinus,
IconPlus,
IconSpark,
} from '../../components/ui/icons';
import { approvalApi, type ApprovalLine, type ApprovalStatus } from './approvalApi'; import { approvalApi, type ApprovalLine, type ApprovalStatus } from './approvalApi';
import './review-details.css'; import './review-details.css';
import './approval-form.css'; import './approval-form.css';
@ -126,7 +118,7 @@ export function ReviewDetailsPage() {
<span className="kx-rev__crumb-cur">{d ? d.title : `부스 ${booth}`}</span> <span className="kx-rev__crumb-cur">{d ? d.title : `부스 ${booth}`}</span>
</nav> </nav>
<div className="kx-rev__title-row"> <div className="kx-rev__title-row">
<h1 className="kx-detailview__title">{d ? d.title : t('approval.reviewTitle')}</h1> <h1 className="kx-detailview__title"><IconApproval className="kx-title-ic" size={20} />{d ? d.title : t('approval.reviewTitle')}</h1>
{d ? ( {d ? (
<span className={`kx-detailview__badge ${statusTone(d.status)}`}>{statusLabel}</span> <span className={`kx-detailview__badge ${statusTone(d.status)}`}>{statusLabel}</span>
) : ( ) : (

View File

@ -45,7 +45,7 @@
} }
.kx-apf__error { .kx-apf__error {
border: 1px solid #fecdca; border: 1px solid #fecdca;
background: #fef3f2; background: var(--color-error-bg);
color: var(--color-error); color: var(--color-error);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
padding: 10px 14px; padding: 10px 14px;

View File

@ -89,7 +89,7 @@
color: var(--color-primary-600) !important; color: var(--color-primary-600) !important;
} }
.kx-aq__stat--alert { .kx-aq__stat--alert {
background: #fef3f2; background: var(--color-error-bg);
border-color: #fecdca; border-color: #fecdca;
} }
.kx-aq__stat--alert span, .kx-aq__stat--alert span,
@ -156,12 +156,12 @@
} }
.kx-aq__ai--block { .kx-aq__ai--block {
color: var(--color-error); color: var(--color-error);
background: #fef3f2; background: var(--color-error-bg);
border: 1px solid #fecdca; border: 1px solid #fecdca;
} }
.kx-aq__ai--pass { .kx-aq__ai--pass {
color: var(--color-success); color: var(--color-success);
background: #e6f4ee; background: var(--color-success-bg);
border: 1px solid #a6e0c8; border: 1px solid #a6e0c8;
} }
.kx-aq__review { .kx-aq__review {
@ -303,7 +303,7 @@
} }
.kx-aq__notice { .kx-aq__notice {
border: 1px solid #a6e0c8; border: 1px solid #a6e0c8;
background: #e6f4ee; background: var(--color-success-bg);
color: #047857; color: #047857;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
padding: 8px 14px; padding: 8px 14px;

View File

@ -252,7 +252,7 @@
font-size: var(--fs-micro); font-size: var(--fs-micro);
font-weight: var(--fw-bold); font-weight: var(--fw-bold);
color: var(--color-error); color: var(--color-error);
background: #fef3f2; background: var(--color-error-bg);
padding: 2px 8px; padding: 2px 8px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
@ -277,7 +277,7 @@
width: 24px; width: 24px;
height: 24px; height: 24px;
border-radius: 50%; border-radius: 50%;
background: #fef3f2; background: var(--color-error-bg);
color: var(--color-error); color: var(--color-error);
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -12,7 +12,7 @@ import { useTranslation } from 'react-i18next';
import { Button } from '../../components/ui/Button'; import { Button } from '../../components/ui/Button';
import { AiLabel } from '../../components/ui/Badge'; import { AiLabel } from '../../components/ui/Badge';
import { ErrorState, Skeleton } from '../../components/ui/States'; import { ErrorState, Skeleton } from '../../components/ui/States';
import { IconDocument, IconExpand, IconImage, IconPlus } from '../../components/ui/icons'; import { IconDocument, IconExpand, IconImage, IconPlus, IconTag } from '../../components/ui/icons';
import { import {
auctionApi, auctionApi,
formatWon, formatWon,
@ -22,7 +22,9 @@ import {
type RankRow, type RankRow,
} from './auctionApi'; } from './auctionApi';
import { subscribeAuctionRanking, type WsStatus } from '../../api/websocket'; import { subscribeAuctionRanking, type WsStatus } from '../../api/websocket';
import { GridPdfButton } from '../../components/GridPdfButton';
import { errMessage, useToast } from './aucShared'; import { errMessage, useToast } from './aucShared';
import '../../components/data-table.css';
import './auction.css'; import './auction.css';
const REF_DESC: Record<MaterialKind, { label: string; title: string; desc: string; ai?: boolean }> = { const REF_DESC: Record<MaterialKind, { label: string; title: string; desc: string; ai?: boolean }> = {
@ -177,7 +179,7 @@ export function AuctionDetailPage() {
</span> </span>
)} )}
<h1 className="kx-detailview__title">{detail.title}</h1> <h1 className="kx-detailview__title"><IconTag className="kx-title-ic" size={20} />{detail.title}</h1>
<span <span
className={`kx-detailview__badge ${ className={`kx-detailview__badge ${
closed ? 'kx-detailview__badge--info' : 'kx-detailview__badge--ok' closed ? 'kx-detailview__badge--info' : 'kx-detailview__badge--ok'
@ -456,6 +458,23 @@ function BoqPanel({
); );
} }
const boq = query.data; const boq = query.data;
const pdfColumns = [
{ key: 'seq', label: t('auction.boq.colSeq') },
{ key: 'trade', label: t('auction.boq.colTrade') },
{ key: 'item', label: t('auction.boq.colItem') },
{ key: 'qty', label: t('auction.boq.colQty'), align: 'right' as const },
{ key: 'unit', label: t('auction.boq.colUnit') },
{ key: 'basis', label: t('auction.boq.colBasis') },
];
const pdfRows = () =>
boq.lines.map((l) => ({
seq: l.seq,
trade: l.trade,
item: l.item,
qty: l.qty.toLocaleString(),
unit: l.unit,
basis: l.basis,
}));
return ( return (
<div className="kx-boq"> <div className="kx-boq">
<div className="kx-boq__head"> <div className="kx-boq__head">
@ -467,6 +486,7 @@ function BoqPanel({
{' · '} {' · '}
{t('auction.boq.summary', { count: boq.boothCount, area: boq.totalAreaM2.toLocaleString() })} {t('auction.boq.summary', { count: boq.boothCount, area: boq.totalAreaM2.toLocaleString() })}
</span> </span>
<GridPdfButton title={t('auction.boq.title')} columns={pdfColumns} rows={pdfRows} disabled={boq.lines.length === 0} />
</div> </div>
{boq.lines.length === 0 ? ( {boq.lines.length === 0 ? (
<p className="kx-boq__note">{boq.note}</p> <p className="kx-boq__note">{boq.note}</p>

View File

@ -10,13 +10,7 @@ import { useNavigate } from 'react-router-dom';
import { Button } from '../../components/ui/Button'; import { Button } from '../../components/ui/Button';
import { DdayChip } from '../../components/ui/Badge'; import { DdayChip } from '../../components/ui/Badge';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { import { IconDocument, IconFloorplan, IconImage, IconPlus, IconSettings, IconTag } from '../../components/ui/icons';
IconPlus,
IconDocument,
IconFloorplan,
IconImage,
IconSettings,
} from '../../components/ui/icons';
import { import {
MATERIAL_LABEL, MATERIAL_LABEL,
auctionApi, auctionApi,
@ -118,7 +112,7 @@ export function AuctionListPage() {
<div className="kx-page kx-auc"> <div className="kx-page kx-auc">
<header className="kx-auc__head"> <header className="kx-auc__head">
<div> <div>
<h1 className="kx-auc__title">· </h1> <h1 className="kx-auc__title"><IconTag className="kx-title-ic" size={20} />· </h1>
<p className="kx-auc__subtitle">AI (M2~M5) · </p> <p className="kx-auc__subtitle">AI (M2~M5) · </p>
</div> </div>
<div className="kx-auc__head-actions"> <div className="kx-auc__head-actions">

View File

@ -12,7 +12,9 @@ import { AiLabel } from '../../components/ui/Badge';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { IconAiBot, IconCheckCircle, IconDownload, IconList, IconWarning } from '../../components/ui/icons'; import { IconAiBot, IconCheckCircle, IconDownload, IconList, IconWarning } from '../../components/ui/icons';
import { auctionApi, formatWon, type Quote } from './auctionApi'; import { auctionApi, formatWon, type Quote } from './auctionApi';
import { GridPdfButton } from '../../components/GridPdfButton';
import { errMessage, useToast } from './aucShared'; import { errMessage, useToast } from './aucShared';
import '../../components/data-table.css';
import './auction.css'; import './auction.css';
/** 인라인 별점 아이콘(★ 텍스트 글리프 대체 — stroke SVG). */ /** 인라인 별점 아이콘(★ 텍스트 글리프 대체 — stroke SVG). */
@ -51,6 +53,24 @@ export function AwardComparePage() {
const alreadyAwarded = !!view?.awarded; const alreadyAwarded = !!view?.awarded;
const selected = quotes.find((x) => x.bidId === selectedBidId) ?? recommended; const selected = quotes.find((x) => x.bidId === selectedBidId) ?? recommended;
const comparePdfColumns = [
{ key: 'metric', label: '비교 항목' },
...quotes.map((qc2) => ({ key: qc2.bidId, label: qc2.alias })),
];
const comparePdfRows = () => {
const mk = (label: string, valFn: (q: Quote) => string) => {
const o: Record<string, unknown> = { metric: label };
quotes.forEach((qc2) => (o[qc2.bidId] = valFn(qc2)));
return o;
};
return [
mk('총액 (VAT 별도)', (qc2) => formatWon(qc2.totalPrice)),
mk('납기 (시공 완료)', (qc2) => `${qc2.leadDays}`),
mk('업체 평판 (최근 1년)', (qc2) => qc2.reputation.toFixed(1)),
mk('종합 점수', (qc2) => qc2.score.toFixed(1)),
];
};
const awardM = useMutation({ const awardM = useMutation({
mutationFn: () => mutationFn: () =>
auctionApi.award(auctionId, { bidId: selected!.bidId, reason: reason.trim() }), auctionApi.award(auctionId, { bidId: selected!.bidId, reason: reason.trim() }),
@ -135,6 +155,7 @@ export function AwardComparePage() {
<div className="kx-card__head"> <div className="kx-card__head">
<h2><IconList className="kx-title-ic" size={18} /> </h2> <h2><IconList className="kx-title-ic" size={18} /> </h2>
<AiLabel>AI </AiLabel> <AiLabel>AI </AiLabel>
<GridPdfButton title="견적서 비교표" columns={comparePdfColumns} rows={comparePdfRows} disabled={quotes.length === 0} />
</div> </div>
<div className="kx-table-scroll"> <div className="kx-table-scroll">
<table className="kx-compare"> <table className="kx-compare">

View File

@ -38,7 +38,7 @@
font-size: var(--fs-micro); font-size: var(--fs-micro);
font-weight: var(--fw-semibold); font-weight: var(--fw-semibold);
color: var(--color-warning); color: var(--color-warning);
background: #fff4e5; background: var(--color-warning-bg);
padding: 3px 10px; padding: 3px 10px;
border-radius: var(--radius-pill); border-radius: var(--radius-pill);
white-space: nowrap; white-space: nowrap;
@ -209,7 +209,7 @@
font-weight: var(--fw-bold); font-weight: var(--fw-bold);
} }
.kx-auc-pill--live { .kx-auc-pill--live {
background: #e6f4ee; background: var(--color-success-bg);
color: var(--color-success); color: var(--color-success);
} }
.kx-auc-pill--closed { .kx-auc-pill--closed {
@ -401,7 +401,7 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
background: #fef3f2; background: var(--color-error-bg);
color: var(--color-error); color: var(--color-error);
font-size: var(--fs-caption); font-size: var(--fs-caption);
font-weight: var(--fw-bold); font-weight: var(--fw-bold);
@ -679,7 +679,7 @@
font-size: var(--fs-micro); font-size: var(--fs-micro);
padding: 1px 8px; padding: 1px 8px;
border-radius: var(--radius-pill); border-radius: var(--radius-pill);
background: #e6f4ee; background: var(--color-success-bg);
color: var(--color-success); color: var(--color-success);
font-weight: var(--fw-semibold); font-weight: var(--fw-semibold);
white-space: nowrap; white-space: nowrap;
@ -973,7 +973,7 @@
display: flex; display: flex;
gap: var(--space-2); gap: var(--space-2);
padding: var(--space-3); padding: var(--space-3);
background: #fef3f2; background: var(--color-error-bg);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-size: var(--fs-micro); font-size: var(--fs-micro);
line-height: 1.5; line-height: 1.5;

View File

@ -121,7 +121,7 @@ export function BoothSalesPage() {
<div className="kx-bsales"> <div className="kx-bsales">
<header className="kx-bsales__head"> <header className="kx-bsales__head">
<div> <div>
<h1 className="kx-bsales__title">{t('boothsales.title')}</h1> <h1 className="kx-bsales__title"><IconGrid className="kx-title-ic" size={20} />{t('boothsales.title')}</h1>
<p className="kx-bsales__subtitle"> <p className="kx-bsales__subtitle">
{canManage ? t('boothsales.subtitleManager') : t('boothsales.subtitleExhibitor')} {canManage ? t('boothsales.subtitleManager') : t('boothsales.subtitleExhibitor')}
</p> </p>

View File

@ -149,7 +149,7 @@ export function CmsWorkflowPage() {
<div className="kx-page"> <div className="kx-page">
<header className="kx-cms__head"> <header className="kx-cms__head">
<div> <div>
<h1 className="kx-cms__title"> (CMS)</h1> <h1 className="kx-cms__title"><IconDocument className="kx-title-ic" size={20} /> (CMS)</h1>
<p className="kx-cms__subtitle"> <p className="kx-cms__subtitle">
· ( ) · · · ( ) · ·
</p> </p>
@ -228,10 +228,16 @@ export function CmsWorkflowPage() {
</div> </div>
<div className="kx-cms-media"> <div className="kx-cms-media">
<div className="kx-cms-media__thumb"> {selected?.heroUrl ? (
<IconImage size={22} /> <div className="kx-cms-media__thumb kx-cms-media__thumb--img">
</div> <img src={selected.heroUrl} alt={`${selected.title} 대표 이미지`} loading="lazy" />
<div className="kx-cms-media__thumb"> </div>
) : (
<div className="kx-cms-media__thumb kx-cms-media__thumb--ph">
<IconImage size={22} />
</div>
)}
<div className="kx-cms-media__thumb kx-cms-media__thumb--ph">
<IconImage size={22} /> <IconImage size={22} />
</div> </div>
<button className="kx-cms-media__add" title="이미지 추가" aria-label="이미지 추가"> <button className="kx-cms-media__add" title="이미지 추가" aria-label="이미지 추가">

View File

@ -0,0 +1,256 @@
/*
* F-B6 () [M17/]. · .
* /// · .
* API: liveNoticeApi(GET · POST · archive · pin ). =useResolvedEventId.
* Nifty 그대로: kx-card/kx-cms-* + kx-ln-*(liveNotice.css) + LiveNoticeBadge. 0.
*/
import { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react';
import { Button } from '../../components/ui/Button';
import { EmptyState } from '../../components/ui/States';
import { ApiRequestError } from '../../api/client';
import { useResolvedEventId } from '../../hooks/useResolvedEventId';
import { LiveNoticeBadge } from '../public/LiveNoticeFeed';
import { liveNoticeApi, type LiveNoticeCategory, type LiveNoticeDto } from './liveNoticeApi';
import { IconBell, IconMapPin, IconPlus, IconWarning, IconCheckCircle } from '../../components/ui/icons';
import './liveNotice.css';
const CATEGORIES: LiveNoticeCategory[] = ['URGENT', 'PROGRAM', 'INFO', 'GENERAL'];
const CAT_LABEL: Record<LiveNoticeCategory, string> = {
URGENT: '긴급',
PROGRAM: '프로그램',
INFO: '안내',
GENERAL: '일반',
};
function fmt(iso: string | null): string {
if (!iso) return '-';
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? '-' : d.toLocaleString('ko-KR', { dateStyle: 'medium', timeStyle: 'short' });
}
export function LiveNoticePublisherPage() {
const eventId = useResolvedEventId();
const [items, setItems] = useState<LiveNoticeDto[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
// 발행 폼 상태
const [category, setCategory] = useState<LiveNoticeCategory>('GENERAL');
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const [pinned, setPinned] = useState(false);
const reload = useCallback(async () => {
if (!eventId) {
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
const list = await liveNoticeApi.list(eventId, undefined, 100);
setItems(list);
} catch (e) {
setError(e instanceof ApiRequestError ? e.message : '공지를 불러오지 못했습니다.');
} finally {
setLoading(false);
}
}, [eventId]);
useEffect(() => {
void reload();
}, [reload]);
const canPublish = !!eventId && title.trim().length > 0 && !busy;
async function publish(e: FormEvent) {
e.preventDefault();
if (!canPublish || !eventId) return;
setBusy(true);
setError(null);
try {
const created = await liveNoticeApi.create(eventId, {
category,
title: title.trim(),
body: body.trim() || null,
pinned,
});
setItems((prev) => [created, ...prev]);
setTitle('');
setBody('');
setPinned(false);
setCategory('GENERAL');
} catch (err) {
setError(err instanceof ApiRequestError ? err.message : '공지 발행에 실패했습니다.');
} finally {
setBusy(false);
}
}
async function togglePin(n: LiveNoticeDto) {
if (!eventId || busy) return;
setBusy(true);
setError(null);
try {
const updated = await liveNoticeApi.pin(eventId, n.id, !n.pinned);
setItems((prev) => prev.map((it) => (it.id === updated.id ? updated : it)));
} catch (err) {
setError(err instanceof ApiRequestError ? err.message : '고정 상태 변경에 실패했습니다.');
} finally {
setBusy(false);
}
}
async function archive(n: LiveNoticeDto) {
if (!eventId || busy) return;
if (!window.confirm(`"${n.title}" 공지를 보관하시겠습니까? 공개 피드에서 제거됩니다.`)) return;
setBusy(true);
setError(null);
try {
const updated = await liveNoticeApi.archive(eventId, n.id);
setItems((prev) => prev.map((it) => (it.id === updated.id ? updated : it)));
} catch (err) {
setError(err instanceof ApiRequestError ? err.message : '보관에 실패했습니다.');
} finally {
setBusy(false);
}
}
const published = useMemo(() => items.filter((n) => n.status === 'published'), [items]);
return (
<div className="kx-page kx-ln">
<header className="kx-cms__head">
<div>
<h1 className="kx-cms__title"><IconBell className="kx-title-ic" size={20} /> </h1>
<p className="kx-cms__subtitle">
· · · / · ·
</p>
</div>
</header>
{error && (
<div className="kx-cms-alert" role="alert">
<IconWarning size={14} /> {error}
</div>
)}
{!eventId ? (
<EmptyState title="행사를 선택해 주세요" description="라이브 공지를 발행할 대상 행사가 지정되지 않았습니다." />
) : (
<div className="kx-ln-grid">
{/* 발행 폼 */}
<form className="kx-card kx-ln-form" onSubmit={publish} aria-label="공지 발행">
<div className="kx-cms-sublabel" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<IconBell size={18} />
</div>
<span className="kx-ln-label"></span>
<div className="kx-ln-cats" role="radiogroup" aria-label="카테고리">
{CATEGORIES.map((c) => (
<button
key={c}
type="button"
role="radio"
aria-checked={category === c}
className={`kx-ln-cat${category === c ? ' is-active' : ''}`}
onClick={() => setCategory(c)}
>
{CAT_LABEL[c]}
</button>
))}
</div>
<span className="kx-ln-label"></span>
<input
className="kx-cms-title-input"
value={title}
maxLength={200}
onChange={(e) => setTitle(e.target.value)}
placeholder="예: [긴급] 제1주차장 만차 안내"
aria-label="제목"
/>
<span className="kx-ln-label"> ()</span>
<textarea
className="kx-ln-textarea"
value={body}
maxLength={2000}
onChange={(e) => setBody(e.target.value)}
placeholder="상세 안내 내용을 입력하세요."
aria-label="본문"
/>
<label className="kx-ln-pinrow">
<input type="checkbox" checked={pinned} onChange={(e) => setPinned(e.target.checked)} />
<IconMapPin size={16} />
</label>
<Button type="submit" block disabled={!canPublish} leadingIcon={<IconPlus size={16} />}>
{busy ? '처리 중…' : '공지 발행'}
</Button>
</form>
{/* 목록 */}
<section className="kx-card" aria-label="공지 목록">
<div className="kx-cms-sublabel" style={{ marginBottom: 12 }}>
{published.length} · {items.length}
</div>
{loading ? (
<div className="kx-cms-empty"> </div>
) : items.length === 0 ? (
<div className="kx-cms-empty"> . .</div>
) : (
<ul className="kx-ln-list">
{items.map((n) => {
const archived = n.status === 'archived';
const urgent = n.category === 'URGENT';
return (
<li
key={n.id}
className={`kx-ln-item${archived ? ' is-archived' : ''}${urgent && !archived ? ' is-urgent' : ''}`}
>
<div className="kx-ln-item__top">
<LiveNoticeBadge category={n.category} />
{n.pinned && !archived && (
<span className="kx-cms-pill kx-cms-pill--approved">
<IconMapPin size={12} />
</span>
)}
<span className="kx-ln-status">
{archived ? (
'보관됨'
) : (
<>
<IconCheckCircle size={12} />
</>
)}
</span>
</div>
<p className="kx-ln-item__title">{n.title}</p>
{n.body && <p className="kx-ln-item__body">{n.body}</p>}
<p className="kx-ln-item__meta">
{[n.authorName, fmt(n.updatedAt ?? n.createdAt)].filter(Boolean).join(' · ')}
</p>
{!archived && (
<div className="kx-ln-item__actions">
<Button variant="secondary" onClick={() => togglePin(n)} disabled={busy}>
{n.pinned ? '고정 해제' : '상단 고정'}
</Button>
<Button variant="secondary" onClick={() => archive(n)} disabled={busy}>
</Button>
</div>
)}
</li>
);
})}
</ul>
)}
</section>
</div>
)}
</div>
);
}

View File

@ -127,7 +127,7 @@ export function MicrositeBuilderPage() {
<div className="kx-page"> <div className="kx-page">
<header className="kx-cms__head"> <header className="kx-cms__head">
<div> <div>
<h1 className="kx-cms__title"> </h1> <h1 className="kx-cms__title"><IconGrid className="kx-title-ic" size={20} /> </h1>
<p className="kx-cms__subtitle">{brand} · (··) </p> <p className="kx-cms__subtitle">{brand} · (··) </p>
</div> </div>
<div className="kx-cms__head-actions"> <div className="kx-cms__head-actions">

View File

@ -9,7 +9,9 @@ import { useEffect, useMemo, useState } from 'react';
import { Button } from '../../components/ui/Button'; import { Button } from '../../components/ui/Button';
import { ApiRequestError } from '../../api/client'; import { ApiRequestError } from '../../api/client';
import { cmsApi, type CmsContent, type CmsTranslation, type Lang, type TransStatus } from './cmsApi'; import { cmsApi, type CmsContent, type CmsTranslation, type Lang, type TransStatus } from './cmsApi';
import { IconSpark, IconWarning, IconClose } from '../../components/ui/icons'; import { IconClose, IconList, IconSpark, IconWarning } from '../../components/ui/icons';
import { GridPdfButton } from '../../components/GridPdfButton';
import '../../components/data-table.css';
import './cms.css'; import './cms.css';
type Target = 'en' | 'zh' | 'ja'; type Target = 'en' | 'zh' | 'ja';
@ -177,11 +179,29 @@ export function MultilingualCmsPage() {
} }
} }
const pdfColumns = [
{ key: 'content', label: '콘텐츠 / 원문(KO)' },
{ key: 'translation', label: `번역본 (${target.toUpperCase()})` },
{ key: 'status', label: '상태' },
{ key: 'updated', label: '최근 업데이트' },
];
const pdfRows = () =>
contents.map((c) => {
const tr = transMap[c.id]?.[target];
const st = tr?.transStatus ?? 'none';
return {
content: c.title,
translation: tr?.title || '번역 대기 중…',
status: STATUS_META[st].label,
updated: fmtRel(tr?.updatedAt ?? null),
};
});
return ( return (
<div className="kx-page"> <div className="kx-page">
<header className="kx-cms__head"> <header className="kx-cms__head">
<div> <div>
<h1 className="kx-cms__title"> </h1> <h1 className="kx-cms__title"><IconList className="kx-title-ic" size={20} /> </h1>
<p className="kx-cms__subtitle"> · · · </p> <p className="kx-cms__subtitle"> · · · </p>
</div> </div>
</header> </header>
@ -238,6 +258,7 @@ export function MultilingualCmsPage() {
> >
{busy ? 'AI 번역 중…' : 'AI 자동 번역'} {busy ? 'AI 번역 중…' : 'AI 자동 번역'}
</Button> </Button>
<GridPdfButton title="다국어 콘텐츠 관리" columns={pdfColumns} rows={pdfRows} disabled={contents.length === 0} />
</div> </div>
<div className="kx-table-scroll"> <div className="kx-table-scroll">

View File

@ -42,7 +42,7 @@
font-size: var(--fs-micro); font-size: var(--fs-micro);
font-weight: var(--fw-bold); font-weight: var(--fw-bold);
color: var(--color-warning); color: var(--color-warning);
background: #fff4e5; background: var(--color-warning-bg);
padding: 2px 10px; padding: 2px 10px;
border-radius: var(--radius-pill); border-radius: var(--radius-pill);
white-space: nowrap; white-space: nowrap;
@ -72,7 +72,7 @@
color: var(--color-primary-700); color: var(--color-primary-700);
} }
.kx-cms-pill--published { .kx-cms-pill--published {
background: #e6f4ee; background: var(--color-success-bg);
color: var(--color-success); color: var(--color-success);
} }
.kx-cms-pill--ai { .kx-cms-pill--ai {
@ -184,7 +184,7 @@
color: var(--color-neutral-900); color: var(--color-neutral-900);
} }
.kx-icon-btn--danger:hover { .kx-icon-btn--danger:hover {
background: #fef3f2; background: var(--color-error-bg);
color: var(--color-error); color: var(--color-error);
} }
@ -322,6 +322,16 @@
color: var(--color-neutral-500); color: var(--color-neutral-500);
overflow: hidden; overflow: hidden;
} }
.kx-cms-media__thumb--img {
width: 156px;
padding: 0;
}
.kx-cms-media__thumb--img img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.kx-cms-media__thumb--ph { .kx-cms-media__thumb--ph {
background: repeating-linear-gradient( background: repeating-linear-gradient(
135deg, 135deg,
@ -866,7 +876,7 @@
gap: 6px; gap: 6px;
padding: 2px 10px; padding: 2px 10px;
border-radius: var(--radius-pill); border-radius: var(--radius-pill);
background: #e6f4ee; background: var(--color-success-bg);
color: var(--color-success); color: var(--color-success);
font-size: var(--fs-micro); font-size: var(--fs-micro);
font-weight: var(--fw-bold); font-weight: var(--fw-bold);

View File

@ -30,6 +30,8 @@ export interface CmsContent {
publishedAt: string | null; publishedAt: string | null;
createdAt: string | null; createdAt: string | null;
updatedAt: string | null; updatedAt: string | null;
/** 대표 이미지(썸네일/히어로) — 정적 자산 경로(/media/posters·/media/hero). null=이미지 없음. */
heroUrl: string | null;
} }
export interface CmsTranslation { export interface CmsTranslation {

View File

@ -0,0 +1,129 @@
/*
* F-B6 라이브 공지 발행 콘솔(조직자) kx 토큰만. 하드코딩 ·폰트 0.
* kx-card/kx-cms-* 재사용 + 아래 보조 클래스(발행 ·목록 액션).
*/
.kx-ln {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.kx-ln-grid {
display: grid;
grid-template-columns: minmax(0, 22rem) minmax(0, 1fr);
gap: var(--space-4);
align-items: start;
}
@media (max-width: 900px) {
.kx-ln-grid {
grid-template-columns: 1fr;
}
}
.kx-ln-form {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.kx-ln-label {
font-size: var(--fs-nano);
font-weight: var(--fw-semibold, var(--fw-medium));
color: var(--color-neutral-700);
}
.kx-ln-cats {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.kx-ln-cat {
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-pill, var(--radius-sm));
border: 1px solid var(--color-neutral-200);
background: var(--color-white);
color: var(--color-neutral-700);
font-size: var(--fs-nano);
font-weight: var(--fw-semibold, var(--fw-medium));
cursor: pointer;
}
.kx-ln-cat.is-active {
border-color: var(--color-primary-600);
background: color-mix(in srgb, var(--color-primary-600) 10%, transparent);
color: var(--color-primary-600);
}
.kx-ln-textarea {
width: 100%;
min-height: 6rem;
padding: var(--space-3);
border: 1px solid var(--color-neutral-200);
border-radius: var(--radius-sm);
background: var(--color-white);
color: var(--color-neutral-900);
font: inherit;
font-size: var(--fs-body);
resize: vertical;
}
.kx-ln-textarea:focus {
outline: none;
border-color: var(--color-primary-600);
}
.kx-ln-pinrow {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--fs-body);
color: var(--color-neutral-700);
}
.kx-ln-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.kx-ln-item {
padding: var(--space-3) var(--space-4);
border: var(--border-card);
border-radius: var(--radius-sm);
background: var(--color-white);
}
.kx-ln-item.is-archived {
opacity: 0.6;
}
.kx-ln-item.is-urgent {
border-color: color-mix(in srgb, var(--color-error) 40%, transparent);
}
.kx-ln-item__top {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2);
}
.kx-ln-item__title {
margin: var(--space-2) 0 0;
font-size: var(--fs-body);
font-weight: var(--fw-semibold, var(--fw-medium));
color: var(--color-neutral-900);
}
.kx-ln-item__body {
margin: var(--space-1) 0 0;
font-size: var(--fs-nano);
color: var(--color-neutral-500);
white-space: pre-line;
}
.kx-ln-item__meta {
margin: var(--space-2) 0 0;
font-size: var(--fs-micro, var(--fs-nano));
color: var(--color-neutral-500);
}
.kx-ln-item__actions {
display: flex;
gap: var(--space-2);
margin-top: var(--space-3);
}
.kx-ln-status {
display: inline-flex;
align-items: center;
gap: var(--space-1);
margin-left: auto;
font-size: var(--fs-micro, var(--fs-nano));
color: var(--color-neutral-500);
}

View File

@ -0,0 +1,48 @@
/*
* F-B6 API ( · ORGANIZER·HALL_MANAGER·ADMIN).
* (../../api/client) . publicApi.listLiveNotices.
* 정본: 백엔드
* - GET /api/events/{eventId}/live-notices?status=&limit= LiveNoticeDto[]
* - POST /api/events/{eventId}/live-notices LiveNoticeDto( )
* - POST /api/events/{eventId}/live-notices/{id}/archive LiveNoticeDto()
* - POST /api/events/{eventId}/live-notices/{id}/pin {pinned} LiveNoticeDto( )
*/
import { api } from '../../api/client';
import type { LiveNoticeCategory, LiveNoticeDto } from '../public/publicApi';
export type { LiveNoticeCategory, LiveNoticeDto };
export interface LiveNoticeCreateRequest {
category: LiveNoticeCategory;
title: string;
body?: string | null;
pinned?: boolean;
}
function qs(params: Record<string, string | undefined>): string {
const sp = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => {
if (v != null && v !== '') sp.set(k, v);
});
const s = sp.toString();
return s ? `?${s}` : '';
}
export const liveNoticeApi = {
/** 전체 상태 목록(status 필터 선택: published|archived). */
list: (eventId: string, status?: string, limit = 100) =>
api.get<LiveNoticeDto[]>(
`/api/events/${encodeURIComponent(eventId)}/live-notices${qs({ status, limit: String(limit) })}`,
),
create: (eventId: string, body: LiveNoticeCreateRequest) =>
api.post<LiveNoticeDto>(`/api/events/${encodeURIComponent(eventId)}/live-notices`, body),
archive: (eventId: string, id: string) =>
api.post<LiveNoticeDto>(
`/api/events/${encodeURIComponent(eventId)}/live-notices/${encodeURIComponent(id)}/archive`,
),
pin: (eventId: string, id: string, pinned: boolean) =>
api.post<LiveNoticeDto>(
`/api/events/${encodeURIComponent(eventId)}/live-notices/${encodeURIComponent(id)}/pin`,
{ pinned },
),
};

View File

@ -22,12 +22,12 @@
font-weight: var(--fw-semibold); font-weight: var(--fw-semibold);
} }
.kx-comp__banner.is-block { .kx-comp__banner.is-block {
background: #fef3f2; background: var(--color-error-bg);
color: var(--color-error); color: var(--color-error);
border: 1px solid #fecdca; border: 1px solid #fecdca;
} }
.kx-comp__banner.is-ok { .kx-comp__banner.is-ok {
background: #e6f4ee; background: var(--color-success-bg);
color: var(--color-success); color: var(--color-success);
border: 1px solid #a6e0c8; border: 1px solid #a6e0c8;
} }
@ -89,7 +89,7 @@
border-radius: var(--radius-pill); border-radius: var(--radius-pill);
} }
.kx-comp__lg--pass { .kx-comp__lg--pass {
background: #e6f4ee; background: var(--color-success-bg);
color: var(--color-success); color: var(--color-success);
} }
.kx-comp__lg--warn { .kx-comp__lg--warn {
@ -97,7 +97,7 @@
color: var(--color-violation-warn-text); color: var(--color-violation-warn-text);
} }
.kx-comp__lg--block { .kx-comp__lg--block {
background: #fef3f2; background: var(--color-error-bg);
color: var(--color-error); color: var(--color-error);
} }
@ -168,7 +168,7 @@
border-radius: var(--radius-pill); border-radius: var(--radius-pill);
} }
.kx-comp__sev--block { .kx-comp__sev--block {
background: #fef3f2; background: var(--color-error-bg);
color: var(--color-error); color: var(--color-error);
} }
.kx-comp__sev--warn { .kx-comp__sev--warn {
@ -176,7 +176,7 @@
color: var(--color-violation-warn-text); color: var(--color-violation-warn-text);
} }
.kx-comp__sev--pass { .kx-comp__sev--pass {
background: #e6f4ee; background: var(--color-success-bg);
color: var(--color-success); color: var(--color-success);
} }
.kx-comp__measured { .kx-comp__measured {

View File

@ -83,7 +83,7 @@
color: var(--color-primary-700); color: var(--color-primary-700);
} }
.kx-booth--sub .kx-booth__event-tag { .kx-booth--sub .kx-booth__event-tag {
background: #e6f4ee; background: var(--color-success-bg);
color: var(--color-success); color: var(--color-success);
} }
.kx-booth__name { .kx-booth__name {
@ -290,7 +290,7 @@
color: var(--color-primary-700); color: var(--color-primary-700);
} }
.kx-feed__dot--warn { .kx-feed__dot--warn {
background: #fef3f2; background: var(--color-error-bg);
color: var(--color-error); color: var(--color-error);
} }
.kx-feed__dot--info { .kx-feed__dot--info {

View File

@ -1,27 +1,32 @@
import { useParams } from 'react-router-dom'; import { Link, useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import { AiLabel } from '../../components/ui/Badge'; import { AiLabel } from '../../components/ui/Badge';
import { AiImage } from '../../components/ui/AiImage'; import { AiImage } from '../../components/ui/AiImage';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { IconBell, IconCheck, IconExhibitors, IconImage } from '../../components/ui/icons'; import { IconBell, IconCheck, IconChevronRight, IconExhibitors, IconImage } from '../../components/ui/icons';
import { dashboardApi } from '../../api/endpoints'; import { dashboardApi } from '../../api/endpoints';
import { ApiRequestError } from '../../api/client'; import { ApiRequestError } from '../../api/client';
import type { DashboardData } from '../../api/types'; import type { DashboardData } from '../../api/types';
import { StatusPill, timeAgo } from '../work/workShared'; import { StatusPill, timeAgo } from '../work/workShared';
import { GridPdfButton } from '../../components/GridPdfButton';
import { import {
SAMPLE_EXHIBITORS, SAMPLE_EXHIBITORS,
SAMPLE_FEED, SAMPLE_FEED,
SAMPLE_KPIS, SAMPLE_KPIS,
SAMPLE_MILESTONES, SAMPLE_MILESTONES,
} from './sampleDashboard'; } from './sampleDashboard';
import '../../components/data-table.css';
import './dashboard.css'; import './dashboard.css';
// Stitch organizer_dashboard 번들 이미지(실 KINTEX 촬영본 — 히어로 시각화 데코, AI 샘플 라벨). // Stitch organizer_dashboard 번들 이미지(실 KINTEX 촬영본 — 히어로 시각화 데코, AI 샘플 라벨).
const MOCK = `${import.meta.env.BASE_URL}mock/organizer_dashboard`; const MOCK = `${import.meta.env.BASE_URL}mock/organizer_dashboard`;
const S7_AERIAL = `${MOCK}/img3.jpg`; const S7_AERIAL = `${MOCK}/img3.jpg`;
/** 대시보드 목록 위젯은 10줄까지만 미리보기 — 초과 시 "전체보기"로 목록 화면 이동. */
const DASH_LIST_LIMIT = 10;
/* /*
* SCR-02 . Stitch organizer_dashboard . * SCR-02 . Stitch organizer_dashboard .
* 경로: GET /api/events/{eventId}/dashboard ( milestones·kpis·exhibitors·feed). * 경로: GET /api/events/{eventId}/dashboard ( milestones·kpis·exhibitors·feed).
@ -52,6 +57,18 @@ export function OrganizerDashboardPage() {
const data: DashboardData | null = q.data ?? (degraded ? FALLBACK : null); const data: DashboardData | null = q.data ?? (degraded ? FALLBACK : null);
const hardError = q.isError && !degraded; const hardError = q.isError && !degraded;
const exhibitorPdfColumns = [
{ key: 'boothNo', label: t('dashboard.colBoothNo') },
{ key: 'company', label: t('dashboard.colCompany') },
{ key: 'status', label: t('dashboard.colStatus') },
];
const exhibitorPdfRows = () =>
(data?.exhibitors ?? []).slice(0, DASH_LIST_LIMIT).map((r) => ({
boothNo: r.boothNo,
company: r.companyName,
status: r.status,
}));
return ( return (
<div className="kx-dash"> <div className="kx-dash">
{/* 행사 헤더 — 인증 워크스페이스 실데이터 */} {/* 행사 헤더 — 인증 워크스페이스 실데이터 */}
@ -135,6 +152,7 @@ export function OrganizerDashboardPage() {
<div className="kx-dash__section-head"> <div className="kx-dash__section-head">
<h2><IconExhibitors className="kx-title-ic" size={18} />{t('dashboard.exhibitorStatus')}</h2> <h2><IconExhibitors className="kx-title-ic" size={18} />{t('dashboard.exhibitorStatus')}</h2>
{degraded && <span className="kx-dash__degraded">{t('dashboard.sampleTag')}</span>} {degraded && <span className="kx-dash__degraded">{t('dashboard.sampleTag')}</span>}
<GridPdfButton title={t('dashboard.exhibitorStatus')} columns={exhibitorPdfColumns} rows={exhibitorPdfRows} disabled={(data?.exhibitors.length ?? 0) === 0} />
</div> </div>
{data.exhibitors.length === 0 ? ( {data.exhibitors.length === 0 ? (
<EmptyState <EmptyState
@ -142,26 +160,34 @@ export function OrganizerDashboardPage() {
description={t('dashboard.exhibitorEmptyDesc')} description={t('dashboard.exhibitorEmptyDesc')}
/> />
) : ( ) : (
<table className="kx-table"> <>
<thead> <table className="kx-table">
<tr> <thead>
<th>{t('dashboard.colBoothNo')}</th> <tr>
<th>{t('dashboard.colCompany')}</th> <th>{t('dashboard.colBoothNo')}</th>
<th>{t('dashboard.colStatus')}</th> <th>{t('dashboard.colCompany')}</th>
</tr> <th>{t('dashboard.colStatus')}</th>
</thead>
<tbody>
{data.exhibitors.map((r, i) => (
<tr key={`${r.boothNo}-${i}`}>
<td className="tnum">{r.boothNo}</td>
<td>{r.companyName}</td>
<td>
<StatusPill value={r.status} />
</td>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {data.exhibitors.slice(0, DASH_LIST_LIMIT).map((r, i) => (
<tr key={`${r.boothNo}-${i}`}>
<td className="tnum">{r.boothNo}</td>
<td>{r.companyName}</td>
<td>
<StatusPill value={r.status} />
</td>
</tr>
))}
</tbody>
</table>
{data.exhibitors.length > DASH_LIST_LIMIT && (
<Link className="kx-dash__more kx-dash__table-more" to="/exhibitors">
{t('dashboard.viewAll', { n: data.exhibitors.length })}
<IconChevronRight size={14} />
</Link>
)}
</>
)} )}
</section> </section>
@ -171,15 +197,23 @@ export function OrganizerDashboardPage() {
{data.feed.length === 0 ? ( {data.feed.length === 0 ? (
<EmptyState title={t('dashboard.feedEmpty')} /> <EmptyState title={t('dashboard.feedEmpty')} />
) : ( ) : (
<ul className="kx-feed__list"> <>
{data.feed.map((f) => ( <ul className="kx-feed__list">
<li key={f.id} className={`kx-feed__item is-${feedKind(f.kind)}`}> {data.feed.slice(0, DASH_LIST_LIMIT).map((f) => (
<span className="kx-feed__dot" aria-hidden="true" /> <li key={f.id} className={`kx-feed__item is-${feedKind(f.kind)}`}>
<span className="kx-feed__text">{f.message}</span> <span className="kx-feed__dot" aria-hidden="true" />
<span className="kx-feed__time">{timeAgo(f.at)}</span> <span className="kx-feed__text">{f.message}</span>
</li> <span className="kx-feed__time">{timeAgo(f.at)}</span>
))} </li>
</ul> ))}
</ul>
{data.feed.length > DASH_LIST_LIMIT && (
<Link className="kx-dash__more" to="/notifications">
{t('dashboard.viewAll', { n: data.feed.length })}
<IconChevronRight size={14} />
</Link>
)}
</>
)} )}
</section> </section>

View File

@ -46,11 +46,12 @@
.kx-timeline { .kx-timeline {
display: grid; display: grid;
grid-auto-flow: column; grid-auto-flow: column;
grid-auto-columns: 1fr; /* 스텝 짤림 방지: 각 스텝이 최소 폭을 확보(부족 시 hero 가로 스크롤). */
grid-auto-columns: minmax(104px, 1fr);
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
min-width: 720px; min-width: 728px;
} }
.kx-timeline__node { .kx-timeline__node {
position: relative; position: relative;
@ -109,6 +110,8 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
text-align: center; text-align: center;
min-width: 0;
max-width: 100%;
} }
.kx-timeline__d { .kx-timeline__d {
font-size: var(--fs-caption); font-size: var(--fs-caption);
@ -118,6 +121,9 @@
font-size: var(--fs-body); font-size: var(--fs-body);
font-weight: var(--fw-semibold); font-weight: var(--fw-semibold);
color: var(--color-neutral-900); color: var(--color-neutral-900);
/* 한글 스텝명이 한 글자씩 쪼개지지 않도록 어절 단위 줄바꿈. */
word-break: keep-all;
overflow-wrap: anywhere;
} }
.kx-timeline__card { .kx-timeline__card {
font-size: var(--fs-caption); font-size: var(--fs-caption);
@ -200,7 +206,7 @@
.kx-dash__degraded { .kx-dash__degraded {
font-size: var(--fs-micro); font-size: var(--fs-micro);
color: var(--color-warning); color: var(--color-warning);
background: #fff4e5; background: var(--color-warning-bg);
padding: 2px 8px; padding: 2px 8px;
border-radius: var(--radius-pill); border-radius: var(--radius-pill);
} }
@ -283,7 +289,9 @@
} }
.kx-feed__item { .kx-feed__item {
display: grid; display: grid;
grid-template-columns: 8px 1fr auto; /* 중앙 트랙을 minmax(0,1fr) 기본 1fr 최소치(min-content) 한글 1글자라
컨테이너가 좁아지면 텍스트가 세로 1글자씩 쪼개지던 근본 원인 제거. */
grid-template-columns: 8px minmax(0, 1fr) auto;
gap: var(--space-2); gap: var(--space-2);
align-items: baseline; align-items: baseline;
font-size: var(--fs-body); font-size: var(--fs-body);
@ -308,13 +316,49 @@
background: var(--color-primary-600); background: var(--color-primary-600);
} }
.kx-feed__text { .kx-feed__text {
min-width: 0;
color: var(--color-neutral-700); color: var(--color-neutral-700);
word-break: keep-all;
overflow-wrap: anywhere;
} }
.kx-feed__time { .kx-feed__time {
font-size: var(--fs-caption); font-size: var(--fs-caption);
color: var(--color-neutral-500); color: var(--color-neutral-500);
white-space: nowrap; white-space: nowrap;
} }
/* 목록 위젯 "전체보기" — 10줄 초과 시 해당 목록 화면으로 이동. */
.kx-dash__more {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
margin-top: var(--space-3);
padding: var(--space-2) var(--space-3);
border: var(--border-card);
border-radius: var(--radius-sm);
background: var(--color-white);
color: var(--color-primary-700);
font-size: var(--fs-caption);
font-weight: var(--fw-semibold);
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease;
}
.kx-dash__more:hover {
background: var(--color-primary-050);
border-color: var(--color-primary-600);
}
.kx-dash__more:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--color-primary-050);
}
/* 참가업체 테이블 하단 전체보기 — 표 좌우 패딩 정렬. */
.kx-dash__table-more {
margin: 0;
border-width: 1px 0 0;
border-radius: 0;
}
.kx-dash__s7-head { .kx-dash__s7-head {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -292,7 +292,7 @@
font-weight: var(--fw-semibold); font-weight: var(--fw-semibold);
} }
.kx-chip--pass { .kx-chip--pass {
background: #e6f4ee; background: var(--color-success-bg);
color: var(--color-success); color: var(--color-success);
} }
.kx-chip--warn { .kx-chip--warn {
@ -300,7 +300,7 @@
color: var(--color-violation-warn-text); color: var(--color-violation-warn-text);
} }
.kx-chip--block { .kx-chip--block {
background: #fef3f2; background: var(--color-error-bg);
color: var(--color-error); color: var(--color-error);
} }
.kx-chip--doc { .kx-chip--doc {

View File

@ -149,7 +149,7 @@
background: var(--color-white); background: var(--color-white);
border: 3px solid var(--color-warning); border: 3px solid var(--color-warning);
color: var(--color-warning); color: var(--color-warning);
box-shadow: 0 0 0 4px #fff4e5; box-shadow: 0 0 0 4px var(--color-warning-bg);
} }
.kx-doc__step-label { .kx-doc__step-label {
font-size: var(--fs-caption); font-size: var(--fs-caption);
@ -303,7 +303,7 @@
border: var(--border-card); border: var(--border-card);
} }
.kx-doc__issue--warn { .kx-doc__issue--warn {
background: #fef3f2; background: var(--color-error-bg);
border-color: #fecdca; border-color: #fecdca;
} }
.kx-doc__issue--warn .kx-doc__issue-icon { .kx-doc__issue--warn .kx-doc__issue-icon {
@ -494,7 +494,7 @@
vertical-align: top; vertical-align: top;
} }
.kx-rep__table th { .kx-rep__table th {
background: #f2f4f7; background: var(--color-neutral-100);
font-weight: var(--fw-bold); font-weight: var(--fw-bold);
width: 22%; width: 22%;
white-space: nowrap; white-space: nowrap;

Some files were not shown because too many files have changed in this diff Show More