From d949f322d2fae0caad05e8429a03e195cf4b0a83 Mon Sep 17 00:00:00 2001 From: zio Date: Sun, 12 Jul 2026 05:26:29 +0900 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20port=2025=20Stitch=20screens?= =?UTF-8?q?=20=E2=80=94=20domain=20modules,=20admin,=20public=20site?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SCR-22/23/25 docs·milestones/report-authoring/dock-reservation (M6/M8, sample) - SCR-26/27/29/38 auction list/detail/award + contractor dashboard (M15, sample, sealed-bid masking + registered-vendor-only UI rules) - SCR-30/32/33/34 visitor registration/lead scoring/EDM/sponsorship (M10/M12, sample, PII masked) - SCR-35/36/37 CMS workflow/microsite builder/multilingual (M17, sample) - SCR-A5/A6 audit log + system settings wired to live backend (PageResponse, secret masking); A8 ruleset viewer (static v1.1 snapshot), A9 tenants (sample) - SCR-P1~P7 public marketing site with self-contained PublicShell (no auth, sample data, PG delegation notice, no card input) - App routes + AppShell nav (도메인 모듈 group); tsc -b clean; QA PASS Co-Authored-By: Claude Opus 4.8 (1M context) --- src/frontend/src/App.tsx | 70 +- .../src/components/layout/AppShell.tsx | 28 +- .../src/screens/admin/AuditLogPage.tsx | 308 ++ .../src/screens/admin/RulesetVersionsPage.tsx | 164 + .../src/screens/admin/SystemSettingsPage.tsx | 335 +++ .../src/screens/admin/TenantAdminPage.tsx | 129 + .../src/screens/admin/admin-modules.css | 406 +++ .../src/screens/admin/adminModulesApi.ts | 100 + .../src/screens/auction/AuctionDetailPage.tsx | 206 ++ .../src/screens/auction/AuctionListPage.tsx | 339 +++ .../src/screens/auction/AwardComparePage.tsx | 283 ++ .../src/screens/auction/aucShared.tsx | 29 + src/frontend/src/screens/auction/auction.css | 998 +++++++ .../src/screens/auction/sampleAuction.ts | 210 ++ .../src/screens/cms/CmsWorkflowPage.tsx | 306 ++ .../src/screens/cms/MicrositeBuilderPage.tsx | 359 +++ .../src/screens/cms/MultilingualCmsPage.tsx | 287 ++ src/frontend/src/screens/cms/cms.css | 1128 +++++++ .../ContractorBoothDashboardPage.tsx | 286 ++ .../src/screens/contractor/contractor.css | 308 ++ .../src/screens/docs/DocsMilestonePage.tsx | 326 ++ .../src/screens/docs/ReportAuthoringPage.tsx | 443 +++ src/frontend/src/screens/docs/docs.css | 600 ++++ .../src/screens/marketing/EdmCampaignPage.tsx | 235 ++ .../src/screens/marketing/SponsorshipPage.tsx | 173 ++ .../src/screens/marketing/marketing.css | 690 +++++ .../src/screens/marketing/sampleMarketing.ts | 185 ++ .../screens/movein/DockReservationPage.tsx | 395 +++ src/frontend/src/screens/movein/movein.css | 373 +++ .../screens/public/PublicEventDetailPage.tsx | 297 ++ .../screens/public/PublicFloorplanPage.tsx | 270 ++ .../src/screens/public/PublicHomePage.tsx | 248 ++ .../src/screens/public/PublicInquiryPage.tsx | 220 ++ .../screens/public/PublicMicrositePage.tsx | 154 + .../screens/public/PublicRegistrationPage.tsx | 259 ++ .../src/screens/public/PublicShell.tsx | 178 ++ .../src/screens/public/PublicTicketPage.tsx | 559 ++++ src/frontend/src/screens/public/index.ts | 12 + src/frontend/src/screens/public/public.css | 2626 +++++++++++++++++ .../src/screens/public/publicIcons.tsx | 314 ++ .../src/screens/visitor/LeadScoringPage.tsx | 226 ++ .../VisitorRegistrationDashboardPage.tsx | 277 ++ .../src/screens/visitor/sampleVisitor.ts | 233 ++ src/frontend/src/screens/visitor/visitor.css | 658 +++++ 44 files changed, 16228 insertions(+), 2 deletions(-) create mode 100644 src/frontend/src/screens/admin/AuditLogPage.tsx create mode 100644 src/frontend/src/screens/admin/RulesetVersionsPage.tsx create mode 100644 src/frontend/src/screens/admin/SystemSettingsPage.tsx create mode 100644 src/frontend/src/screens/admin/TenantAdminPage.tsx create mode 100644 src/frontend/src/screens/admin/admin-modules.css create mode 100644 src/frontend/src/screens/admin/adminModulesApi.ts create mode 100644 src/frontend/src/screens/auction/AuctionDetailPage.tsx create mode 100644 src/frontend/src/screens/auction/AuctionListPage.tsx create mode 100644 src/frontend/src/screens/auction/AwardComparePage.tsx create mode 100644 src/frontend/src/screens/auction/aucShared.tsx create mode 100644 src/frontend/src/screens/auction/auction.css create mode 100644 src/frontend/src/screens/auction/sampleAuction.ts create mode 100644 src/frontend/src/screens/cms/CmsWorkflowPage.tsx create mode 100644 src/frontend/src/screens/cms/MicrositeBuilderPage.tsx create mode 100644 src/frontend/src/screens/cms/MultilingualCmsPage.tsx create mode 100644 src/frontend/src/screens/cms/cms.css create mode 100644 src/frontend/src/screens/contractor/ContractorBoothDashboardPage.tsx create mode 100644 src/frontend/src/screens/contractor/contractor.css create mode 100644 src/frontend/src/screens/docs/DocsMilestonePage.tsx create mode 100644 src/frontend/src/screens/docs/ReportAuthoringPage.tsx create mode 100644 src/frontend/src/screens/docs/docs.css create mode 100644 src/frontend/src/screens/marketing/EdmCampaignPage.tsx create mode 100644 src/frontend/src/screens/marketing/SponsorshipPage.tsx create mode 100644 src/frontend/src/screens/marketing/marketing.css create mode 100644 src/frontend/src/screens/marketing/sampleMarketing.ts create mode 100644 src/frontend/src/screens/movein/DockReservationPage.tsx create mode 100644 src/frontend/src/screens/movein/movein.css create mode 100644 src/frontend/src/screens/public/PublicEventDetailPage.tsx create mode 100644 src/frontend/src/screens/public/PublicFloorplanPage.tsx create mode 100644 src/frontend/src/screens/public/PublicHomePage.tsx create mode 100644 src/frontend/src/screens/public/PublicInquiryPage.tsx create mode 100644 src/frontend/src/screens/public/PublicMicrositePage.tsx create mode 100644 src/frontend/src/screens/public/PublicRegistrationPage.tsx create mode 100644 src/frontend/src/screens/public/PublicShell.tsx create mode 100644 src/frontend/src/screens/public/PublicTicketPage.tsx create mode 100644 src/frontend/src/screens/public/index.ts create mode 100644 src/frontend/src/screens/public/public.css create mode 100644 src/frontend/src/screens/public/publicIcons.tsx create mode 100644 src/frontend/src/screens/visitor/LeadScoringPage.tsx create mode 100644 src/frontend/src/screens/visitor/VisitorRegistrationDashboardPage.tsx create mode 100644 src/frontend/src/screens/visitor/sampleVisitor.ts create mode 100644 src/frontend/src/screens/visitor/visitor.css diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index a0be737..4105604 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { Navigate, Outlet, Route, Routes } from 'react-router-dom'; +import { Navigate, Outlet, Route, Routes, useNavigate } from 'react-router-dom'; import { useAuthStore } from './store/authStore'; import { LoginPage } from './screens/login/LoginPage'; import { OtpSetupPage } from './screens/login/OtpSetupPage'; @@ -31,8 +31,41 @@ import { MeetingPage } from './screens/work/MeetingPage'; import { ReportPage } from './screens/work/ReportPage'; import { NotificationCenterPage } from './screens/work/NotificationCenterPage'; import { MyPage } from './screens/work/MyPage'; +import { DocsMilestonePage } from './screens/docs/DocsMilestonePage'; +import { ReportAuthoringPage } from './screens/docs/ReportAuthoringPage'; +import { DockReservationPage } from './screens/movein/DockReservationPage'; +import { AuctionListPage } from './screens/auction/AuctionListPage'; +import { AuctionDetailPage } from './screens/auction/AuctionDetailPage'; +import { AwardComparePage } from './screens/auction/AwardComparePage'; +import { ContractorBoothDashboardPage } from './screens/contractor/ContractorBoothDashboardPage'; +import { VisitorRegistrationDashboardPage } from './screens/visitor/VisitorRegistrationDashboardPage'; +import { LeadScoringPage } from './screens/visitor/LeadScoringPage'; +import { EdmCampaignPage } from './screens/marketing/EdmCampaignPage'; +import { SponsorshipPage } from './screens/marketing/SponsorshipPage'; +import { CmsWorkflowPage } from './screens/cms/CmsWorkflowPage'; +import { MicrositeBuilderPage } from './screens/cms/MicrositeBuilderPage'; +import { MultilingualCmsPage } from './screens/cms/MultilingualCmsPage'; +import { AuditLogPage } from './screens/admin/AuditLogPage'; +import { SystemSettingsPage } from './screens/admin/SystemSettingsPage'; +import { RulesetVersionsPage } from './screens/admin/RulesetVersionsPage'; +import { TenantAdminPage } from './screens/admin/TenantAdminPage'; +import { + PublicHomePage, + PublicEventDetailPage, + PublicFloorplanPage, + PublicRegistrationPage, + PublicMicrositePage, + PublicInquiryPage, + PublicTicketPage, +} from './screens/public'; import { AppShell } from './components/layout/AppShell'; +/** SCR-22 → SCR-23 작성 화면 진입 배선. */ +function DocsMilestoneRoute() { + const navigate = useNavigate(); + return navigate('/docs/authoring')} />; +} + /** 인증 가드 — 미인증 시 로그인으로. (역할별 라우팅은 화면 추가 시 확장) */ function RequireAuth({ children }: { children: React.ReactNode }) { const isAuthenticated = useAuthStore((s) => s.isAuthenticated); @@ -130,8 +163,43 @@ export function App() { {/* SCR-47 알림센터 · SCR-48 마이페이지 */} } /> } /> + + {/* SCR-22·23 서류·마일스톤 / 신고서류 작성 (M6 — 샘플) */} + } /> + } /> + {/* SCR-25 반입/반출 도크 슬롯 예약 (M8 — 샘플) */} + } /> + {/* SCR-26·27·29 공사/장치 옥션 (M15 — 샘플) · SCR-38 수주 부스 대시보드 */} + } /> + } /> + } /> + } /> + {/* SCR-30·32 관람객·리드 (M10 — 샘플) · SCR-33·34 마케팅 (M12 — 샘플) */} + } /> + } /> + } /> + } /> + {/* SCR-35~37 CMS (M17 — 샘플) */} + } /> + } /> + } /> + {/* SCR-A5~A9 관리자 모듈 (감사로그·설정=실배선, 룰셋·테넌트=샘플) */} + } /> + } /> + } /> + } /> + {/* SCR-P1~P7 공개 홍보 사이트 — 비로그인, 자체 PublicShell */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + {/* 모바일 전용 (390) — 셸 없이 전폭, 조회·현장 */} ; t { key: 'schedule', label: '전시 일정', Icon: IconCalendar, to: '/schedule' }, { key: 'operations', label: '현장운영', Icon: IconOperations, to: '/ops/operations' }, { key: 'analytics', label: '경영분석', Icon: IconAnalytics, to: '/analytics' }, - { key: 'documents', label: '서류·마일스톤', Icon: IconDocument }, + { key: 'documents', label: '서류·마일스톤', Icon: IconDocument, to: '/docs' }, { key: 'settlement', label: '정산', Icon: IconSettlement }, { key: 'admin', label: '관리자', Icon: IconSettings, to: '/admin' }, ]; +/** 도메인 모듈(SCR-25~38 이식분) — 백엔드 미구현 모듈은 화면에 "샘플 데이터" 배지 표기. */ +const DOMAIN_NAV: { key: string; label: string; Icon: ComponentType; to: string }[] = [ + { key: 'logistics', label: '반입·반출', Icon: IconOperations, to: '/logistics' }, + { key: 'auctions', label: '공사 옥션', Icon: IconSettlement, to: '/auctions' }, + { key: 'visitors', label: '관람객', Icon: IconUsers, to: '/visitors' }, + { key: 'leads', label: '리드', Icon: IconExhibitors, to: '/leads' }, + { key: 'campaigns', label: '마케팅', Icon: IconBell, to: '/campaigns' }, + { key: 'sponsorship', label: '스폰서십', Icon: IconSettlement, to: '/sponsorship' }, + { key: 'cms', label: '콘텐츠 CMS', Icon: IconDocument, to: '/cms' }, +]; + /** §5B 공통 업무 기능(SCR-39~48) — 전 역할 공통 영역. */ const WORK_NAV: { key: string; label: string; Icon: ComponentType; to: string }[] = [ { key: 'worklog', label: '업무일지', Icon: IconDocument, to: '/work/worklog' }, @@ -100,6 +111,21 @@ export function AppShell() { ))} +
도메인 모듈
+
    + {DOMAIN_NAV.map((item) => ( +
  • + `kx-shell__nav-link ${isActive ? 'is-active' : ''}`} + > + + {item.label} + +
  • + ))} +
