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>
BIN
src/frontend/public/brand/kintex-ci.jpg
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
src/frontend/public/login-slides/login-slide-01.jpg
Normal file
|
After Width: | Height: | Size: 148 KiB |
BIN
src/frontend/public/login-slides/login-slide-02.jpg
Normal file
|
After Width: | Height: | Size: 166 KiB |
BIN
src/frontend/public/login-slides/login-slide-03.jpg
Normal file
|
After Width: | Height: | Size: 184 KiB |
BIN
src/frontend/public/login-slides/login-slide-04.jpg
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
src/frontend/public/login-slides/login-slide-05.jpg
Normal file
|
After Width: | Height: | Size: 295 KiB |
BIN
src/frontend/public/login-slides/login-slide-06.jpg
Normal file
|
After Width: | Height: | Size: 431 KiB |
BIN
src/frontend/public/login-slides/login-slide-07.jpg
Normal file
|
After Width: | Height: | Size: 493 KiB |
BIN
src/frontend/public/login-slides/login-slide-08.jpg
Normal file
|
After Width: | Height: | Size: 176 KiB |
BIN
src/frontend/public/login-slides/login-slide-09.jpg
Normal file
|
After Width: | Height: | Size: 147 KiB |
BIN
src/frontend/public/login-slides/login-slide-10.jpg
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
src/frontend/public/media/hero/hero-6.jpg
Normal file
|
After Width: | Height: | Size: 309 KiB |
BIN
src/frontend/public/media/hero/hero-7.jpg
Normal file
|
After Width: | Height: | Size: 186 KiB |
BIN
src/frontend/public/media/track/track-agency-01.jpg
Normal file
|
After Width: | Height: | Size: 453 KiB |
BIN
src/frontend/public/media/track/track-agency-02.jpg
Normal file
|
After Width: | Height: | Size: 186 KiB |
BIN
src/frontend/public/media/track/track-agency-03.jpg
Normal file
|
After Width: | Height: | Size: 309 KiB |
BIN
src/frontend/public/media/track/track-agency-04.jpg
Normal file
|
After Width: | Height: | Size: 450 KiB |
BIN
src/frontend/public/media/track/track-agency-05.jpg
Normal file
|
After Width: | Height: | Size: 402 KiB |
BIN
src/frontend/public/media/track/track-business-01.jpg
Normal file
|
After Width: | Height: | Size: 182 KiB |
BIN
src/frontend/public/media/track/track-business-02.jpg
Normal file
|
After Width: | Height: | Size: 210 KiB |
BIN
src/frontend/public/media/track/track-business-03.jpg
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
src/frontend/public/media/track/track-business-04.jpg
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
src/frontend/public/media/track/track-business-05.jpg
Normal file
|
After Width: | Height: | Size: 141 KiB |
BIN
src/frontend/public/media/track/track-visitor-01.jpg
Normal file
|
After Width: | Height: | Size: 162 KiB |
BIN
src/frontend/public/media/track/track-visitor-02.jpg
Normal file
|
After Width: | Height: | Size: 193 KiB |
BIN
src/frontend/public/media/track/track-visitor-03.jpg
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
src/frontend/public/media/track/track-visitor-04.jpg
Normal file
|
After Width: | Height: | Size: 139 KiB |
BIN
src/frontend/public/media/track/track-visitor-05.jpg
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
src/frontend/public/media/venue/kintex-hall-map.jpg
Normal file
|
After Width: | Height: | Size: 184 KiB |
@ -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
|
||||||
|
|||||||
@ -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) =>
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
74
src/frontend/src/components/GridPdfButton.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
BIN
src/frontend/src/components/gridPdf.ts
Normal 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"
|
||||||
|
|||||||
96
src/frontend/src/components/layout/TenantSwitcher.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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')],
|
||||||
|
|||||||
@ -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': '일정',
|
||||||
|
|||||||
23
src/frontend/src/components/ui/BrandLogo.tsx
Normal 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" />;
|
||||||
|
}
|
||||||
131
src/frontend/src/components/ui/Modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/frontend/src/components/ui/brand-logo.css
Normal 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;
|
||||||
|
}
|
||||||
131
src/frontend/src/components/ui/modal.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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": "イベントを見る"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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": "행사 둘러보기"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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": "浏览活动"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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 배너와 함께 노출. */
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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)}>
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
35
src/frontend/src/screens/admin/adminDashboardTypes.ts
Normal 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';
|
||||||
|
}
|
||||||
@ -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 && (
|
||||||
|
|||||||
@ -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 ? (
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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 ? (
|
||||||
|
|||||||
@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 ? (
|
||||||
|
<div className="kx-cms-media__thumb kx-cms-media__thumb--img">
|
||||||
|
<img src={selected.heroUrl} alt={`${selected.title} 대표 이미지`} loading="lazy" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="kx-cms-media__thumb kx-cms-media__thumb--ph">
|
||||||
<IconImage size={22} />
|
<IconImage size={22} />
|
||||||
</div>
|
</div>
|
||||||
<div className="kx-cms-media__thumb">
|
)}
|
||||||
|
<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="이미지 추가">
|
||||||
|
|||||||
256
src/frontend/src/screens/cms/LiveNoticePublisherPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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">
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
129
src/frontend/src/screens/cms/liveNotice.css
Normal 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);
|
||||||
|
}
|
||||||
48
src/frontend/src/screens/cms/liveNoticeApi.ts
Normal 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 },
|
||||||
|
),
|
||||||
|
};
|
||||||
@ -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 {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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,6 +160,7 @@ export function OrganizerDashboardPage() {
|
|||||||
description={t('dashboard.exhibitorEmptyDesc')}
|
description={t('dashboard.exhibitorEmptyDesc')}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
<table className="kx-table">
|
<table className="kx-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@ -151,7 +170,7 @@ export function OrganizerDashboardPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{data.exhibitors.map((r, i) => (
|
{data.exhibitors.slice(0, DASH_LIST_LIMIT).map((r, i) => (
|
||||||
<tr key={`${r.boothNo}-${i}`}>
|
<tr key={`${r.boothNo}-${i}`}>
|
||||||
<td className="tnum">{r.boothNo}</td>
|
<td className="tnum">{r.boothNo}</td>
|
||||||
<td>{r.companyName}</td>
|
<td>{r.companyName}</td>
|
||||||
@ -162,6 +181,13 @@ export function OrganizerDashboardPage() {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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,8 +197,9 @@ 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">
|
<ul className="kx-feed__list">
|
||||||
{data.feed.map((f) => (
|
{data.feed.slice(0, DASH_LIST_LIMIT).map((f) => (
|
||||||
<li key={f.id} className={`kx-feed__item is-${feedKind(f.kind)}`}>
|
<li key={f.id} className={`kx-feed__item is-${feedKind(f.kind)}`}>
|
||||||
<span className="kx-feed__dot" aria-hidden="true" />
|
<span className="kx-feed__dot" aria-hidden="true" />
|
||||||
<span className="kx-feed__text">{f.message}</span>
|
<span className="kx-feed__text">{f.message}</span>
|
||||||
@ -180,6 +207,13 @@ export function OrganizerDashboardPage() {
|
|||||||
</li>
|
</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>
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||