+
업무 공통
    {WORK_NAV.map((item) => ( diff --git a/src/frontend/src/screens/admin/AuditLogPage.tsx b/src/frontend/src/screens/admin/AuditLogPage.tsx new file mode 100644 index 0000000..6bf4131 --- /dev/null +++ b/src/frontend/src/screens/admin/AuditLogPage.tsx @@ -0,0 +1,308 @@ +/* + * SCR-A5 감사로그 (M18 · §5B-1). Stitch scr_a5 이식 + 라이브 백엔드 실배선. + * 정본: GET /api/admin/audit?action&actorId&eventId&page&size → PageResponse (AuditLogController). + * 보안: 백엔드 DTO에 IP/자격증명 원문 미포함 → IP 컬럼 미표시(포렌식·PII 무결성). summary 는 백엔드가 정제(민감정보 제거). + * 상태 3종: 로딩 스켈레톤 / 빈 / 에러(재시도). 포렌식 무결성 원칙상 샘플 폴백을 두지 않는다. + * CSV 내보내기: 현재 페이지 로드분만 클라이언트 생성(서버측 전량 export 엔드포인트는 미제공 — 갭). + */ +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; +import { Button } from '../../components/ui/Button'; +import { IconDownload, IconSearch } from '../../components/ui/icons'; +import { auditApi, KNOWN_AUDIT_ACTIONS, type AuditLogRow } from './adminModulesApi'; +import './admin-modules.css'; + +const PAGE_SIZES = [20, 50, 100]; + +export function AuditLogPage() { + const [action, setAction] = useState(''); + const [actorId, setActorId] = useState(''); + const [eventId, setEventId] = useState(''); + const [size, setSize] = useState(50); + const [page, setPage] = useState(0); + // 실제 조회에 반영된 확정 필터(입력과 분리 — "필터 적용" 클릭 시 반영) + const [applied, setApplied] = useState<{ action: string; actorId: string; eventId: string }>({ + action: '', + actorId: '', + eventId: '', + }); + + const q = useQuery({ + queryKey: ['admin-audit', applied, page, size], + queryFn: () => + auditApi.search({ + action: applied.action || undefined, + actorId: applied.actorId || undefined, + eventId: applied.eventId || undefined, + page, + size, + }), + retry: false, + }); + + const rows = q.data?.items ?? []; + const total = q.data?.total ?? 0; + const totalPages = Math.max(1, Math.ceil(total / size)); + const from = total === 0 ? 0 : page * size + 1; + const to = Math.min(total, (page + 1) * size); + + function applyFilters() { + setPage(0); + setApplied({ action, actorId, eventId }); + } + function resetFilters() { + setAction(''); + setActorId(''); + setEventId(''); + setPage(0); + setApplied({ action: '', actorId: '', eventId: '' }); + } + + return ( +
    +
    +
    +

    감사 로그

    +

    + 승인·낙찰·설정변경 등 모든 관리 액션과 중요 도메인 이벤트를 추적합니다. (무결성 유지) +

    +
    + +
    + + {/* 필터 바 */} +
    +
    + + + +
    + + +
    +
    +
    + + {/* 본문 */} + {q.isLoading && } + + {q.isError && !q.isLoading && ( + q.refetch()} /> + )} + + {!q.isLoading && !q.isError && ( +
    + {rows.length === 0 ? ( + + ) : ( + <> +
    + + + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + + ))} + +
    시각사용자액션대상행사결과
    {fmtTs(r.createdAt)} +
    + + + {r.actorName ?? '—'} + {r.actorId && ( + {r.actorId} + )} + +
    +
    + {r.action ?? '—'} + {r.summary && {r.summary}} + + {r.targetType ? ( + + {r.targetType} + {r.targetId && ( + #{r.targetId} + )} + + ) : ( + + )} + {r.eventId ?? '—'} + +
    +
    + +
    +

    + 표시 중 {from}-{to} / 총{' '} + {total.toLocaleString()}개 +

    +
    + + + {page + 1} / {totalPages} + + +
    + +
    + + )} +
    + )} +
    + ); +} + +function ResultPill({ value }: { value: string | null }) { + const v = (value ?? '').toUpperCase(); + const tone = + v === 'SUCCESS' || v === 'OK' + ? 'success' + : v === 'FAILURE' || v === 'FAIL' || v === 'ERROR' || v === 'DENIED' + ? 'error' + : 'neutral'; + const label = tone === 'success' ? '성공' : tone === 'error' ? '실패' : value ?? '—'; + return {label}; +} + +function TableSkeleton() { + return ( + + ); +} + +function initial(name: string | null | undefined): string { + const s = (name ?? '').trim(); + return s ? s.charAt(0) : '?'; +} + +function fmtTs(s: string | null | undefined): string { + if (!s) return '—'; + const d = new Date(s); + if (Number.isNaN(d.getTime())) return s; + const p = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; +} + +/** 현재 로드된 페이지 rows 를 CSV 로 내보낸다. IP/자격증명 컬럼은 애초에 존재하지 않는다. */ +function exportCsv(rows: AuditLogRow[]): void { + if (rows.length === 0) return; + const headers = ['시각', '담당자ID', '담당자명', '액션', '대상유형', '대상ID', '행사ID', '결과', '요약']; + const esc = (v: unknown) => { + const s = v == null ? '' : String(v); + return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; + }; + const lines = [ + headers.join(','), + ...rows.map((r) => + [r.createdAt, r.actorId, r.actorName, r.action, r.targetType, r.targetId, r.eventId, r.result, r.summary] + .map(esc) + .join(','), + ), + ]; + const blob = new Blob(['' + lines.join('\r\n')], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `audit-log-${new Date().toISOString().slice(0, 10)}.csv`; + a.click(); + URL.revokeObjectURL(url); +} diff --git a/src/frontend/src/screens/admin/RulesetVersionsPage.tsx b/src/frontend/src/screens/admin/RulesetVersionsPage.tsx new file mode 100644 index 0000000..72566fa --- /dev/null +++ b/src/frontend/src/screens/admin/RulesetVersionsPage.tsx @@ -0,0 +1,164 @@ +/* + * SCR-A8 규정 룰셋 버전 관리 (M18). Stitch scr_a8 이식. + * 데이터 원천: 샘플(읽기 전용). 백엔드는 룰셋을 classpath 리소스(resources/rulesets/compliance-v1.json)로만 로드하고, + * 조회/버전 관리용 REST 컨트롤러가 없다(RuleSetLoader 는 내부 검증 엔진 전용). → 실배선 불가, 갭 문서화. + * 화면의 v1.1 룰 목록은 실제 compliance-v1.json 내용을 그대로 반영한 정적 샘플이다. + */ +import { useState } from 'react'; +import './admin-modules.css'; + +interface RuleItem { + code: string; + label: string; + value: string; + logic: string; + severity: 'block' | 'warn'; +} +interface RulesetVersion { + version: string; + effectiveDate: string; + status: 'current' | 'expired' | 'draft'; + note?: string; + rules: RuleItem[]; +} + +// 실제 compliance-v1.json(rulesetVersion=compliance-v1.1, effectiveDate=2026-07-12) 반영 — 읽기 전용 샘플. +const V11_RULES: RuleItem[] = [ + { code: 'HEIGHT_MAX', label: '장치물 높이', value: '≤ 5m', logic: '최대 높이 제한', severity: 'block' }, + { code: 'RIGGING_RANGE', label: '리깅 높이', value: '6.5–8.5m', logic: '구조계산서 제출(D-7) 필요', severity: 'warn' }, + { code: 'MEZZANINE_RATIO', label: '복층 부스', value: '≤ 1/2 면적', logic: '전체 점유 면적 대비', severity: 'block' }, + { code: 'FIRE_RETARDANT', label: '방염 성능', value: 'A급 필수', logic: '전 자재 방염', severity: 'block' }, + { code: 'FLOOR_LOAD', label: '바닥 하중', value: '홀6 2 · 홀7~10 5 t/㎡', logic: '홀별 하중 초과 금지', severity: 'block' }, + { code: 'AISLE_WIDTH_MIN', label: '피난 통로 폭', value: '≥ 3m', logic: 'PostGIS 산출값 대조', severity: 'block' }, + { code: 'EXIT_ACCESS', label: '비상구 접근성', value: '차단 0', logic: '부스가 비상구 차단 금지', severity: 'block' }, + { code: 'BOOTH_OVERLAP', label: '부스 겹침', value: '겹침 0', logic: 'ST_Intersects 무결성', severity: 'block' }, + { code: 'CLEARANCE_WALL', label: '벽 이격', value: '≥ 0.3m', logic: '인접 벽 이격', severity: 'warn' }, + { code: 'CLEARANCE_CEILING', label: '천장 이격', value: '≥ 0.6m', logic: '천장 이격', severity: 'warn' }, + { code: 'NOISE_LIMIT', label: '소음 규정', value: '≤ 75dB', logic: '장내 상시 기준', severity: 'warn' }, + { code: 'PROHIBITED_WORK', label: '금지 작업', value: '전기톱·용접·페인트', logic: '장내 금지작업 미포함', severity: 'warn' }, + { code: 'LIGHTING_BRING_IN', label: '조명 반입', value: '지정 조명만', logic: '조명 반입 금지', severity: 'warn' }, +]; + +const VERSIONS: RulesetVersion[] = [ + { + version: 'compliance-v1.1', + effectiveDate: '2026-07-12', + status: 'current', + note: '최신 전시 규정 통합본 (PostGIS 배치 무결성 규칙 추가)', + rules: V11_RULES, + }, + { + version: 'compliance-v1.0', + effectiveDate: '2026-01-01', + status: 'expired', + note: '초기 규정 룰셋', + rules: V11_RULES.slice(0, 4), + }, +]; + +export function RulesetVersionsPage() { + const [selected, setSelected] = useState(VERSIONS[0].version); + const current = VERSIONS.find((v) => v.version === selected) ?? VERSIONS[0]; + + return ( +
    +
    +
    +

    규정 룰셋 버전 관리

    +

    규정 개정 대응을 위한 룰셋 버전 이력과 규칙 항목입니다.

    +
    +
    + +
    + 샘플 · 읽기 전용 + 룰셋 조회/버전 관리 REST API가 아직 없어 화면은 현재 배포 룰셋(compliance-v1.json)의 정적 스냅샷을 + 표시합니다. 편집·버전 생성은 비활성입니다. +
    + +
    + {/* 좌: 버전 히스토리 */} + + + {/* 우: 규칙 목록 */} +
    +
    +
    +

    {current.version} 규칙 ({current.rules.length}개)

    +

    발효일 {current.effectiveDate}

    +
    + +
    +
    + + + + + + + + + + + {current.rules.map((r) => ( + + + + + + + ))} + +
    규칙명수치/설정값논리 / 요구사항위반 수준
    + {r.label} + {r.code} + {r.value}{r.logic} + + {r.severity === 'block' ? '차단' : '경고'} + +
    +
    +

    + 본 룰셋은 사전 필터이며 최종 승인은 킨텍스 및 구조기술사의 판단에 따릅니다. +

    +
    +
    +
    + ); +} + +function VersionBadge({ status }: { status: RulesetVersion['status'] }) { + const map = { + current: { cls: 'success', label: '현재 발효' }, + expired: { cls: 'neutral', label: '만료' }, + draft: { cls: 'warn', label: '초안' }, + } as const; + const m = map[status]; + return {m.label}; +} diff --git a/src/frontend/src/screens/admin/SystemSettingsPage.tsx b/src/frontend/src/screens/admin/SystemSettingsPage.tsx new file mode 100644 index 0000000..84346dc --- /dev/null +++ b/src/frontend/src/screens/admin/SystemSettingsPage.tsx @@ -0,0 +1,335 @@ +/* + * SCR-A6 시스템설정 (M18 · §5B-1). Stitch scr_a6 이식 + 라이브 백엔드 실배선. + * 정본: GET/POST /api/admin/settings, DELETE /{key} (SettingController). 백엔드는 범용 key/value 저장소. + * 보안: secretYn='Y' 값은 백엔드가 ******** 로 마스킹해 도착 → 화면은 마스킹 유지, 신규 입력이 있을 때만 저장 전송(원문 미노출·미상실). + * Stitch 의 그룹 카드(마감정책·AI게이트·알림·보안)는 개념 매핑 — 실제 렌더는 key 접두어(prefix)로 그룹화한 실데이터. + * 상태: 로딩 스켈레톤 / 빈 / 에러(재시도). + */ +import { useMemo, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; +import { Button } from '../../components/ui/Button'; +import { IconPlus } from '../../components/ui/icons'; +import { errMessage, useToast } from '../work/workShared'; +import { settingApi, type SettingRow, type SettingSaveBody } from './adminModulesApi'; +import './admin-modules.css'; + +const VALUE_TYPES = ['STRING', 'NUMBER', 'BOOLEAN', 'JSON']; + +export function SystemSettingsPage() { + const qc = useQueryClient(); + const { show, node: toast } = useToast(); + + const q = useQuery({ queryKey: ['admin-settings'], queryFn: settingApi.list, retry: false }); + + const saveM = useMutation({ + mutationFn: (body: SettingSaveBody) => settingApi.save(body), + onSuccess: () => { + show('설정을 저장했습니다.'); + qc.invalidateQueries({ queryKey: ['admin-settings'] }); + }, + onError: (e) => show(errMessage(e)), + }); + const delM = useMutation({ + mutationFn: (key: string) => settingApi.remove(key), + onSuccess: () => { + show('설정을 삭제했습니다.'); + qc.invalidateQueries({ queryKey: ['admin-settings'] }); + }, + onError: (e) => show(errMessage(e)), + }); + + const groups = useMemo(() => groupByPrefix(q.data ?? []), [q.data]); + + return ( +
    +
    +
    +

    시스템 설정

    +

    + 전역 정책·기능 토글·AI 게이트 등 시스템 파라미터를 관리합니다. 시크릿 값은 마스킹 표시됩니다. +

    +
    +
    + + {q.isLoading && } + + {q.isError && !q.isLoading && ( + q.refetch()} /> + )} + + {!q.isLoading && !q.isError && ( + <> + {(q.data ?? []).length === 0 ? ( + + ) : ( +
    + {groups.map((g) => ( + saveM.mutate(b)} + onDelete={(k) => { + if (window.confirm(`설정 '${k}' 을(를) 삭제하시겠습니까?`)) delM.mutate(k); + }} + /> + ))} +
    + )} + + saveM.mutate(b)} /> + + )} + {toast} +
    + ); +} + +function SettingGroupCard({ + name, + rows, + saving, + deleting, + onSave, + onDelete, +}: { + name: string; + rows: SettingRow[]; + saving: boolean; + deleting: boolean; + onSave: (b: SettingSaveBody) => void; + onDelete: (key: string) => void; +}) { + return ( +
    +
    + ); +} + +function SettingRowItem({ + row, + saving, + deleting, + onSave, + onDelete, +}: { + row: SettingRow; + saving: boolean; + deleting: boolean; + onSave: (b: SettingSaveBody) => void; + onDelete: (key: string) => void; +}) { + const isSecret = row.secretYn === 'Y'; + const isBool = (row.valueType ?? '').toUpperCase() === 'BOOLEAN'; + // 시크릿: 입력 비움 상태에서 시작(마스킹 값을 재저장하지 않기 위함). 비-시크릿: 현재값 편집. + const [val, setVal] = useState(isSecret ? '' : row.settingValue ?? ''); + const [dirty, setDirty] = useState(false); + + const boolChecked = String(row.settingValue ?? '').toLowerCase() === 'true'; + + function submit(nextValue: string) { + onSave({ + settingKey: row.settingKey, + settingValue: nextValue, + valueType: row.valueType, + description: row.description, + secretYn: row.secretYn, + }); + setDirty(false); + } + + return ( +
  • +
    + + {row.settingKey} + {isSecret && 시크릿} + {row.valueType && {row.valueType}} + + {row.description && {row.description}} +
    + +
    + {isBool ? ( + + ) : ( + <> + { + setVal(e.target.value); + setDirty(true); + }} + /> + + + )} + +
    + {isSecret && ( +

    + 시크릿 원문은 노출되지 않습니다. 값을 입력하지 않고 저장하면 기존 값이 유지됩니다. +

    + )} +
  • + ); +} + +function AddSettingCard({ + saving, + onSave, +}: { + saving: boolean; + onSave: (b: SettingSaveBody) => void; +}) { + const [key, setKey] = useState(''); + const [value, setValue] = useState(''); + const [type, setType] = useState('STRING'); + const [desc, setDesc] = useState(''); + const [secret, setSecret] = useState(false); + + const canSave = key.trim() !== ''; + + function submit() { + if (!canSave) return; + onSave({ + settingKey: key.trim(), + settingValue: value, + valueType: type, + description: desc || null, + secretYn: secret ? 'Y' : 'N', + }); + setKey(''); + setValue(''); + setDesc(''); + setSecret(false); + setType('STRING'); + } + + return ( +
    +

    설정 추가 / 재정의

    +
    + + + + + +
    + +
    +
    +
    + ); +} + +function SettingsSkeleton() { + return ( + + ); +} + +/** settingKey 의 '.' 앞 접두어로 그룹화. 접두어 없으면 '일반'. */ +function groupByPrefix(rows: SettingRow[]): { name: string; rows: SettingRow[] }[] { + const map = new Map(); + for (const r of rows) { + const dot = r.settingKey.indexOf('.'); + const name = dot > 0 ? r.settingKey.slice(0, dot) : '일반'; + const arr = map.get(name) ?? []; + arr.push(r); + map.set(name, arr); + } + return [...map.entries()] + .map(([name, rs]) => ({ name, rows: rs.sort((a, b) => a.settingKey.localeCompare(b.settingKey)) })) + .sort((a, b) => a.name.localeCompare(b.name)); +} diff --git a/src/frontend/src/screens/admin/TenantAdminPage.tsx b/src/frontend/src/screens/admin/TenantAdminPage.tsx new file mode 100644 index 0000000..8cc5447 --- /dev/null +++ b/src/frontend/src/screens/admin/TenantAdminPage.tsx @@ -0,0 +1,129 @@ +/* + * SCR-A9 테넌트(전시관) 관리·온보딩 (M18 · §1A · 플랫폼 슈퍼관리자 전용). Stitch scr_a9 이식. + * 데이터 원천: 샘플. 멀티테넌시(테넌트 CRUD·온보딩·격리)가 백엔드 미구현 → 실배선 대상 없음. 갭 문서화. + * 화면은 온보딩 위저드 IA 와 테넌트 목록 레이아웃을 확정해 후속 구현의 계약 참조로 남긴다. + */ +import { useState } from 'react'; +import './admin-modules.css'; + +interface TenantRow { + code: string; + name: string; + domain: string; + status: 'active' | 'onboarding' | 'suspended'; + events: number; + users: number; +} + +const TENANTS: TenantRow[] = [ + { code: 'KINTEX', name: '킨텍스', domain: 'kintex.wise.ai.kr', status: 'active', events: 24, users: 1280 }, + { code: 'COEX', name: '코엑스', domain: 'coex.wise.ai.kr', status: 'onboarding', events: 0, users: 3 }, + { code: 'BEXCO', name: '벡스코', domain: 'bexco.wise.ai.kr', status: 'suspended', events: 5, users: 210 }, +]; + +const WIZARD_STEPS = [ + { key: 'create', label: '테넌트 생성', desc: '코드·표시명 등록' }, + { key: 'domain', label: '서브도메인', desc: 'DNS·브랜딩 도메인 배정' }, + { key: 'brand', label: '브랜딩', desc: '로고·색상·공개사이트 테마' }, + { key: 'master', label: '마스터데이터 입력', desc: '홀·요율·룰셋 시드' }, + { key: 'admin', label: '관리자 계정', desc: '테넌트 관리자 초대' }, + { key: 'smoke', label: '격리 스모크', desc: '크로스-테넌트 격리 검증' }, + { key: 'activate', label: '활성화', desc: '운영 전환' }, +]; + +export function TenantAdminPage() { + const [selected, setSelected] = useState(TENANTS[1].code); + const tenant = TENANTS.find((t) => t.code === selected) ?? TENANTS[0]; + // 온보딩 진행 단계(샘플): active=완료 / onboarding=4단계 진행 / suspended=완료 후 중지 + const currentStep = tenant.status === 'onboarding' ? 3 : WIZARD_STEPS.length; + + return ( +
    +
    +
    +

    테넌트 관리·온보딩

    +

    플랫폼 슈퍼관리자 전용 · 코드 배포 없이 데이터 온보딩으로 전시관을 추가합니다.

    +
    +
    + +
    + 샘플 · 미구현 + 멀티테넌시(테넌트 CRUD·온보딩·격리)가 백엔드에 아직 없어 화면은 목표 IA 를 표시합니다. 실 데이터 연동은 + 멀티테넌시 도입 후 배선됩니다. +
    + +
    +
    +
    +

    전시관

    + +
    +
    + + + + + + + + + + + + + {TENANTS.map((t) => ( + setSelected(t.code)} + style={{ cursor: 'pointer' }} + > + + + + + + + + ))} + +
    전시관코드도메인상태행사사용자
    {t.name}{t.code}{t.domain}{t.events}{t.users.toLocaleString()}
    +
    +
    + + +
    +
    + ); +} + +function TenantStatus({ status }: { status: TenantRow['status'] }) { + const map = { + active: { cls: 'success', label: '활성' }, + onboarding: { cls: 'warn', label: '온보딩중' }, + suspended: { cls: 'error', label: '중지' }, + } as const; + const m = map[status]; + return {m.label}; +} diff --git a/src/frontend/src/screens/admin/admin-modules.css b/src/frontend/src/screens/admin/admin-modules.css new file mode 100644 index 0000000..d4e335c --- /dev/null +++ b/src/frontend/src/screens/admin/admin-modules.css @@ -0,0 +1,406 @@ +/* + * M18 관리자 모듈(SCR-A5·A6·A8·A9) 전용 스타일. design.md §1 토큰만 사용. + * shared.css 프리미티브(kx-page·kx-card·kx-table·kx-select 등) 선반영. + */ +@import '../shared.css'; + +/* ── 공통 헤더 ── */ +.kx-adm__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-4); + flex-wrap: wrap; +} +.kx-adm__title { + font-size: var(--fs-h1); + line-height: var(--lh-h1); + font-weight: 700; + color: var(--color-neutral-900); +} +.kx-adm__subtitle { + margin-top: 4px; + font-size: var(--fs-body); + color: var(--color-neutral-500); + max-width: 720px; +} + +/* ── 폼 필드 프리미티브 ── */ +.kx-field { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} +.kx-field__label { + font-size: var(--fs-caption); + font-weight: 600; + color: var(--color-neutral-500); +} +.kx-field__input { + height: 36px; + padding: 0 var(--space-3); + border: var(--border-card); + border-radius: var(--radius-sm); + background: var(--color-white); + font-size: var(--fs-body); + color: var(--color-neutral-900); + width: 100%; +} +.kx-field__input:focus { + outline: 2px solid var(--color-primary-100); + border-color: var(--color-primary-600); +} + +/* ── 상태 pill ── */ +.kx-adm-pill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 10px; + border-radius: var(--radius-pill); + font-size: var(--fs-caption); + font-weight: 700; + white-space: nowrap; +} +.kx-adm-pill--success { color: var(--color-success); background: #e6f4ee; } +.kx-adm-pill--error { color: var(--color-error); background: #fef3f2; } +.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-muted { color: var(--color-neutral-500); } +.kx-adm-mono { + font-family: ui-monospace, 'JetBrains Mono', 'Menlo', monospace; + font-size: var(--fs-mono); + color: var(--color-neutral-700); + white-space: nowrap; +} + +/* ── 샘플/degraded 배너 ── */ +.kx-adm-sample-banner { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + border: 1px dashed var(--color-neutral-200); + border-radius: var(--radius-lg); + background: var(--color-neutral-050); + font-size: var(--fs-body); + color: var(--color-neutral-700); +} + +/* ═══════════ SCR-A5 감사로그 ═══════════ */ +.kx-adm-filter__grid { + display: grid; + grid-template-columns: repeat(3, 1fr) auto; + gap: var(--space-4); + align-items: end; +} +.kx-adm-filter__actions { + display: flex; + gap: var(--space-2); + justify-content: flex-end; +} +.kx-adm-table-card { padding: 0; overflow: hidden; } + +.kx-adm-audit { width: 100%; border-collapse: collapse; } +.kx-adm-audit th, +.kx-adm-audit td { + padding: var(--space-3) var(--space-4); + text-align: left; + font-size: var(--fs-body); + vertical-align: top; +} +.kx-adm-audit thead th { + background: var(--color-neutral-100); + font-size: var(--fs-caption); + font-weight: 700; + color: var(--color-neutral-500); + text-transform: uppercase; + letter-spacing: 0.03em; + border-bottom: var(--border-card); +} +.kx-adm-audit tbody tr { border-bottom: 1px solid var(--color-neutral-100); } +.kx-adm-audit tbody tr:hover { background: var(--color-primary-050); } + +.kx-adm-actor { display: flex; align-items: center; gap: var(--space-2); } +.kx-adm-actor__avatar { + width: 26px; + height: 26px; + flex: 0 0 26px; + border-radius: var(--radius-pill); + background: var(--color-primary-100); + color: var(--color-primary-700); + font-size: 11px; + font-weight: 700; + display: inline-flex; + align-items: center; + justify-content: center; +} +.kx-adm-actor__body { display: flex; flex-direction: column; } +.kx-adm-actor__name { font-weight: 600; color: var(--color-neutral-900); } +.kx-adm-actor__id { font-size: var(--fs-caption); color: var(--color-neutral-500); } + +.kx-adm-action { + display: inline-block; + font-weight: 600; + color: var(--color-neutral-900); +} +.kx-adm-summary { + display: block; + margin-top: 2px; + font-size: var(--fs-caption); + color: var(--color-neutral-500); + max-width: 320px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.kx-adm-target { display: flex; flex-direction: column; } +.kx-adm-target__type { font-weight: 600; color: var(--color-neutral-700); } +.kx-adm-target__id { font-size: var(--fs-caption); color: var(--color-neutral-500); } + +.kx-adm-pager { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + padding: var(--space-3) var(--space-4); + border-top: var(--border-card); + background: var(--color-neutral-050); + flex-wrap: wrap; +} +.kx-adm-pager__info { font-size: var(--fs-caption); color: var(--color-neutral-500); } +.kx-adm-pager__nav { display: flex; align-items: center; gap: var(--space-3); } +.kx-adm-pager__page { font-size: var(--fs-body); font-weight: 600; color: var(--color-neutral-700); } +.kx-adm-pager__size { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: var(--fs-caption); + color: var(--color-neutral-500); +} + +/* ═══════════ SCR-A6 시스템설정 ═══════════ */ +.kx-adm-settings { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(420px, 1fr)); + gap: var(--space-4); +} +.kx-adm-group { padding: 0; overflow: hidden; display: flex; } +.kx-adm-group__accent { width: 4px; flex: 0 0 4px; background: var(--color-primary-600); } +.kx-adm-group__body { flex: 1; min-width: 0; padding: var(--space-4); } +.kx-adm-group__title { + font-size: var(--fs-h3); + font-weight: 700; + color: var(--color-neutral-900); + margin-bottom: var(--space-3); + text-transform: capitalize; +} +.kx-adm-setrows { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-3); } +.kx-adm-setrow { + padding-bottom: var(--space-3); + border-bottom: 1px solid var(--color-neutral-100); +} +.kx-adm-setrow:last-child { border-bottom: none; padding-bottom: 0; } +.kx-adm-setrow__meta { display: flex; flex-direction: column; gap: 2px; margin-bottom: 8px; } +.kx-adm-setrow__key { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + font-size: var(--fs-body); + font-weight: 600; + color: var(--color-neutral-900); + font-family: ui-monospace, 'JetBrains Mono', monospace; +} +.kx-adm-setrow__desc { font-size: var(--fs-caption); color: var(--color-neutral-500); } +.kx-adm-setrow__control { display: flex; align-items: center; gap: var(--space-2); } +.kx-adm-setrow__input { flex: 1; min-width: 0; } +.kx-adm-setrow__hint { margin-top: 6px; font-size: 11px; color: var(--color-neutral-500); } +.kx-adm-secret { + font-size: 10px; + font-weight: 700; + color: var(--color-warning); + background: #fff4e5; + padding: 1px 6px; + border-radius: var(--radius-pill); +} +.kx-adm-typechip { + font-size: 10px; + font-weight: 700; + color: var(--color-neutral-500); + background: var(--color-neutral-100); + padding: 1px 6px; + border-radius: var(--radius-pill); +} +.kx-adm-del { color: var(--color-error); } + +/* boolean switch */ +.kx-adm-switch { display: inline-flex; align-items: center; gap: var(--space-2); cursor: pointer; } +.kx-adm-switch input { position: absolute; opacity: 0; width: 0; height: 0; } +.kx-adm-switch__track { + width: 40px; + height: 22px; + border-radius: var(--radius-pill); + background: var(--color-neutral-200); + position: relative; + transition: background 0.15s; + flex: 0 0 40px; +} +.kx-adm-switch__track::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 18px; + height: 18px; + border-radius: var(--radius-pill); + background: var(--color-white); + transition: transform 0.15s; +} +.kx-adm-switch input:checked + .kx-adm-switch__track { background: var(--color-primary-600); } +.kx-adm-switch input:checked + .kx-adm-switch__track::after { transform: translateX(18px); } +.kx-adm-switch input:focus-visible + .kx-adm-switch__track { outline: 2px solid var(--color-primary-100); } +.kx-adm-switch__label { font-size: var(--fs-caption); color: var(--color-neutral-700); } + +.kx-adm-add__title { font-size: var(--fs-h3); font-weight: 700; color: var(--color-neutral-900); margin-bottom: var(--space-3); } +.kx-adm-add__grid { + display: grid; + grid-template-columns: 1.4fr 1.4fr 0.8fr; + gap: var(--space-4); + align-items: end; +} +.kx-adm-add__desc { grid-column: 1 / -1; } +.kx-adm-add__actions { grid-column: 1 / -1; display: flex; justify-content: flex-end; } +.kx-adm-check { + display: flex; + align-items: center; + gap: 8px; + font-size: var(--fs-body); + color: var(--color-neutral-700); +} + +/* ═══════════ SCR-A8 룰셋 버전 ═══════════ */ +.kx-adm-ruleset { display: grid; grid-template-columns: 300px 1fr; gap: var(--space-4); align-items: start; } +.kx-adm-ruleset__versions { display: flex; flex-direction: column; gap: var(--space-3); } +.kx-adm-ruleset__vhead { display: flex; align-items: center; justify-content: space-between; } +.kx-adm-ruleset__vhead h2 { font-size: var(--fs-h3); font-weight: 700; color: var(--color-neutral-900); } +.kx-adm-vlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-2); } +.kx-adm-vitem { + width: 100%; + text-align: left; + display: flex; + flex-direction: column; + gap: 4px; + padding: var(--space-3); + border: var(--border-card); + border-left: 4px solid transparent; + border-radius: var(--radius-lg); + background: var(--color-white); + cursor: pointer; +} +.kx-adm-vitem:hover { background: var(--color-neutral-050); } +.kx-adm-vitem.is-active { border-left-color: var(--color-primary-600); background: var(--color-primary-050); } +.kx-adm-vitem__top { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); } +.kx-adm-vitem__top strong { font-size: var(--fs-body); color: var(--color-neutral-900); } +.kx-adm-vitem__date { font-size: var(--fs-caption); color: var(--color-neutral-500); } +.kx-adm-vitem__note { font-size: var(--fs-caption); color: var(--color-neutral-500); font-style: italic; } + +.kx-adm-ruleset__editor { padding: 0; overflow: hidden; } +.kx-adm-ruleset__ehead { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + padding: var(--space-4); + border-bottom: var(--border-card); +} +.kx-adm-ruleset__ehead h2 { font-size: var(--fs-h3); font-weight: 700; color: var(--color-neutral-900); } +.kx-adm-rules { width: 100%; border-collapse: collapse; } +.kx-adm-rules th, .kx-adm-rules td { padding: var(--space-3) var(--space-4); text-align: left; font-size: var(--fs-body); } +.kx-adm-rules thead th { + background: var(--color-neutral-100); + font-size: var(--fs-caption); + font-weight: 700; + color: var(--color-neutral-500); + text-transform: uppercase; +} +.kx-adm-rules tbody tr { border-bottom: 1px solid var(--color-neutral-100); } +.kx-adm-rulename { display: block; font-weight: 600; color: var(--color-neutral-900); } +.kx-adm-rulecode { display: block; font-size: 11px; color: var(--color-neutral-500); font-family: ui-monospace, monospace; } +.kx-adm-disclaimer { padding: var(--space-3) var(--space-4); font-size: var(--fs-caption); color: var(--color-neutral-500); border-top: var(--border-card); } + +/* ═══════════ SCR-A9 테넌트 ═══════════ */ +.kx-adm-tenant { display: grid; grid-template-columns: 1fr 340px; gap: var(--space-4); align-items: start; } +.kx-adm-tenant__list { padding: 0; overflow: hidden; } +.kx-adm-tenant__lhead { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-4); + border-bottom: var(--border-card); +} +.kx-adm-tenant__lhead h2 { font-size: var(--fs-h3); font-weight: 700; color: var(--color-neutral-900); } +.kx-adm-tenants { width: 100%; border-collapse: collapse; } +.kx-adm-tenants th, .kx-adm-tenants td { padding: var(--space-3) var(--space-4); text-align: left; font-size: var(--fs-body); } +.kx-adm-tenants thead th { + background: var(--color-neutral-100); + font-size: var(--fs-caption); + font-weight: 700; + color: var(--color-neutral-500); + text-transform: uppercase; +} +.kx-adm-tenants tbody tr { border-bottom: 1px solid var(--color-neutral-100); } +.kx-adm-tenants tbody tr:hover { background: var(--color-primary-050); } +.kx-adm-tenants tbody tr.is-selected { background: var(--color-primary-050); } +.kx-adm-tenants__name { font-weight: 600; color: var(--color-neutral-900); } + +.kx-adm-tenant__wizard { display: flex; flex-direction: column; gap: 4px; } +.kx-adm-wiz__title { font-size: var(--fs-h3); font-weight: 700; color: var(--color-neutral-900); } +.kx-adm-wiz { list-style: none; margin: var(--space-3) 0 0; padding: 0; display: flex; flex-direction: column; } +.kx-adm-wiz__step { display: flex; gap: var(--space-3); padding-bottom: var(--space-4); position: relative; } +.kx-adm-wiz__step:not(:last-child)::before { + content: ''; + position: absolute; + left: 13px; + top: 28px; + bottom: 0; + width: 2px; + background: var(--color-neutral-200); +} +.kx-adm-wiz__dot { + width: 28px; + height: 28px; + flex: 0 0 28px; + border-radius: var(--radius-pill); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: var(--fs-caption); + font-weight: 700; + background: var(--color-neutral-100); + color: var(--color-neutral-500); + z-index: 1; +} +.kx-adm-wiz__step.is-done .kx-adm-wiz__dot { background: var(--color-success); color: var(--color-white); } +.kx-adm-wiz__step.is-current .kx-adm-wiz__dot { background: var(--color-primary-600); color: var(--color-white); } +.kx-adm-wiz__body { display: flex; flex-direction: column; gap: 2px; padding-top: 3px; } +.kx-adm-wiz__label { font-size: var(--fs-body); font-weight: 600; color: var(--color-neutral-900); } +.kx-adm-wiz__step.is-todo .kx-adm-wiz__label { color: var(--color-neutral-500); } +.kx-adm-wiz__desc { font-size: var(--fs-caption); color: var(--color-neutral-500); } + +/* ── 반응형 ── */ +@media (max-width: 1024px) { + .kx-adm-filter__grid { grid-template-columns: 1fr 1fr; } + .kx-adm-filter__actions { grid-column: 1 / -1; } + .kx-adm-ruleset { grid-template-columns: 1fr; } + .kx-adm-tenant { grid-template-columns: 1fr; } + .kx-adm-add__grid { grid-template-columns: 1fr 1fr; } +} +@media (max-width: 640px) { + .kx-adm-filter__grid { grid-template-columns: 1fr; } + .kx-adm-settings { grid-template-columns: 1fr; } + .kx-adm-add__grid { grid-template-columns: 1fr; } + .kx-adm-pager { flex-direction: column; align-items: stretch; } +} diff --git a/src/frontend/src/screens/admin/adminModulesApi.ts b/src/frontend/src/screens/admin/adminModulesApi.ts new file mode 100644 index 0000000..3ee5f38 --- /dev/null +++ b/src/frontend/src/screens/admin/adminModulesApi.ts @@ -0,0 +1,100 @@ +/* + * M18 관리자 모듈(SCR-A5·A6) 실배선 API 계약. + * 공용 클라이언트(../../api/client)만 사용 — client.ts·endpoints.ts 무수정. + * 정본: 백엔드 컨트롤러 시그니처 + * - GET /api/admin/audit → ApiResponse> (AuditLogController) + * - GET /api/admin/settings, GET /{key}, POST, DELETE /{key} (SettingController) + * ★ 감사로그 DTO에는 자격증명·IP 원문이 포함되지 않는다(백엔드가 ipHint=null·민감정보 제외로 기록). + * 설정 DTO는 secretYn='Y' 값이 백엔드에서 마스킹(********)되어 도착한다. + */ +import { api } from '../../api/client'; +import type { PageResponse } from '../../api/types'; + +// ── SCR-A5 감사로그 ── +/** 감사 로그 행(조회). 민감정보 미포함(계약 §0-3/§7-3). */ +export interface AuditLogRow { + id: number; + actorId: string | null; + actorName: string | null; + action: string | null; + targetType: string | null; + targetId: string | null; + eventId: string | null; + summary: string | null; + result: string | null; + createdAt: string | null; +} + +export interface AuditSearchParams { + action?: string; + actorId?: string; + eventId?: string; + page?: number; + size?: number; +} + +export const auditApi = { + search: (p: AuditSearchParams = {}) => { + const qs = new URLSearchParams(); + if (p.action) qs.set('action', p.action); + if (p.actorId) qs.set('actorId', p.actorId); + if (p.eventId) qs.set('eventId', p.eventId); + qs.set('page', String(p.page ?? 0)); + qs.set('size', String(p.size ?? 20)); + return api.get>(`/api/admin/audit?${qs.toString()}`); + }, +}; + +/** 백엔드 @Audited 액션 상수(datalist 힌트) — 필터 자동완성용. */ +export const KNOWN_AUDIT_ACTIONS = [ + 'LOGIN_SLIDE_CREATE', + 'LOGIN_SLIDE_UPDATE', + 'LOGIN_SLIDE_DELETE', + 'SETTING_SAVE', + 'SETTING_DELETE', + 'ROLE_SAVE', + 'ROLE_DELETE', + 'MENU_SAVE', + 'MENU_DELETE', + 'CODE_SAVE', + 'CODE_DELETE', + 'CODE_GROUP_SAVE', + 'CODE_GROUP_DELETE', + 'NOTICE_CREATE', + 'NOTICE_UPDATE', + 'NOTICE_DELETE', + 'OPINION_COMMENT', + 'OPINION_STATUS', + 'SYS_USER_CREATE', + 'SYS_USER_UPDATE', + 'SYS_USER_STATUS', + 'SYS_USER_PW_RESET', + 'SYS_USER_OTP_RESET', +] as const; + +// ── SCR-A6 시스템설정 ── +/** 시스템 설정 항목. secretYn='Y' 이면 settingValue 는 마스킹(********)되어 전달된다(원문 미노출). */ +export interface SettingRow { + settingKey: string; + settingValue: string | null; + valueType: string | null; + description: string | null; + secretYn: string | null; +} + +export interface SettingSaveBody { + settingKey: string; + settingValue?: string | null; + valueType?: string | null; + description?: string | null; + secretYn?: string | null; +} + +/** 백엔드 마스킹 값(SettingService.MASK) — 마스킹된 시크릿은 미변경 시 저장 전송 제외. */ +export const SETTING_MASK = '********'; + +export const settingApi = { + list: () => api.get('/api/admin/settings'), + save: (body: SettingSaveBody) => api.post('/api/admin/settings', body), + remove: (key: string) => api.del(`/api/admin/settings/${encodeURIComponent(key)}`), +}; diff --git a/src/frontend/src/screens/auction/AuctionDetailPage.tsx b/src/frontend/src/screens/auction/AuctionDetailPage.tsx new file mode 100644 index 0000000..3857c7b --- /dev/null +++ b/src/frontend/src/screens/auction/AuctionDetailPage.tsx @@ -0,0 +1,206 @@ +/* + * SCR-27 옥션 상세·실시간 순위·응찰 [M15]. Stitch scr_27_auction_detail 이식. + * 대상: 장치업체(응찰). 좌 AI 자료 뷰어(배치·설계·BOQ·예상이미지) + 우 실시간 순위·응찰. + * ★ M15 엔진 미구현 → SAMPLE_RANKS 정적 데이터. 실 API/WebSocket 없음(카운트다운은 로컬 데모). + * 보안(봉인 입찰): 마감 전 경쟁 견적 금액 비공개 — 현재 최저가(공개 벤치마크)와 내 견적만 노출, + * 그 외 타사 금액은 "비공개" 마스킹. "익명 순위" 토글로 순위만 확인. + * 응찰은 등록업체 게이트(안내) + 로컬 상태 데모("백엔드 연동 예정"). + */ +import { useEffect, useState } from 'react'; +import { Button } from '../../components/ui/Button'; +import { AiLabel } from '../../components/ui/Badge'; +import { IconDocument, IconExpand, IconImage, IconPlus } from '../../components/ui/icons'; +import { + REFERENCE_TABS, + SAMPLE_RANKS, + formatWon, + type MaterialKind, + type RankRow, +} from './sampleAuction'; +import { SampleBadge, useToast } from './aucShared'; +import './auction.css'; + +const REF_DESC: Record = { + layout: { + title: '배치도 (M2)', + desc: 'A-102 부스 위치·주변 통로·트렌치 좌표가 표시된 홀 배치도입니다.', + }, + design: { + title: '부스 설계안 (M3)', + desc: '선택·병합된 최종 부스 설계 초안(3D/평면)입니다.', + }, + boq: { + title: '물량서 (M4 · BOQ)', + desc: '공종·자재·수량·규격이 정리된 시공 물량 산출서입니다.', + }, + aiimage: { + title: '예상 이미지 (M5)', + desc: '나노바나나로 생성한 시공 후 예상 결과 이미지입니다.', + ai: true, + }, +}; + +export function AuctionDetailPage() { + const { show, node: toast } = useToast(); + const [tab, setTab] = useState('aiimage'); + const [anon, setAnon] = useState(true); + const [seconds, setSeconds] = useState(5076); // 01:24:36 + + useEffect(() => { + const id = window.setInterval(() => setSeconds((s) => (s > 0 ? s - 1 : 0)), 1000); + return () => window.clearInterval(id); + }, []); + + const active = REF_DESC[tab]; + + return ( +
    + {/* 상단 컨텍스트 바 */} +
    +
    + + +

    + A-102 독립부스 시공 +

    + 역경매 + 라운드 2 + +
    +
    +
    + 상태 + 활성 응찰 중 +
    +
    + 현재 최저가 + {formatWon(8_400_000)} +
    +
    +
    + +
    + {/* 좌 — AI 자료 뷰어 */} +
    +
    + {REFERENCE_TABS.map((t) => ( + + ))} +
    +
    +
    + {tab === 'aiimage' ? : } +

    {active.title}

    +

    {active.desc}

    + {active.ai && AI 생성 예상 이미지} +
    + {active.ai && AI 생성 예상 이미지} +
    + +
    +
    +
    + + {/* 우 — 실시간 순위·응찰 */} + +
    + {toast} +
    + ); +} + +function RankRowItem({ row, anon }: { row: RankRow; anon: boolean }) { + // 봉인 규칙: 내 견적 + 현재 최저가(공개 벤치마크)만 금액 노출, 그 외 타사는 마스킹. + const revealPrice = row.isMe || row.isLowest; + const cls = row.isMe ? 'kx-rank__row--me' : row.isLowest ? 'kx-rank__row--lowest' : ''; + const name = anon ? (row.isMe ? '나의 응찰 (ME)' : row.alias) : row.isMe ? '나의 응찰 (ME)' : `(주)협력사 ${row.rank}`; + return ( +
    + {row.rank} +
    +

    {name}

    + {revealPrice ? ( +

    {formatWon(row.price)}

    + ) : ( +

    + 비공개 · 봉인 +

    + )} +
    + {row.isLowest && 최저가} + {row.isMe && ( + + 나의 순위 + + )} +
    + ); +} + +function fmtClock(total: number): string { + const h = Math.floor(total / 3600); + const m = Math.floor((total % 3600) / 60); + const s = total % 60; + const p = (n: number) => n.toString().padStart(2, '0'); + return `${p(h)}:${p(m)}:${p(s)}`; +} diff --git a/src/frontend/src/screens/auction/AuctionListPage.tsx b/src/frontend/src/screens/auction/AuctionListPage.tsx new file mode 100644 index 0000000..e329b90 --- /dev/null +++ b/src/frontend/src/screens/auction/AuctionListPage.tsx @@ -0,0 +1,339 @@ +/* + * SCR-26 공사/장치 옥션 목록·개설 [M15]. Stitch scr_26_auction_list 이식. + * 대상: 참가업체·주최자(발주). 좌 옥션 카드 목록 + 우 옥션 개설 패널. + * ★ M15 엔진 미구현 → SAMPLE_AUCTIONS 정적 데이터("샘플 데이터" 배지). 실 API fetch 없음. + * 개설·초대는 로컬 상태 데모 + "백엔드 연동 예정" 안내. + * 보안: 등록업체만 초대 가능(verified 게이트) — 미등록 업체 체크박스 비활성. + */ +import { useMemo, useState, type ReactNode } from 'react'; +import { Button } from '../../components/ui/Button'; +import { DdayChip } from '../../components/ui/Badge'; +import { EmptyState } from '../../components/ui/States'; +import { + IconPlus, + IconDocument, + IconFloorplan, + IconImage, + IconSpark, + IconSettings, +} from '../../components/ui/icons'; +import { + INVITABLE_COMPANIES, + MATERIAL_LABEL, + SAMPLE_AUCTIONS, + formatWon, + type AuctionStatus, + type AuctionSummary, + type AwardCriteria, + type MaterialKind, +} from './sampleAuction'; +import { SampleBadge, useToast } from './aucShared'; +import './auction.css'; + +type TabKey = 'live' | 'closed' | 'mine'; +const TABS: { key: TabKey; label: string }[] = [ + { key: 'live', label: '진행중' }, + { key: 'closed', label: '마감' }, + { key: 'mine', label: '내가 개설' }, +]; + +const MAT_ICON: Record = { + layout: , + design: , + boq: , + aiimage: , +}; + +export function AuctionListPage() { + const { show, node: toast } = useToast(); + const [tab, setTab] = useState('live'); + const [criteria, setCriteria] = useState('comprehensive'); + const [weights, setWeights] = useState({ price: 60, reputation: 25, delivery: 15 }); + const [invited, setInvited] = useState>({ + '(주)에이치디자인': true, + }); + + const visible = useMemo(() => { + if (tab === 'closed') return SAMPLE_AUCTIONS.filter((a) => a.status !== '진행중'); + if (tab === 'mine') return SAMPLE_AUCTIONS.filter((a) => a.type === '역경매'); + return SAMPLE_AUCTIONS.filter((a) => a.status === '진행중'); + }, [tab]); + + return ( +
    +
    +
    +
    +

    공사·장치 옥션

    + +
    +

    AI 설계자료(M2~M5) 기반 역경매 · 등록업체 응찰

    +
    +
    +
    + {TABS.map((t) => ( + + ))} +
    +
    +
    + +
    + {/* 좌 — 옥션 카드 목록 */} +
    + {visible.length === 0 ? ( + + ) : ( + visible.map((a) => show(`${a.title} 상세 — 백엔드 연동 예정`)} />) + )} +
    + + {/* 우 — 옥션 개설 패널 */} + +
    + {toast} +
    + ); +} + +function WeightSlider({ + label, + value, + onChange, +}: { + label: string; + value: number; + onChange: (v: number) => void; +}) { + return ( +
    +
    + {label} + {value}% +
    + onChange(Number(e.target.value))} + aria-label={`${label} 가중치`} + /> +
    + ); +} + +function StatusPill({ status }: { status: AuctionStatus }) { + if (status === '진행중') return 진행중; + if (status === '정산중') return 정산 중; + return 마감 완료; +} + +function AuctionCard({ auction, onOpen }: { auction: AuctionSummary; onOpen: () => void }) { + const closed = auction.status !== '진행중'; + return ( +
    +
    +
    +
    + {auction.category} + + {auction.type} + +
    +

    {auction.title}

    +
    +
    + {closed ? ( + 마감 완료 + ) : ( + + )} + +
    +
    + +
    +
    + + {auction.awardedCompany ? '낙찰 업체' : '응찰 현황'} + + + {auction.awardedCompany ?? `${auction.bidderCount}개 업체 응찰 중`} + +
    +
    + + {auction.finalPrice ? '최종 낙찰가' : '현재 최저가'} + + + {auction.finalPrice != null + ? formatWon(auction.finalPrice) + : auction.lowestPrice != null + ? formatWon(auction.lowestPrice) + : '—'} + +
    +
    + + {!closed && ( +
    +
    + {auction.materials.map((m) => ( + + {MAT_ICON[m]} + {MATERIAL_LABEL[m]} + + ))} +
    + +
    + )} +
    + ); +} diff --git a/src/frontend/src/screens/auction/AwardComparePage.tsx b/src/frontend/src/screens/auction/AwardComparePage.tsx new file mode 100644 index 0000000..39ff7ac --- /dev/null +++ b/src/frontend/src/screens/auction/AwardComparePage.tsx @@ -0,0 +1,283 @@ +/* + * SCR-29 낙찰 비교·선정(Award) [M15]. Stitch scr_29_award_compare 이식. + * 대상: 발주자(주최자·참가업체). 마감 후 견적서 비교 → 낙찰 → 계약·발주 전환. + * ★ M15 엔진 미구현 → SAMPLE_QUOTES 정적 데이터. 낙찰 승인은 로컬 상태 데모("백엔드 연동 예정"). + * 보안: 마감 후 발주자 뷰이므로 전 견적 공개(봉인 해제). 낙찰 사유 필수 입력 후 승인. + */ +import { useState } from 'react'; +import { Button } from '../../components/ui/Button'; +import { AiLabel } from '../../components/ui/Badge'; +import { EmptyState } from '../../components/ui/States'; +import { IconAiBot, IconCheckCircle, IconDownload, IconWarning } from '../../components/ui/icons'; +import { RISK_BARS, SAMPLE_QUOTES, formatWon, type QuoteColumn } from './sampleAuction'; +import { SampleBadge, useToast } from './aucShared'; +import './auction.css'; + +export function AwardComparePage() { + const { show, node: toast } = useToast(); + const quotes = SAMPLE_QUOTES; + const recommended = quotes.find((q) => q.recommended) ?? quotes[0]; + const [reason, setReason] = useState(''); + const [awarded, setAwarded] = useState(false); + + const onAward = () => { + if (!reason.trim()) { + show('선정 사유를 입력해 주세요.'); + return; + } + setAwarded(true); + show(`${recommended.alias} 낙찰 승인 — 계약·발주 전환은 백엔드 연동 예정`); + }; + + return ( +
    +
    +
    +
    +

    역경매 응찰 비교 및 낙찰

    + +
    +

    A-102 독립부스 시공 · 마감 후 발주자 뷰(봉인 해제)

    +
    + +
    + +
    +
    + 공고명 + A-102 독립부스 시공 +
    +
    + 응찰 현황 + 총 {quotes.length}개사 참여 +
    +
    + 낙찰 기준 + 종합평가 (가격 40% + 역량 60%) +
    +
    + + {quotes.length === 0 ? ( + + ) : ( +
    +
    + {/* 비교 매트릭스 */} +
    +
    +

    견적서 비교표

    + AI 가중 스코어 +
    +
    + + + + + {quotes.map((q) => ( + + ))} + + + + + + {quotes.map((q) => ( + + ))} + + + + {quotes.map((q) => ( + + ))} + + + + {quotes.map((q) => { + const best = q.reputation === Math.max(...quotes.map((x) => x.reputation)); + return ( + + ); + })} + + + + {quotes.map((q) => ( + + ))} + + +
    비교 항목 + {q.recommended && 최적 추천} + {q.alias} + {q.subtitle} +
    총액 (VAT 별도) + {formatWon(q.totalPrice)} + + {q.isLowestPrice ? '▼ 최저가' : `+${q.priceDeltaPct}% 차이`} + +
    납기 (시공 완료) + {q.leadDays}일 + {q.isShortestLead && ( + 최단 납기 + )} +
    업체 평판 (최근 1년) + {' '} + {q.reputation.toFixed(1)} +
    종합 점수 + {q.recommended ? ( + {q.score.toFixed(1)} + ) : ( + + {q.score.toFixed(1)} + + )} +
    +
    +
    + + {/* AI 리스크 분석 */} +
    +
    +

    AI 시뮬레이션 · 리스크 분석

    + AI 분석 +
    +
    +
      + {RISK_BARS.map((b) => ( +
    • +
      + {b.label} + + {b.pct}% + +
      +
      + +
      +
    • + ))} +
    +
    + AI 최종 등급 + A+ + 안정적 시공 보장 +
    +
    +

    + 업체 A는 최근 3년 KINTEX 내 유사 규모(100~150㎡) 시공 12회 수행, 클레임 발생률 0%를 + 기록했습니다. +

    +
    +
    + + {/* 우 — 낙찰 선정 패널 */} +