diff --git a/docs/WORK_STATUS.md b/docs/WORK_STATUS.md index 1c2d020..42224b3 100644 --- a/docs/WORK_STATUS.md +++ b/docs/WORK_STATUS.md @@ -99,6 +99,7 @@ | 2026-07-12 | **Stitch 전 화면 확보 완료(64/64)** — 서비스 회복 후 수확 패스 재실행: 잔여 39화면 전부 서버측 지연 생성 완료 상태(프로젝트 27→155 화면)로 재생성 0회·다운로드 43파일(보조 변형 5 포함: P4 3단계·P7 예매확인·M15 권종선택) 실패 0. `_workspace/stitch_gen/{harvest2.py,screen_titles.txt,harvest_report.md}`. 다음: frontend/mobile dev 이식 + design.md 🔲미생성 마커 갱신(designer 경유) | | 2026-07-12 | **실데이터·2FA 트랙 마감** — ①실데이터 집계 API 5패키지(analytics·dashboard·ops·admin·catalog + V13 인덱스) ②TOTP 2FA 완결(SecretCipher AES-256-GCM·V12·OtpChallengePanel/OtpSetupPage·OTP_ENFORCE env) ③공통 업무기능 SCR-39~48 화면(`screens/work/`) 배선 ④M2 부스 겹침 검사(BOOTH_OVERLAP·compliance-v1.1) + 단위테스트 3종. 검증: backend compileJava+test·frontend tsc 통과, QA PASS(blocker/major 0, minor 2=RIGGING_RANGE semantics 소유자 결정 대기·운영 KINTEX_OTP_ENC_KEY 주입 게이트 — `_workspace/09_qa_track_close.md`). 계약 갭 기록: `_workspace/{07_work_api_gaps,08_m2m5_contract_changes}.md` | | 2026-07-12 | **Stitch 확보 화면 이식 완료(웹 24 + 공개 7단계플로우 + 모바일 1)** — 에이전트 7팀 병렬(폴더 소유권 분리·공유파일 통합자 단일 배선). ①도메인: SCR-22/23/25(서류·신고서류·도크, M6/M8 샘플)·SCR-26/27/29/38(옥션 3종+수주 대시보드, M15 샘플, 봉인입찰 마스킹·등록업체만 응찰 UI)·SCR-30/32/33/34(관람객·리드·EDM·스폰서십, M10/M12 샘플, PII 마스킹)·SCR-35/36/37(CMS 3종, M17 샘플) ②관리자: **A5 감사로그·A6 시스템설정 = 라이브 백엔드 실배선**(PageResponse·시크릿 마스킹), A8 룰셋(정적 v1.1 스냅샷)·A9 테넌트(샘플) ③공개: P1~P7 자체 PublicShell(비인증 /public/*·/tickets/*, PG위임 고지) ④모바일: M15 티켓지갑(mobile/app/tickets, QR placeholder) ⑤통합: App 라우트 18종+AppShell "도메인 모듈" 네비. tsc -b EXIT 0·QA PASS(minor 2 코스메틱: kx-field 접두, ★/✓ 글리프). 각 팀 API 계약 초안 = `_workspace/port_*.md`. 커밋 becbc55 | +| 2026-07-12 | **7차 웨이브(MDI+테넌시2단계+i18n 스윕2)** — ①MDI 셸: 탭 문서 바(`MdiTabBar`+`mdiStore` sessionStorage·라우트 동기화·dedup·MAX 10·키보드 접근성·다크 토큰) 순증 레이어 — 잔여: DocumentHost 상태 보존(§2.7-3)·드래그 재정렬 ②테넌시 2단계: JWT `tid` 클레임(하위호환 폴백)·`EventAccessGuard` 테넌트 fail-closed(교차 테넌트 403 — `EventTenantMapper`+격리 테스트 3종)·전역 집계(analytics/admin/sysuser) 명시 스코핑 — company·audit_log는 Phase 3 후보 ③i18n 스윕2: 코어+admin 17화면·네임스페이스 30종(4개 언어) — 잔여 P2(도메인 모듈·work 공통)·P3(공유 ui) 목록=_workspace/impl_i18n_sweep2.md. 검증: tsc·vite·compileJava·test(75개) 전부 EXIT 0 | | 2026-07-12 | **5·6차 웨이브(NFR+멀티테넌시) + 운영 도메인 라이브** — ①W5-1 다크테마(global.css `--dk-*`→`[data-theme=dark]` remap·시스템 선호 폴백·themeStore(localStorage)·AppShell 토글·공유 CSS 하드코딩 hex 토큰화·focus-ring/reduced-motion/스킵링크) ②W5-2 i18n(react-i18next ko/en/zh/ja·공개 P1~P7+로그인/가입/비번찾기 전 문구·LanguageSwitcher(공개셸+로그인+AppShell 배선)·fallback ko) ③W6 멀티테넌시 1단계(V26 tenant 마스터+event/hall/app_user tenant_id 순증·TenantContextFilter(X-Tenant-Id→Host→kintex)·catalog/hall 스코핑·`/api/admin/tenants` GET/POST 실배선 — 회귀 0) ④**운영 도메인 https://kintex.wise.ai.kr 라이브**(위 §2 참조). 검증: tsc·vite·compileJava·test 전부 EXIT 0 | | 2026-07-12 | **4차 웨이브(백로그 4트랙) + ★OTP 로그인 루프 라이브 장애 수정** — ①**OTP 장애**(사용자 보고 "로그인 후 메인화면 안 나옴"): `verifyOtp`/`enrollVerify`가 코드 검증 **전에** 챌린지를 소비 → 오입력 1회면 이후 정답도 "세션 만료" 루프. 수정=성공 시에만 소비+실패 5회 한도(`OtpChallengeStore.recordFailure`)+`OtpChallengeStoreTest` ②SCR-14 현장운영 실전환(혼잡=체크인 파생·전력=utility 부하, IoT는 "센서 미연동" 정직 표기) ③M9 정산 신설(`/api/settlement`·V24 invoice/invoice_payment·`/settlement` 화면, ST_Area×요율+VAT 서버 권위·PG위임) ④M1 홀배정+자동견적(`/api/halls`·V25 기간 컬럼 순증·충돌 409+상세·rates-v1.0 견적·`/halls` 화면) ⑤공유 레이어: AuthUser.roleCode 순증 노출(JWT `rc` 클레임)+AdminGuard 정밀화+AppShell 플레이스홀더 네비 전량 배선(행사 컨텍스트 계산 경로)+`/_styleguide` prod 제외. 검증: compileJava·test·tsc -b --force·vite build 전부 EXIT 0 | | 2026-07-12 | **3차 웨이브(백로그 3트랙) + 배포 크래시 근본수정** — ①크래시 규명: c6f3a8d jar가 MyBatis ` """) - List> search(@Param("keyword") String keyword, @Param("status") String status, + List> search(@Param("tenantId") String tenantId, + @Param("keyword") String keyword, @Param("status") String status, @Param("roleCode") String roleCode, @Param("size") int size, @Param("offset") int offset); @@ -44,6 +46,7 @@ public interface SysUserMapper { """) - long count(@Param("keyword") String keyword, @Param("status") String status, + long count(@Param("tenantId") String tenantId, + @Param("keyword") String keyword, @Param("status") String status, @Param("roleCode") String roleCode); @Select(""" @@ -64,9 +68,9 @@ public interface SysUserMapper { @org.apache.ibatis.annotations.Insert(""" INSERT INTO app_user (id, email, display_name, password_hash, hall_manager, - role_code, dept_id, status, verify_method) + role_code, dept_id, status, verify_method, tenant_id) VALUES (#{userId}, #{email}, #{displayName}, #{passwordHash}, #{hallManager}, - #{roleCode}, #{deptId}, 'ACTIVE', 'EMAIL') + #{roleCode}, #{deptId}, 'ACTIVE', 'EMAIL', #{tenantId}) """) int insert(Map p); diff --git a/src/backend/src/main/java/com/zioinfo/kintex/system/sysuser/SysUserService.java b/src/backend/src/main/java/com/zioinfo/kintex/system/sysuser/SysUserService.java index cd50d3f..216dab0 100644 --- a/src/backend/src/main/java/com/zioinfo/kintex/system/sysuser/SysUserService.java +++ b/src/backend/src/main/java/com/zioinfo/kintex/system/sysuser/SysUserService.java @@ -4,6 +4,7 @@ import com.zioinfo.kintex.common.PageResponse; import com.zioinfo.kintex.common.error.ApiException; import com.zioinfo.kintex.common.error.ErrorCode; import com.zioinfo.kintex.security.AccountSecurityMapper; +import com.zioinfo.kintex.tenant.TenantContext; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -34,8 +35,10 @@ public class SysUserService { public PageResponse list(String keyword, String status, String roleCode, int page, int size) { int p = Math.max(page, 0); int s = size <= 0 ? 20 : Math.min(size, 200); - List> rows = mapper.search(keyword, status, roleCode, s, p * s); - long total = mapper.count(keyword, status, roleCode); + // 사용자 목록은 현재 테넌트로 격리(§8-2). 기준 테넌트('kintex') 단일 운영 시 회귀 0. + String tenantId = TenantContext.currentTenantId(); + List> rows = mapper.search(tenantId, keyword, status, roleCode, s, p * s); + long total = mapper.count(tenantId, keyword, status, roleCode); return PageResponse.of(rows.stream().map(SysUserService::toDto).toList(), p, s, total); } @@ -61,6 +64,8 @@ public class SysUserService { p.put("hallManager", Boolean.TRUE.equals(req.hallManager())); p.put("roleCode", req.roleCode() == null ? "USER" : req.roleCode()); p.put("deptId", req.deptId()); + // 소속 테넌트는 클라이언트 입력이 아닌 컨텍스트에서 세팅(위조 차단, §8-2). + p.put("tenantId", TenantContext.currentTenantId()); mapper.insert(p); return get(id); } diff --git a/src/backend/src/main/java/com/zioinfo/kintex/tenant/EventTenantMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/tenant/EventTenantMapper.java new file mode 100644 index 0000000..e803cc4 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/tenant/EventTenantMapper.java @@ -0,0 +1,19 @@ +package com.zioinfo.kintex.tenant; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +/** + * 행사→테넌트 소속 조회 매퍼 (멀티테넌시 2단계 fail-closed 가드용, §1A-3-2). + * {@code event_id} 직접 조회 경로에서 대상 행사가 요청자의 테넌트에 속하는지 검증한다 + * ({@link com.zioinfo.kintex.auth.EventAccessGuard}). 홀매니저처럼 전 행사 접근권이 넓은 + * 주체의 교차-테넌트 열람을 차단한다. {@code event.tenant_id}는 인덱스({@code idx_event_tenant}) 선행 컬럼. + */ +@Mapper +public interface EventTenantMapper { + + /** 행사의 소속 테넌트 코드. 없으면(행사 미존재) null. */ + @Select("SELECT tenant_id FROM event WHERE id = #{eventId}") + String findEventTenant(@Param("eventId") String eventId); +} diff --git a/src/backend/src/test/java/com/zioinfo/kintex/analytics/AnalyticsServiceTest.java b/src/backend/src/test/java/com/zioinfo/kintex/analytics/AnalyticsServiceTest.java index 7e3b2cf..2a8d38c 100644 --- a/src/backend/src/test/java/com/zioinfo/kintex/analytics/AnalyticsServiceTest.java +++ b/src/backend/src/test/java/com/zioinfo/kintex/analytics/AnalyticsServiceTest.java @@ -12,6 +12,8 @@ import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -69,28 +71,29 @@ class AnalyticsServiceTest { @Test void overviewComputesRetentionOccupancyAndForecast() { + // 행사 횡단 집계는 테넌트 스코프(currentTenantId 기본 'kintex') — 매처는 테넌트 무관 검증. when(mapper.findRentalRatePerM2()).thenReturn(2000.0); - when(mapper.countHalls()).thenReturn(10L); - when(mapper.countEvents()).thenReturn(1098L); - when(mapper.findUtilityRevenueByEvent()).thenReturn(List.of()); + when(mapper.countHalls(anyString())).thenReturn(10L); + when(mapper.countEvents(anyString())).thenReturn(1098L); + when(mapper.findUtilityRevenueByEvent(anyString())).thenReturn(List.of()); Map ev = new HashMap<>(); ev.put("eventId", "e1"); ev.put("eventName", "스마트팩토리"); ev.put("areaM2", 50000.0); ev.put("boothCount", 300L); - when(mapper.findEventAreaRanking(8)).thenReturn(List.of(ev)); + when(mapper.findEventAreaRanking(anyString(), eq(8))).thenReturn(List.of(ev)); Map occ = new HashMap<>(); occ.put("period", "2026-02"); // 28일 occ.put("bookedDays", 56L); // 56 / (10*28)=20% - when(mapper.findMonthlyEventDays(60)).thenReturn(List.of(occ)); + when(mapper.findMonthlyEventDays(anyString(), eq(60))).thenReturn(List.of(occ)); Map retSum = new HashMap<>(); retSum.put("totalCompanies", 10L); retSum.put("returningCompanies", 4L); // 40% - when(mapper.findRetentionSummary()).thenReturn(retSum); - when(mapper.findRetentionByYear()).thenReturn(List.of()); + when(mapper.findRetentionSummary(anyString())).thenReturn(retSum); + when(mapper.findRetentionByYear(anyString())).thenReturn(List.of()); // 연 3건씩 증가하는 완벽한 선형 → 다음해 투영 = 마지막+3. Map y1 = new HashMap<>(); @@ -99,7 +102,7 @@ class AnalyticsServiceTest { y2.put("yr", 2025); y2.put("eventCount", 103L); Map y3 = new HashMap<>(); y3.put("yr", 2026); y3.put("eventCount", 106L); - when(mapper.findYearlyEventCounts()).thenReturn(List.of(y1, y2, y3)); + when(mapper.findYearlyEventCounts(anyString())).thenReturn(List.of(y1, y2, y3)); AnalyticsOverviewDto dto = service.getOverview(5); diff --git a/src/backend/src/test/java/com/zioinfo/kintex/auth/EventAccessGuardTenantTest.java b/src/backend/src/test/java/com/zioinfo/kintex/auth/EventAccessGuardTenantTest.java new file mode 100644 index 0000000..6088d83 --- /dev/null +++ b/src/backend/src/test/java/com/zioinfo/kintex/auth/EventAccessGuardTenantTest.java @@ -0,0 +1,63 @@ +package com.zioinfo.kintex.auth; + +import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; +import com.zioinfo.kintex.tenant.EventTenantMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 멀티테넌시 2단계 격리 단위테스트 — {@link EventAccessGuard}가 교차-테넌트 행사 접근을 fail-closed로 차단한다(§1A-3-2). + * 홀매니저처럼 전 행사 접근권(hasAccess=true)이 넓은 주체도 다른 테넌트 행사는 열람 불가여야 한다. + */ +class EventAccessGuardTenantTest { + + private EventTenantMapper eventTenantMapper; + private EventAccessGuard guard; + + @BeforeEach + void setUp() { + eventTenantMapper = mock(EventTenantMapper.class); + guard = new EventAccessGuard(eventTenantMapper); + } + + /** 홀매니저(전 행사 접근권)라도 타 테넌트 행사면 403 — 교차-테넌트 격리. */ + @Test + void blocksCrossTenantEventAccessEvenForHallManager() { + KintexPrincipal kintexHallManager = new KintexPrincipal( + "u-kintex", "홀매니저", Map.of(), true, "MANAGER", "kintex"); + when(eventTenantMapper.findEventTenant("ev-coex")).thenReturn("coex"); + + ApiException ex = assertThrows(ApiException.class, + () -> guard.requireEventAccess(kintexHallManager, "ev-coex")); + assertEquals(ErrorCode.FORBIDDEN, ex.getCode()); + } + + /** 동일 테넌트 행사는 정상 통과. */ + @Test + void allowsSameTenantEventAccess() { + KintexPrincipal kintexHallManager = new KintexPrincipal( + "u-kintex", "홀매니저", Map.of(), true, "MANAGER", "kintex"); + when(eventTenantMapper.findEventTenant("ev-kintex")).thenReturn("kintex"); + + assertDoesNotThrow(() -> guard.requireEventAccess(kintexHallManager, "ev-kintex")); + } + + /** 행사 미존재(테넌트 미확인)는 기존 접근권 판정을 그대로 유지(회귀 0) — 여기선 통과. */ + @Test + void passesWhenEventTenantUnknown() { + KintexPrincipal principal = new KintexPrincipal( + "u-kintex", "홀매니저", Map.of(), true, "MANAGER", "kintex"); + when(eventTenantMapper.findEventTenant("ev-missing")).thenReturn(null); + + assertDoesNotThrow(() -> guard.requireEventAccess(principal, "ev-missing")); + } +} diff --git a/src/frontend/src/components/layout/AppShell.tsx b/src/frontend/src/components/layout/AppShell.tsx index 84d551b..c044323 100644 --- a/src/frontend/src/components/layout/AppShell.tsx +++ b/src/frontend/src/components/layout/AppShell.tsx @@ -1,7 +1,11 @@ import type { ComponentType } from 'react'; -import { NavLink, Outlet, useNavigate } from 'react-router-dom'; +import { useEffect } from 'react'; +import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'; import { useAuthStore } from '../../store/authStore'; import { useThemeStore } from '../../store/themeStore'; +import { useMdiStore } from '../../store/mdiStore'; +import { MdiTabBar } from './MdiTabBar'; +import { labelForPath } from './mdiLabels'; import { useResolvedEventId } from '../../hooks/useResolvedEventId'; import { DdayChip } from '../ui/Badge'; import { LanguageSwitcher } from '../ui/LanguageSwitcher'; @@ -99,9 +103,20 @@ export function AppShell() { const themeResolved = useThemeStore((s) => s.resolved); const toggleTheme = useThemeStore((s) => s.toggle); const navigate = useNavigate(); + const location = useLocation(); + const openTab = useMdiStore((s) => s.openTab); const resolvedEventId = useResolvedEventId(); const isDark = themeResolved === 'dark'; + /** + * 라우트 → MDI 탭 동기화(§2.7): 셸 안의 모든 라우트 진입(네비 클릭·화면 내 링크·딥링크)이 + * 문서 탭을 열거나 활성 전환한다. openTab 은 dedup(중복 경로면 활성만) + 상한 관리를 담당한다. + * navigate 는 여기서 호출하지 않는다(탭 상호작용·네비가 이미 라우팅을 트리거) → 루프 없음. + */ + useEffect(() => { + openTab(location.pathname, labelForPath(location.pathname)); + }, [location.pathname, openTab]); + /** 행사 스코프 항목 진입 — 대상 행사 해소 시 계산 경로로, 미해소 시 행사 선택(/login)으로. */ function goScoped(key: string) { const build = EVENT_SCOPED_PATH[key]; @@ -243,6 +258,8 @@ export function AppShell() { + + diff --git a/src/frontend/src/components/layout/MdiTabBar.tsx b/src/frontend/src/components/layout/MdiTabBar.tsx new file mode 100644 index 0000000..01b1b74 --- /dev/null +++ b/src/frontend/src/components/layout/MdiTabBar.tsx @@ -0,0 +1,84 @@ +import { useRef } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { useMdiStore } from '../../store/mdiStore'; +import { IconClose } from '../ui/icons'; + +/** + * MDI 문서 탭 스트립 (design.md §2.7-2) — 유틸리티 바 아래 전폭. + * + * 탭 = 라우트 북마크: 열린 문서 경로를 좌→우로 나열하고, 클릭 시 해당 라우트로 이동한다. + * 탭 추가/활성은 AppShell 의 라우트 동기화 이펙트(openTab)가 담당하고, 여기서는 표시·상호작용만 맡는다. + * 키보드: role=tablist/tab · aria-selected · 좌/우 화살표로 인접 탭 이동(포커스+활성). + * 모바일(<720px)에서는 CSS 로 숨김. + */ +export function MdiTabBar() { + const tabs = useMdiStore((s) => s.tabs); + const activePath = useMdiStore((s) => s.activePath); + const closeTab = useMdiStore((s) => s.closeTab); + const navigate = useNavigate(); + const location = useLocation(); + const btnRefs = useRef<(HTMLButtonElement | null)[]>([]); + + if (tabs.length === 0) return null; + + function activate(path: string) { + if (path !== location.pathname) navigate(path); + } + + function handleClose(e: React.MouseEvent, path: string) { + e.stopPropagation(); + const nextActive = closeTab(path); + // 활성 탭을 닫아 활성 경로가 바뀌었으면 그 경로로 이동(현재와 다를 때만). + if (nextActive && nextActive !== location.pathname) navigate(nextActive); + } + + function handleKeyDown(e: React.KeyboardEvent, index: number) { + if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; + e.preventDefault(); + const dir = e.key === 'ArrowRight' ? 1 : -1; + const nextIndex = (index + dir + tabs.length) % tabs.length; + const target = tabs[nextIndex]; + btnRefs.current[nextIndex]?.focus(); + activate(target.path); + } + + return ( + + {tabs.map((tab, i) => { + const isActive = tab.path === activePath; + return ( + + { + btnRefs.current[i] = el; + }} + className="kx-mdi__tab-btn" + title={tab.label} + onClick={() => activate(tab.path)} + onKeyDown={(e) => handleKeyDown(e, i)} + > + {tab.label} + + handleClose(e, tab.path)} + > + + + + ); + })} + + ); +} diff --git a/src/frontend/src/components/layout/app-shell.css b/src/frontend/src/components/layout/app-shell.css index ec6cae1..cac39b8 100644 --- a/src/frontend/src/components/layout/app-shell.css +++ b/src/frontend/src/components/layout/app-shell.css @@ -250,3 +250,87 @@ overflow: hidden; display: flex; } + +/* ── MDI 문서 탭 스트립 (design.md §2.7-2) ── */ +.kx-mdi { + flex-shrink: 0; + display: flex; + align-items: stretch; + gap: 2px; + height: 40px; + padding: 0 var(--space-3); + background: var(--color-neutral-050); + border-bottom: var(--border-card); + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; +} +.kx-mdi__tab { + display: inline-flex; + align-items: center; + gap: 2px; + max-width: 200px; + padding: 0 var(--space-2) 0 var(--space-3); + border: 1px solid transparent; + border-bottom: none; + border-top-left-radius: var(--radius-sm); + border-top-right-radius: var(--radius-sm); + align-self: flex-end; + height: 34px; +} +.kx-mdi__tab:hover { + background: var(--color-neutral-100); +} +/* 활성 탭 — 흰 서피스 + 상단 2px primary 인디케이터(§2.7-2). */ +.kx-mdi__tab.is-active { + background: var(--color-white); + border-color: var(--color-neutral-200); + box-shadow: inset 0 2px 0 var(--color-primary-600); +} +.kx-mdi__tab-btn { + min-width: 0; + flex: 1; + display: inline-flex; + align-items: center; + border: none; + background: transparent; + cursor: pointer; + padding: 0; + font-family: inherit; + font-size: var(--fs-caption); + font-weight: 500; + color: var(--color-neutral-500); +} +.kx-mdi__tab.is-active .kx-mdi__tab-btn { + color: var(--color-neutral-900); + font-weight: 600; +} +.kx-mdi__tab-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.kx-mdi__tab-close { + flex-shrink: 0; + width: 18px; + height: 18px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-neutral-500); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +} +.kx-mdi__tab-close:hover { + background: var(--color-neutral-200); + color: var(--color-neutral-900); +} + +/* 모바일 폭에서는 탭 스트립 숨김(단일 화면 포커스 — §2.7-6). */ +@media (max-width: 720px) { + .kx-mdi { + display: none; + } +} diff --git a/src/frontend/src/components/layout/mdiLabels.ts b/src/frontend/src/components/layout/mdiLabels.ts new file mode 100644 index 0000000..a2135fb --- /dev/null +++ b/src/frontend/src/components/layout/mdiLabels.ts @@ -0,0 +1,112 @@ +/* + * 라우트 → 화면명(탭 라벨) 매핑 — MDI 탭 라벨 해소(design.md §2.7-2). + * + * 1) 정적 경로: 사이드바 네비 정의(AppShell)와 정합하는 라벨 테이블. + * 2) 행사 스코프 동적 경로(/events/:eventId/...): 패턴 매칭. + * 3) 미등록 경로: 경로 세그먼트 기반 축약 폴백(:id 유사 세그먼트 제외). + * + * screens/** 를 import 하지 않는다(라벨은 여기서 단일 관리). 네비 라벨과 동일 문자열을 사용해 + * 사이드바 활성 표시와 탭 라벨이 일치하도록 한다. + */ + +/** 정적(정확 일치) 경로 → 라벨. AppShell NAV/DOMAIN/WORK 정의와 동일 문자열. */ +const STATIC_LABELS: Record = { + // 주 메뉴 + '/schedule': '전시 일정', + '/ops/operations': '현장운영', + '/analytics': '경영분석', + '/docs': '서류·마일스톤', + '/settlement': '정산', + '/admin': '관리자', + // 도메인 모듈 + '/halls': '홀 배정', + '/logistics': '반입·반출', + '/auctions': '공사 옥션', + '/visitors': '관람객', + '/visitors/checkin': '체크인 데스크', + '/leads': '리드', + '/campaigns': '마케팅', + '/sponsorship': '스폰서십', + '/cms': '콘텐츠 CMS', + '/cms/microsite': '마이크로사이트 빌더', + '/cms/i18n': '다국어 CMS', + // 업무 공통 + '/work/worklog': '업무일지', + '/work/schedule': '일정', + '/work/message': '쪽지', + '/work/notice': '공지', + '/work/meeting': '회의록', + '/work/report': '업무보고', + '/work/opinion': '의견접수', + '/work/search': '통합검색', + // 개인·알림 + '/notifications': '알림센터', + '/me': '마이페이지', + // 서류·수주 + '/docs/authoring': '신고서류 작성', + '/contractor/dashboard': '수주 부스', + // 관리자 하위 + '/admin/login-slides': '로그인 슬라이드', + '/admin/audit': '감사 로그', + '/admin/settings': '시스템 설정', + '/admin/masterdata/rulesets': '룰셋 버전', + '/admin/tenants': '테넌트 관리', + '/admin/users': '사용자 관리', + '/admin/roles': '역할 관리', + '/admin/codes': '공통 코드', + '/admin/menus': '메뉴 관리', +}; + +/** 동적 경로 패턴(정규식) → 라벨. 구체적 규칙을 먼저 둔다. */ +const PATTERN_LABELS: { re: RegExp; label: string }[] = [ + { re: /^\/events\/[^/]+\/dashboard$/, label: '대시보드' }, + { re: /^\/events\/[^/]+\/booths\/[^/]+\/home$/, label: '참가업체 홈' }, + { re: /^\/events\/[^/]+\/halls\/[^/]+\/layout$/, label: '플로어플랜 스튜디오' }, + { re: /^\/events\/[^/]+\/halls\/[^/]+\/comparison$/, label: 'AI 배치안 비교' }, + { re: /^\/events\/[^/]+\/booths\/[^/]+\/design$/, label: '부스 설계 스튜디오' }, + { re: /^\/events\/[^/]+\/booths\/[^/]+\/utility\/order$/, label: '유틸리티 신청' }, + { re: /^\/events\/[^/]+\/booths\/[^/]+\/utility$/, label: '유틸리티 배선' }, + { re: /^\/events\/[^/]+\/booths\/[^/]+\/compliance$/, label: '규정 검증' }, + { re: /^\/events\/[^/]+\/halls\/[^/]+\/booths\/[^/]+\/review$/, label: '검수 상세' }, + { re: /^\/events\/[^/]+\/halls\/[^/]+\/approvals$/, label: '검수 대기열' }, + { re: /^\/events\/[^/]+\/booths\/[^/]+\/gallery$/, label: '시각화 갤러리' }, + { re: /^\/auctions\/[^/]+\/award$/, label: '낙찰 비교' }, + { re: /^\/auctions\/[^/]+$/, label: '옥션 상세' }, +]; + +/** 폴백 세그먼트 → 라벨(경로 기반 축약에 사용). */ +const SEGMENT_LABELS: Record = { + dashboard: '대시보드', + layout: '플로어플랜', + design: '설계', + utility: '배선', + gallery: '갤러리', + approvals: '검수', + review: '검수 상세', + admin: '관리자', + work: '업무', + cms: 'CMS', + auctions: '옥션', +}; + +/** id 유사 세그먼트(폴백 라벨에서 제외) — UUID·숫자·짧은 코드. */ +function isIdLike(seg: string): boolean { + return /^[0-9]+$/.test(seg) || /^[0-9a-fA-F-]{8,}$/.test(seg) || /^[A-Z]\d+$/.test(seg); +} + +/** 라우트 경로 → 탭 라벨 해소. */ +export function labelForPath(path: string): string { + const clean = path.split('?')[0].split('#')[0]; + if (STATIC_LABELS[clean]) return STATIC_LABELS[clean]; + for (const { re, label } of PATTERN_LABELS) { + if (re.test(clean)) return label; + } + // 폴백: 뒤에서부터 id 아닌 첫 세그먼트를 라벨화. + const segs = clean.split('/').filter(Boolean); + for (let i = segs.length - 1; i >= 0; i -= 1) { + const seg = segs[i]; + if (isIdLike(seg)) continue; + return SEGMENT_LABELS[seg] ?? seg; + } + return '문서'; +} diff --git a/src/frontend/src/i18n/locales/en.json b/src/frontend/src/i18n/locales/en.json index 57adcce..ced444f 100644 --- a/src/frontend/src/i18n/locales/en.json +++ b/src/frontend/src/i18n/locales/en.json @@ -14,6 +14,17 @@ "states": { "loading": "Loading…", "empty": "Nothing to display." + }, + "aiWatermark": "AI-generated preview — actual construction may differ", + "aiNotice": "AI-generated images cannot be used for contracts or review documents — drawings are the construction standard", + "weekday": { + "0": "Sun", + "1": "Mon", + "2": "Tue", + "3": "Wed", + "4": "Thu", + "5": "Fri", + "6": "Sat" } }, "lang": { @@ -27,7 +38,9 @@ "transport": "Directions" }, "searchPlaceholder": "Search events", - "cta": { "register": "Visitor Pre-registration" }, + "cta": { + "register": "Visitor Pre-registration" + }, "menuOpen": "Open menu", "menuClose": "Close menu", "homeAria": "{{brand}} home", @@ -156,7 +169,12 @@ "fabCta": "Go to Pre-registration" }, "register": { - "steps": { "type": "Type", "info": "Info", "interest": "Interests", "done": "Done" }, + "steps": { + "type": "Type", + "info": "Info", + "interest": "Interests", + "done": "Done" + }, "stepsAria": "Pre-registration steps", "type": { "general": "General Visitor", @@ -358,7 +376,12 @@ "vipDesc": "Exhibition all-pass + VIP lounge access", "vipPrice": "₩50,000" }, - "steps": { "s1": "Event · Type", "s2": "Buyer", "s3": "Payment", "s4": "Done" }, + "steps": { + "s1": "Event · Type", + "s2": "Buyer", + "s3": "Payment", + "s4": "Done" + }, "stepsAria": "Booking steps", "bannerTitle": "Smart Factory Korea 2026", "bannerDate": "2026.09.05 – 09.08", @@ -517,5 +540,743 @@ "errRequest": "An error occurred during the request.", "errReset": "An error occurred during the reset.", "cancel": "Cancel" + }, + "dashboard": { + "countdown": "Opening D-{{n}}", + "degradedTag": "Offline — Sample", + "degradedMsg": "Could not reach the aggregation API; showing demo sample data.", + "loadError": "Failed to load dashboard metrics.", + "timelineAria": "Milestone timeline", + "milestoneEmptyTitle": "No derived schedule", + "milestoneEmptyDesc": "Milestones appear once the event start date is confirmed.", + "mstate": { + "done": "Done", + "current": "In progress", + "upcoming": "Upcoming" + }, + "kpisAria": "Key metrics", + "kpiEmpty": "No aggregated metrics", + "exhibitorStatus": "Exhibitor status", + "sampleTag": "Sample", + "exhibitorEmptyTitle": "No exhibitor booths", + "exhibitorEmptyDesc": "Booths appear here once layouts and designs are registered.", + "colBoothNo": "Booth No.", + "colCompany": "Company", + "colStatus": "Design status", + "feedAria": "Notification feed", + "feedTitle": "Notifications", + "feedEmpty": "No new notifications", + "aerialAria": "Hall aerial view", + "aerialTitle": "Hall aerial view", + "aerialShot": "S7 Hall aerial", + "aerialAlt": "Hall aerial preview image" + }, + "schedule": { + "title": "Exhibition Schedule", + "subtitle": "KINTEX all-hall event scheduling & monitoring — {{n}} events", + "crawlFallback": "(kintex.com crawl fallback)", + "yearFilter": "Year filter", + "allYears": "All years", + "yearN": "{{y}}", + "hallFilter": "Hall filter", + "allHalls": "All halls (1-10)", + "hall1": "Hall 1 Center (Halls 1-5)", + "hall2": "Hall 2 Center (Halls 6-10)", + "catFilter": "Category filter", + "allCats": "All categories", + "statusFilter": "Status filter", + "allStatus": "All statuses", + "cat": { + "exhibition": "Exhibition", + "culture": "Culture & Arts", + "trade": "Trade & Industry", + "tech": "Technology" + }, + "status": { + "ongoing": "Ongoing", + "upcoming": "Upcoming", + "ended": "Ended" + }, + "catExhibit": "{{cat}} show", + "apiWait": "Awaiting event API (crawl data)", + "viewSwitch": "Switch view", + "viewList": "List", + "viewCalendar": "Calendar", + "listLoading": "Loading event list", + "listAria": "Event list", + "loadError": "Failed to load the exhibition schedule.", + "count": "{{n}} events", + "emptyTitle": "No registered events", + "emptyDesc": "Adjust filters or create a new event.", + "newEvent": "New event", + "period": "Period", + "assignedHall": "Assigned hall", + "estVisitors": "Est. visitors", + "editDetail": "Edit details", + "viewLogistics": "View move-in/logistics", + "monthCal": "Month calendar", + "prev": "Prev", + "next": "Next", + "today": "Today", + "yearMonth": "{{m}}/{{y}}", + "miniCal": "Mini calendar", + "prevMonth": "Previous month", + "nextMonth": "Next month", + "offlineSample": "Offline — Sample", + "day": "Day {{d}}", + "dayHasEvent": "Day {{d}} (has event)", + "hallUtil": "Hall utilization", + "hallUtilMonth": "Hall utilization ({{m}}/{{y}})" + }, + "settlement": { + "title": "Settlement & Payment", + "subtitle": "Manage exhibitor billing & collection status", + "generate": "Generate invoices", + "generating": "Generating…", + "noEventTitle": "Please select an event", + "noEventDesc": "No event has been designated for settlement.", + "genDone": "Invoices generated — {{created}} new, {{skipped}} kept.", + "genError": "Failed to generate invoices. Check permissions or target data.", + "dashError": "Failed to load settlement aggregation.", + "kpisAria": "Settlement key metrics", + "kpiBilled": "Total billed", + "kpiCollected": "Collected", + "kpiOutstanding": "Outstanding", + "kpiOverdue": "Overdue invoices", + "count": "{{n}}", + "collectionRate": "Collection rate {{rate}}%", + "outstandingSub": "Outstanding balance", + "overdueSub": "Past due date", + "distTitle": "Billing status distribution", + "distEmpty": "No aggregated billing", + "listTitle": "Invoice list", + "invoices": "Invoices", + "statusFilter": "Status filter", + "status": { + "all": "All", + "pending": "Unissued", + "invoiced": "Invoiced", + "paid": "Paid", + "overdue": "Overdue" + }, + "listError": "Failed to load the invoice list.", + "emptyTitle": "No invoices", + "emptyManage": "Use Generate invoices above to aggregate billing per exhibitor.", + "emptyView": "No invoices have been issued yet.", + "colNo": "Invoice No.", + "colExhibitor": "Exhibitor", + "colBilled": "Billed", + "colPaid": "Paid", + "colOutstanding": "Outstanding", + "colDue": "Due date", + "colStatus": "Status", + "detail": "Details", + "prev": "Prev", + "next": "Next", + "pageInfo": "{{page}} / {{total}} · {{cnt}} total", + "modalAria": "Invoice details", + "invoice": "Invoice", + "close": "Close", + "detailError": "Failed to load invoice details.", + "liItem": "Item", + "liQty": "Qty", + "liUnit": "Unit price", + "liAmount": "Amount", + "noLines": "No line items", + "supplyAmount": "Supply amount", + "vat": "VAT (10%)", + "grandTotal": "Total billed", + "paidOutstanding": "Paid / Outstanding", + "payHistory": "Payment history", + "noPayments": "No payment records.", + "won": "KRW {{v}}", + "issue": "Issue invoice", + "issuing": "Issuing…", + "payAmount": "Payment amount", + "payAmountPh": "Payment amount (KRW)", + "payMethod": "Payment method", + "methodTransfer": "Bank transfer", + "methodCard": "Card", + "methodOther": "Other", + "memo": "Memo", + "memoPh": "Memo (optional)", + "recordPay": "Record payment", + "recording": "Recording…", + "payFail": "Failed to record payment. Check the amount (within total) and permissions.", + "errPositive": "Please enter an amount greater than 0.", + "errExceed": "Cannot exceed the outstanding balance.", + "pgNote": "Actual payment is delegated to an external PG. This screen is for recording payment confirmation only.", + "managerOnly": "Only organizers/hall managers can record or issue payments." + }, + "hallassign": { + "title": "Hall Assignment · Auto Quote", + "subtitle": "Hall×period assignment status and instant rate-ruleset quotes (M1).", + "start": "From", + "end": "To", + "newAssign": "New assignment", + "board": "Assignment board", + "boardHint": "Overlapping assignments are shown as conflicts.", + "forbiddenTitle": "Hall manager board only", + "forbiddenDesc": "Only KINTEX hall manager accounts can view assignment status. Use the auto quote below.", + "noHalls": "No hall master data.", + "noAssign": "No assignment", + "release": "Release assignment", + "releaseConfirm": "Release the {{hall}} assignment for {{event}}?", + "assignedToast": "Hall assigned.", + "releasedToast": "Assignment released.", + "releaseFail": "Release failed (check permissions).", + "conflictErr": "The hall/period overlaps with another event.", + "assignFail": "Assignment failed.", + "pickEventHall": "Select an event and a hall.", + "modalTitle": "Hall assignment", + "close": "Close", + "event": "Event", + "pickEvent": "Select event…", + "hall": "Hall", + "pickHall": "Select hall…", + "occupyStart": "Occupancy start", + "occupyEnd": "Occupancy end", + "primary": "Primary hall", + "note": "Note", + "optional": "Optional", + "cancel": "Cancel", + "assign": "Assign", + "assigning": "Assigning…", + "autoQuote": "Auto quote", + "quoteHint": "Based on rate ruleset · final amount confirmed by KINTEX", + "startDate": "Start date", + "endDate": "End date", + "boothSize": "Booth size (optional)", + "countPh": "Count", + "calcQuote": "Calculate quote", + "calculating": "Calculating…", + "quoteFail": "Quote calculation failed.", + "quoteReq": "Enter hall, start and end dates.", + "qItem": "Item", + "qBasis": "Basis", + "qAmount": "Amount", + "rentalSubtotal": "Rental subtotal", + "rentalTotal": "Rental total ({{days}} days)", + "deposit": "Management deposit (separate)", + "ruleset": "(ruleset {{v}})" + }, + "analytics": { + "title": "Business Analytics", + "subtitle": "KINTEX operating profitability · exhibitor insights", + "perspective": "Analysis perspective", + "operator": "Operator", + "exhibitor": "Exhibitor", + "periodAria": "Analysis period", + "periodEvent": "This event", + "periodD90": "Last 90 days", + "periodAnnual": "Annual", + "exportPdf": "Export PDF", + "exportExcel": "Export Excel", + "noEventTitle": "Please select an event", + "noEventDesc": "No event has been designated for analysis.", + "offlineSample": "Offline — Sample", + "degradedMsg": "Could not reach the aggregation API; showing demo sample data.", + "loadError": "Failed to load business analytics.", + "kpisAria": "Key metrics", + "kpiEmpty": "No aggregated metrics", + "trendAria": "Trend chart", + "trendTitle": "Revenue · Booth trend", + "monthlyAgg": "Monthly aggregation", + "trendHint": "Monthly rental/utility revenue and booth count trend (revenue in KRW).", + "trendEmptyTitle": "No trend data", + "trendEmptyDesc": "No revenue/booth records for an aggregable period.", + "revenue": "Revenue", + "booth": "Booths", + "boothsUnit": "{{n}} booths", + "sectorsAria": "Sector composition", + "sectorsTitle": "Sector composition", + "sectorsEmptyTitle": "Sectors not aggregated", + "sectorsEmptyDesc": "Displayed once the sector classification source (M13) is connected.", + "perfTable": "Performance table", + "pnlTitle": "P&L composition", + "exhibitorPerfTitle": "Exhibitor performance", + "sampleTag": "Sample", + "pnlEmptyTitle": "No P&L items", + "pnlEmptyDesc": "Displayed once booth rental/utility revenue records are aggregated.", + "colItem": "Item", + "colAmount": "Amount", + "colGrade": "Grade", + "perfEmptyTitle": "Exhibitor performance not aggregated", + "perfEmptyDesc": "Displayed once the lead/conversion source (M13 visitors) is connected.", + "colCompany": "Company", + "colLeads": "Leads", + "colConversion": "Conversion" + }, + "global": { + "sectionAria": "Tenant-wide business analytics", + "title": "Enterprise analytics (cross-event)", + "subtitle": "Cross-event revenue comparison · hall utilization trend · exhibitor retention · demand forecast", + "yearsAria": "Analysis years", + "years3": "Last 3 years", + "years5": "Last 5 years", + "years10": "Last 10 years", + "loadErrorTitle": "Failed to load global aggregation", + "loadErrorDesc": "Please try again shortly.", + "kpisAria": "Global key metrics", + "kpiEmpty": "No aggregated metrics", + "evComp": "Cross-event revenue comparison", + "top8": "Top 8", + "evCompHint": "Rental + utility revenue ranking of events with booth layouts (in KRW).", + "evCompEmptyTitle": "No events to compare", + "evCompEmptyDesc": "Displayed once revenue of events with booth layouts is aggregated.", + "revenue": "Revenue", + "retention": "Exhibitor retention", + "retentionCum": "Cumulative return rate", + "returning": "returning", + "retentionEmptyTitle": "Retention by year not aggregated", + "retentionEmptyDesc": "Displayed once the booth-assigned company source is connected.", + "retentionRate": "Return rate", + "occupancy": "Hall utilization trend (monthly)", + "occupancyHint": "Monthly utilization = event-days ÷ (halls × days in month).", + "occupancyEmpty": "No utilization data", + "occupancyRate": "Utilization", + "forecast": "Demand forecast", + "forecastTitle": "Demand forecast (hosting trend)", + "trendProjection": "Trend projection", + "forecastHint": "Actual annual event count + least-squares linear trend projected 2 years ahead (dotted).", + "forecastEmptyTitle": "No history to forecast", + "forecastEmptyDesc": "Displayed once annual hosting history is aggregated.", + "casesUnit": "{{n}}", + "actual": "Actual", + "projected": "Forecast" + }, + "ops": { + "title": "Hall Operations", + "subtitle": "Status based on check-in / utility live aggregation", + "modeAria": "View mode", + "live": "Live", + "history": "History", + "noEventTitle": "Please select an event", + "noEventDesc": "No event has been designated for on-site operations.", + "offlineSample": "Offline — Sample", + "degradedMsg": "Could not reach the aggregation API; showing demo sample data.", + "loadError": "Failed to load on-site operations status.", + "hallStatus": "Hall status", + "checkinDerived": "Derived from check-in (M10) · distributed by area weight", + "sensorNoneHalls": "No sensor · assigned hall list", + "noHallsTitle": "No assigned halls", + "noHallsDesc": "Displayed once halls are assigned to the event.", + "busy": "Busy", + "smooth": "Smooth", + "moderate": "Moderate", + "people": "{{n}}", + "onSiteCheckin": "On-site check-in <0>{{n}}0>", + "noCheckinAgg": "No check-in aggregation", + "powerLoad": "Power load", + "totalKw": "Total <0>{{v}}0>kW", + "utilLoadAgg": "Utility request (M4) load aggregation", + "noLoadTitle": "No requested load", + "noLoadDesc": "Displayed per hall once electrical utility requests are received.", + "kw": "{{v}}kW", + "hourlyCheckin": "Hourly check-in", + "hourlyAgg": "Check-in (M10) hourly aggregation", + "noSeriesTitle": "No check-in trend", + "noSeriesDesc": "Hourly trend appears once on-site check-ins are recorded.", + "facilitySensor": "Facility sensor integration", + "facilitySensorTitle": "Facility sensors (HVAC · Lighting · Parking)", + "sensorNoneTitle": "Facility sensors not connected", + "sensorNoneDesc": "HVAC · lighting IoT · parking (iparking) integration is shown once sensors (M14) are connected.", + "temp": "Temp", + "humidity": "Humidity", + "parkingSlots": "{{zone}}: {{occ}}/{{cap}} slots" + }, + "admin": { + "deniedTitle": "Access denied", + "deniedAuthed": "This page is available only to system administrators (Hall Manager · ADMIN). Contact an administrator if you need access.", + "deniedGuest": "The admin back office is available after login.", + "overviewTitle": "System Overview", + "overviewSub": "Real-time metrics and operational status", + "loginSlides": "Manage login slides", + "tenantSwitch": "Switch tenant", + "systemNormal": "System normal", + "moduleShortcuts": "Admin module shortcuts", + "offlineSample": "Offline — Sample", + "degradedMsg": "Could not reach the aggregation API; showing demo sample data.", + "dashLoadError": "Failed to load the admin dashboard.", + "kpisAria": "Key metrics", + "kpiEmpty": "No aggregated metrics", + "visitorTrend": "Visitor trend", + "hourlyVisitors": "Hourly visitors", + "visitors": "Visitors", + "visitorTrendEmptyTitle": "Visitor trend not aggregated", + "visitorTrendEmptyDesc": "Hourly trend appears once visitor sensors (M14) are connected.", + "liveEvents": "Live events", + "noLiveEvents": "No ongoing events", + "hallTbd": "Hall unassigned", + "occupancy": "Occupancy {{n}}%", + "modUsers": "Users", + "modRoles": "Roles & Permissions", + "modCodes": "Common codes", + "modMenus": "Menus", + "modAudit": "Audit log", + "modSettings": "System settings", + "modRulesets": "Rulesets", + "modTenants": "Tenants" + }, + "tenant": { + "title": "Tenant Management · Onboarding", + "subtitle": "Platform super-admin only · add venues via data onboarding without code deployment.", + "sampleTag": "Sample · Not implemented", + "sampleNote": "Multi-tenancy (tenant CRUD, onboarding, isolation) is not yet in the backend, so this screen shows the target IA. Live data will be wired after multi-tenancy is introduced.", + "listAria": "Tenant list", + "venue": "Venue", + "notImpl": "Not implemented", + "addVenue": "Add venue", + "code": "Code", + "domain": "Domain", + "status": "Status", + "events": "Events", + "users": "Users", + "wizardAria": "Onboarding wizard", + "onboardingOf": "{{name}} onboarding", + "dataOnboarding": "Data onboarding without code deployment", + "wiz": { + "create": { + "label": "Create tenant", + "desc": "Register code & display name" + }, + "domain": { + "label": "Subdomain", + "desc": "Assign DNS & branding domain" + }, + "brand": { + "label": "Branding", + "desc": "Logo, colors, public-site theme" + }, + "master": { + "label": "Master data entry", + "desc": "Seed halls, rates, rulesets" + }, + "admin": { + "label": "Admin account", + "desc": "Invite tenant administrator" + }, + "smoke": { + "label": "Isolation smoke test", + "desc": "Verify cross-tenant isolation" + }, + "activate": { + "label": "Activate", + "desc": "Switch to operation" + } + }, + "tstatus": { + "active": "Active", + "onboarding": "Onboarding", + "suspended": "Suspended" + } + }, + "ruleset": { + "title": "Regulation Ruleset Versions", + "subtitle": "Ruleset version history and rule items for regulation revisions.", + "readonly": "Read only", + "readonlyNote": "Rulesets are version-controlled as deployment resources (rulesets/*.json). Editing and version creation are done by replacing resources when regulations are revised; this screen is view-only.", + "listError": "Failed to load the ruleset list.", + "noRulesets": "No deployed rulesets", + "versionHistory": "Version history", + "rulesCount": "{{n}} rules", + "effectiveDate": "Effective {{date}}", + "ruleList": "Rule list", + "rulesOf": "{{name}} rules ({{n}})", + "detailError": "Failed to load the full rules.", + "noRulesTitle": "Ruleset has no rule array", + "noRulesDesc": "A ruleset that defines only rates/unit prices without rules (e.g. rate master).", + "colRuleName": "Rule name", + "colValue": "Value/setting", + "colLogic": "Logic / Requirement", + "colSeverity": "Violation level", + "block": "Block", + "warn": "Warn", + "required": "Required", + "perHall": "Per hall {{unit}}", + "docNeeded": "Document required: {{doc}}" + }, + "audit": { + "title": "Audit Log", + "subtitle": "Tracks all admin actions (approvals, awards, setting changes) and key domain events. (Integrity preserved)", + "exportCsv": "Export CSV", + "filterAria": "Audit log filter", + "actionType": "Action type", + "allActions": "All actions", + "actorId": "Actor ID", + "actorIdPh": "e.g. user-102", + "eventId": "Event ID", + "eventIdPh": "e.g. EVT-2026-01", + "reset": "Reset", + "applyFilter": "Apply filter", + "loadError": "Failed to load the audit log.", + "tableAria": "Audit log table", + "emptyTitle": "No audit records match the criteria", + "emptyDesc": "Adjust or reset filters and search again.", + "colTime": "Time", + "colUser": "User", + "colAction": "Action", + "colTarget": "Target", + "colEvent": "Event", + "colResult": "Result", + "showing": "Showing <0>{{from}}-{{to}}0> / <1>{{total}}1> total", + "prev": "Prev", + "next": "Next", + "rowsShow": "Rows", + "rowsPerPage": "Rows per page", + "rowsN": "{{n}}", + "success": "Success", + "failure": "Failure", + "csvTime": "Time", + "csvActorId": "ActorID", + "csvActorName": "ActorName", + "csvAction": "Action", + "csvTargetType": "TargetType", + "csvTargetId": "TargetID", + "csvEventId": "EventID", + "csvResult": "Result", + "csvSummary": "Summary" + }, + "role": { + "title": "Roles & Permissions (RBAC)", + "subtitle": "Assign resource permissions per role (Organizer, Exhibitor, Contractor, Hall Manager, Admin, Visitor) in a matrix.", + "addRole": "Add role", + "loadError": "Failed to load roles/permissions.", + "noRolesTitle": "No roles defined", + "noRolesDesc": "Add a role to configure RBAC.", + "roleList": "Role list", + "role": "Role", + "codePerm": "{{code}} · {{n}} permissions", + "unused": "Unused", + "permMatrix": "Permission matrix", + "rolePerm": "{{name}} permissions", + "roleInfo": "Role info", + "deleteConfirm": "Delete the role '{{name}}'?", + "delete": "Delete", + "noPerms": "No permissions defined", + "selectRole": "Select a role", + "saved": "Role saved.", + "deleted": "Role deleted.", + "addRoleModal": "Add role", + "editRole": "Edit role info", + "modalDesc": "Permission assignment is managed by checkboxes in the matrix after saving.", + "roleCode": "Role code *", + "roleCodePh": "e.g. ORGANIZER", + "roleName": "Role name *", + "description": "Description", + "useYn": "In use", + "use": "In use", + "noUse": "Unused", + "cancel": "Cancel", + "save": "Save", + "saving": "Saving…", + "errRequired": "Role code and name are required.", + "errDupCode": "This role code already exists." + }, + "menu": { + "title": "Menu Management", + "subtitle": "Manage the per-role navigation menu tree with paths and access roles.", + "addMenu": "Add menu", + "loadError": "Failed to load menus.", + "treeAria": "Menu tree", + "emptyTitle": "No registered menus", + "emptyDesc": "Add a menu.", + "colName": "Menu name", + "colPath": "Path", + "colType": "Type", + "colRole": "Access role", + "colSort": "Order", + "colUse": "In use", + "colManage": "Manage", + "all": "All", + "unused": "Unused", + "used": "In use", + "edit": "Edit", + "delete": "Delete", + "deleteConfirm": "Delete the menu '{{name}}'? Sub-menus, if any, will be cleaned up together.", + "saved": "Menu saved.", + "deleted": "Menu deleted.", + "editMenu": "Edit menu", + "menuId": "Menu ID (unique key) *", + "menuIdPh": "e.g. admin-users", + "menuName": "Menu name *", + "parent": "Parent menu", + "topLevel": "(Top level)", + "path": "Path", + "pathPh": "e.g. /admin/users", + "type": "Type", + "accessRole": "Access role", + "accessRolePh": "e.g. ADMIN (empty = all)", + "sort": "Order", + "useYn": "In use", + "cancel": "Cancel", + "save": "Save", + "saving": "Saving…", + "errName": "Menu name is required.", + "errId": "Menu ID (unique key) is required.", + "errDupId": "This menu ID already exists." + }, + "code": { + "title": "Common Code Management", + "subtitle": "Manage common code groups and code values used in dropdowns and classifications.", + "addGroup": "Add group", + "groupLoadError": "Failed to load code groups.", + "noGroupsTitle": "No code groups", + "noGroupsDesc": "Add a group to configure common codes.", + "codeGroup": "Code group", + "codeValues": "Code values", + "valuesOf": "{{name}} values", + "editGroup": "Edit code group", + "deleteGroup": "Delete group", + "deleteGroupConfirm": "Delete group '{{name}}' and its codes?", + "addCode": "Add code", + "codeLoadError": "Failed to load code values.", + "noCodesTitle": "No codes", + "noCodesDesc": "Add a code.", + "code": "Code", + "name": "Name", + "value": "Value", + "sort": "Order", + "use": "In use", + "manage": "Manage", + "unused": "Unused", + "used": "In use", + "edit": "Edit", + "delete": "Delete", + "deleteCodeConfirm": "Delete code '{{name}}'?", + "groupSaved": "Code group saved.", + "groupDeleted": "Code group deleted.", + "codeSaved": "Code saved.", + "codeDeleted": "Code deleted.", + "groupCode": "Group code *", + "groupName": "Group name *", + "description": "Description", + "useYn": "In use", + "cancel": "Cancel", + "save": "Save", + "saving": "Saving…", + "errGroupRequired": "Group code and name are required.", + "errDupGroup": "This group code already exists.", + "editCode": "Edit code", + "group": "Group", + "codeReq": "Code *", + "nameReq": "Name *", + "attr1": "Attribute 1 (attr1)", + "errCodeRequired": "Code and name are required.", + "errDupCode": "This code already exists." + }, + "sysuser": { + "title": "User Management", + "subtitle": "Manage account creation/editing, role assignment, status (active/locked/disabled), and OTP reset. Passwords are masked and not displayed.", + "addUser": "Add user", + "searchAria": "User search", + "keyword": "Keyword", + "keywordPh": "Name·Email", + "role": "Role", + "status": "Status", + "all": "All", + "reset": "Reset", + "search": "Search", + "loadError": "Failed to load the user list.", + "listAria": "User list", + "emptyTitle": "No users", + "emptyDesc": "Adjust search criteria or add a user.", + "colUser": "User", + "colEmail": "Email", + "colRole": "Role", + "colDept": "Dept", + "colStatus": "Status", + "colLastLogin": "Last login", + "colManage": "Manage", + "hallManagerTag": "Hall Mgr", + "otpSet": "Set", + "otpUnset": "Not set", + "edit": "Edit", + "lock": "Lock", + "unlock": "Unlock", + "otpResetBtn": "Reset OTP", + "otpResetConfirm": "Reset OTP for {{name}}?", + "saved": "User saved.", + "statusChanged": "Status changed.", + "otpReset": "OTP reset.", + "active": "Active", + "locked": "Locked", + "disabled": "Disabled", + "editUser": "Edit user", + "newDesc": "The initial password is not displayed on screen after saving.", + "editDesc": "Email cannot be edited. Password reset follows a separate policy.", + "email": "Email *", + "displayName": "Display name *", + "dept": "Department", + "initPassword": "Initial password *", + "hallManagerCheck": "Hall Manager (internal operator)", + "cancel": "Cancel", + "save": "Save", + "saving": "Saving…", + "errRequired": "Email and display name are required.", + "errPassword": "New users require an initial password.", + "totalUsers": "<0>{{total}}0> users total", + "prev": "Prev", + "next": "Next" + }, + "settings": { + "title": "System Settings", + "subtitle": "Manage system parameters such as global policies, feature toggles, and AI gates. Secret values are masked.", + "loadError": "Failed to load system settings.", + "emptyTitle": "No settings registered", + "emptyDesc": "Add your first setting item in the form below.", + "saved": "Setting saved.", + "deleted": "Setting deleted.", + "deleteConfirm": "Delete the setting '{{key}}'?", + "general": "General", + "groupAria": "Settings group {{name}}", + "secret": "Secret", + "secretTitle": "Secret — value masked", + "on": "On", + "off": "Off", + "secretPh": "Set (masked) — enter new value to change", + "save": "Save", + "delete": "Delete", + "deleteKey": "Delete {{key}}", + "secretHint": "The secret value is never exposed. Saving without entering a value keeps the existing value.", + "addAria": "Add setting", + "addTitle": "Add / override setting", + "settingKey": "Setting key", + "settingKeyPh": "e.g. deadline.utility.days", + "value": "Value", + "type": "Type", + "description": "Description", + "secretCheck": "Secret (mask value)" + }, + "slide": { + "title": "Login Slide Management", + "subtitle": "Login screen left carousel images/text — the <0>latest 5 by registration order0> among active slides rotate.", + "addAria": "Add new slide", + "addTitle": "Add new slide", + "imageLabel": "Image (PNG/JPG/WebP, up to 8MB)", + "preview": "Preview", + "titleLabel": "Title text", + "titlePh": "e.g. KINTEX, Asia's leading exhibition & convention center", + "captionLabel": "Caption (subtext)", + "captionPh": "e.g. Discover business opportunities across 100,000 sqm of exhibition space.", + "submitting": "Registering…", + "submit": "Register slide", + "listAria": "Slide list", + "registered": "Registered slides", + "countActive": "{{total}} total · {{active}} active", + "emptyTitle": "No registered slides", + "emptyDesc": "Registered images and text appear in the login carousel. (5 defaults shown when none registered)", + "live": "Live", + "inactive": "Inactive", + "registeredAt": "Registered {{date}}", + "active": "Active", + "delete": "Delete", + "listError": "Failed to load the list.", + "errImage": "Please select a slide image.", + "errTitle": "Please enter the title text.", + "createFail": "Registration failed.", + "updateFail": "Update failed.", + "deleteFail": "Deletion failed.", + "deleteConfirm": "Delete the slide \"{{title}}\"?" } } diff --git a/src/frontend/src/i18n/locales/ja.json b/src/frontend/src/i18n/locales/ja.json index 51f60e4..7fc2571 100644 --- a/src/frontend/src/i18n/locales/ja.json +++ b/src/frontend/src/i18n/locales/ja.json @@ -14,6 +14,17 @@ "states": { "loading": "読み込み中…", "empty": "表示する内容がありません。" + }, + "aiWatermark": "AI 生成の予想画像 — 実際の施工結果と異なる場合があります", + "aiNotice": "AI 生成画像は契約・審査書類には使用できません — 施工基準は図面です", + "weekday": { + "0": "日", + "1": "月", + "2": "火", + "3": "水", + "4": "木", + "5": "金", + "6": "土" } }, "lang": { @@ -27,7 +38,9 @@ "transport": "交通" }, "searchPlaceholder": "イベントを検索", - "cta": { "register": "来場事前登録" }, + "cta": { + "register": "来場事前登録" + }, "menuOpen": "メニューを開く", "menuClose": "メニューを閉じる", "homeAria": "{{brand}} ホーム", @@ -156,7 +169,12 @@ "fabCta": "事前登録へ" }, "register": { - "steps": { "type": "タイプ", "info": "情報", "interest": "関心分野", "done": "完了" }, + "steps": { + "type": "タイプ", + "info": "情報", + "interest": "関心分野", + "done": "完了" + }, "stepsAria": "事前登録ステップ", "type": { "general": "一般来場者", @@ -358,7 +376,12 @@ "vipDesc": "展示会オールパス + VIP ラウンジ利用券", "vipPrice": "₩50,000" }, - "steps": { "s1": "イベント·券種", "s2": "予約者", "s3": "決済", "s4": "完了" }, + "steps": { + "s1": "イベント·券種", + "s2": "予約者", + "s3": "決済", + "s4": "完了" + }, "stepsAria": "予約ステップ", "bannerTitle": "スマートファクトリー・コリア 2026", "bannerDate": "2026.09.05 – 09.08", @@ -517,5 +540,743 @@ "errRequest": "リクエスト中にエラーが発生しました。", "errReset": "再設定中にエラーが発生しました。", "cancel": "キャンセル" + }, + "dashboard": { + "countdown": "開場 D-{{n}}", + "degradedTag": "オフライン — サンプル", + "degradedMsg": "集計 API に接続できず、デモ用サンプルを表示しています。", + "loadError": "ダッシュボード集計を読み込めませんでした。", + "timelineAria": "マイルストーンタイムライン", + "milestoneEmptyTitle": "派生スケジュールがありません", + "milestoneEmptyDesc": "イベント開始日が確定するとマイルストーンが表示されます。", + "mstate": { + "done": "完了", + "current": "進行中", + "upcoming": "予定" + }, + "kpisAria": "主要指標", + "kpiEmpty": "集計された指標がありません", + "exhibitorStatus": "出展社状況", + "sampleTag": "サンプル", + "exhibitorEmptyTitle": "出展社ブースがありません", + "exhibitorEmptyDesc": "ブース配置・設計案が登録されるとここに表示されます。", + "colBoothNo": "ブース番号", + "colCompany": "会社名", + "colStatus": "設計状態", + "feedAria": "通知フィード", + "feedTitle": "通知", + "feedEmpty": "新しい通知はありません", + "aerialAria": "ホール全景俯瞰", + "aerialTitle": "ホール全景俯瞰", + "aerialShot": "S7 ホール全景", + "aerialAlt": "ホール全景俯瞰の予想画像" + }, + "schedule": { + "title": "展示スケジュール", + "subtitle": "KINTEX 全ホールのイベント日程管理・モニタリング — {{n}}件", + "crawlFallback": "(kintex.com クロールフォールバック)", + "yearFilter": "年度フィルター", + "allYears": "全年度", + "yearN": "{{y}}年", + "hallFilter": "ホールフィルター", + "allHalls": "全ホール (1-10)", + "hall1": "第1展示場 (ホール 1-5)", + "hall2": "第2展示場 (ホール 6-10)", + "catFilter": "カテゴリーフィルター", + "allCats": "全カテゴリー", + "statusFilter": "ステータスフィルター", + "allStatus": "全ステータス", + "cat": { + "exhibition": "展示", + "culture": "文化・芸術", + "trade": "貿易・産業", + "tech": "技術" + }, + "status": { + "ongoing": "開催中", + "upcoming": "予定", + "ended": "終了" + }, + "catExhibit": "{{cat}}展", + "apiWait": "イベント API 待機 (クロールデータ)", + "viewSwitch": "表示切替", + "viewList": "リスト", + "viewCalendar": "カレンダー", + "listLoading": "イベント一覧を読み込み中", + "listAria": "イベント一覧", + "loadError": "展示スケジュールを読み込めませんでした。", + "count": "{{n}}件", + "emptyTitle": "登録されたイベントがありません", + "emptyDesc": "フィルターを調整するか新規イベントを作成してください。", + "newEvent": "新規イベント作成", + "period": "期間", + "assignedHall": "割当ホール", + "estVisitors": "推定人数", + "editDetail": "詳細編集", + "viewLogistics": "搬入/物流を見る", + "monthCal": "月カレンダー", + "prev": "前へ", + "next": "次へ", + "today": "今日", + "yearMonth": "{{y}}年{{m}}月", + "miniCal": "ミニカレンダー", + "prevMonth": "前月", + "nextMonth": "翌月", + "offlineSample": "オフライン — サンプル", + "day": "{{d}}日", + "dayHasEvent": "{{d}}日 (イベントあり)", + "hallUtil": "ホール稼働率", + "hallUtilMonth": "ホール稼働率 ({{y}}年{{m}}月)" + }, + "settlement": { + "title": "精算・決済", + "subtitle": "イベント出展社の請求・入金状況を管理", + "generate": "請求書生成", + "generating": "生成中…", + "noEventTitle": "イベントを選択してください", + "noEventDesc": "精算状況を照会するイベントが指定されていません。", + "genDone": "請求書生成完了 — 新規 {{created}}件、既存維持 {{skipped}}件。", + "genError": "請求書の生成に失敗しました。権限または対象データを確認してください。", + "dashError": "精算状況の集計を読み込めませんでした。", + "kpisAria": "精算主要指標", + "kpiBilled": "総請求額", + "kpiCollected": "入金額", + "kpiOutstanding": "未収金", + "kpiOverdue": "延滞請求", + "count": "{{n}}件", + "collectionRate": "入金率 {{rate}}%", + "outstandingSub": "未収残高", + "overdueSub": "納付期限超過", + "distTitle": "請求ステータス分布", + "distEmpty": "集計された請求がありません", + "listTitle": "請求書一覧", + "invoices": "請求書", + "statusFilter": "ステータスフィルター", + "status": { + "all": "全て", + "pending": "未発行", + "invoiced": "請求済", + "paid": "完納", + "overdue": "延滞" + }, + "listError": "請求書一覧を読み込めませんでした。", + "emptyTitle": "請求書がありません", + "emptyManage": "上部の請求書生成で出展社別に請求を集計してください。", + "emptyView": "まだ発行された請求書がありません。", + "colNo": "請求番号", + "colExhibitor": "出展社", + "colBilled": "請求額", + "colPaid": "入金", + "colOutstanding": "未収", + "colDue": "納付期限", + "colStatus": "ステータス", + "detail": "詳細", + "prev": "前へ", + "next": "次へ", + "pageInfo": "{{page}} / {{total}} · 全 {{cnt}}件", + "modalAria": "請求書詳細", + "invoice": "請求書", + "close": "閉じる", + "detailError": "請求書詳細を読み込めませんでした。", + "liItem": "項目", + "liQty": "数量", + "liUnit": "単価", + "liAmount": "金額", + "noLines": "明細項目なし", + "supplyAmount": "供給価額", + "vat": "消費税(10%)", + "grandTotal": "請求総額", + "paidOutstanding": "入金 / 未収", + "payHistory": "入金履歴", + "noPayments": "入金記録がありません。", + "won": "{{v}}ウォン", + "issue": "請求書発行", + "issuing": "発行中…", + "payAmount": "入金額", + "payAmountPh": "入金額(ウォン)", + "payMethod": "入金方法", + "methodTransfer": "口座振込", + "methodCard": "カード", + "methodOther": "その他", + "memo": "メモ", + "memoPh": "メモ(任意)", + "recordPay": "入金記録", + "recording": "記録中…", + "payFail": "入金記録に失敗しました。金額(総額以内)・権限を確認してください。", + "errPositive": "0より大きい金額を入力してください。", + "errExceed": "未収残高を超えることはできません。", + "pgNote": "実際の決済は外部 PG に委任されます。本画面は入金確認記録専用です。", + "managerOnly": "入金・発行は主催者/ホールマネージャーのみ実行できます。" + }, + "hallassign": { + "title": "ホール割当 · 自動見積", + "subtitle": "ホール×期間の割当状況と料率ルールセットに基づく即時見積(M1)。", + "start": "開始", + "end": "終了", + "newAssign": "新規割当", + "board": "割当ボード", + "boardHint": "重複する割当は競合として表示されます。", + "forbiddenTitle": "ホールマネージャー専用ボード", + "forbiddenDesc": "割当状況の照会は KINTEX ホールマネージャーアカウントのみ可能です。自動見積は下部をご利用ください。", + "noHalls": "ホールマスターがありません。", + "noAssign": "割当なし", + "release": "割当解除", + "releaseConfirm": "{{event}} の {{hall}} 割当を解除しますか?", + "assignedToast": "ホールが割り当てられました。", + "releasedToast": "割当が解除されました。", + "releaseFail": "解除に失敗しました(権限確認)。", + "conflictErr": "割り当てるホール・期間が他のイベントと重複します。", + "assignFail": "割当に失敗しました。", + "pickEventHall": "イベントとホールを選択してください。", + "modalTitle": "ホール割当", + "close": "閉じる", + "event": "イベント", + "pickEvent": "イベント選択…", + "hall": "ホール", + "pickHall": "ホール選択…", + "occupyStart": "占有開始", + "occupyEnd": "占有終了", + "primary": "主割当ホール(primary)", + "note": "備考", + "optional": "任意入力", + "cancel": "キャンセル", + "assign": "割当", + "assigning": "割当中…", + "autoQuote": "自動見積", + "quoteHint": "料率ルールセット基準 · 最終金額は KINTEX 確定時に案内", + "startDate": "開始日", + "endDate": "終了日", + "boothSize": "ブース規模(任意)", + "countPh": "数量", + "calcQuote": "見積計算", + "calculating": "計算中…", + "quoteFail": "見積計算に失敗しました。", + "quoteReq": "ホール・開始日・終了日を入力してください。", + "qItem": "項目", + "qBasis": "算出根拠", + "qAmount": "金額", + "rentalSubtotal": "賃料小計", + "rentalTotal": "賃料合計 ({{days}}日)", + "deposit": "管理費予託金(別途)", + "ruleset": "(ルールセット {{v}})" + }, + "analytics": { + "title": "経営分析", + "subtitle": "KINTEX 運営収益性 · 出展社インサイト", + "perspective": "分析視点", + "operator": "運営社", + "exhibitor": "出展社", + "periodAria": "分析期間", + "periodEvent": "今回のイベント", + "periodD90": "直近 90 日", + "periodAnnual": "年間", + "exportPdf": "PDF 出力", + "exportExcel": "Excel 出力", + "noEventTitle": "イベントを選択してください", + "noEventDesc": "分析するイベントが指定されていません。", + "offlineSample": "オフライン — サンプル", + "degradedMsg": "集計 API に接続できず、デモ用サンプルを表示しています。", + "loadError": "経営分析集計を読み込めませんでした。", + "kpisAria": "主要指標", + "kpiEmpty": "集計された指標がありません", + "trendAria": "トレンドチャート", + "trendTitle": "売上 · ブース推移", + "monthlyAgg": "月別集計", + "trendHint": "月別の賃貸・ユーティリティ売上とブース数の推移です(売上単位:ウォン)。", + "trendEmptyTitle": "推移データがありません", + "trendEmptyDesc": "集計可能な期間の売上・ブースレコードがありません。", + "revenue": "売上", + "booth": "ブース", + "boothsUnit": "{{n}} ブース", + "sectorsAria": "業種別構成", + "sectorsTitle": "業種別構成", + "sectorsEmptyTitle": "業種分類未集計", + "sectorsEmptyDesc": "業種分類ソース(M13)が連携されると表示されます。", + "perfTable": "成果テーブル", + "pnlTitle": "損益(P&L)構成", + "exhibitorPerfTitle": "出展社成果", + "sampleTag": "サンプル", + "pnlEmptyTitle": "損益項目がありません", + "pnlEmptyDesc": "ブース賃貸・ユーティリティ売上レコードが集計されると表示されます。", + "colItem": "項目", + "colAmount": "金額", + "colGrade": "等級", + "perfEmptyTitle": "出展社成果未集計", + "perfEmptyDesc": "リード・転換率ソース(M13 来場者)が連携されると表示されます。", + "colCompany": "会社名", + "colLeads": "リード", + "colConversion": "転換率" + }, + "global": { + "sectionAria": "テナント全域経営分析", + "title": "全社経営分析 (イベント横断)", + "subtitle": "イベント間売上比較 · ホール稼働率推移 · 出展社リテンション · 需要予測", + "yearsAria": "分析年数", + "years3": "直近 3 年", + "years5": "直近 5 年", + "years10": "直近 10 年", + "loadErrorTitle": "全域集計を読み込めませんでした", + "loadErrorDesc": "しばらくしてから再試行してください。", + "kpisAria": "全域主要指標", + "kpiEmpty": "集計された指標がありません", + "evComp": "イベント間売上比較", + "top8": "上位 8 件", + "evCompHint": "ブースが配置されたイベントの賃貸+ユーティリティ売上ランキングです(単位:ウォン)。", + "evCompEmptyTitle": "比較するイベントがありません", + "evCompEmptyDesc": "ブースが配置されたイベント売上が集計されると表示されます。", + "revenue": "売上", + "retention": "出展社リテンション", + "retentionCum": "累積再出展率", + "returning": "再出展", + "retentionEmptyTitle": "年度別リテンション未集計", + "retentionEmptyDesc": "ブース割当社ソースが連携されると表示されます。", + "retentionRate": "再出展率", + "occupancy": "ホール稼働率推移 (月別)", + "occupancyHint": "開催イベント-日数 ÷ (ホール数 × その月の日数) 基準の月別稼働率です。", + "occupancyEmpty": "稼働率データがありません", + "occupancyRate": "稼働率", + "forecast": "需要予測", + "forecastTitle": "需要予測 (開催トレンド)", + "trendProjection": "トレンド投影", + "forecastHint": "年度別開催イベント数の実測 + 最小二乗線形トレンドで今後 2 年を投影(点線)。", + "forecastEmptyTitle": "予測する履歴がありません", + "forecastEmptyDesc": "年度別開催履歴が集計されると表示されます。", + "casesUnit": "{{n}}件", + "actual": "実測", + "projected": "予測" + }, + "ops": { + "title": "ホール現場運営", + "subtitle": "チェックイン・ユーティリティ実集計に基づく状況", + "modeAria": "照会モード", + "live": "リアルタイム", + "history": "履歴照会", + "noEventTitle": "イベントを選択してください", + "noEventDesc": "現場運営を照会するイベントが指定されていません。", + "offlineSample": "オフライン — サンプル", + "degradedMsg": "集計 API に接続できず、デモ用サンプルを表示しています。", + "loadError": "現場運営状況を読み込めませんでした。", + "hallStatus": "ホール別状況", + "checkinDerived": "チェックイン(M10)派生 · 面積比重で分配", + "sensorNoneHalls": "センサー未連携 · 割当ホール一覧", + "noHallsTitle": "割り当てられたホールがありません", + "noHallsDesc": "イベントにホールが割り当てられると表示されます。", + "busy": "混雑", + "smooth": "円滑", + "moderate": "普通", + "people": "{{n}}名", + "onSiteCheckin": "現場チェックイン <0>{{n}}0>名", + "noCheckinAgg": "チェックイン集計なし", + "powerLoad": "電力負荷", + "totalKw": "計 <0>{{v}}0>kW", + "utilLoadAgg": "ユーティリティ申請(M4)負荷集計", + "noLoadTitle": "申請負荷なし", + "noLoadDesc": "電気ユーティリティ申請が受け付けられるとホール別負荷が表示されます。", + "kw": "{{v}}kW", + "hourlyCheckin": "時間帯別チェックイン", + "hourlyAgg": "チェックイン(M10)時間帯集計", + "noSeriesTitle": "チェックイン推移なし", + "noSeriesDesc": "現場チェックインが記録されると時間帯別推移が表示されます。", + "facilitySensor": "施設センサー連携", + "facilitySensorTitle": "施設センサー (空調·照明·駐車)", + "sensorNoneTitle": "施設センサー未連携", + "sensorNoneDesc": "空調(HVAC)·照明 IoT·駐車(iparking)連携はセンサー(M14)連携時に表示されます。", + "temp": "温度", + "humidity": "湿度", + "parkingSlots": "{{zone}}: {{occ}}/{{cap}}台" + }, + "admin": { + "deniedTitle": "アクセス権限がありません", + "deniedAuthed": "このページはシステム管理者(ホールマネージャー·ADMIN)のみ利用できます。アクセスが必要な場合は管理者にお問い合わせください。", + "deniedGuest": "管理バックオフィスはログイン後に利用できます。", + "overviewTitle": "システム概要", + "overviewSub": "リアルタイム指標と運用状態", + "loginSlides": "ログインスライド管理", + "tenantSwitch": "テナント切替", + "systemNormal": "システム正常", + "moduleShortcuts": "管理モジュールショートカット", + "offlineSample": "オフライン — サンプル", + "degradedMsg": "集計 API に接続できず、デモ用サンプルを表示しています。", + "dashLoadError": "管理者ダッシュボードを読み込めませんでした。", + "kpisAria": "主要指標", + "kpiEmpty": "集計された指標がありません", + "visitorTrend": "来場者推移", + "hourlyVisitors": "時間帯別来場者", + "visitors": "来場者", + "visitorTrendEmptyTitle": "来場者推移未集計", + "visitorTrendEmptyDesc": "来場者計測センサー(M14)が連携されると時間帯別推移が表示されます。", + "liveEvents": "開催中イベント", + "noLiveEvents": "開催中のイベントがありません", + "hallTbd": "ホール未割当", + "occupancy": "占有 {{n}}%", + "modUsers": "ユーザー", + "modRoles": "役割·権限", + "modCodes": "共通コード", + "modMenus": "メニュー", + "modAudit": "監査ログ", + "modSettings": "システム設定", + "modRulesets": "ルールセット", + "modTenants": "テナント" + }, + "tenant": { + "title": "テナント管理·オンボーディング", + "subtitle": "プラットフォームスーパー管理者専用 · コード配備なしにデータオンボーディングで展示館を追加します。", + "sampleTag": "サンプル · 未実装", + "sampleNote": "マルチテナンシー(テナント CRUD·オンボーディング·分離)がバックエンドにまだ無いため、画面は目標 IA を表示します。実データ連携はマルチテナンシー導入後に配線されます。", + "listAria": "テナント一覧", + "venue": "展示館", + "notImpl": "未実装", + "addVenue": "展示館追加", + "code": "コード", + "domain": "ドメイン", + "status": "ステータス", + "events": "イベント", + "users": "ユーザー", + "wizardAria": "オンボーディングウィザード", + "onboardingOf": "{{name}} オンボーディング", + "dataOnboarding": "コード配備なしのデータオンボーディング", + "wiz": { + "create": { + "label": "テナント作成", + "desc": "コード·表示名の登録" + }, + "domain": { + "label": "サブドメイン", + "desc": "DNS·ブランディングドメイン割当" + }, + "brand": { + "label": "ブランディング", + "desc": "ロゴ·色·公開サイトテーマ" + }, + "master": { + "label": "マスターデータ入力", + "desc": "ホール·料率·ルールセットシード" + }, + "admin": { + "label": "管理者アカウント", + "desc": "テナント管理者を招待" + }, + "smoke": { + "label": "分離スモーク", + "desc": "クロステナント分離検証" + }, + "activate": { + "label": "有効化", + "desc": "運用へ切替" + } + }, + "tstatus": { + "active": "有効", + "onboarding": "オンボーディング中", + "suspended": "停止" + } + }, + "ruleset": { + "title": "規定ルールセットバージョン管理", + "subtitle": "規定改定対応のためのルールセットバージョン履歴と規則項目です。", + "readonly": "読み取り専用", + "readonlyNote": "ルールセットはデプロイリソース(rulesets/*.json)でバージョン管理されます。編集・バージョン作成は規定改定時のリソース差し替えで行われ、画面では照会のみ可能です。", + "listError": "ルールセット一覧を読み込めませんでした。", + "noRulesets": "デプロイされたルールセットがありません", + "versionHistory": "バージョン履歴", + "rulesCount": "規則 {{n}}", + "effectiveDate": "発効日 {{date}}", + "ruleList": "規則一覧", + "rulesOf": "{{name}} 規則 ({{n}}件)", + "detailError": "規則全文を読み込めませんでした。", + "noRulesTitle": "規則配列がないルールセットです", + "noRulesDesc": "料率マスターなど、規則(rules)なしで料率・単価のみ定義されたルールセットです。", + "colRuleName": "規則名", + "colValue": "数値/設定値", + "colLogic": "論理 / 要件", + "colSeverity": "違反レベル", + "block": "遮断", + "warn": "警告", + "required": "必須", + "perHall": "ホール別 {{unit}}", + "docNeeded": "文書必要: {{doc}}" + }, + "audit": { + "title": "監査ログ", + "subtitle": "承認·落札·設定変更などすべての管理アクションと重要なドメインイベントを追跡します。(整合性維持)", + "exportCsv": "CSV 出力", + "filterAria": "監査ログフィルター", + "actionType": "アクション種別", + "allActions": "全アクション", + "actorId": "担当者 ID", + "actorIdPh": "例: user-102", + "eventId": "イベント ID", + "eventIdPh": "例: EVT-2026-01", + "reset": "リセット", + "applyFilter": "フィルター適用", + "loadError": "監査ログを読み込めませんでした。", + "tableAria": "監査ログテーブル", + "emptyTitle": "条件に一致する監査記録がありません", + "emptyDesc": "フィルターを調整またはリセットして再照会してください。", + "colTime": "時刻", + "colUser": "ユーザー", + "colAction": "アクション", + "colTarget": "対象", + "colEvent": "イベント", + "colResult": "結果", + "showing": "表示中 <0>{{from}}-{{to}}0> / 全 <1>{{total}}1>件", + "prev": "前へ", + "next": "次へ", + "rowsShow": "行表示", + "rowsPerPage": "ページあたり行数", + "rowsN": "{{n}}件", + "success": "成功", + "failure": "失敗", + "csvTime": "時刻", + "csvActorId": "担当者ID", + "csvActorName": "担当者名", + "csvAction": "アクション", + "csvTargetType": "対象種別", + "csvTargetId": "対象ID", + "csvEventId": "イベントID", + "csvResult": "結果", + "csvSummary": "要約" + }, + "role": { + "title": "役割 · 権限 (RBAC)", + "subtitle": "役割(主催者·出展社·装置業者·ホールマネージャー·管理者·来場者)ごとにリソース権限をマトリックスで割り当てます。", + "addRole": "役割追加", + "loadError": "役割·権限情報を読み込めませんでした。", + "noRolesTitle": "定義された役割がありません", + "noRolesDesc": "役割を追加して RBAC を構成してください。", + "roleList": "役割一覧", + "role": "役割", + "codePerm": "{{code}} · 権限 {{n}}", + "unused": "未使用", + "permMatrix": "権限マトリックス", + "rolePerm": "{{name}} 権限", + "roleInfo": "役割情報", + "deleteConfirm": "役割 '{{name}}' を削除しますか?", + "delete": "削除", + "noPerms": "定義された権限がありません", + "selectRole": "役割を選択してください", + "saved": "役割を保存しました。", + "deleted": "役割を削除しました。", + "addRoleModal": "役割追加", + "editRole": "役割情報の編集", + "modalDesc": "権限割当は保存後にマトリックスでチェックにより管理します。", + "roleCode": "役割コード *", + "roleCodePh": "例: ORGANIZER", + "roleName": "役割名 *", + "description": "説明", + "useYn": "使用可否", + "use": "使用", + "noUse": "未使用", + "cancel": "キャンセル", + "save": "保存", + "saving": "保存中…", + "errRequired": "役割コードと名前は必須です。", + "errDupCode": "既に存在する役割コードです。" + }, + "menu": { + "title": "メニュー管理", + "subtitle": "役割別ナビゲーションメニューツリーとパス·アクセス役割を管理します。", + "addMenu": "メニュー追加", + "loadError": "メニューを読み込めませんでした。", + "treeAria": "メニューツリー", + "emptyTitle": "登録されたメニューがありません", + "emptyDesc": "メニューを追加してください。", + "colName": "メニュー名", + "colPath": "パス", + "colType": "種別", + "colRole": "アクセス役割", + "colSort": "並び順", + "colUse": "使用", + "colManage": "管理", + "all": "全体", + "unused": "未使用", + "used": "使用", + "edit": "編集", + "delete": "削除", + "deleteConfirm": "メニュー '{{name}}' を削除しますか?下位メニューがある場合は一緒に整理されます。", + "saved": "メニューを保存しました。", + "deleted": "メニューを削除しました。", + "editMenu": "メニュー編集", + "menuId": "メニュー ID (一意キー) *", + "menuIdPh": "例: admin-users", + "menuName": "メニュー名 *", + "parent": "上位メニュー", + "topLevel": "(最上位)", + "path": "パス(path)", + "pathPh": "例: /admin/users", + "type": "種別", + "accessRole": "アクセス役割", + "accessRolePh": "例: ADMIN (空欄で全体)", + "sort": "並び順", + "useYn": "使用可否", + "cancel": "キャンセル", + "save": "保存", + "saving": "保存中…", + "errName": "メニュー名は必須です。", + "errId": "メニュー ID(一意キー)は必須です。", + "errDupId": "既に存在するメニュー ID です。" + }, + "code": { + "title": "共通コード管理", + "subtitle": "ドロップダウン·分類に使う共通コードグループとコード値を管理します。", + "addGroup": "グループ追加", + "groupLoadError": "コードグループを読み込めませんでした。", + "noGroupsTitle": "コードグループがありません", + "noGroupsDesc": "グループを追加して共通コードを構成してください。", + "codeGroup": "コードグループ", + "codeValues": "コード値", + "valuesOf": "{{name}} 値", + "editGroup": "コードグループ編集", + "deleteGroup": "グループ削除", + "deleteGroupConfirm": "グループ '{{name}}' と下位コードを削除しますか?", + "addCode": "コード追加", + "codeLoadError": "コード値を読み込めませんでした。", + "noCodesTitle": "コードがありません", + "noCodesDesc": "コードを追加してください。", + "code": "コード", + "name": "名前", + "value": "値", + "sort": "並び順", + "use": "使用", + "manage": "管理", + "unused": "未使用", + "used": "使用", + "edit": "編集", + "delete": "削除", + "deleteCodeConfirm": "コード '{{name}}' を削除しますか?", + "groupSaved": "コードグループを保存しました。", + "groupDeleted": "コードグループを削除しました。", + "codeSaved": "コードを保存しました。", + "codeDeleted": "コードを削除しました。", + "groupCode": "グループコード *", + "groupName": "グループ名 *", + "description": "説明", + "useYn": "使用可否", + "cancel": "キャンセル", + "save": "保存", + "saving": "保存中…", + "errGroupRequired": "グループコードと名前は必須です。", + "errDupGroup": "既に存在するグループコードです。", + "editCode": "コード編集", + "group": "グループ", + "codeReq": "コード *", + "nameReq": "名前 *", + "attr1": "属性1(attr1)", + "errCodeRequired": "コードと名前は必須です。", + "errDupCode": "既に存在するコードです。" + }, + "sysuser": { + "title": "ユーザー管理", + "subtitle": "アカウント作成·編集、役割割当、ステータス(有効/ロック/無効)と OTP 初期化を管理します。パスワードはマスクされ表示されません。", + "addUser": "ユーザー追加", + "searchAria": "ユーザー検索", + "keyword": "検索語", + "keywordPh": "名前·メール", + "role": "役割", + "status": "ステータス", + "all": "全体", + "reset": "リセット", + "search": "検索", + "loadError": "ユーザー一覧を読み込めませんでした。", + "listAria": "ユーザー一覧", + "emptyTitle": "ユーザーがいません", + "emptyDesc": "検索条件を調整するかユーザーを追加してください。", + "colUser": "ユーザー", + "colEmail": "メール", + "colRole": "役割", + "colDept": "部署", + "colStatus": "ステータス", + "colLastLogin": "最終ログイン", + "colManage": "管理", + "hallManagerTag": "ホールMgr", + "otpSet": "設定済", + "otpUnset": "未設定", + "edit": "編集", + "lock": "ロック", + "unlock": "ロック解除", + "otpResetBtn": "OTP初期化", + "otpResetConfirm": "{{name}} の OTP を初期化しますか?", + "saved": "ユーザーを保存しました。", + "statusChanged": "ステータスを変更しました。", + "otpReset": "OTP を初期化しました。", + "active": "有効", + "locked": "ロック", + "disabled": "無効", + "editUser": "ユーザー編集", + "newDesc": "初期パスワードは保存後に画面に表示されません。", + "editDesc": "メールは編集できません。パスワード再設定は別途ポリシーに従います。", + "email": "メール *", + "displayName": "表示名 *", + "dept": "部署", + "initPassword": "初期パスワード *", + "hallManagerCheck": "ホールマネージャー(内部運営者)", + "cancel": "キャンセル", + "save": "保存", + "saving": "保存中…", + "errRequired": "メールと表示名は必須です。", + "errPassword": "新規ユーザーは初期パスワードが必要です。", + "totalUsers": "全 <0>{{total}}0>名", + "prev": "前へ", + "next": "次へ" + }, + "settings": { + "title": "システム設定", + "subtitle": "グローバルポリシー·機能トグル·AI ゲートなどのシステムパラメータを管理します。シークレット値はマスキング表示されます。", + "loadError": "システム設定を読み込めませんでした。", + "emptyTitle": "登録された設定がありません", + "emptyDesc": "下のフォームで最初の設定項目を追加してください。", + "saved": "設定を保存しました。", + "deleted": "設定を削除しました。", + "deleteConfirm": "設定 '{{key}}' を削除しますか?", + "general": "一般", + "groupAria": "設定グループ {{name}}", + "secret": "シークレット", + "secretTitle": "シークレット — 値マスキング", + "on": "オン", + "off": "オフ", + "secretPh": "設定済(マスキング) — 変更時に新しい値を入力", + "save": "保存", + "delete": "削除", + "deleteKey": "{{key}} 削除", + "secretHint": "シークレット原文は表示されません。値を入力せず保存すると既存の値が維持されます。", + "addAria": "設定追加", + "addTitle": "設定追加 / 再定義", + "settingKey": "設定キー", + "settingKeyPh": "例: deadline.utility.days", + "value": "値", + "type": "タイプ", + "description": "説明", + "secretCheck": "シークレット(値マスキング)" + }, + "slide": { + "title": "ログインスライド管理", + "subtitle": "ログイン画面左側カルーセルの画像·文言 — 有効なスライドのうち<0>最新登録順5件0>がローテーションされます。", + "addAria": "新規スライド追加", + "addTitle": "新規スライド追加", + "imageLabel": "画像 (PNG/JPG/WebP, 8MB 以下)", + "preview": "プレビュー", + "titleLabel": "タイトル文言", + "titlePh": "例) アジアを代表する展示コンベンションセンター、KINTEX", + "captionLabel": "キャプション(補助文言)", + "captionPh": "例) 10万㎡ の展示スペースで広がるビジネスチャンスをご体験ください。", + "submitting": "登録中…", + "submit": "スライド登録", + "listAria": "スライド一覧", + "registered": "登録されたスライド", + "countActive": "全 {{total}}件 · 有効 {{active}}件", + "emptyTitle": "登録されたスライドがありません", + "emptyDesc": "画像と文言を登録するとログイン画面のカルーセルに表示されます。(未登録時はデフォルト5種を表示)", + "live": "表示中", + "inactive": "無効", + "registeredAt": "登録 {{date}}", + "active": "有効", + "delete": "削除", + "listError": "一覧を読み込めませんでした。", + "errImage": "スライド画像を選択してください。", + "errTitle": "タイトル文言を入力してください。", + "createFail": "登録に失敗しました。", + "updateFail": "変更に失敗しました。", + "deleteFail": "削除に失敗しました。", + "deleteConfirm": "\"{{title}}\" スライドを削除しますか?" } } diff --git a/src/frontend/src/i18n/locales/ko.json b/src/frontend/src/i18n/locales/ko.json index fe7fdb6..51855cc 100644 --- a/src/frontend/src/i18n/locales/ko.json +++ b/src/frontend/src/i18n/locales/ko.json @@ -14,6 +14,17 @@ "states": { "loading": "불러오는 중입니다…", "empty": "표시할 내용이 없습니다." + }, + "aiWatermark": "AI 생성 예상 이미지 — 실제 시공 결과와 다를 수 있습니다", + "aiNotice": "AI 생성 이미지는 계약·심사 서류에 사용할 수 없습니다 — 시공 기준은 도면입니다", + "weekday": { + "0": "일", + "1": "월", + "2": "화", + "3": "수", + "4": "목", + "5": "금", + "6": "토" } }, "lang": { @@ -27,7 +38,9 @@ "transport": "교통" }, "searchPlaceholder": "행사 검색", - "cta": { "register": "관람 사전등록" }, + "cta": { + "register": "관람 사전등록" + }, "menuOpen": "메뉴 열기", "menuClose": "메뉴 닫기", "homeAria": "{{brand}} 홈", @@ -156,7 +169,12 @@ "fabCta": "사전등록 바로가기" }, "register": { - "steps": { "type": "유형", "info": "정보", "interest": "관심분야", "done": "완료" }, + "steps": { + "type": "유형", + "info": "정보", + "interest": "관심분야", + "done": "완료" + }, "stepsAria": "사전등록 단계", "type": { "general": "일반 관람객", @@ -358,7 +376,12 @@ "vipDesc": "전시회 올패스 + VIP 라운지 이용권", "vipPrice": "₩50,000" }, - "steps": { "s1": "행사·권종", "s2": "예매자", "s3": "결제", "s4": "완료" }, + "steps": { + "s1": "행사·권종", + "s2": "예매자", + "s3": "결제", + "s4": "완료" + }, "stepsAria": "예매 단계", "bannerTitle": "스마트팩토리 코리아 2026", "bannerDate": "2026.09.05 – 09.08", @@ -517,5 +540,743 @@ "errRequest": "요청 중 오류가 발생했습니다.", "errReset": "재설정 중 오류가 발생했습니다.", "cancel": "취소" + }, + "dashboard": { + "countdown": "개장 D-{{n}}", + "degradedTag": "오프라인 — 샘플", + "degradedMsg": "집계 API에 연결하지 못해 시연용 샘플을 표시합니다.", + "loadError": "대시보드 집계를 불러오지 못했습니다.", + "timelineAria": "마일스톤 타임라인", + "milestoneEmptyTitle": "파생 일정이 없습니다", + "milestoneEmptyDesc": "행사 시작일이 확정되면 마일스톤이 표시됩니다.", + "mstate": { + "done": "완료", + "current": "진행 중", + "upcoming": "예정" + }, + "kpisAria": "핵심 지표", + "kpiEmpty": "집계된 지표가 없습니다", + "exhibitorStatus": "참가업체 현황", + "sampleTag": "샘플", + "exhibitorEmptyTitle": "참가업체 부스가 없습니다", + "exhibitorEmptyDesc": "부스 배치·설계안이 등록되면 여기에 표시됩니다.", + "colBoothNo": "부스번호", + "colCompany": "업체명", + "colStatus": "설계 상태", + "feedAria": "알림 피드", + "feedTitle": "알림", + "feedEmpty": "새 알림이 없습니다", + "aerialAria": "홀 전경 조감", + "aerialTitle": "홀 전경 조감", + "aerialShot": "S7 홀 전경", + "aerialAlt": "홀 전경 조감 예상 이미지" + }, + "schedule": { + "title": "전시 일정", + "subtitle": "킨텍스 전 홀 행사 일정 관리·모니터링 — {{n}}건", + "crawlFallback": "(kintex.com 크롤링 폴백)", + "yearFilter": "연도 필터", + "allYears": "전체 연도", + "yearN": "{{y}}년", + "hallFilter": "홀 필터", + "allHalls": "전체 홀 (1-10)", + "hall1": "제1전시장 (홀 1-5)", + "hall2": "제2전시장 (홀 6-10)", + "catFilter": "카테고리 필터", + "allCats": "전체 카테고리", + "statusFilter": "상태 필터", + "allStatus": "전체 상태", + "cat": { + "exhibition": "전시", + "culture": "문화·예술", + "trade": "무역·산업", + "tech": "기술" + }, + "status": { + "ongoing": "진행중", + "upcoming": "예정", + "ended": "종료" + }, + "catExhibit": "{{cat}} 전시", + "apiWait": "행사 API 대기 (크롤링 데이터)", + "viewSwitch": "보기 전환", + "viewList": "목록", + "viewCalendar": "캘린더", + "listLoading": "행사 목록 로딩 중", + "listAria": "행사 목록", + "loadError": "전시 일정을 불러오지 못했습니다.", + "count": "{{n}}건", + "emptyTitle": "등록된 행사가 없습니다", + "emptyDesc": "필터를 조정하거나 새 행사를 생성하세요.", + "newEvent": "새 행사 생성", + "period": "기간", + "assignedHall": "배정 홀", + "estVisitors": "추정 인원", + "editDetail": "상세 편집", + "viewLogistics": "반입/물류 보기", + "monthCal": "월 캘린더", + "prev": "이전", + "next": "다음", + "today": "오늘", + "yearMonth": "{{y}}년 {{m}}월", + "miniCal": "미니 캘린더", + "prevMonth": "이전 달", + "nextMonth": "다음 달", + "offlineSample": "오프라인 — 샘플", + "day": "{{d}}일", + "dayHasEvent": "{{d}}일 (행사 있음)", + "hallUtil": "홀 가동률", + "hallUtilMonth": "홀 가동률 ({{y}}년 {{m}}월)" + }, + "settlement": { + "title": "정산·결제", + "subtitle": "행사 참가업체 청구 · 수납 현황 관리", + "generate": "청구서 생성", + "generating": "생성 중…", + "noEventTitle": "행사를 선택해 주세요", + "noEventDesc": "정산 현황을 조회할 행사가 지정되지 않았습니다.", + "genDone": "청구서 생성 완료 — 신규 {{created}}건, 기존 유지 {{skipped}}건.", + "genError": "청구서 생성에 실패했습니다. 권한 또는 대상 데이터를 확인해 주세요.", + "dashError": "정산 현황 집계를 불러오지 못했습니다.", + "kpisAria": "정산 핵심 지표", + "kpiBilled": "총 청구액", + "kpiCollected": "수납액", + "kpiOutstanding": "미수금", + "kpiOverdue": "연체 청구", + "count": "{{n}}건", + "collectionRate": "수납률 {{rate}}%", + "outstandingSub": "미수 잔액", + "overdueSub": "납부 기한 경과", + "distTitle": "청구 상태 분포", + "distEmpty": "집계된 청구가 없습니다", + "listTitle": "청구서 목록", + "invoices": "청구서", + "statusFilter": "상태 필터", + "status": { + "all": "전체", + "pending": "미발행", + "invoiced": "청구됨", + "paid": "완납", + "overdue": "연체" + }, + "listError": "청구서 목록을 불러오지 못했습니다.", + "emptyTitle": "청구서가 없습니다", + "emptyManage": "상단의 청구서 생성으로 참가업체별 청구를 집계하세요.", + "emptyView": "아직 발행된 청구서가 없습니다.", + "colNo": "청구번호", + "colExhibitor": "참가업체", + "colBilled": "청구액", + "colPaid": "수납", + "colOutstanding": "미수", + "colDue": "납부기한", + "colStatus": "상태", + "detail": "상세", + "prev": "이전", + "next": "다음", + "pageInfo": "{{page}} / {{total}} · 총 {{cnt}}건", + "modalAria": "청구서 상세", + "invoice": "청구서", + "close": "닫기", + "detailError": "청구서 상세를 불러오지 못했습니다.", + "liItem": "항목", + "liQty": "수량", + "liUnit": "단가", + "liAmount": "금액", + "noLines": "라인 항목 없음", + "supplyAmount": "공급가액", + "vat": "부가세(10%)", + "grandTotal": "청구 총액", + "paidOutstanding": "수납 / 미수", + "payHistory": "수납 이력", + "noPayments": "수납 기록이 없습니다.", + "won": "{{v}}원", + "issue": "청구서 발행", + "issuing": "발행 중…", + "payAmount": "수납 금액", + "payAmountPh": "수납 금액(원)", + "payMethod": "수납 방법", + "methodTransfer": "계좌이체", + "methodCard": "카드", + "methodOther": "기타", + "memo": "메모", + "memoPh": "메모(선택)", + "recordPay": "수납 기록", + "recording": "기록 중…", + "payFail": "수납 기록에 실패했습니다. 금액(총액 이내)·권한을 확인해 주세요.", + "errPositive": "0보다 큰 금액을 입력해 주세요.", + "errExceed": "미수 잔액을 초과할 수 없습니다.", + "pgNote": "실제 결제는 외부 PG로 위임됩니다. 본 화면은 수납 확인 기록 전용입니다.", + "managerOnly": "수납·발행은 주최자/홀매니저만 수행할 수 있습니다." + }, + "hallassign": { + "title": "홀 배정 · 자동 견적", + "subtitle": "홀×기간 배정 현황과 요율 룰셋 기반 즉시 견적(M1).", + "start": "시작", + "end": "종료", + "newAssign": "새 배정", + "board": "배정 보드", + "boardHint": "겹치는 배정은 충돌로 표시됩니다.", + "forbiddenTitle": "홀매니저 전용 보드", + "forbiddenDesc": "배정 현황 조회는 킨텍스 홀매니저 계정만 가능합니다. 자동 견적은 아래에서 이용하세요.", + "noHalls": "홀 마스터가 없습니다.", + "noAssign": "배정 없음", + "release": "배정 해제", + "releaseConfirm": "{{event}}의 {{hall}} 배정을 해제할까요?", + "assignedToast": "홀이 배정되었습니다.", + "releasedToast": "배정이 해제되었습니다.", + "releaseFail": "해제에 실패했습니다(권한 확인).", + "conflictErr": "배정하려는 홀·기간이 다른 행사와 겹칩니다.", + "assignFail": "배정에 실패했습니다.", + "pickEventHall": "행사와 홀을 선택하세요.", + "modalTitle": "홀 배정", + "close": "닫기", + "event": "행사", + "pickEvent": "행사 선택…", + "hall": "홀", + "pickHall": "홀 선택…", + "occupyStart": "점유 시작", + "occupyEnd": "점유 종료", + "primary": "주 배정 홀(primary)", + "note": "비고", + "optional": "선택 입력", + "cancel": "취소", + "assign": "배정", + "assigning": "배정 중…", + "autoQuote": "자동 견적", + "quoteHint": "요율 룰셋 기준 · 최종 금액은 킨텍스 확정 시 안내", + "startDate": "시작일", + "endDate": "종료일", + "boothSize": "부스 규모(선택)", + "countPh": "개수", + "calcQuote": "견적 계산", + "calculating": "계산 중…", + "quoteFail": "견적 계산에 실패했습니다.", + "quoteReq": "홀·시작일·종료일을 입력하세요.", + "qItem": "항목", + "qBasis": "산출 근거", + "qAmount": "금액", + "rentalSubtotal": "임대료 소계", + "rentalTotal": "임대료 합계 ({{days}}일)", + "deposit": "관리비 예치금(별도)", + "ruleset": "(룰셋 {{v}})" + }, + "analytics": { + "title": "경영분석", + "subtitle": "킨텍스 운영 수익성 · 참가업체 인사이트", + "perspective": "분석 관점", + "operator": "운영사", + "exhibitor": "참가업체", + "periodAria": "분석 기간", + "periodEvent": "이번 행사", + "periodD90": "최근 90일", + "periodAnnual": "연간", + "exportPdf": "PDF 내보내기", + "exportExcel": "Excel 내보내기", + "noEventTitle": "행사를 선택해 주세요", + "noEventDesc": "분석할 행사가 지정되지 않았습니다.", + "offlineSample": "오프라인 — 샘플", + "degradedMsg": "집계 API에 연결하지 못해 시연용 샘플을 표시합니다.", + "loadError": "경영분석 집계를 불러오지 못했습니다.", + "kpisAria": "핵심 지표", + "kpiEmpty": "집계된 지표가 없습니다", + "trendAria": "추세 차트", + "trendTitle": "매출 · 부스 추이", + "monthlyAgg": "월별 집계", + "trendHint": "월별 임대·유틸리티 매출과 부스 수 추이입니다 (매출 단위: 원).", + "trendEmptyTitle": "추이 데이터가 없습니다", + "trendEmptyDesc": "집계 가능한 기간의 매출·부스 레코드가 없습니다.", + "revenue": "매출", + "booth": "부스", + "boothsUnit": "{{n}} 부스", + "sectorsAria": "업종별 구성", + "sectorsTitle": "업종별 구성", + "sectorsEmptyTitle": "업종 분류 미집계", + "sectorsEmptyDesc": "업종 분류 원천(M13)이 연동되면 표시됩니다.", + "perfTable": "성과 테이블", + "pnlTitle": "손익(P&L) 구성", + "exhibitorPerfTitle": "참가사 성과", + "sampleTag": "샘플", + "pnlEmptyTitle": "손익 항목이 없습니다", + "pnlEmptyDesc": "부스 임대·유틸리티 매출 레코드가 집계되면 표시됩니다.", + "colItem": "항목", + "colAmount": "금액", + "colGrade": "등급", + "perfEmptyTitle": "참가사 성과 미집계", + "perfEmptyDesc": "리드·전환율 원천(M13 방문자)이 연동되면 표시됩니다.", + "colCompany": "업체명", + "colLeads": "리드", + "colConversion": "전환율" + }, + "global": { + "sectionAria": "테넌트 전역 경영분석", + "title": "전사 경영분석 (행사 횡단)", + "subtitle": "행사 간 매출 비교 · 홀 가동률 추이 · 참가사 리텐션 · 수요예측", + "yearsAria": "분석 연수", + "years3": "최근 3년", + "years5": "최근 5년", + "years10": "최근 10년", + "loadErrorTitle": "전역 집계를 불러오지 못했습니다", + "loadErrorDesc": "잠시 후 다시 시도해 주세요.", + "kpisAria": "전역 핵심 지표", + "kpiEmpty": "집계된 지표가 없습니다", + "evComp": "행사 간 매출 비교", + "top8": "상위 8개", + "evCompHint": "부스가 배치된 행사의 임대+유틸리티 매출 순위입니다 (단위: 원).", + "evCompEmptyTitle": "비교할 행사가 없습니다", + "evCompEmptyDesc": "부스가 배치된 행사 매출이 집계되면 표시됩니다.", + "revenue": "매출", + "retention": "참가사 리텐션", + "retentionCum": "누적 재참가율", + "returning": "재참가", + "retentionEmptyTitle": "연도별 리텐션 미집계", + "retentionEmptyDesc": "부스 배정사 원천이 연동되면 표시됩니다.", + "retentionRate": "재참가율", + "occupancy": "홀 가동률 추이 (월별)", + "occupancyHint": "개최 행사-일수 ÷ (홀수 × 그달 일수) 기준 월별 가동률입니다.", + "occupancyEmpty": "가동률 데이터가 없습니다", + "occupancyRate": "가동률", + "forecast": "수요예측", + "forecastTitle": "수요예측 (개최 추세)", + "trendProjection": "추세 투영", + "forecastHint": "연도별 개최 행사 수 실측 + 최소자승 선형 추세로 향후 2년 투영(점선).", + "forecastEmptyTitle": "예측할 이력이 없습니다", + "forecastEmptyDesc": "연도별 개최 이력이 집계되면 표시됩니다.", + "casesUnit": "{{n}}건", + "actual": "실측", + "projected": "예측" + }, + "ops": { + "title": "홀 현장운영", + "subtitle": "체크인·유틸리티 실집계 기반 현황", + "modeAria": "조회 모드", + "live": "실시간", + "history": "이력 조회", + "noEventTitle": "행사를 선택해 주세요", + "noEventDesc": "현장 운영을 조회할 행사가 지정되지 않았습니다.", + "offlineSample": "오프라인 — 샘플", + "degradedMsg": "집계 API에 연결하지 못해 시연용 샘플을 표시합니다.", + "loadError": "현장 운영 현황을 불러오지 못했습니다.", + "hallStatus": "홀별 현황", + "checkinDerived": "체크인(M10) 파생 · 면적 비중 분배", + "sensorNoneHalls": "센서 미연동 · 배정 홀 목록", + "noHallsTitle": "배정된 홀이 없습니다", + "noHallsDesc": "행사에 홀이 배정되면 표시됩니다.", + "busy": "혼잡", + "smooth": "원활", + "moderate": "보통", + "people": "{{n}}명", + "onSiteCheckin": "현장 체크인 <0>{{n}}0>명", + "noCheckinAgg": "체크인 집계 없음", + "powerLoad": "전력 부하", + "totalKw": "총 <0>{{v}}0>kW", + "utilLoadAgg": "유틸리티 신청(M4) 부하 집계", + "noLoadTitle": "신청 부하 없음", + "noLoadDesc": "전기 유틸리티 신청이 접수되면 홀별 부하가 표시됩니다.", + "kw": "{{v}}kW", + "hourlyCheckin": "시간대별 체크인", + "hourlyAgg": "체크인(M10) 시간대 집계", + "noSeriesTitle": "체크인 추이 없음", + "noSeriesDesc": "현장 체크인이 기록되면 시간대별 추이가 표시됩니다.", + "facilitySensor": "시설 센서 연동", + "facilitySensorTitle": "시설 센서 (공조·조명·주차)", + "sensorNoneTitle": "시설 센서 미연동", + "sensorNoneDesc": "공조(HVAC)·조명 IoT·주차(iparking) 연계는 센서(M14) 연동 시 표시됩니다.", + "temp": "온도", + "humidity": "습도", + "parkingSlots": "{{zone}}: {{occ}}/{{cap}}면" + }, + "admin": { + "deniedTitle": "접근 권한이 없습니다", + "deniedAuthed": "이 페이지는 시스템 관리자(홀매니저·ADMIN)만 이용할 수 있습니다. 접근이 필요하면 관리자에게 문의하세요.", + "deniedGuest": "관리자 백오피스는 로그인 후 이용할 수 있습니다.", + "overviewTitle": "시스템 개요", + "overviewSub": "실시간 지표 및 운영 상태", + "loginSlides": "로그인 슬라이드 관리", + "tenantSwitch": "테넌트 전환", + "systemNormal": "시스템 정상", + "moduleShortcuts": "관리 모듈 바로가기", + "offlineSample": "오프라인 — 샘플", + "degradedMsg": "집계 API에 연결하지 못해 시연용 샘플을 표시합니다.", + "dashLoadError": "관리자 대시보드를 불러오지 못했습니다.", + "kpisAria": "핵심 지표", + "kpiEmpty": "집계된 지표가 없습니다", + "visitorTrend": "방문객 추이", + "hourlyVisitors": "시간대별 관람객", + "visitors": "관람객", + "visitorTrendEmptyTitle": "관람객 추이 미집계", + "visitorTrendEmptyDesc": "관람객 계측 센서(M14)가 연동되면 시간대별 추이가 표시됩니다.", + "liveEvents": "진행 중 이벤트", + "noLiveEvents": "진행 중인 행사가 없습니다", + "hallTbd": "홀 미배정", + "occupancy": "점유 {{n}}%", + "modUsers": "사용자", + "modRoles": "역할·권한", + "modCodes": "공통코드", + "modMenus": "메뉴", + "modAudit": "감사로그", + "modSettings": "시스템설정", + "modRulesets": "룰셋", + "modTenants": "테넌트" + }, + "tenant": { + "title": "테넌트 관리·온보딩", + "subtitle": "플랫폼 슈퍼관리자 전용 · 코드 배포 없이 데이터 온보딩으로 전시관을 추가합니다.", + "sampleTag": "샘플 · 미구현", + "sampleNote": "멀티테넌시(테넌트 CRUD·온보딩·격리)가 백엔드에 아직 없어 화면은 목표 IA 를 표시합니다. 실 데이터 연동은 멀티테넌시 도입 후 배선됩니다.", + "listAria": "테넌트 목록", + "venue": "전시관", + "notImpl": "미구현", + "addVenue": "전시관 추가", + "code": "코드", + "domain": "도메인", + "status": "상태", + "events": "행사", + "users": "사용자", + "wizardAria": "온보딩 위저드", + "onboardingOf": "{{name}} 온보딩", + "dataOnboarding": "코드 배포 없이 데이터 온보딩", + "wiz": { + "create": { + "label": "테넌트 생성", + "desc": "코드·표시명 등록" + }, + "domain": { + "label": "서브도메인", + "desc": "DNS·브랜딩 도메인 배정" + }, + "brand": { + "label": "브랜딩", + "desc": "로고·색상·공개사이트 테마" + }, + "master": { + "label": "마스터데이터 입력", + "desc": "홀·요율·룰셋 시드" + }, + "admin": { + "label": "관리자 계정", + "desc": "테넌트 관리자 초대" + }, + "smoke": { + "label": "격리 스모크", + "desc": "크로스-테넌트 격리 검증" + }, + "activate": { + "label": "활성화", + "desc": "운영 전환" + } + }, + "tstatus": { + "active": "활성", + "onboarding": "온보딩중", + "suspended": "중지" + } + }, + "ruleset": { + "title": "규정 룰셋 버전 관리", + "subtitle": "규정 개정 대응을 위한 룰셋 버전 이력과 규칙 항목입니다.", + "readonly": "읽기 전용", + "readonlyNote": "룰셋은 배포 리소스(rulesets/*.json)로 버전 관리됩니다. 편집·버전 생성은 규정 개정 시 리소스 교체로 수행되며 화면에서는 조회만 가능합니다.", + "listError": "룰셋 목록을 불러오지 못했습니다.", + "noRulesets": "배포된 룰셋이 없습니다", + "versionHistory": "버전 히스토리", + "rulesCount": "규칙 {{n}}", + "effectiveDate": "발효일 {{date}}", + "ruleList": "규칙 목록", + "rulesOf": "{{name}} 규칙 ({{n}}개)", + "detailError": "규칙 전문을 불러오지 못했습니다.", + "noRulesTitle": "규칙 배열이 없는 룰셋입니다", + "noRulesDesc": "요율 마스터 등 규칙(rules) 없이 요율·단가만 정의된 룰셋입니다.", + "colRuleName": "규칙명", + "colValue": "수치/설정값", + "colLogic": "논리 / 요구사항", + "colSeverity": "위반 수준", + "block": "차단", + "warn": "경고", + "required": "필수", + "perHall": "홀별 {{unit}}", + "docNeeded": "문서 필요: {{doc}}" + }, + "audit": { + "title": "감사 로그", + "subtitle": "승인·낙찰·설정변경 등 모든 관리 액션과 중요 도메인 이벤트를 추적합니다. (무결성 유지)", + "exportCsv": "CSV 내보내기", + "filterAria": "감사 로그 필터", + "actionType": "액션 유형", + "allActions": "전체 액션", + "actorId": "담당자 ID", + "actorIdPh": "예: user-102", + "eventId": "행사 ID", + "eventIdPh": "예: EVT-2026-01", + "reset": "초기화", + "applyFilter": "필터 적용", + "loadError": "감사 로그를 불러오지 못했습니다.", + "tableAria": "감사 로그 테이블", + "emptyTitle": "조건에 맞는 감사 기록이 없습니다", + "emptyDesc": "필터를 조정하거나 초기화해 다시 조회하세요.", + "colTime": "시각", + "colUser": "사용자", + "colAction": "액션", + "colTarget": "대상", + "colEvent": "행사", + "colResult": "결과", + "showing": "표시 중 <0>{{from}}-{{to}}0> / 총 <1>{{total}}1>개", + "prev": "이전", + "next": "다음", + "rowsShow": "행 표시", + "rowsPerPage": "페이지당 행 수", + "rowsN": "{{n}}개", + "success": "성공", + "failure": "실패", + "csvTime": "시각", + "csvActorId": "담당자ID", + "csvActorName": "담당자명", + "csvAction": "액션", + "csvTargetType": "대상유형", + "csvTargetId": "대상ID", + "csvEventId": "행사ID", + "csvResult": "결과", + "csvSummary": "요약" + }, + "role": { + "title": "역할 · 권한 (RBAC)", + "subtitle": "역할(주최자·참가업체·장치업체·홀매니저·관리자·관람객)별 리소스 권한을 매트릭스로 배정합니다.", + "addRole": "역할 추가", + "loadError": "역할·권한 정보를 불러오지 못했습니다.", + "noRolesTitle": "정의된 역할이 없습니다", + "noRolesDesc": "역할을 추가해 RBAC 를 구성하세요.", + "roleList": "역할 목록", + "role": "역할", + "codePerm": "{{code}} · 권한 {{n}}", + "unused": "미사용", + "permMatrix": "권한 매트릭스", + "rolePerm": "{{name}} 권한", + "roleInfo": "역할 정보", + "deleteConfirm": "역할 '{{name}}' 을(를) 삭제하시겠습니까?", + "delete": "삭제", + "noPerms": "정의된 권한이 없습니다", + "selectRole": "역할을 선택하세요", + "saved": "역할을 저장했습니다.", + "deleted": "역할을 삭제했습니다.", + "addRoleModal": "역할 추가", + "editRole": "역할 정보 수정", + "modalDesc": "권한 배정은 저장 후 매트릭스에서 체크로 관리합니다.", + "roleCode": "역할 코드 *", + "roleCodePh": "예: ORGANIZER", + "roleName": "역할 이름 *", + "description": "설명", + "useYn": "사용 여부", + "use": "사용", + "noUse": "미사용", + "cancel": "취소", + "save": "저장", + "saving": "저장 중…", + "errRequired": "역할 코드와 이름은 필수입니다.", + "errDupCode": "이미 존재하는 역할 코드입니다." + }, + "menu": { + "title": "메뉴 관리", + "subtitle": "역할별 내비게이션 메뉴 트리와 경로·접근 역할을 관리합니다.", + "addMenu": "메뉴 추가", + "loadError": "메뉴를 불러오지 못했습니다.", + "treeAria": "메뉴 트리", + "emptyTitle": "등록된 메뉴가 없습니다", + "emptyDesc": "메뉴를 추가하세요.", + "colName": "메뉴명", + "colPath": "경로", + "colType": "유형", + "colRole": "접근 역할", + "colSort": "정렬", + "colUse": "사용", + "colManage": "관리", + "all": "전체", + "unused": "미사용", + "used": "사용", + "edit": "수정", + "delete": "삭제", + "deleteConfirm": "메뉴 '{{name}}' 을(를) 삭제하시겠습니까? 하위 메뉴가 있으면 함께 정리됩니다.", + "saved": "메뉴를 저장했습니다.", + "deleted": "메뉴를 삭제했습니다.", + "editMenu": "메뉴 수정", + "menuId": "메뉴 ID (고유 키) *", + "menuIdPh": "예: admin-users", + "menuName": "메뉴명 *", + "parent": "상위 메뉴", + "topLevel": "(최상위)", + "path": "경로(path)", + "pathPh": "예: /admin/users", + "type": "유형", + "accessRole": "접근 역할", + "accessRolePh": "예: ADMIN (비우면 전체)", + "sort": "정렬", + "useYn": "사용 여부", + "cancel": "취소", + "save": "저장", + "saving": "저장 중…", + "errName": "메뉴명은 필수입니다.", + "errId": "메뉴 ID(고유 키)는 필수입니다.", + "errDupId": "이미 존재하는 메뉴 ID 입니다." + }, + "code": { + "title": "공통코드 관리", + "subtitle": "드롭다운·분류에 쓰이는 공통코드 그룹과 코드 값을 관리합니다.", + "addGroup": "그룹 추가", + "groupLoadError": "코드 그룹을 불러오지 못했습니다.", + "noGroupsTitle": "코드 그룹이 없습니다", + "noGroupsDesc": "그룹을 추가해 공통코드를 구성하세요.", + "codeGroup": "코드 그룹", + "codeValues": "코드 값", + "valuesOf": "{{name}} 값", + "editGroup": "코드 그룹 수정", + "deleteGroup": "그룹 삭제", + "deleteGroupConfirm": "그룹 '{{name}}' 과 하위 코드를 삭제하시겠습니까?", + "addCode": "코드 추가", + "codeLoadError": "코드 값을 불러오지 못했습니다.", + "noCodesTitle": "코드가 없습니다", + "noCodesDesc": "코드를 추가하세요.", + "code": "코드", + "name": "이름", + "value": "값", + "sort": "정렬", + "use": "사용", + "manage": "관리", + "unused": "미사용", + "used": "사용", + "edit": "수정", + "delete": "삭제", + "deleteCodeConfirm": "코드 '{{name}}' 을(를) 삭제하시겠습니까?", + "groupSaved": "코드 그룹을 저장했습니다.", + "groupDeleted": "코드 그룹을 삭제했습니다.", + "codeSaved": "코드를 저장했습니다.", + "codeDeleted": "코드를 삭제했습니다.", + "groupCode": "그룹 코드 *", + "groupName": "그룹 이름 *", + "description": "설명", + "useYn": "사용 여부", + "cancel": "취소", + "save": "저장", + "saving": "저장 중…", + "errGroupRequired": "그룹 코드와 이름은 필수입니다.", + "errDupGroup": "이미 존재하는 그룹 코드입니다.", + "editCode": "코드 수정", + "group": "그룹", + "codeReq": "코드 *", + "nameReq": "이름 *", + "attr1": "속성1(attr1)", + "errCodeRequired": "코드와 이름은 필수입니다.", + "errDupCode": "이미 존재하는 코드입니다." + }, + "sysuser": { + "title": "사용자 관리", + "subtitle": "계정 생성·수정, 역할 배정, 상태(활성/잠금/비활성)와 OTP 초기화를 관리합니다. 비밀번호는 마스킹되어 표시되지 않습니다.", + "addUser": "사용자 추가", + "searchAria": "사용자 검색", + "keyword": "검색어", + "keywordPh": "이름·이메일", + "role": "역할", + "status": "상태", + "all": "전체", + "reset": "초기화", + "search": "검색", + "loadError": "사용자 목록을 불러오지 못했습니다.", + "listAria": "사용자 목록", + "emptyTitle": "사용자가 없습니다", + "emptyDesc": "검색 조건을 조정하거나 사용자를 추가하세요.", + "colUser": "사용자", + "colEmail": "이메일", + "colRole": "역할", + "colDept": "부서", + "colStatus": "상태", + "colLastLogin": "최근 로그인", + "colManage": "관리", + "hallManagerTag": "홀매니저", + "otpSet": "설정됨", + "otpUnset": "미설정", + "edit": "수정", + "lock": "잠금", + "unlock": "잠금해제", + "otpResetBtn": "OTP초기화", + "otpResetConfirm": "{{name}} 의 OTP 를 초기화하시겠습니까?", + "saved": "사용자를 저장했습니다.", + "statusChanged": "상태를 변경했습니다.", + "otpReset": "OTP 를 초기화했습니다.", + "active": "활성", + "locked": "잠금", + "disabled": "비활성", + "editUser": "사용자 수정", + "newDesc": "초기 비밀번호는 저장 후 화면에 표시되지 않습니다.", + "editDesc": "이메일은 수정할 수 없습니다. 비밀번호 재설정은 별도 정책을 따릅니다.", + "email": "이메일 *", + "displayName": "표시 이름 *", + "dept": "부서", + "initPassword": "초기 비밀번호 *", + "hallManagerCheck": "홀매니저(내부 운영자)", + "cancel": "취소", + "save": "저장", + "saving": "저장 중…", + "errRequired": "이메일과 표시 이름은 필수입니다.", + "errPassword": "신규 사용자는 초기 비밀번호가 필요합니다.", + "totalUsers": "총 <0>{{total}}0>명", + "prev": "이전", + "next": "다음" + }, + "settings": { + "title": "시스템 설정", + "subtitle": "전역 정책·기능 토글·AI 게이트 등 시스템 파라미터를 관리합니다. 시크릿 값은 마스킹 표시됩니다.", + "loadError": "시스템 설정을 불러오지 못했습니다.", + "emptyTitle": "등록된 설정이 없습니다", + "emptyDesc": "아래 폼에서 첫 설정 항목을 추가하세요.", + "saved": "설정을 저장했습니다.", + "deleted": "설정을 삭제했습니다.", + "deleteConfirm": "설정 '{{key}}' 을(를) 삭제하시겠습니까?", + "general": "일반", + "groupAria": "설정 그룹 {{name}}", + "secret": "시크릿", + "secretTitle": "시크릿 — 값 마스킹", + "on": "켜짐", + "off": "꺼짐", + "secretPh": "설정됨(마스킹) — 변경 시 새 값 입력", + "save": "저장", + "delete": "삭제", + "deleteKey": "{{key}} 삭제", + "secretHint": "시크릿 원문은 노출되지 않습니다. 값을 입력하지 않고 저장하면 기존 값이 유지됩니다.", + "addAria": "설정 추가", + "addTitle": "설정 추가 / 재정의", + "settingKey": "설정 키", + "settingKeyPh": "예: deadline.utility.days", + "value": "값", + "type": "타입", + "description": "설명", + "secretCheck": "시크릿(값 마스킹)" + }, + "slide": { + "title": "로그인 슬라이드 관리", + "subtitle": "로그인 화면 좌측 캐러셀 이미지·문구 — 활성 슬라이드 중 <0>최신 등록순 5건0>이 로테이션됩니다.", + "addAria": "새 슬라이드 추가", + "addTitle": "새 슬라이드 추가", + "imageLabel": "이미지 (PNG/JPG/WebP, 8MB 이하)", + "preview": "미리보기", + "titleLabel": "타이틀 문구", + "titlePh": "예) 아시아를 대표하는 전시컨벤션센터, KINTEX", + "captionLabel": "캡션(보조 문구)", + "captionPh": "예) 10만㎡ 전시 공간에서 펼쳐지는 비즈니스 기회를 만나보세요.", + "submitting": "등록 중…", + "submit": "슬라이드 등록", + "listAria": "슬라이드 목록", + "registered": "등록된 슬라이드", + "countActive": "총 {{total}}건 · 활성 {{active}}건", + "emptyTitle": "등록된 슬라이드가 없습니다", + "emptyDesc": "이미지와 문구를 등록하면 로그인 화면 캐러셀에 노출됩니다. (미등록 시 기본 5종 표시)", + "live": "노출 중", + "inactive": "비활성", + "registeredAt": "등록 {{date}}", + "active": "활성", + "delete": "삭제", + "listError": "목록을 불러오지 못했습니다.", + "errImage": "슬라이드 이미지를 선택해 주세요.", + "errTitle": "타이틀 문구를 입력해 주세요.", + "createFail": "등록에 실패했습니다.", + "updateFail": "변경에 실패했습니다.", + "deleteFail": "삭제에 실패했습니다.", + "deleteConfirm": "\"{{title}}\" 슬라이드를 삭제할까요?" } } diff --git a/src/frontend/src/i18n/locales/zh.json b/src/frontend/src/i18n/locales/zh.json index c43f6d7..460f8b4 100644 --- a/src/frontend/src/i18n/locales/zh.json +++ b/src/frontend/src/i18n/locales/zh.json @@ -14,6 +14,17 @@ "states": { "loading": "加载中…", "empty": "暂无内容。" + }, + "aiWatermark": "AI 生成的预览图 — 实际施工效果可能不同", + "aiNotice": "AI 生成图像不可用于合同或审核文件 — 施工以图纸为准", + "weekday": { + "0": "日", + "1": "一", + "2": "二", + "3": "三", + "4": "四", + "5": "五", + "6": "六" } }, "lang": { @@ -27,7 +38,9 @@ "transport": "交通" }, "searchPlaceholder": "搜索展会", - "cta": { "register": "观众预登记" }, + "cta": { + "register": "观众预登记" + }, "menuOpen": "打开菜单", "menuClose": "关闭菜单", "homeAria": "{{brand}} 首页", @@ -156,7 +169,12 @@ "fabCta": "前往预登记" }, "register": { - "steps": { "type": "类型", "info": "信息", "interest": "兴趣领域", "done": "完成" }, + "steps": { + "type": "类型", + "info": "信息", + "interest": "兴趣领域", + "done": "完成" + }, "stepsAria": "预登记步骤", "type": { "general": "普通观众", @@ -358,7 +376,12 @@ "vipDesc": "展会通票 + VIP 休息室使用权", "vipPrice": "₩50,000" }, - "steps": { "s1": "展会·票种", "s2": "购票人", "s3": "支付", "s4": "完成" }, + "steps": { + "s1": "展会·票种", + "s2": "购票人", + "s3": "支付", + "s4": "完成" + }, "stepsAria": "预订步骤", "bannerTitle": "智能工厂韩国 2026", "bannerDate": "2026.09.05 – 09.08", @@ -517,5 +540,743 @@ "errRequest": "请求时发生错误。", "errReset": "重置时发生错误。", "cancel": "取消" + }, + "dashboard": { + "countdown": "开展 D-{{n}}", + "degradedTag": "离线 — 示例", + "degradedMsg": "无法连接汇总接口,正在显示演示示例数据。", + "loadError": "无法加载仪表盘汇总数据。", + "timelineAria": "里程碑时间轴", + "milestoneEmptyTitle": "暂无派生日程", + "milestoneEmptyDesc": "确认活动开始日期后将显示里程碑。", + "mstate": { + "done": "已完成", + "current": "进行中", + "upcoming": "待进行" + }, + "kpisAria": "核心指标", + "kpiEmpty": "暂无汇总指标", + "exhibitorStatus": "参展商现状", + "sampleTag": "示例", + "exhibitorEmptyTitle": "暂无参展商展位", + "exhibitorEmptyDesc": "登记展位布局与设计后将在此显示。", + "colBoothNo": "展位号", + "colCompany": "公司名称", + "colStatus": "设计状态", + "feedAria": "通知动态", + "feedTitle": "通知", + "feedEmpty": "暂无新通知", + "aerialAria": "展厅鸟瞰", + "aerialTitle": "展厅鸟瞰", + "aerialShot": "S7 展厅鸟瞰", + "aerialAlt": "展厅鸟瞰预览图" + }, + "schedule": { + "title": "展览日程", + "subtitle": "KINTEX 全展厅活动日程管理与监控 — {{n}} 项", + "crawlFallback": "(kintex.com 采集回退)", + "yearFilter": "年份筛选", + "allYears": "全部年份", + "yearN": "{{y}}年", + "hallFilter": "展厅筛选", + "allHalls": "全部展厅 (1-10)", + "hall1": "第一展馆 (1-5 厅)", + "hall2": "第二展馆 (6-10 厅)", + "catFilter": "类别筛选", + "allCats": "全部类别", + "statusFilter": "状态筛选", + "allStatus": "全部状态", + "cat": { + "exhibition": "展览", + "culture": "文化艺术", + "trade": "贸易产业", + "tech": "技术" + }, + "status": { + "ongoing": "进行中", + "upcoming": "待举办", + "ended": "已结束" + }, + "catExhibit": "{{cat}}展", + "apiWait": "等待活动接口 (采集数据)", + "viewSwitch": "切换视图", + "viewList": "列表", + "viewCalendar": "日历", + "listLoading": "正在加载活动列表", + "listAria": "活动列表", + "loadError": "无法加载展览日程。", + "count": "{{n}} 项", + "emptyTitle": "暂无已登记活动", + "emptyDesc": "请调整筛选条件或创建新活动。", + "newEvent": "新建活动", + "period": "期间", + "assignedHall": "分配展厅", + "estVisitors": "预计人数", + "editDetail": "编辑详情", + "viewLogistics": "查看进场/物流", + "monthCal": "月历", + "prev": "上一个", + "next": "下一个", + "today": "今天", + "yearMonth": "{{y}}年{{m}}月", + "miniCal": "迷你日历", + "prevMonth": "上个月", + "nextMonth": "下个月", + "offlineSample": "离线 — 示例", + "day": "{{d}}日", + "dayHasEvent": "{{d}}日 (有活动)", + "hallUtil": "展厅使用率", + "hallUtilMonth": "展厅使用率 ({{y}}年{{m}}月)" + }, + "settlement": { + "title": "结算·付款", + "subtitle": "管理参展商的账单与收款状况", + "generate": "生成账单", + "generating": "生成中…", + "noEventTitle": "请选择活动", + "noEventDesc": "尚未指定要查询结算状况的活动。", + "genDone": "账单生成完成 — 新增 {{created}} 项,保留 {{skipped}} 项。", + "genError": "账单生成失败。请检查权限或目标数据。", + "dashError": "无法加载结算汇总数据。", + "kpisAria": "结算核心指标", + "kpiBilled": "总账单额", + "kpiCollected": "收款额", + "kpiOutstanding": "未收款", + "kpiOverdue": "逾期账单", + "count": "{{n}} 项", + "collectionRate": "收款率 {{rate}}%", + "outstandingSub": "未收余额", + "overdueSub": "已过缴款期限", + "distTitle": "账单状态分布", + "distEmpty": "暂无汇总账单", + "listTitle": "账单列表", + "invoices": "账单", + "statusFilter": "状态筛选", + "status": { + "all": "全部", + "pending": "未开具", + "invoiced": "已开具", + "paid": "已付清", + "overdue": "逾期" + }, + "listError": "无法加载账单列表。", + "emptyTitle": "暂无账单", + "emptyManage": "请使用上方的生成账单,按参展商汇总账单。", + "emptyView": "尚未开具任何账单。", + "colNo": "账单编号", + "colExhibitor": "参展商", + "colBilled": "账单额", + "colPaid": "收款", + "colOutstanding": "未收", + "colDue": "缴款期限", + "colStatus": "状态", + "detail": "详情", + "prev": "上一页", + "next": "下一页", + "pageInfo": "{{page}} / {{total}} · 共 {{cnt}} 项", + "modalAria": "账单详情", + "invoice": "账单", + "close": "关闭", + "detailError": "无法加载账单详情。", + "liItem": "项目", + "liQty": "数量", + "liUnit": "单价", + "liAmount": "金额", + "noLines": "无明细项", + "supplyAmount": "供货金额", + "vat": "增值税(10%)", + "grandTotal": "账单总额", + "paidOutstanding": "收款 / 未收", + "payHistory": "收款记录", + "noPayments": "暂无收款记录。", + "won": "{{v}}韩元", + "issue": "开具账单", + "issuing": "开具中…", + "payAmount": "收款金额", + "payAmountPh": "收款金额(韩元)", + "payMethod": "收款方式", + "methodTransfer": "银行转账", + "methodCard": "刷卡", + "methodOther": "其他", + "memo": "备注", + "memoPh": "备注(可选)", + "recordPay": "记录收款", + "recording": "记录中…", + "payFail": "记录收款失败。请检查金额(不超过总额)和权限。", + "errPositive": "请输入大于 0 的金额。", + "errExceed": "不能超过未收余额。", + "pgNote": "实际付款委托外部支付网关处理。本页面仅用于记录收款确认。", + "managerOnly": "仅主办方/展厅经理可记录或开具收款。" + }, + "hallassign": { + "title": "展厅分配 · 自动报价", + "subtitle": "展厅×期间分配现状与基于费率规则集的即时报价(M1)。", + "start": "开始", + "end": "结束", + "newAssign": "新分配", + "board": "分配看板", + "boardHint": "重叠的分配将显示为冲突。", + "forbiddenTitle": "展厅经理专用看板", + "forbiddenDesc": "分配现状查询仅限 KINTEX 展厅经理账户。自动报价请在下方使用。", + "noHalls": "暂无展厅主数据。", + "noAssign": "无分配", + "release": "解除分配", + "releaseConfirm": "解除 {{event}} 的 {{hall}} 分配吗?", + "assignedToast": "展厅已分配。", + "releasedToast": "分配已解除。", + "releaseFail": "解除失败(请检查权限)。", + "conflictErr": "所选展厅·期间与其他活动重叠。", + "assignFail": "分配失败。", + "pickEventHall": "请选择活动和展厅。", + "modalTitle": "展厅分配", + "close": "关闭", + "event": "活动", + "pickEvent": "选择活动…", + "hall": "展厅", + "pickHall": "选择展厅…", + "occupyStart": "占用开始", + "occupyEnd": "占用结束", + "primary": "主分配展厅(primary)", + "note": "备注", + "optional": "可选输入", + "cancel": "取消", + "assign": "分配", + "assigning": "分配中…", + "autoQuote": "自动报价", + "quoteHint": "按费率规则集 · 最终金额以 KINTEX 确定为准", + "startDate": "开始日期", + "endDate": "结束日期", + "boothSize": "展位规模(可选)", + "countPh": "数量", + "calcQuote": "计算报价", + "calculating": "计算中…", + "quoteFail": "报价计算失败。", + "quoteReq": "请输入展厅·开始日期·结束日期。", + "qItem": "项目", + "qBasis": "计算依据", + "qAmount": "金额", + "rentalSubtotal": "租金小计", + "rentalTotal": "租金合计 ({{days}}天)", + "deposit": "管理费押金(另计)", + "ruleset": "(规则集 {{v}})" + }, + "analytics": { + "title": "经营分析", + "subtitle": "KINTEX 运营盈利能力 · 参展商洞察", + "perspective": "分析视角", + "operator": "运营方", + "exhibitor": "参展商", + "periodAria": "分析期间", + "periodEvent": "本次活动", + "periodD90": "最近 90 天", + "periodAnnual": "年度", + "exportPdf": "导出 PDF", + "exportExcel": "导出 Excel", + "noEventTitle": "请选择活动", + "noEventDesc": "尚未指定要分析的活动。", + "offlineSample": "离线 — 示例", + "degradedMsg": "无法连接汇总接口,正在显示演示示例数据。", + "loadError": "无法加载经营分析汇总。", + "kpisAria": "核心指标", + "kpiEmpty": "暂无汇总指标", + "trendAria": "趋势图表", + "trendTitle": "营收 · 展位趋势", + "monthlyAgg": "按月汇总", + "trendHint": "按月租赁/公用事业营收与展位数量趋势(营收单位:韩元)。", + "trendEmptyTitle": "暂无趋势数据", + "trendEmptyDesc": "可汇总期间内暂无营收/展位记录。", + "revenue": "营收", + "booth": "展位", + "boothsUnit": "{{n}} 个展位", + "sectorsAria": "行业构成", + "sectorsTitle": "行业构成", + "sectorsEmptyTitle": "行业分类未汇总", + "sectorsEmptyDesc": "接入行业分类来源(M13)后将显示。", + "perfTable": "绩效表", + "pnlTitle": "损益(P&L)构成", + "exhibitorPerfTitle": "参展商绩效", + "sampleTag": "示例", + "pnlEmptyTitle": "暂无损益项", + "pnlEmptyDesc": "汇总展位租赁/公用事业营收记录后将显示。", + "colItem": "项目", + "colAmount": "金额", + "colGrade": "等级", + "perfEmptyTitle": "参展商绩效未汇总", + "perfEmptyDesc": "接入潜客/转化率来源(M13 访客)后将显示。", + "colCompany": "公司名称", + "colLeads": "潜客", + "colConversion": "转化率" + }, + "global": { + "sectionAria": "租户全域经营分析", + "title": "全公司经营分析 (跨活动)", + "subtitle": "跨活动营收对比 · 展厅使用率趋势 · 参展商留存 · 需求预测", + "yearsAria": "分析年数", + "years3": "最近 3 年", + "years5": "最近 5 年", + "years10": "最近 10 年", + "loadErrorTitle": "无法加载全域汇总", + "loadErrorDesc": "请稍后再试。", + "kpisAria": "全域核心指标", + "kpiEmpty": "暂无汇总指标", + "evComp": "跨活动营收对比", + "top8": "前 8 名", + "evCompHint": "已布展活动的租赁+公用事业营收排名(单位:韩元)。", + "evCompEmptyTitle": "暂无可对比的活动", + "evCompEmptyDesc": "汇总已布展活动营收后将显示。", + "revenue": "营收", + "retention": "参展商留存", + "retentionCum": "累计再参展率", + "returning": "再参展", + "retentionEmptyTitle": "按年度留存未汇总", + "retentionEmptyDesc": "接入展位分配企业来源后将显示。", + "retentionRate": "再参展率", + "occupancy": "展厅使用率趋势 (按月)", + "occupancyHint": "按 举办活动-天数 ÷ (展厅数 × 当月天数) 计算的月度使用率。", + "occupancyEmpty": "暂无使用率数据", + "occupancyRate": "使用率", + "forecast": "需求预测", + "forecastTitle": "需求预测 (举办趋势)", + "trendProjection": "趋势投影", + "forecastHint": "按年度举办活动数实测 + 最小二乘线性趋势,向未来 2 年投影(虚线)。", + "forecastEmptyTitle": "暂无可预测的历史", + "forecastEmptyDesc": "汇总年度举办历史后将显示。", + "casesUnit": "{{n}} 项", + "actual": "实测", + "projected": "预测" + }, + "ops": { + "title": "展厅现场运营", + "subtitle": "基于签到·公用事业实时汇总的现状", + "modeAria": "查看模式", + "live": "实时", + "history": "历史查询", + "noEventTitle": "请选择活动", + "noEventDesc": "尚未指定要查询现场运营的活动。", + "offlineSample": "离线 — 示例", + "degradedMsg": "无法连接汇总接口,正在显示演示示例数据。", + "loadError": "无法加载现场运营状况。", + "hallStatus": "各展厅现状", + "checkinDerived": "由签到(M10)派生 · 按面积占比分配", + "sensorNoneHalls": "无传感器 · 分配展厅列表", + "noHallsTitle": "暂无已分配展厅", + "noHallsDesc": "为活动分配展厅后将显示。", + "busy": "拥挤", + "smooth": "顺畅", + "moderate": "一般", + "people": "{{n}}人", + "onSiteCheckin": "现场签到 <0>{{n}}0>人", + "noCheckinAgg": "无签到汇总", + "powerLoad": "电力负荷", + "totalKw": "共 <0>{{v}}0>kW", + "utilLoadAgg": "公用事业申请(M4)负荷汇总", + "noLoadTitle": "无申请负荷", + "noLoadDesc": "受理电力公用事业申请后将按展厅显示负荷。", + "kw": "{{v}}kW", + "hourlyCheckin": "分时段签到", + "hourlyAgg": "签到(M10)分时段汇总", + "noSeriesTitle": "无签到趋势", + "noSeriesDesc": "记录现场签到后将显示分时段趋势。", + "facilitySensor": "设施传感器接入", + "facilitySensorTitle": "设施传感器 (空调·照明·停车)", + "sensorNoneTitle": "设施传感器未接入", + "sensorNoneDesc": "空调(HVAC)·照明 IoT·停车(iparking)联动将在接入传感器(M14)后显示。", + "temp": "温度", + "humidity": "湿度", + "parkingSlots": "{{zone}}: {{occ}}/{{cap}} 车位" + }, + "admin": { + "deniedTitle": "无访问权限", + "deniedAuthed": "本页面仅系统管理员(展厅经理·ADMIN)可使用。如需访问请联系管理员。", + "deniedGuest": "管理后台需登录后使用。", + "overviewTitle": "系统概览", + "overviewSub": "实时指标与运营状态", + "loginSlides": "登录轮播管理", + "tenantSwitch": "切换租户", + "systemNormal": "系统正常", + "moduleShortcuts": "管理模块快捷入口", + "offlineSample": "离线 — 示例", + "degradedMsg": "无法连接汇总接口,正在显示演示示例数据。", + "dashLoadError": "无法加载管理员仪表盘。", + "kpisAria": "核心指标", + "kpiEmpty": "暂无汇总指标", + "visitorTrend": "访客趋势", + "hourlyVisitors": "分时段访客", + "visitors": "访客", + "visitorTrendEmptyTitle": "访客趋势未汇总", + "visitorTrendEmptyDesc": "接入访客计量传感器(M14)后将显示分时段趋势。", + "liveEvents": "进行中活动", + "noLiveEvents": "暂无进行中的活动", + "hallTbd": "展厅未分配", + "occupancy": "占用 {{n}}%", + "modUsers": "用户", + "modRoles": "角色·权限", + "modCodes": "通用代码", + "modMenus": "菜单", + "modAudit": "审计日志", + "modSettings": "系统设置", + "modRulesets": "规则集", + "modTenants": "租户" + }, + "tenant": { + "title": "租户管理·上线", + "subtitle": "仅平台超级管理员 · 无需部署代码,通过数据上线添加展馆。", + "sampleTag": "示例 · 未实现", + "sampleNote": "多租户(租户 CRUD·上线·隔离)后端尚未实现,本页面展示目标信息架构。多租户引入后将接入实数据。", + "listAria": "租户列表", + "venue": "展馆", + "notImpl": "未实现", + "addVenue": "添加展馆", + "code": "代码", + "domain": "域名", + "status": "状态", + "events": "活动", + "users": "用户", + "wizardAria": "上线向导", + "onboardingOf": "{{name}} 上线", + "dataOnboarding": "无需部署代码的数据上线", + "wiz": { + "create": { + "label": "创建租户", + "desc": "登记代码·显示名" + }, + "domain": { + "label": "子域名", + "desc": "分配 DNS·品牌域名" + }, + "brand": { + "label": "品牌", + "desc": "Logo·颜色·公开站主题" + }, + "master": { + "label": "录入主数据", + "desc": "展厅·费率·规则集种子" + }, + "admin": { + "label": "管理员账户", + "desc": "邀请租户管理员" + }, + "smoke": { + "label": "隔离冒烟测试", + "desc": "验证跨租户隔离" + }, + "activate": { + "label": "激活", + "desc": "转入运营" + } + }, + "tstatus": { + "active": "活跃", + "onboarding": "上线中", + "suspended": "已停用" + } + }, + "ruleset": { + "title": "规则集版本管理", + "subtitle": "应对法规修订的规则集版本历史与规则项。", + "readonly": "只读", + "readonlyNote": "规则集以部署资源(rulesets/*.json)进行版本管理。编辑与版本创建通过在法规修订时替换资源完成,本页面仅供查询。", + "listError": "无法加载规则集列表。", + "noRulesets": "暂无已部署的规则集", + "versionHistory": "版本历史", + "rulesCount": "规则 {{n}}", + "effectiveDate": "生效日 {{date}}", + "ruleList": "规则列表", + "rulesOf": "{{name}} 规则 ({{n}} 项)", + "detailError": "无法加载规则全文。", + "noRulesTitle": "该规则集无规则数组", + "noRulesDesc": "仅定义费率/单价而无规则(rules)的规则集,如费率主数据。", + "colRuleName": "规则名", + "colValue": "数值/设定值", + "colLogic": "逻辑 / 要求", + "colSeverity": "违规级别", + "block": "阻断", + "warn": "警告", + "required": "必填", + "perHall": "各展厅 {{unit}}", + "docNeeded": "需文件: {{doc}}" + }, + "audit": { + "title": "审计日志", + "subtitle": "追踪审批·中标·设置变更等所有管理操作及重要领域事件。(保持完整性)", + "exportCsv": "导出 CSV", + "filterAria": "审计日志筛选", + "actionType": "操作类型", + "allActions": "全部操作", + "actorId": "操作人 ID", + "actorIdPh": "例: user-102", + "eventId": "活动 ID", + "eventIdPh": "例: EVT-2026-01", + "reset": "重置", + "applyFilter": "应用筛选", + "loadError": "无法加载审计日志。", + "tableAria": "审计日志表", + "emptyTitle": "无符合条件的审计记录", + "emptyDesc": "请调整或重置筛选条件后重新查询。", + "colTime": "时间", + "colUser": "用户", + "colAction": "操作", + "colTarget": "对象", + "colEvent": "活动", + "colResult": "结果", + "showing": "显示 <0>{{from}}-{{to}}0> / 共 <1>{{total}}1> 条", + "prev": "上一页", + "next": "下一页", + "rowsShow": "行显示", + "rowsPerPage": "每页行数", + "rowsN": "{{n}} 条", + "success": "成功", + "failure": "失败", + "csvTime": "时间", + "csvActorId": "操作人ID", + "csvActorName": "操作人姓名", + "csvAction": "操作", + "csvTargetType": "对象类型", + "csvTargetId": "对象ID", + "csvEventId": "活动ID", + "csvResult": "结果", + "csvSummary": "摘要" + }, + "role": { + "title": "角色 · 权限 (RBAC)", + "subtitle": "按角色(主办方·参展商·装置商·展厅经理·管理员·访客)以矩阵方式分配资源权限。", + "addRole": "添加角色", + "loadError": "无法加载角色·权限信息。", + "noRolesTitle": "尚未定义角色", + "noRolesDesc": "添加角色以配置 RBAC。", + "roleList": "角色列表", + "role": "角色", + "codePerm": "{{code}} · 权限 {{n}}", + "unused": "未使用", + "permMatrix": "权限矩阵", + "rolePerm": "{{name}} 权限", + "roleInfo": "角色信息", + "deleteConfirm": "确定删除角色 '{{name}}' 吗?", + "delete": "删除", + "noPerms": "尚未定义权限", + "selectRole": "请选择角色", + "saved": "角色已保存。", + "deleted": "角色已删除。", + "addRoleModal": "添加角色", + "editRole": "编辑角色信息", + "modalDesc": "权限分配在保存后于矩阵中通过勾选管理。", + "roleCode": "角色代码 *", + "roleCodePh": "例: ORGANIZER", + "roleName": "角色名称 *", + "description": "说明", + "useYn": "是否使用", + "use": "使用", + "noUse": "未使用", + "cancel": "取消", + "save": "保存", + "saving": "保存中…", + "errRequired": "角色代码和名称为必填。", + "errDupCode": "该角色代码已存在。" + }, + "menu": { + "title": "菜单管理", + "subtitle": "管理按角色的导航菜单树及路径·访问角色。", + "addMenu": "添加菜单", + "loadError": "无法加载菜单。", + "treeAria": "菜单树", + "emptyTitle": "暂无已登记菜单", + "emptyDesc": "请添加菜单。", + "colName": "菜单名", + "colPath": "路径", + "colType": "类型", + "colRole": "访问角色", + "colSort": "排序", + "colUse": "使用", + "colManage": "管理", + "all": "全部", + "unused": "未使用", + "used": "使用", + "edit": "编辑", + "delete": "删除", + "deleteConfirm": "确定删除菜单 '{{name}}' 吗?如有子菜单将一并清理。", + "saved": "菜单已保存。", + "deleted": "菜单已删除。", + "editMenu": "编辑菜单", + "menuId": "菜单 ID (唯一键) *", + "menuIdPh": "例: admin-users", + "menuName": "菜单名 *", + "parent": "上级菜单", + "topLevel": "(最上级)", + "path": "路径(path)", + "pathPh": "例: /admin/users", + "type": "类型", + "accessRole": "访问角色", + "accessRolePh": "例: ADMIN (留空=全部)", + "sort": "排序", + "useYn": "是否使用", + "cancel": "取消", + "save": "保存", + "saving": "保存中…", + "errName": "菜单名为必填。", + "errId": "菜单 ID(唯一键)为必填。", + "errDupId": "该菜单 ID 已存在。" + }, + "code": { + "title": "通用代码管理", + "subtitle": "管理下拉·分类所用的通用代码组与代码值。", + "addGroup": "添加组", + "groupLoadError": "无法加载代码组。", + "noGroupsTitle": "暂无代码组", + "noGroupsDesc": "添加组以配置通用代码。", + "codeGroup": "代码组", + "codeValues": "代码值", + "valuesOf": "{{name}} 值", + "editGroup": "编辑代码组", + "deleteGroup": "删除组", + "deleteGroupConfirm": "确定删除组 '{{name}}' 及其下代码吗?", + "addCode": "添加代码", + "codeLoadError": "无法加载代码值。", + "noCodesTitle": "暂无代码", + "noCodesDesc": "请添加代码。", + "code": "代码", + "name": "名称", + "value": "值", + "sort": "排序", + "use": "使用", + "manage": "管理", + "unused": "未使用", + "used": "使用", + "edit": "编辑", + "delete": "删除", + "deleteCodeConfirm": "确定删除代码 '{{name}}' 吗?", + "groupSaved": "代码组已保存。", + "groupDeleted": "代码组已删除。", + "codeSaved": "代码已保存。", + "codeDeleted": "代码已删除。", + "groupCode": "组代码 *", + "groupName": "组名称 *", + "description": "说明", + "useYn": "是否使用", + "cancel": "取消", + "save": "保存", + "saving": "保存中…", + "errGroupRequired": "组代码和名称为必填。", + "errDupGroup": "该组代码已存在。", + "editCode": "编辑代码", + "group": "组", + "codeReq": "代码 *", + "nameReq": "名称 *", + "attr1": "属性1(attr1)", + "errCodeRequired": "代码和名称为必填。", + "errDupCode": "该代码已存在。" + }, + "sysuser": { + "title": "用户管理", + "subtitle": "管理账户创建·编辑、角色分配、状态(激活/锁定/停用)及 OTP 重置。密码以掩码显示,不予展示。", + "addUser": "添加用户", + "searchAria": "用户搜索", + "keyword": "搜索词", + "keywordPh": "姓名·邮箱", + "role": "角色", + "status": "状态", + "all": "全部", + "reset": "重置", + "search": "搜索", + "loadError": "无法加载用户列表。", + "listAria": "用户列表", + "emptyTitle": "暂无用户", + "emptyDesc": "请调整搜索条件或添加用户。", + "colUser": "用户", + "colEmail": "邮箱", + "colRole": "角色", + "colDept": "部门", + "colStatus": "状态", + "colLastLogin": "最近登录", + "colManage": "管理", + "hallManagerTag": "展厅经理", + "otpSet": "已设置", + "otpUnset": "未设置", + "edit": "编辑", + "lock": "锁定", + "unlock": "解锁", + "otpResetBtn": "重置OTP", + "otpResetConfirm": "确定重置 {{name}} 的 OTP 吗?", + "saved": "用户已保存。", + "statusChanged": "状态已变更。", + "otpReset": "OTP 已重置。", + "active": "激活", + "locked": "锁定", + "disabled": "停用", + "editUser": "编辑用户", + "newDesc": "初始密码在保存后不会显示在界面上。", + "editDesc": "邮箱不可编辑。密码重置遵循单独策略。", + "email": "邮箱 *", + "displayName": "显示名称 *", + "dept": "部门", + "initPassword": "初始密码 *", + "hallManagerCheck": "展厅经理(内部运营者)", + "cancel": "取消", + "save": "保存", + "saving": "保存中…", + "errRequired": "邮箱和显示名称为必填。", + "errPassword": "新用户需设置初始密码。", + "totalUsers": "共 <0>{{total}}0> 人", + "prev": "上一页", + "next": "下一页" + }, + "settings": { + "title": "系统设置", + "subtitle": "管理全局策略·功能开关·AI 门控等系统参数。密钥值以掩码显示。", + "loadError": "无法加载系统设置。", + "emptyTitle": "暂无已登记设置", + "emptyDesc": "请在下方表单中添加第一个设置项。", + "saved": "设置已保存。", + "deleted": "设置已删除。", + "deleteConfirm": "确定删除设置 '{{key}}' 吗?", + "general": "常规", + "groupAria": "设置组 {{name}}", + "secret": "密钥", + "secretTitle": "密钥 — 值掩码", + "on": "开", + "off": "关", + "secretPh": "已设置(掩码) — 更改时输入新值", + "save": "保存", + "delete": "删除", + "deleteKey": "删除 {{key}}", + "secretHint": "密钥原文不会展示。不输入值直接保存则保留原值。", + "addAria": "添加设置", + "addTitle": "添加 / 覆盖设置", + "settingKey": "设置键", + "settingKeyPh": "例: deadline.utility.days", + "value": "值", + "type": "类型", + "description": "说明", + "secretCheck": "密钥(值掩码)" + }, + "slide": { + "title": "登录轮播管理", + "subtitle": "登录界面左侧轮播图片·文案 — 活跃幻灯片中<0>按登记顺序最新 5 张0>进行轮播。", + "addAria": "添加新幻灯片", + "addTitle": "添加新幻灯片", + "imageLabel": "图片 (PNG/JPG/WebP, 8MB 以内)", + "preview": "预览", + "titleLabel": "标题文案", + "titlePh": "例) 代表亚洲的展览会议中心 KINTEX", + "captionLabel": "副标题(辅助文案)", + "captionPh": "例) 在 10 万㎡ 展览空间中探索商机。", + "submitting": "登记中…", + "submit": "登记幻灯片", + "listAria": "幻灯片列表", + "registered": "已登记幻灯片", + "countActive": "共 {{total}} 张 · 活跃 {{active}} 张", + "emptyTitle": "暂无已登记幻灯片", + "emptyDesc": "登记图片和文案后将在登录界面轮播中展示。(未登记时显示默认 5 种)", + "live": "展示中", + "inactive": "未激活", + "registeredAt": "登记 {{date}}", + "active": "激活", + "delete": "删除", + "listError": "无法加载列表。", + "errImage": "请选择幻灯片图片。", + "errTitle": "请输入标题文案。", + "createFail": "登记失败。", + "updateFail": "变更失败。", + "deleteFail": "删除失败。", + "deleteConfirm": "确定删除幻灯片 \"{{title}}\" 吗?" } } diff --git a/src/frontend/src/screens/admin/AdminDashboardPage.tsx b/src/frontend/src/screens/admin/AdminDashboardPage.tsx index 4b5af6b..a810ca9 100644 --- a/src/frontend/src/screens/admin/AdminDashboardPage.tsx +++ b/src/frontend/src/screens/admin/AdminDashboardPage.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { Link } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; import { Bar, BarChart, @@ -25,6 +26,7 @@ import './admin.css'; * 폴백: NETWORK/NOT_FOUND 시에만 sampleAdmin 로 강등. 관람객 추이는 센서 부재로 백엔드 빈배열 → 빈 상태. */ export function AdminDashboardPage() { + const { t } = useTranslation(); const [tenant, setTenant] = useState(undefined); const q = useQuery({ @@ -43,53 +45,53 @@ export function AdminDashboardPage() { - 시스템 개요 - 실시간 지표 및 운영 상태 + {t('admin.overviewTitle')} + {t('admin.overviewSub')} - 로그인 슬라이드 관리 + {t('admin.loginSlides')} setTenant(e.target.value)} > - {tenants.map((t) => ( - {t} + {tenants.map((tn) => ( + {tn} ))} - 시스템 정상 + {t('admin.systemNormal')} - - {ADMIN_MODULES.map(([label, to]) => ( - {label} + + {ADMIN_MODULES.map(([labelKey, to]) => ( + {t(labelKey)} ))} {degraded && ( - 오프라인 — 샘플 - 집계 API에 연결하지 못해 시연용 샘플을 표시합니다. + {t('admin.offlineSample')} + {t('admin.degradedMsg')} )} {q.isLoading && } {hardError && ( - q.refetch()} /> + q.refetch()} /> )} {data && ( <> - + {data.kpis.length === 0 ? ( - + ) : ( data.kpis.map((k, i) => ( @@ -102,15 +104,15 @@ export function AdminDashboardPage() { - + - 시간대별 관람객 + {t('admin.hourlyVisitors')} {data.visitorTrend.length === 0 ? ( ) : ( @@ -119,19 +121,19 @@ export function AdminDashboardPage() { - + )} - @@ -210,14 +212,14 @@ const FALLBACK: AdminDashboardData = { tenants: TENANTS, }; -/** 관리 모듈 바로가기(M18) — 라우트는 App.tsx AdminGuard 배선과 동일. */ +/** 관리 모듈 바로가기(M18) — 라우트는 App.tsx AdminGuard 배선과 동일. 첫 항목은 i18n 키. */ const ADMIN_MODULES: [string, string][] = [ - ['사용자', '/admin/users'], - ['역할·권한', '/admin/roles'], - ['공통코드', '/admin/codes'], - ['메뉴', '/admin/menus'], - ['감사로그', '/admin/audit'], - ['시스템설정', '/admin/settings'], - ['룰셋', '/admin/masterdata/rulesets'], - ['테넌트', '/admin/tenants'], + ['admin.modUsers', '/admin/users'], + ['admin.modRoles', '/admin/roles'], + ['admin.modCodes', '/admin/codes'], + ['admin.modMenus', '/admin/menus'], + ['admin.modAudit', '/admin/audit'], + ['admin.modSettings', '/admin/settings'], + ['admin.modRulesets', '/admin/masterdata/rulesets'], + ['admin.modTenants', '/admin/tenants'], ]; diff --git a/src/frontend/src/screens/admin/AdminGuard.tsx b/src/frontend/src/screens/admin/AdminGuard.tsx index 0dac5c6..a148a5b 100644 --- a/src/frontend/src/screens/admin/AdminGuard.tsx +++ b/src/frontend/src/screens/admin/AdminGuard.tsx @@ -13,6 +13,7 @@ * 사용(통합자 배선): 관리자 라우트 element 를 로 감싼다. App.tsx 는 이 트랙에서 수정하지 않는다. */ import type { ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; import { useAuthStore } from '../../store/authStore'; import { IconBlock } from '../../components/ui/icons'; import './admin-modules.css'; @@ -29,6 +30,7 @@ export function useIsAdmin(): boolean { } export function AdminGuard({ children }: { children: ReactNode }) { + const { t } = useTranslation(); const authed = useAuthStore((s) => s.isAuthenticated); const isAdmin = useIsAdmin(); @@ -40,11 +42,9 @@ export function AdminGuard({ children }: { children: ReactNode }) { - 접근 권한이 없습니다 + {t('admin.deniedTitle')} - {authed - ? '이 페이지는 시스템 관리자(홀매니저·ADMIN)만 이용할 수 있습니다. 접근이 필요하면 관리자에게 문의하세요.' - : '관리자 백오피스는 로그인 후 이용할 수 있습니다.'} + {authed ? t('admin.deniedAuthed') : t('admin.deniedGuest')} 403 · FORBIDDEN diff --git a/src/frontend/src/screens/admin/AuditLogPage.tsx b/src/frontend/src/screens/admin/AuditLogPage.tsx index 5065226..70f2ab2 100644 --- a/src/frontend/src/screens/admin/AuditLogPage.tsx +++ b/src/frontend/src/screens/admin/AuditLogPage.tsx @@ -7,6 +7,8 @@ */ import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; +import { Trans, useTranslation } from 'react-i18next'; +import type { TFunction } from 'i18next'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { Button } from '../../components/ui/Button'; import { IconDownload, IconSearch } from '../../components/ui/icons'; @@ -16,6 +18,7 @@ import './admin-modules.css'; const PAGE_SIZES = [20, 50, 100]; export function AuditLogPage() { + const { t } = useTranslation(); const [action, setAction] = useState(''); const [actorId, setActorId] = useState(''); const [eventId, setEventId] = useState(''); @@ -63,30 +66,30 @@ export function AuditLogPage() { - 감사 로그 + {t('audit.title')} - 승인·낙찰·설정변경 등 모든 관리 액션과 중요 도메인 이벤트를 추적합니다. (무결성 유지) + {t('audit.subtitle')} } - onClick={() => exportCsv(rows)} + onClick={() => exportCsv(t, rows)} disabled={rows.length === 0} > - CSV 내보내기 + {t('audit.exportCsv')} {/* 필터 바 */} - + - 액션 유형 + {t('audit.actionType')} setAction(e.target.value)} /> @@ -97,29 +100,29 @@ export function AuditLogPage() { - 담당자 ID + {t('audit.actorId')} setActorId(e.target.value)} /> - 행사 ID + {t('audit.eventId')} setEventId(e.target.value)} /> - 초기화 + {t('audit.reset')} } onClick={applyFilters}> - 필터 적용 + {t('audit.applyFilter')} @@ -129,15 +132,15 @@ export function AuditLogPage() { {q.isLoading && } {q.isError && !q.isLoading && ( - q.refetch()} /> + q.refetch()} /> )} {!q.isLoading && !q.isError && ( - + {rows.length === 0 ? ( ) : ( <> @@ -145,12 +148,12 @@ export function AuditLogPage() { - 시각 - 사용자 - 액션 - 대상 - 행사 - 결과 + {t('audit.colTime')} + {t('audit.colUser')} + {t('audit.colAction')} + {t('audit.colTarget')} + {t('audit.colEvent')} + {t('audit.colResult')} @@ -198,8 +201,7 @@ export function AuditLogPage() { - 표시 중 {from}-{to} / 총{' '} - {total.toLocaleString()}개 + , ]} /> setPage((p) => Math.max(0, p - 1))} disabled={page === 0} > - 이전 + {t('audit.prev')} {page + 1} / {totalPages} @@ -217,11 +219,11 @@ export function AuditLogPage() { onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))} disabled={page >= totalPages - 1} > - 다음 + {t('audit.next')} - 행 표시 + {t('audit.rowsShow')} {PAGE_SIZES.map((s) => ( - {s}개 + {t('audit.rowsN', { n: s })} ))} @@ -248,6 +250,7 @@ export function AuditLogPage() { } function ResultPill({ value }: { value: string | null }) { + const { t } = useTranslation(); const v = (value ?? '').toUpperCase(); const tone = v === 'SUCCESS' || v === 'OK' @@ -255,7 +258,7 @@ function ResultPill({ value }: { value: string | null }) { : v === 'FAILURE' || v === 'FAIL' || v === 'ERROR' || v === 'DENIED' ? 'error' : 'neutral'; - const label = tone === 'success' ? '성공' : tone === 'error' ? '실패' : value ?? '—'; + const label = tone === 'success' ? t('audit.success') : tone === 'error' ? t('audit.failure') : value ?? '—'; return {label}; } @@ -283,9 +286,12 @@ function fmtTs(s: string | null | undefined): string { } /** 현재 로드된 페이지 rows 를 CSV 로 내보낸다. IP/자격증명 컬럼은 애초에 존재하지 않는다. */ -function exportCsv(rows: AuditLogRow[]): void { +function exportCsv(t: TFunction, rows: AuditLogRow[]): void { if (rows.length === 0) return; - const headers = ['시각', '담당자ID', '담당자명', '액션', '대상유형', '대상ID', '행사ID', '결과', '요약']; + const headers = [ + t('audit.csvTime'), t('audit.csvActorId'), t('audit.csvActorName'), t('audit.csvAction'), + t('audit.csvTargetType'), t('audit.csvTargetId'), t('audit.csvEventId'), t('audit.csvResult'), t('audit.csvSummary'), + ]; const esc = (v: unknown) => { const s = v == null ? '' : String(v); return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; diff --git a/src/frontend/src/screens/admin/CommonCodeAdminPage.tsx b/src/frontend/src/screens/admin/CommonCodeAdminPage.tsx index 7cade49..feef8a6 100644 --- a/src/frontend/src/screens/admin/CommonCodeAdminPage.tsx +++ b/src/frontend/src/screens/admin/CommonCodeAdminPage.tsx @@ -6,6 +6,7 @@ */ import { useEffect, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { Button } from '../../components/ui/Button'; import { IconPlus } from '../../components/ui/icons'; @@ -14,6 +15,7 @@ import { codeApi, type Code, type CodeGroup } from './adminCrudApi'; import './admin-modules.css'; export function CommonCodeAdminPage() { + const { t } = useTranslation(); const qc = useQueryClient(); const { show, node: toast } = useToast(); @@ -39,7 +41,7 @@ export function CommonCodeAdminPage() { const saveGroupM = useMutation({ mutationFn: (g: CodeGroup) => codeApi.saveGroup(g), onSuccess: (_d, g) => { - show('코드 그룹을 저장했습니다.'); + show(t('code.groupSaved')); setGroupModal(undefined); setSelected(g.grpCode); qc.invalidateQueries({ queryKey: ['admin-code-groups'] }); @@ -49,7 +51,7 @@ export function CommonCodeAdminPage() { const delGroupM = useMutation({ mutationFn: (grpCode: string) => codeApi.removeGroup(grpCode), onSuccess: () => { - show('코드 그룹을 삭제했습니다.'); + show(t('code.groupDeleted')); setSelected(null); qc.invalidateQueries({ queryKey: ['admin-code-groups'] }); }, @@ -58,7 +60,7 @@ export function CommonCodeAdminPage() { const saveCodeM = useMutation({ mutationFn: (c: Code) => codeApi.saveCode(c), onSuccess: () => { - show('코드를 저장했습니다.'); + show(t('code.codeSaved')); setCodeModal(undefined); qc.invalidateQueries({ queryKey: ['admin-codes', selected] }); }, @@ -67,7 +69,7 @@ export function CommonCodeAdminPage() { const delCodeM = useMutation({ mutationFn: ({ grpCode, code }: { grpCode: string; code: string }) => codeApi.removeCode(grpCode, code), onSuccess: () => { - show('코드를 삭제했습니다.'); + show(t('code.codeDeleted')); qc.invalidateQueries({ queryKey: ['admin-codes', selected] }); }, onError: (e) => show(errMessage(e)), @@ -79,26 +81,26 @@ export function CommonCodeAdminPage() { - 공통코드 관리 - 드롭다운·분류에 쓰이는 공통코드 그룹과 코드 값을 관리합니다. + {t('code.title')} + {t('code.subtitle')} - } onClick={() => setGroupModal(null)}>그룹 추가 + } onClick={() => setGroupModal(null)}>{t('code.addGroup')} {groupsQ.isLoading && } {groupsQ.isError && !groupsQ.isLoading && ( - groupsQ.refetch()} /> + groupsQ.refetch()} /> )} {!groupsQ.isLoading && !groupsQ.isError && ( groups.length === 0 ? ( - + ) : ( {/* 좌: 그룹 */} - + - 코드 그룹 + {t('code.codeGroup')} {groups.map((g) => ( @@ -109,7 +111,7 @@ export function CommonCodeAdminPage() { onClick={() => setSelected(g.grpCode)} > {g.grpName} - {g.grpCode}{g.useYn === 'N' ? ' · 미사용' : ''} + {g.grpCode}{g.useYn === 'N' ? ` · ${t('code.unused')}` : ''} ))} @@ -117,28 +119,28 @@ export function CommonCodeAdminPage() { {/* 우: 코드 값 */} - + - {activeGroup?.grpName ?? '코드'} 값 + {t('code.valuesOf', { name: activeGroup?.grpName ?? t('code.code') })} {activeGroup?.description && {activeGroup.description}} {activeGroup && ( <> - setGroupModal(activeGroup)}>그룹 수정 + setGroupModal(activeGroup)}>{t('code.editGroup')} { - if (window.confirm(`그룹 '${activeGroup.grpName}' 과 하위 코드를 삭제하시겠습니까?`)) + if (window.confirm(t('code.deleteGroupConfirm', { name: activeGroup.grpName }))) delGroupM.mutate(activeGroup.grpCode); }} > - 그룹 삭제 + {t('code.deleteGroup')} - } onClick={() => setCodeModal(null)}>코드 추가 + } onClick={() => setCodeModal(null)}>{t('code.addCode')} > )} @@ -147,25 +149,25 @@ export function CommonCodeAdminPage() { {codesQ.isLoading && } {codesQ.isError && !codesQ.isLoading && ( - codesQ.refetch()} /> + codesQ.refetch()} /> )} {!codesQ.isLoading && !codesQ.isError && ( codes.length === 0 ? ( - + ) : ( - 코드 - 이름 - 값 - 정렬 - 사용 - 관리 + {t('code.code')} + {t('code.name')} + {t('code.value')} + {t('code.sort')} + {t('code.use')} + {t('code.manage')} @@ -177,22 +179,22 @@ export function CommonCodeAdminPage() { {c.sortOrder} - {c.useYn === 'N' ? '미사용' : '사용'} + {c.useYn === 'N' ? t('code.unused') : t('code.used')} - setCodeModal(c)}>수정 + setCodeModal(c)}>{t('code.edit')} { - if (window.confirm(`코드 '${c.codeName}' 을(를) 삭제하시겠습니까?`)) + if (window.confirm(t('code.deleteCodeConfirm', { name: c.codeName }))) delCodeM.mutate({ grpCode: c.grpCode, code: c.code }); }} > - 삭제 + {t('code.delete')} @@ -245,6 +247,7 @@ function GroupModal({ onClose: () => void; onSubmit: (g: CodeGroup) => void; }) { + const { t } = useTranslation(); const isNew = group === null; const [grpCode, setGrpCode] = useState(group?.grpCode ?? ''); const [grpName, setGrpName] = useState(group?.grpName ?? ''); @@ -257,11 +260,11 @@ function GroupModal({ e.preventDefault(); setError(null); if (!grpCode.trim() || !grpName.trim()) { - setError('그룹 코드와 이름은 필수입니다.'); + setError(t('code.errGroupRequired')); return; } if (isNew && existingCodes.includes(grpCode.trim())) { - setError('이미 존재하는 그룹 코드입니다.'); + setError(t('code.errDupGroup')); return; } onSubmit({ @@ -277,36 +280,36 @@ function GroupModal({ - {isNew ? '코드 그룹 추가' : '코드 그룹 수정'} + {isNew ? t('code.addGroup') : t('code.editGroup')} - 그룹 코드 * + {t('code.groupCode')} setGrpCode(e.target.value)} /> - 그룹 이름 * + {t('code.groupName')} setGrpName(e.target.value)} /> - 설명 + {t('code.description')} setDescription(e.target.value)} /> - 정렬 + {t('code.sort')} setSortOrder(e.target.value)} /> - 사용 여부 + {t('code.useYn')} setUseYn(e.target.value)}> - 사용 - 미사용 + {t('code.used')} + {t('code.unused')} {error && {error}} - 취소 - {saving ? '저장 중…' : '저장'} + {t('code.cancel')} + {saving ? t('code.saving') : t('code.save')} @@ -328,6 +331,7 @@ function CodeModal({ onClose: () => void; onSubmit: (c: Code) => void; }) { + const { t } = useTranslation(); const isNew = code === null; const [codeVal, setCodeVal] = useState(code?.code ?? ''); const [codeName, setCodeName] = useState(code?.codeName ?? ''); @@ -341,11 +345,11 @@ function CodeModal({ e.preventDefault(); setError(null); if (!codeVal.trim() || !codeName.trim()) { - setError('코드와 이름은 필수입니다.'); + setError(t('code.errCodeRequired')); return; } if (isNew && existingCodes.includes(codeVal.trim())) { - setError('이미 존재하는 코드입니다.'); + setError(t('code.errDupCode')); return; } onSubmit({ @@ -363,41 +367,41 @@ function CodeModal({ - {isNew ? '코드 추가' : '코드 수정'} - 그룹 {grpCode} + {isNew ? t('code.addCode') : t('code.editCode')} + {t('code.group')} {grpCode} - 코드 * + {t('code.codeReq')} setCodeVal(e.target.value)} /> - 이름 * + {t('code.nameReq')} setCodeName(e.target.value)} /> - 값 + {t('code.value')} setCodeValue(e.target.value)} /> - 속성1(attr1) + {t('code.attr1')} setAttr1(e.target.value)} /> - 정렬 + {t('code.sort')} setSortOrder(e.target.value)} /> - 사용 여부 + {t('code.useYn')} setUseYn(e.target.value)}> - 사용 - 미사용 + {t('code.used')} + {t('code.unused')} {error && {error}} - 취소 - {saving ? '저장 중…' : '저장'} + {t('code.cancel')} + {saving ? t('code.saving') : t('code.save')} diff --git a/src/frontend/src/screens/admin/LoginSlideAdminPage.tsx b/src/frontend/src/screens/admin/LoginSlideAdminPage.tsx index b8e87ac..993a1bb 100644 --- a/src/frontend/src/screens/admin/LoginSlideAdminPage.tsx +++ b/src/frontend/src/screens/admin/LoginSlideAdminPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; import { loginSlideApi } from '../../api/endpoints'; import { ApiRequestError } from '../../api/client'; import type { LoginSlideDto } from '../../api/types'; @@ -12,6 +13,7 @@ import './admin.css'; * 공개 노출 규칙: 활성 슬라이드 중 최신 등록순(created_at DESC) 최대 5건. */ export function LoginSlideAdminPage() { + const { t } = useTranslation(); const [slides, setSlides] = useState(null); const [loadError, setLoadError] = useState(null); @@ -32,7 +34,7 @@ export function LoginSlideAdminPage() { setSlides(await loginSlideApi.adminList()); } catch (err) { setSlides([]); - setLoadError(err instanceof ApiRequestError ? err.message : '목록을 불러오지 못했습니다.'); + setLoadError(err instanceof ApiRequestError ? err.message : t('slide.listError')); } } @@ -55,11 +57,11 @@ export function LoginSlideAdminPage() { e.preventDefault(); setFormError(null); if (!file) { - setFormError('슬라이드 이미지를 선택해 주세요.'); + setFormError(t('slide.errImage')); return; } if (!title.trim()) { - setFormError('타이틀 문구를 입력해 주세요.'); + setFormError(t('slide.errTitle')); return; } setSubmitting(true); @@ -71,7 +73,7 @@ export function LoginSlideAdminPage() { setCaption(''); if (fileInputRef.current) fileInputRef.current.value = ''; } catch (err) { - setFormError(err instanceof ApiRequestError ? err.message : '등록에 실패했습니다.'); + setFormError(err instanceof ApiRequestError ? err.message : t('slide.createFail')); } finally { setSubmitting(false); } @@ -88,20 +90,20 @@ export function LoginSlideAdminPage() { }); setSlides((prev) => (prev ?? []).map((s) => (s.id === slide.id ? updated : s))); } catch (err) { - setLoadError(err instanceof ApiRequestError ? err.message : '변경에 실패했습니다.'); + setLoadError(err instanceof ApiRequestError ? err.message : t('slide.updateFail')); } finally { setBusyId(null); } } async function handleDelete(slide: LoginSlideDto) { - if (!window.confirm(`"${slide.title}" 슬라이드를 삭제할까요?`)) return; + if (!window.confirm(t('slide.deleteConfirm', { title: slide.title }))) return; setBusyId(slide.id); try { await loginSlideApi.remove(slide.id); setSlides((prev) => (prev ?? []).filter((s) => s.id !== slide.id)); } catch (err) { - setLoadError(err instanceof ApiRequestError ? err.message : '삭제에 실패했습니다.'); + setLoadError(err instanceof ApiRequestError ? err.message : t('slide.deleteFail')); } finally { setBusyId(null); } @@ -113,24 +115,23 @@ export function LoginSlideAdminPage() { - 로그인 슬라이드 관리 + {t('slide.title')} - 로그인 화면 좌측 캐러셀 이미지·문구 — 활성 슬라이드 중 최신 등록순 5건이 - 로테이션됩니다. + ]} /> {/* 새 슬라이드 추가 */} - + - 새 슬라이드 추가 + {t('slide.addTitle')} - 이미지 (PNG/JPG/WebP, 8MB 이하) + {t('slide.imageLabel')} - {!preview && 미리보기} + {!preview && {t('slide.preview')}} - 타이틀 문구 + {t('slide.titleLabel')} setTitle(e.target.value)} required /> - 캡션(보조 문구) + {t('slide.captionLabel')} setCaption(e.target.value)} /> @@ -177,7 +178,7 @@ export function LoginSlideAdminPage() { )} - {submitting ? '등록 중…' : '슬라이드 등록'} + {submitting ? t('slide.submitting') : t('slide.submit')} @@ -186,13 +187,13 @@ export function LoginSlideAdminPage() { {/* 목록 */} - + - 등록된 슬라이드{' '} + {t('slide.registered')}{' '} {slides && ( - 총 {slides.length}건 · 활성 {activeCount}건 + {t('slide.countActive', { total: slides.length, active: activeCount })} )} @@ -208,8 +209,8 @@ export function LoginSlideAdminPage() { ) : slides.length === 0 && !loadError ? ( ) : ( @@ -223,11 +224,11 @@ export function LoginSlideAdminPage() { {s.title} - {s.active && i < 5 && 노출 중} - {!s.active && 비활성} + {s.active && i < 5 && {t('slide.live')}} + {!s.active && {t('slide.inactive')}} {s.caption && {s.caption}} - 등록 {s.createdAt} + {t('slide.registeredAt', { date: s.createdAt })} @@ -237,7 +238,7 @@ export function LoginSlideAdminPage() { disabled={busyId === s.id} onChange={() => void handleToggleActive(s)} /> - 활성 + {t('slide.active')} void handleDelete(s)} > - 삭제 + {t('slide.delete')} diff --git a/src/frontend/src/screens/admin/MenuAdminPage.tsx b/src/frontend/src/screens/admin/MenuAdminPage.tsx index abb7ac5..36815ad 100644 --- a/src/frontend/src/screens/admin/MenuAdminPage.tsx +++ b/src/frontend/src/screens/admin/MenuAdminPage.tsx @@ -5,6 +5,7 @@ */ import { useMemo, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { Button } from '../../components/ui/Button'; import { IconPlus } from '../../components/ui/icons'; @@ -48,6 +49,7 @@ function toDepthList(nodes: MenuNode[]): FlatMenu[] { } export function MenuAdminPage() { + const { t } = useTranslation(); const qc = useQueryClient(); const { show, node: toast } = useToast(); @@ -59,7 +61,7 @@ export function MenuAdminPage() { const saveM = useMutation({ mutationFn: (body: MenuSaveBody) => menuApi.save(body), onSuccess: () => { - show('메뉴를 저장했습니다.'); + show(t('menu.saved')); setEditing(undefined); qc.invalidateQueries({ queryKey: ['admin-menus'] }); }, @@ -68,7 +70,7 @@ export function MenuAdminPage() { const delM = useMutation({ mutationFn: (id: string) => menuApi.remove(id), onSuccess: () => { - show('메뉴를 삭제했습니다.'); + show(t('menu.deleted')); qc.invalidateQueries({ queryKey: ['admin-menus'] }); }, onError: (e) => show(errMessage(e)), @@ -78,33 +80,33 @@ export function MenuAdminPage() { - 메뉴 관리 - 역할별 내비게이션 메뉴 트리와 경로·접근 역할을 관리합니다. + {t('menu.title')} + {t('menu.subtitle')} - } onClick={() => setEditing(null)}>메뉴 추가 + } onClick={() => setEditing(null)}>{t('menu.addMenu')} {q.isLoading && } {q.isError && !q.isLoading && ( - q.refetch()} /> + q.refetch()} /> )} {!q.isLoading && !q.isError && ( - + {flat.length === 0 ? ( - + ) : ( - 메뉴명 - 경로 - 유형 - 접근 역할 - 정렬 - 사용 - 관리 + {t('menu.colName')} + {t('menu.colPath')} + {t('menu.colType')} + {t('menu.colRole')} + {t('menu.colSort')} + {t('menu.colUse')} + {t('menu.colManage')} @@ -122,26 +124,26 @@ export function MenuAdminPage() { {m.menuPath ?? '—'} {m.prgType ?? '—'} - {m.requiredRole ?? '전체'} + {m.requiredRole ?? t('menu.all')} {m.sortOrder} - {m.useYn === 'N' ? '미사용' : '사용'} + {m.useYn === 'N' ? t('menu.unused') : t('menu.used')} - setEditing(m)}>수정 + setEditing(m)}>{t('menu.edit')} { - if (window.confirm(`메뉴 '${m.menuName}' 을(를) 삭제하시겠습니까? 하위 메뉴가 있으면 함께 정리됩니다.`)) + if (window.confirm(t('menu.deleteConfirm', { name: m.menuName }))) delM.mutate(m.id); }} > - 삭제 + {t('menu.delete')} @@ -181,6 +183,7 @@ function MenuModal({ onClose: () => void; onSubmit: (body: MenuSaveBody) => void; }) { + const { t } = useTranslation(); const isNew = menu === null; const [id, setId] = useState(menu?.id ?? ''); const [menuName, setMenuName] = useState(menu?.menuName ?? ''); @@ -199,15 +202,15 @@ function MenuModal({ e.preventDefault(); setError(null); if (!menuName.trim()) { - setError('메뉴명은 필수입니다.'); + setError(t('menu.errName')); return; } if (isNew && !id.trim()) { - setError('메뉴 ID(고유 키)는 필수입니다.'); + setError(t('menu.errId')); return; } if (isNew && options.some((o) => o.id === id.trim())) { - setError('이미 존재하는 메뉴 ID 입니다.'); + setError(t('menu.errDupId')); return; } onSubmit({ @@ -226,59 +229,59 @@ function MenuModal({ - {isNew ? '메뉴 추가' : '메뉴 수정'} + {isNew ? t('menu.addMenu') : t('menu.editMenu')} {isNew && ( - 메뉴 ID (고유 키) * - setId(e.target.value)} /> + {t('menu.menuId')} + setId(e.target.value)} /> )} - 메뉴명 * + {t('menu.menuName')} setMenuName(e.target.value)} /> - 상위 메뉴 + {t('menu.parent')} setParentId(e.target.value)}> - (최상위) + {t('menu.topLevel')} {parentOptions.map((o) => ( {'—'.repeat(o.depth)} {o.menuName} ))} - 경로(path) - setMenuPath(e.target.value)} /> + {t('menu.path')} + setMenuPath(e.target.value)} /> - 유형 + {t('menu.type')} setPrgType(e.target.value)}> - {MENU_PRG_TYPES.map((t) => ( - {t} + {MENU_PRG_TYPES.map((pt) => ( + {pt} ))} - 접근 역할 - setRequiredRole(e.target.value)} /> + {t('menu.accessRole')} + setRequiredRole(e.target.value)} /> - 정렬 + {t('menu.sort')} setSortOrder(e.target.value)} /> - 사용 여부 + {t('menu.useYn')} setUseYn(e.target.value)}> - 사용 - 미사용 + {t('menu.used')} + {t('menu.unused')} {error && {error}} - 취소 - {saving ? '저장 중…' : '저장'} + {t('menu.cancel')} + {saving ? t('menu.saving') : t('menu.save')} diff --git a/src/frontend/src/screens/admin/RoleAdminPage.tsx b/src/frontend/src/screens/admin/RoleAdminPage.tsx index 6dc0a78..441e85b 100644 --- a/src/frontend/src/screens/admin/RoleAdminPage.tsx +++ b/src/frontend/src/screens/admin/RoleAdminPage.tsx @@ -6,6 +6,7 @@ */ import { useEffect, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { Button } from '../../components/ui/Button'; import { IconPlus } from '../../components/ui/icons'; @@ -14,6 +15,7 @@ import { roleApi, type Role } from './adminCrudApi'; import './admin-modules.css'; export function RoleAdminPage() { + const { t } = useTranslation(); const qc = useQueryClient(); const { show, node: toast } = useToast(); @@ -35,7 +37,7 @@ export function RoleAdminPage() { const saveM = useMutation({ mutationFn: (role: Role) => roleApi.save(role), onSuccess: () => { - show('역할을 저장했습니다.'); + show(t('role.saved')); setEditing(undefined); qc.invalidateQueries({ queryKey: ['admin-roles'] }); }, @@ -44,7 +46,7 @@ export function RoleAdminPage() { const delM = useMutation({ mutationFn: (roleCode: string) => roleApi.remove(roleCode), onSuccess: () => { - show('역할을 삭제했습니다.'); + show(t('role.deleted')); setSelected(null); qc.invalidateQueries({ queryKey: ['admin-roles'] }); }, @@ -66,18 +68,18 @@ export function RoleAdminPage() { - 역할 · 권한 (RBAC) + {t('role.title')} - 역할(주최자·참가업체·장치업체·홀매니저·관리자·관람객)별 리소스 권한을 매트릭스로 배정합니다. + {t('role.subtitle')} - } onClick={() => setEditing(null)}>역할 추가 + } onClick={() => setEditing(null)}>{t('role.addRole')} {loading && } {errored && !loading && ( { rolesQ.refetch(); permsQ.refetch(); @@ -87,13 +89,13 @@ export function RoleAdminPage() { {!loading && !errored && ( roles.length === 0 ? ( - + ) : ( {/* 좌: 역할 목록 */} - + - 역할 + {t('role.role')} {roles.map((r) => ( @@ -105,8 +107,8 @@ export function RoleAdminPage() { > {r.roleName} - {r.roleCode} · 권한 {r.permissions.length} - {r.useYn === 'N' ? ' · 미사용' : ''} + {t('role.codePerm', { code: r.roleCode, n: r.permissions.length })} + {r.useYn === 'N' ? ` · ${t('role.unused')}` : ''} @@ -115,32 +117,32 @@ export function RoleAdminPage() { {/* 우: 권한 매트릭스 */} - + {activeRole ? ( <> - {activeRole.roleName} 권한 + {t('role.rolePerm', { name: activeRole.roleName })} {activeRole.description ?? activeRole.roleCode} - setEditing(activeRole)}>역할 정보 + setEditing(activeRole)}>{t('role.roleInfo')} { - if (window.confirm(`역할 '${activeRole.roleName}' 을(를) 삭제하시겠습니까?`)) + if (window.confirm(t('role.deleteConfirm', { name: activeRole.roleName }))) delM.mutate(activeRole.roleCode); }} > - 삭제 + {t('role.delete')} {perms.length === 0 ? ( - + ) : ( {perms.map((p) => { @@ -167,7 +169,7 @@ export function RoleAdminPage() { )} > ) : ( - + )} @@ -201,6 +203,7 @@ function RoleModal({ onClose: () => void; onSubmit: (role: Role) => void; }) { + const { t } = useTranslation(); const isNew = role === null; const [roleCode, setRoleCode] = useState(role?.roleCode ?? ''); const [roleName, setRoleName] = useState(role?.roleName ?? ''); @@ -212,11 +215,11 @@ function RoleModal({ e.preventDefault(); setError(null); if (!roleCode.trim() || !roleName.trim()) { - setError('역할 코드와 이름은 필수입니다.'); + setError(t('role.errRequired')); return; } if (isNew && existingCodes.includes(roleCode.trim().toUpperCase())) { - setError('이미 존재하는 역할 코드입니다.'); + setError(t('role.errDupCode')); return; } onSubmit({ @@ -232,39 +235,39 @@ function RoleModal({ - {isNew ? '역할 추가' : '역할 정보 수정'} - 권한 배정은 저장 후 매트릭스에서 체크로 관리합니다. + {isNew ? t('role.addRole') : t('role.editRole')} + {t('role.modalDesc')} - 역할 코드 * + {t('role.roleCode')} setRoleCode(e.target.value)} /> - 역할 이름 * + {t('role.roleName')} setRoleName(e.target.value)} /> - 설명 + {t('role.description')} setDescription(e.target.value)} /> - 사용 여부 + {t('role.useYn')} setUseYn(e.target.value)}> - 사용 - 미사용 + {t('role.use')} + {t('role.noUse')} {error && {error}} - 취소 - {saving ? '저장 중…' : '저장'} + {t('role.cancel')} + {saving ? t('role.saving') : t('role.save')} diff --git a/src/frontend/src/screens/admin/RulesetVersionsPage.tsx b/src/frontend/src/screens/admin/RulesetVersionsPage.tsx index a4796c5..a7431e5 100644 --- a/src/frontend/src/screens/admin/RulesetVersionsPage.tsx +++ b/src/frontend/src/screens/admin/RulesetVersionsPage.tsx @@ -5,11 +5,14 @@ */ import { useEffect, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import type { TFunction } from 'i18next'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { rulesetApi, type RulesetRule } from './adminModulesApi'; import './admin-modules.css'; export function RulesetVersionsPage() { + const { t } = useTranslation(); const listQ = useQuery({ queryKey: ['admin-rulesets'], queryFn: () => rulesetApi.list(), @@ -42,32 +45,31 @@ export function RulesetVersionsPage() { - 규정 룰셋 버전 관리 - 규정 개정 대응을 위한 룰셋 버전 이력과 규칙 항목입니다. + {t('ruleset.title')} + {t('ruleset.subtitle')} - 읽기 전용 - 룰셋은 배포 리소스(rulesets/*.json)로 버전 관리됩니다. 편집·버전 생성은 규정 개정 시 리소스 교체로 - 수행되며 화면에서는 조회만 가능합니다. + {t('ruleset.readonly')} + {t('ruleset.readonlyNote')} {listQ.isLoading && } {listQ.isError && ( - listQ.refetch()} /> + listQ.refetch()} /> )} {!listQ.isLoading && !listQ.isError && versions.length === 0 && ( - + )} {versions.length > 0 && ( {/* 좌: 버전 히스토리 */} -
{@code event_id} 직접 조회 경로에서 대상 행사가 요청자의 테넌트에 속하는지 검증한다 + * ({@link com.zioinfo.kintex.auth.EventAccessGuard}). 홀매니저처럼 전 행사 접근권이 넓은 + * 주체의 교차-테넌트 열람을 차단한다. {@code event.tenant_id}는 인덱스({@code idx_event_tenant}) 선행 컬럼. + */ +@Mapper +public interface EventTenantMapper { + + /** 행사의 소속 테넌트 코드. 없으면(행사 미존재) null. */ + @Select("SELECT tenant_id FROM event WHERE id = #{eventId}") + String findEventTenant(@Param("eventId") String eventId); +} diff --git a/src/backend/src/test/java/com/zioinfo/kintex/analytics/AnalyticsServiceTest.java b/src/backend/src/test/java/com/zioinfo/kintex/analytics/AnalyticsServiceTest.java index 7e3b2cf..2a8d38c 100644 --- a/src/backend/src/test/java/com/zioinfo/kintex/analytics/AnalyticsServiceTest.java +++ b/src/backend/src/test/java/com/zioinfo/kintex/analytics/AnalyticsServiceTest.java @@ -12,6 +12,8 @@ import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -69,28 +71,29 @@ class AnalyticsServiceTest { @Test void overviewComputesRetentionOccupancyAndForecast() { + // 행사 횡단 집계는 테넌트 스코프(currentTenantId 기본 'kintex') — 매처는 테넌트 무관 검증. when(mapper.findRentalRatePerM2()).thenReturn(2000.0); - when(mapper.countHalls()).thenReturn(10L); - when(mapper.countEvents()).thenReturn(1098L); - when(mapper.findUtilityRevenueByEvent()).thenReturn(List.of()); + when(mapper.countHalls(anyString())).thenReturn(10L); + when(mapper.countEvents(anyString())).thenReturn(1098L); + when(mapper.findUtilityRevenueByEvent(anyString())).thenReturn(List.of()); Map ev = new HashMap<>(); ev.put("eventId", "e1"); ev.put("eventName", "스마트팩토리"); ev.put("areaM2", 50000.0); ev.put("boothCount", 300L); - when(mapper.findEventAreaRanking(8)).thenReturn(List.of(ev)); + when(mapper.findEventAreaRanking(anyString(), eq(8))).thenReturn(List.of(ev)); Map occ = new HashMap<>(); occ.put("period", "2026-02"); // 28일 occ.put("bookedDays", 56L); // 56 / (10*28)=20% - when(mapper.findMonthlyEventDays(60)).thenReturn(List.of(occ)); + when(mapper.findMonthlyEventDays(anyString(), eq(60))).thenReturn(List.of(occ)); Map retSum = new HashMap<>(); retSum.put("totalCompanies", 10L); retSum.put("returningCompanies", 4L); // 40% - when(mapper.findRetentionSummary()).thenReturn(retSum); - when(mapper.findRetentionByYear()).thenReturn(List.of()); + when(mapper.findRetentionSummary(anyString())).thenReturn(retSum); + when(mapper.findRetentionByYear(anyString())).thenReturn(List.of()); // 연 3건씩 증가하는 완벽한 선형 → 다음해 투영 = 마지막+3. Map y1 = new HashMap<>(); @@ -99,7 +102,7 @@ class AnalyticsServiceTest { y2.put("yr", 2025); y2.put("eventCount", 103L); Map y3 = new HashMap<>(); y3.put("yr", 2026); y3.put("eventCount", 106L); - when(mapper.findYearlyEventCounts()).thenReturn(List.of(y1, y2, y3)); + when(mapper.findYearlyEventCounts(anyString())).thenReturn(List.of(y1, y2, y3)); AnalyticsOverviewDto dto = service.getOverview(5); diff --git a/src/backend/src/test/java/com/zioinfo/kintex/auth/EventAccessGuardTenantTest.java b/src/backend/src/test/java/com/zioinfo/kintex/auth/EventAccessGuardTenantTest.java new file mode 100644 index 0000000..6088d83 --- /dev/null +++ b/src/backend/src/test/java/com/zioinfo/kintex/auth/EventAccessGuardTenantTest.java @@ -0,0 +1,63 @@ +package com.zioinfo.kintex.auth; + +import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; +import com.zioinfo.kintex.tenant.EventTenantMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 멀티테넌시 2단계 격리 단위테스트 — {@link EventAccessGuard}가 교차-테넌트 행사 접근을 fail-closed로 차단한다(§1A-3-2). + * 홀매니저처럼 전 행사 접근권(hasAccess=true)이 넓은 주체도 다른 테넌트 행사는 열람 불가여야 한다. + */ +class EventAccessGuardTenantTest { + + private EventTenantMapper eventTenantMapper; + private EventAccessGuard guard; + + @BeforeEach + void setUp() { + eventTenantMapper = mock(EventTenantMapper.class); + guard = new EventAccessGuard(eventTenantMapper); + } + + /** 홀매니저(전 행사 접근권)라도 타 테넌트 행사면 403 — 교차-테넌트 격리. */ + @Test + void blocksCrossTenantEventAccessEvenForHallManager() { + KintexPrincipal kintexHallManager = new KintexPrincipal( + "u-kintex", "홀매니저", Map.of(), true, "MANAGER", "kintex"); + when(eventTenantMapper.findEventTenant("ev-coex")).thenReturn("coex"); + + ApiException ex = assertThrows(ApiException.class, + () -> guard.requireEventAccess(kintexHallManager, "ev-coex")); + assertEquals(ErrorCode.FORBIDDEN, ex.getCode()); + } + + /** 동일 테넌트 행사는 정상 통과. */ + @Test + void allowsSameTenantEventAccess() { + KintexPrincipal kintexHallManager = new KintexPrincipal( + "u-kintex", "홀매니저", Map.of(), true, "MANAGER", "kintex"); + when(eventTenantMapper.findEventTenant("ev-kintex")).thenReturn("kintex"); + + assertDoesNotThrow(() -> guard.requireEventAccess(kintexHallManager, "ev-kintex")); + } + + /** 행사 미존재(테넌트 미확인)는 기존 접근권 판정을 그대로 유지(회귀 0) — 여기선 통과. */ + @Test + void passesWhenEventTenantUnknown() { + KintexPrincipal principal = new KintexPrincipal( + "u-kintex", "홀매니저", Map.of(), true, "MANAGER", "kintex"); + when(eventTenantMapper.findEventTenant("ev-missing")).thenReturn(null); + + assertDoesNotThrow(() -> guard.requireEventAccess(principal, "ev-missing")); + } +} diff --git a/src/frontend/src/components/layout/AppShell.tsx b/src/frontend/src/components/layout/AppShell.tsx index 84d551b..c044323 100644 --- a/src/frontend/src/components/layout/AppShell.tsx +++ b/src/frontend/src/components/layout/AppShell.tsx @@ -1,7 +1,11 @@ import type { ComponentType } from 'react'; -import { NavLink, Outlet, useNavigate } from 'react-router-dom'; +import { useEffect } from 'react'; +import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'; import { useAuthStore } from '../../store/authStore'; import { useThemeStore } from '../../store/themeStore'; +import { useMdiStore } from '../../store/mdiStore'; +import { MdiTabBar } from './MdiTabBar'; +import { labelForPath } from './mdiLabels'; import { useResolvedEventId } from '../../hooks/useResolvedEventId'; import { DdayChip } from '../ui/Badge'; import { LanguageSwitcher } from '../ui/LanguageSwitcher'; @@ -99,9 +103,20 @@ export function AppShell() { const themeResolved = useThemeStore((s) => s.resolved); const toggleTheme = useThemeStore((s) => s.toggle); const navigate = useNavigate(); + const location = useLocation(); + const openTab = useMdiStore((s) => s.openTab); const resolvedEventId = useResolvedEventId(); const isDark = themeResolved === 'dark'; + /** + * 라우트 → MDI 탭 동기화(§2.7): 셸 안의 모든 라우트 진입(네비 클릭·화면 내 링크·딥링크)이 + * 문서 탭을 열거나 활성 전환한다. openTab 은 dedup(중복 경로면 활성만) + 상한 관리를 담당한다. + * navigate 는 여기서 호출하지 않는다(탭 상호작용·네비가 이미 라우팅을 트리거) → 루프 없음. + */ + useEffect(() => { + openTab(location.pathname, labelForPath(location.pathname)); + }, [location.pathname, openTab]); + /** 행사 스코프 항목 진입 — 대상 행사 해소 시 계산 경로로, 미해소 시 행사 선택(/login)으로. */ function goScoped(key: string) { const build = EVENT_SCOPED_PATH[key]; @@ -243,6 +258,8 @@ export function AppShell() { + + diff --git a/src/frontend/src/components/layout/MdiTabBar.tsx b/src/frontend/src/components/layout/MdiTabBar.tsx new file mode 100644 index 0000000..01b1b74 --- /dev/null +++ b/src/frontend/src/components/layout/MdiTabBar.tsx @@ -0,0 +1,84 @@ +import { useRef } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { useMdiStore } from '../../store/mdiStore'; +import { IconClose } from '../ui/icons'; + +/** + * MDI 문서 탭 스트립 (design.md §2.7-2) — 유틸리티 바 아래 전폭. + * + * 탭 = 라우트 북마크: 열린 문서 경로를 좌→우로 나열하고, 클릭 시 해당 라우트로 이동한다. + * 탭 추가/활성은 AppShell 의 라우트 동기화 이펙트(openTab)가 담당하고, 여기서는 표시·상호작용만 맡는다. + * 키보드: role=tablist/tab · aria-selected · 좌/우 화살표로 인접 탭 이동(포커스+활성). + * 모바일(<720px)에서는 CSS 로 숨김. + */ +export function MdiTabBar() { + const tabs = useMdiStore((s) => s.tabs); + const activePath = useMdiStore((s) => s.activePath); + const closeTab = useMdiStore((s) => s.closeTab); + const navigate = useNavigate(); + const location = useLocation(); + const btnRefs = useRef<(HTMLButtonElement | null)[]>([]); + + if (tabs.length === 0) return null; + + function activate(path: string) { + if (path !== location.pathname) navigate(path); + } + + function handleClose(e: React.MouseEvent, path: string) { + e.stopPropagation(); + const nextActive = closeTab(path); + // 활성 탭을 닫아 활성 경로가 바뀌었으면 그 경로로 이동(현재와 다를 때만). + if (nextActive && nextActive !== location.pathname) navigate(nextActive); + } + + function handleKeyDown(e: React.KeyboardEvent, index: number) { + if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; + e.preventDefault(); + const dir = e.key === 'ArrowRight' ? 1 : -1; + const nextIndex = (index + dir + tabs.length) % tabs.length; + const target = tabs[nextIndex]; + btnRefs.current[nextIndex]?.focus(); + activate(target.path); + } + + return ( + + {tabs.map((tab, i) => { + const isActive = tab.path === activePath; + return ( + + { + btnRefs.current[i] = el; + }} + className="kx-mdi__tab-btn" + title={tab.label} + onClick={() => activate(tab.path)} + onKeyDown={(e) => handleKeyDown(e, i)} + > + {tab.label} + + handleClose(e, tab.path)} + > + + + + ); + })} + + ); +} diff --git a/src/frontend/src/components/layout/app-shell.css b/src/frontend/src/components/layout/app-shell.css index ec6cae1..cac39b8 100644 --- a/src/frontend/src/components/layout/app-shell.css +++ b/src/frontend/src/components/layout/app-shell.css @@ -250,3 +250,87 @@ overflow: hidden; display: flex; } + +/* ── MDI 문서 탭 스트립 (design.md §2.7-2) ── */ +.kx-mdi { + flex-shrink: 0; + display: flex; + align-items: stretch; + gap: 2px; + height: 40px; + padding: 0 var(--space-3); + background: var(--color-neutral-050); + border-bottom: var(--border-card); + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; +} +.kx-mdi__tab { + display: inline-flex; + align-items: center; + gap: 2px; + max-width: 200px; + padding: 0 var(--space-2) 0 var(--space-3); + border: 1px solid transparent; + border-bottom: none; + border-top-left-radius: var(--radius-sm); + border-top-right-radius: var(--radius-sm); + align-self: flex-end; + height: 34px; +} +.kx-mdi__tab:hover { + background: var(--color-neutral-100); +} +/* 활성 탭 — 흰 서피스 + 상단 2px primary 인디케이터(§2.7-2). */ +.kx-mdi__tab.is-active { + background: var(--color-white); + border-color: var(--color-neutral-200); + box-shadow: inset 0 2px 0 var(--color-primary-600); +} +.kx-mdi__tab-btn { + min-width: 0; + flex: 1; + display: inline-flex; + align-items: center; + border: none; + background: transparent; + cursor: pointer; + padding: 0; + font-family: inherit; + font-size: var(--fs-caption); + font-weight: 500; + color: var(--color-neutral-500); +} +.kx-mdi__tab.is-active .kx-mdi__tab-btn { + color: var(--color-neutral-900); + font-weight: 600; +} +.kx-mdi__tab-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.kx-mdi__tab-close { + flex-shrink: 0; + width: 18px; + height: 18px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-neutral-500); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +} +.kx-mdi__tab-close:hover { + background: var(--color-neutral-200); + color: var(--color-neutral-900); +} + +/* 모바일 폭에서는 탭 스트립 숨김(단일 화면 포커스 — §2.7-6). */ +@media (max-width: 720px) { + .kx-mdi { + display: none; + } +} diff --git a/src/frontend/src/components/layout/mdiLabels.ts b/src/frontend/src/components/layout/mdiLabels.ts new file mode 100644 index 0000000..a2135fb --- /dev/null +++ b/src/frontend/src/components/layout/mdiLabels.ts @@ -0,0 +1,112 @@ +/* + * 라우트 → 화면명(탭 라벨) 매핑 — MDI 탭 라벨 해소(design.md §2.7-2). + * + * 1) 정적 경로: 사이드바 네비 정의(AppShell)와 정합하는 라벨 테이블. + * 2) 행사 스코프 동적 경로(/events/:eventId/...): 패턴 매칭. + * 3) 미등록 경로: 경로 세그먼트 기반 축약 폴백(:id 유사 세그먼트 제외). + * + * screens/** 를 import 하지 않는다(라벨은 여기서 단일 관리). 네비 라벨과 동일 문자열을 사용해 + * 사이드바 활성 표시와 탭 라벨이 일치하도록 한다. + */ + +/** 정적(정확 일치) 경로 → 라벨. AppShell NAV/DOMAIN/WORK 정의와 동일 문자열. */ +const STATIC_LABELS: Record = { + // 주 메뉴 + '/schedule': '전시 일정', + '/ops/operations': '현장운영', + '/analytics': '경영분석', + '/docs': '서류·마일스톤', + '/settlement': '정산', + '/admin': '관리자', + // 도메인 모듈 + '/halls': '홀 배정', + '/logistics': '반입·반출', + '/auctions': '공사 옥션', + '/visitors': '관람객', + '/visitors/checkin': '체크인 데스크', + '/leads': '리드', + '/campaigns': '마케팅', + '/sponsorship': '스폰서십', + '/cms': '콘텐츠 CMS', + '/cms/microsite': '마이크로사이트 빌더', + '/cms/i18n': '다국어 CMS', + // 업무 공통 + '/work/worklog': '업무일지', + '/work/schedule': '일정', + '/work/message': '쪽지', + '/work/notice': '공지', + '/work/meeting': '회의록', + '/work/report': '업무보고', + '/work/opinion': '의견접수', + '/work/search': '통합검색', + // 개인·알림 + '/notifications': '알림센터', + '/me': '마이페이지', + // 서류·수주 + '/docs/authoring': '신고서류 작성', + '/contractor/dashboard': '수주 부스', + // 관리자 하위 + '/admin/login-slides': '로그인 슬라이드', + '/admin/audit': '감사 로그', + '/admin/settings': '시스템 설정', + '/admin/masterdata/rulesets': '룰셋 버전', + '/admin/tenants': '테넌트 관리', + '/admin/users': '사용자 관리', + '/admin/roles': '역할 관리', + '/admin/codes': '공통 코드', + '/admin/menus': '메뉴 관리', +}; + +/** 동적 경로 패턴(정규식) → 라벨. 구체적 규칙을 먼저 둔다. */ +const PATTERN_LABELS: { re: RegExp; label: string }[] = [ + { re: /^\/events\/[^/]+\/dashboard$/, label: '대시보드' }, + { re: /^\/events\/[^/]+\/booths\/[^/]+\/home$/, label: '참가업체 홈' }, + { re: /^\/events\/[^/]+\/halls\/[^/]+\/layout$/, label: '플로어플랜 스튜디오' }, + { re: /^\/events\/[^/]+\/halls\/[^/]+\/comparison$/, label: 'AI 배치안 비교' }, + { re: /^\/events\/[^/]+\/booths\/[^/]+\/design$/, label: '부스 설계 스튜디오' }, + { re: /^\/events\/[^/]+\/booths\/[^/]+\/utility\/order$/, label: '유틸리티 신청' }, + { re: /^\/events\/[^/]+\/booths\/[^/]+\/utility$/, label: '유틸리티 배선' }, + { re: /^\/events\/[^/]+\/booths\/[^/]+\/compliance$/, label: '규정 검증' }, + { re: /^\/events\/[^/]+\/halls\/[^/]+\/booths\/[^/]+\/review$/, label: '검수 상세' }, + { re: /^\/events\/[^/]+\/halls\/[^/]+\/approvals$/, label: '검수 대기열' }, + { re: /^\/events\/[^/]+\/booths\/[^/]+\/gallery$/, label: '시각화 갤러리' }, + { re: /^\/auctions\/[^/]+\/award$/, label: '낙찰 비교' }, + { re: /^\/auctions\/[^/]+$/, label: '옥션 상세' }, +]; + +/** 폴백 세그먼트 → 라벨(경로 기반 축약에 사용). */ +const SEGMENT_LABELS: Record = { + dashboard: '대시보드', + layout: '플로어플랜', + design: '설계', + utility: '배선', + gallery: '갤러리', + approvals: '검수', + review: '검수 상세', + admin: '관리자', + work: '업무', + cms: 'CMS', + auctions: '옥션', +}; + +/** id 유사 세그먼트(폴백 라벨에서 제외) — UUID·숫자·짧은 코드. */ +function isIdLike(seg: string): boolean { + return /^[0-9]+$/.test(seg) || /^[0-9a-fA-F-]{8,}$/.test(seg) || /^[A-Z]\d+$/.test(seg); +} + +/** 라우트 경로 → 탭 라벨 해소. */ +export function labelForPath(path: string): string { + const clean = path.split('?')[0].split('#')[0]; + if (STATIC_LABELS[clean]) return STATIC_LABELS[clean]; + for (const { re, label } of PATTERN_LABELS) { + if (re.test(clean)) return label; + } + // 폴백: 뒤에서부터 id 아닌 첫 세그먼트를 라벨화. + const segs = clean.split('/').filter(Boolean); + for (let i = segs.length - 1; i >= 0; i -= 1) { + const seg = segs[i]; + if (isIdLike(seg)) continue; + return SEGMENT_LABELS[seg] ?? seg; + } + return '문서'; +} diff --git a/src/frontend/src/i18n/locales/en.json b/src/frontend/src/i18n/locales/en.json index 57adcce..ced444f 100644 --- a/src/frontend/src/i18n/locales/en.json +++ b/src/frontend/src/i18n/locales/en.json @@ -14,6 +14,17 @@ "states": { "loading": "Loading…", "empty": "Nothing to display." + }, + "aiWatermark": "AI-generated preview — actual construction may differ", + "aiNotice": "AI-generated images cannot be used for contracts or review documents — drawings are the construction standard", + "weekday": { + "0": "Sun", + "1": "Mon", + "2": "Tue", + "3": "Wed", + "4": "Thu", + "5": "Fri", + "6": "Sat" } }, "lang": { @@ -27,7 +38,9 @@ "transport": "Directions" }, "searchPlaceholder": "Search events", - "cta": { "register": "Visitor Pre-registration" }, + "cta": { + "register": "Visitor Pre-registration" + }, "menuOpen": "Open menu", "menuClose": "Close menu", "homeAria": "{{brand}} home", @@ -156,7 +169,12 @@ "fabCta": "Go to Pre-registration" }, "register": { - "steps": { "type": "Type", "info": "Info", "interest": "Interests", "done": "Done" }, + "steps": { + "type": "Type", + "info": "Info", + "interest": "Interests", + "done": "Done" + }, "stepsAria": "Pre-registration steps", "type": { "general": "General Visitor", @@ -358,7 +376,12 @@ "vipDesc": "Exhibition all-pass + VIP lounge access", "vipPrice": "₩50,000" }, - "steps": { "s1": "Event · Type", "s2": "Buyer", "s3": "Payment", "s4": "Done" }, + "steps": { + "s1": "Event · Type", + "s2": "Buyer", + "s3": "Payment", + "s4": "Done" + }, "stepsAria": "Booking steps", "bannerTitle": "Smart Factory Korea 2026", "bannerDate": "2026.09.05 – 09.08", @@ -517,5 +540,743 @@ "errRequest": "An error occurred during the request.", "errReset": "An error occurred during the reset.", "cancel": "Cancel" + }, + "dashboard": { + "countdown": "Opening D-{{n}}", + "degradedTag": "Offline — Sample", + "degradedMsg": "Could not reach the aggregation API; showing demo sample data.", + "loadError": "Failed to load dashboard metrics.", + "timelineAria": "Milestone timeline", + "milestoneEmptyTitle": "No derived schedule", + "milestoneEmptyDesc": "Milestones appear once the event start date is confirmed.", + "mstate": { + "done": "Done", + "current": "In progress", + "upcoming": "Upcoming" + }, + "kpisAria": "Key metrics", + "kpiEmpty": "No aggregated metrics", + "exhibitorStatus": "Exhibitor status", + "sampleTag": "Sample", + "exhibitorEmptyTitle": "No exhibitor booths", + "exhibitorEmptyDesc": "Booths appear here once layouts and designs are registered.", + "colBoothNo": "Booth No.", + "colCompany": "Company", + "colStatus": "Design status", + "feedAria": "Notification feed", + "feedTitle": "Notifications", + "feedEmpty": "No new notifications", + "aerialAria": "Hall aerial view", + "aerialTitle": "Hall aerial view", + "aerialShot": "S7 Hall aerial", + "aerialAlt": "Hall aerial preview image" + }, + "schedule": { + "title": "Exhibition Schedule", + "subtitle": "KINTEX all-hall event scheduling & monitoring — {{n}} events", + "crawlFallback": "(kintex.com crawl fallback)", + "yearFilter": "Year filter", + "allYears": "All years", + "yearN": "{{y}}", + "hallFilter": "Hall filter", + "allHalls": "All halls (1-10)", + "hall1": "Hall 1 Center (Halls 1-5)", + "hall2": "Hall 2 Center (Halls 6-10)", + "catFilter": "Category filter", + "allCats": "All categories", + "statusFilter": "Status filter", + "allStatus": "All statuses", + "cat": { + "exhibition": "Exhibition", + "culture": "Culture & Arts", + "trade": "Trade & Industry", + "tech": "Technology" + }, + "status": { + "ongoing": "Ongoing", + "upcoming": "Upcoming", + "ended": "Ended" + }, + "catExhibit": "{{cat}} show", + "apiWait": "Awaiting event API (crawl data)", + "viewSwitch": "Switch view", + "viewList": "List", + "viewCalendar": "Calendar", + "listLoading": "Loading event list", + "listAria": "Event list", + "loadError": "Failed to load the exhibition schedule.", + "count": "{{n}} events", + "emptyTitle": "No registered events", + "emptyDesc": "Adjust filters or create a new event.", + "newEvent": "New event", + "period": "Period", + "assignedHall": "Assigned hall", + "estVisitors": "Est. visitors", + "editDetail": "Edit details", + "viewLogistics": "View move-in/logistics", + "monthCal": "Month calendar", + "prev": "Prev", + "next": "Next", + "today": "Today", + "yearMonth": "{{m}}/{{y}}", + "miniCal": "Mini calendar", + "prevMonth": "Previous month", + "nextMonth": "Next month", + "offlineSample": "Offline — Sample", + "day": "Day {{d}}", + "dayHasEvent": "Day {{d}} (has event)", + "hallUtil": "Hall utilization", + "hallUtilMonth": "Hall utilization ({{m}}/{{y}})" + }, + "settlement": { + "title": "Settlement & Payment", + "subtitle": "Manage exhibitor billing & collection status", + "generate": "Generate invoices", + "generating": "Generating…", + "noEventTitle": "Please select an event", + "noEventDesc": "No event has been designated for settlement.", + "genDone": "Invoices generated — {{created}} new, {{skipped}} kept.", + "genError": "Failed to generate invoices. Check permissions or target data.", + "dashError": "Failed to load settlement aggregation.", + "kpisAria": "Settlement key metrics", + "kpiBilled": "Total billed", + "kpiCollected": "Collected", + "kpiOutstanding": "Outstanding", + "kpiOverdue": "Overdue invoices", + "count": "{{n}}", + "collectionRate": "Collection rate {{rate}}%", + "outstandingSub": "Outstanding balance", + "overdueSub": "Past due date", + "distTitle": "Billing status distribution", + "distEmpty": "No aggregated billing", + "listTitle": "Invoice list", + "invoices": "Invoices", + "statusFilter": "Status filter", + "status": { + "all": "All", + "pending": "Unissued", + "invoiced": "Invoiced", + "paid": "Paid", + "overdue": "Overdue" + }, + "listError": "Failed to load the invoice list.", + "emptyTitle": "No invoices", + "emptyManage": "Use Generate invoices above to aggregate billing per exhibitor.", + "emptyView": "No invoices have been issued yet.", + "colNo": "Invoice No.", + "colExhibitor": "Exhibitor", + "colBilled": "Billed", + "colPaid": "Paid", + "colOutstanding": "Outstanding", + "colDue": "Due date", + "colStatus": "Status", + "detail": "Details", + "prev": "Prev", + "next": "Next", + "pageInfo": "{{page}} / {{total}} · {{cnt}} total", + "modalAria": "Invoice details", + "invoice": "Invoice", + "close": "Close", + "detailError": "Failed to load invoice details.", + "liItem": "Item", + "liQty": "Qty", + "liUnit": "Unit price", + "liAmount": "Amount", + "noLines": "No line items", + "supplyAmount": "Supply amount", + "vat": "VAT (10%)", + "grandTotal": "Total billed", + "paidOutstanding": "Paid / Outstanding", + "payHistory": "Payment history", + "noPayments": "No payment records.", + "won": "KRW {{v}}", + "issue": "Issue invoice", + "issuing": "Issuing…", + "payAmount": "Payment amount", + "payAmountPh": "Payment amount (KRW)", + "payMethod": "Payment method", + "methodTransfer": "Bank transfer", + "methodCard": "Card", + "methodOther": "Other", + "memo": "Memo", + "memoPh": "Memo (optional)", + "recordPay": "Record payment", + "recording": "Recording…", + "payFail": "Failed to record payment. Check the amount (within total) and permissions.", + "errPositive": "Please enter an amount greater than 0.", + "errExceed": "Cannot exceed the outstanding balance.", + "pgNote": "Actual payment is delegated to an external PG. This screen is for recording payment confirmation only.", + "managerOnly": "Only organizers/hall managers can record or issue payments." + }, + "hallassign": { + "title": "Hall Assignment · Auto Quote", + "subtitle": "Hall×period assignment status and instant rate-ruleset quotes (M1).", + "start": "From", + "end": "To", + "newAssign": "New assignment", + "board": "Assignment board", + "boardHint": "Overlapping assignments are shown as conflicts.", + "forbiddenTitle": "Hall manager board only", + "forbiddenDesc": "Only KINTEX hall manager accounts can view assignment status. Use the auto quote below.", + "noHalls": "No hall master data.", + "noAssign": "No assignment", + "release": "Release assignment", + "releaseConfirm": "Release the {{hall}} assignment for {{event}}?", + "assignedToast": "Hall assigned.", + "releasedToast": "Assignment released.", + "releaseFail": "Release failed (check permissions).", + "conflictErr": "The hall/period overlaps with another event.", + "assignFail": "Assignment failed.", + "pickEventHall": "Select an event and a hall.", + "modalTitle": "Hall assignment", + "close": "Close", + "event": "Event", + "pickEvent": "Select event…", + "hall": "Hall", + "pickHall": "Select hall…", + "occupyStart": "Occupancy start", + "occupyEnd": "Occupancy end", + "primary": "Primary hall", + "note": "Note", + "optional": "Optional", + "cancel": "Cancel", + "assign": "Assign", + "assigning": "Assigning…", + "autoQuote": "Auto quote", + "quoteHint": "Based on rate ruleset · final amount confirmed by KINTEX", + "startDate": "Start date", + "endDate": "End date", + "boothSize": "Booth size (optional)", + "countPh": "Count", + "calcQuote": "Calculate quote", + "calculating": "Calculating…", + "quoteFail": "Quote calculation failed.", + "quoteReq": "Enter hall, start and end dates.", + "qItem": "Item", + "qBasis": "Basis", + "qAmount": "Amount", + "rentalSubtotal": "Rental subtotal", + "rentalTotal": "Rental total ({{days}} days)", + "deposit": "Management deposit (separate)", + "ruleset": "(ruleset {{v}})" + }, + "analytics": { + "title": "Business Analytics", + "subtitle": "KINTEX operating profitability · exhibitor insights", + "perspective": "Analysis perspective", + "operator": "Operator", + "exhibitor": "Exhibitor", + "periodAria": "Analysis period", + "periodEvent": "This event", + "periodD90": "Last 90 days", + "periodAnnual": "Annual", + "exportPdf": "Export PDF", + "exportExcel": "Export Excel", + "noEventTitle": "Please select an event", + "noEventDesc": "No event has been designated for analysis.", + "offlineSample": "Offline — Sample", + "degradedMsg": "Could not reach the aggregation API; showing demo sample data.", + "loadError": "Failed to load business analytics.", + "kpisAria": "Key metrics", + "kpiEmpty": "No aggregated metrics", + "trendAria": "Trend chart", + "trendTitle": "Revenue · Booth trend", + "monthlyAgg": "Monthly aggregation", + "trendHint": "Monthly rental/utility revenue and booth count trend (revenue in KRW).", + "trendEmptyTitle": "No trend data", + "trendEmptyDesc": "No revenue/booth records for an aggregable period.", + "revenue": "Revenue", + "booth": "Booths", + "boothsUnit": "{{n}} booths", + "sectorsAria": "Sector composition", + "sectorsTitle": "Sector composition", + "sectorsEmptyTitle": "Sectors not aggregated", + "sectorsEmptyDesc": "Displayed once the sector classification source (M13) is connected.", + "perfTable": "Performance table", + "pnlTitle": "P&L composition", + "exhibitorPerfTitle": "Exhibitor performance", + "sampleTag": "Sample", + "pnlEmptyTitle": "No P&L items", + "pnlEmptyDesc": "Displayed once booth rental/utility revenue records are aggregated.", + "colItem": "Item", + "colAmount": "Amount", + "colGrade": "Grade", + "perfEmptyTitle": "Exhibitor performance not aggregated", + "perfEmptyDesc": "Displayed once the lead/conversion source (M13 visitors) is connected.", + "colCompany": "Company", + "colLeads": "Leads", + "colConversion": "Conversion" + }, + "global": { + "sectionAria": "Tenant-wide business analytics", + "title": "Enterprise analytics (cross-event)", + "subtitle": "Cross-event revenue comparison · hall utilization trend · exhibitor retention · demand forecast", + "yearsAria": "Analysis years", + "years3": "Last 3 years", + "years5": "Last 5 years", + "years10": "Last 10 years", + "loadErrorTitle": "Failed to load global aggregation", + "loadErrorDesc": "Please try again shortly.", + "kpisAria": "Global key metrics", + "kpiEmpty": "No aggregated metrics", + "evComp": "Cross-event revenue comparison", + "top8": "Top 8", + "evCompHint": "Rental + utility revenue ranking of events with booth layouts (in KRW).", + "evCompEmptyTitle": "No events to compare", + "evCompEmptyDesc": "Displayed once revenue of events with booth layouts is aggregated.", + "revenue": "Revenue", + "retention": "Exhibitor retention", + "retentionCum": "Cumulative return rate", + "returning": "returning", + "retentionEmptyTitle": "Retention by year not aggregated", + "retentionEmptyDesc": "Displayed once the booth-assigned company source is connected.", + "retentionRate": "Return rate", + "occupancy": "Hall utilization trend (monthly)", + "occupancyHint": "Monthly utilization = event-days ÷ (halls × days in month).", + "occupancyEmpty": "No utilization data", + "occupancyRate": "Utilization", + "forecast": "Demand forecast", + "forecastTitle": "Demand forecast (hosting trend)", + "trendProjection": "Trend projection", + "forecastHint": "Actual annual event count + least-squares linear trend projected 2 years ahead (dotted).", + "forecastEmptyTitle": "No history to forecast", + "forecastEmptyDesc": "Displayed once annual hosting history is aggregated.", + "casesUnit": "{{n}}", + "actual": "Actual", + "projected": "Forecast" + }, + "ops": { + "title": "Hall Operations", + "subtitle": "Status based on check-in / utility live aggregation", + "modeAria": "View mode", + "live": "Live", + "history": "History", + "noEventTitle": "Please select an event", + "noEventDesc": "No event has been designated for on-site operations.", + "offlineSample": "Offline — Sample", + "degradedMsg": "Could not reach the aggregation API; showing demo sample data.", + "loadError": "Failed to load on-site operations status.", + "hallStatus": "Hall status", + "checkinDerived": "Derived from check-in (M10) · distributed by area weight", + "sensorNoneHalls": "No sensor · assigned hall list", + "noHallsTitle": "No assigned halls", + "noHallsDesc": "Displayed once halls are assigned to the event.", + "busy": "Busy", + "smooth": "Smooth", + "moderate": "Moderate", + "people": "{{n}}", + "onSiteCheckin": "On-site check-in <0>{{n}}0>", + "noCheckinAgg": "No check-in aggregation", + "powerLoad": "Power load", + "totalKw": "Total <0>{{v}}0>kW", + "utilLoadAgg": "Utility request (M4) load aggregation", + "noLoadTitle": "No requested load", + "noLoadDesc": "Displayed per hall once electrical utility requests are received.", + "kw": "{{v}}kW", + "hourlyCheckin": "Hourly check-in", + "hourlyAgg": "Check-in (M10) hourly aggregation", + "noSeriesTitle": "No check-in trend", + "noSeriesDesc": "Hourly trend appears once on-site check-ins are recorded.", + "facilitySensor": "Facility sensor integration", + "facilitySensorTitle": "Facility sensors (HVAC · Lighting · Parking)", + "sensorNoneTitle": "Facility sensors not connected", + "sensorNoneDesc": "HVAC · lighting IoT · parking (iparking) integration is shown once sensors (M14) are connected.", + "temp": "Temp", + "humidity": "Humidity", + "parkingSlots": "{{zone}}: {{occ}}/{{cap}} slots" + }, + "admin": { + "deniedTitle": "Access denied", + "deniedAuthed": "This page is available only to system administrators (Hall Manager · ADMIN). Contact an administrator if you need access.", + "deniedGuest": "The admin back office is available after login.", + "overviewTitle": "System Overview", + "overviewSub": "Real-time metrics and operational status", + "loginSlides": "Manage login slides", + "tenantSwitch": "Switch tenant", + "systemNormal": "System normal", + "moduleShortcuts": "Admin module shortcuts", + "offlineSample": "Offline — Sample", + "degradedMsg": "Could not reach the aggregation API; showing demo sample data.", + "dashLoadError": "Failed to load the admin dashboard.", + "kpisAria": "Key metrics", + "kpiEmpty": "No aggregated metrics", + "visitorTrend": "Visitor trend", + "hourlyVisitors": "Hourly visitors", + "visitors": "Visitors", + "visitorTrendEmptyTitle": "Visitor trend not aggregated", + "visitorTrendEmptyDesc": "Hourly trend appears once visitor sensors (M14) are connected.", + "liveEvents": "Live events", + "noLiveEvents": "No ongoing events", + "hallTbd": "Hall unassigned", + "occupancy": "Occupancy {{n}}%", + "modUsers": "Users", + "modRoles": "Roles & Permissions", + "modCodes": "Common codes", + "modMenus": "Menus", + "modAudit": "Audit log", + "modSettings": "System settings", + "modRulesets": "Rulesets", + "modTenants": "Tenants" + }, + "tenant": { + "title": "Tenant Management · Onboarding", + "subtitle": "Platform super-admin only · add venues via data onboarding without code deployment.", + "sampleTag": "Sample · Not implemented", + "sampleNote": "Multi-tenancy (tenant CRUD, onboarding, isolation) is not yet in the backend, so this screen shows the target IA. Live data will be wired after multi-tenancy is introduced.", + "listAria": "Tenant list", + "venue": "Venue", + "notImpl": "Not implemented", + "addVenue": "Add venue", + "code": "Code", + "domain": "Domain", + "status": "Status", + "events": "Events", + "users": "Users", + "wizardAria": "Onboarding wizard", + "onboardingOf": "{{name}} onboarding", + "dataOnboarding": "Data onboarding without code deployment", + "wiz": { + "create": { + "label": "Create tenant", + "desc": "Register code & display name" + }, + "domain": { + "label": "Subdomain", + "desc": "Assign DNS & branding domain" + }, + "brand": { + "label": "Branding", + "desc": "Logo, colors, public-site theme" + }, + "master": { + "label": "Master data entry", + "desc": "Seed halls, rates, rulesets" + }, + "admin": { + "label": "Admin account", + "desc": "Invite tenant administrator" + }, + "smoke": { + "label": "Isolation smoke test", + "desc": "Verify cross-tenant isolation" + }, + "activate": { + "label": "Activate", + "desc": "Switch to operation" + } + }, + "tstatus": { + "active": "Active", + "onboarding": "Onboarding", + "suspended": "Suspended" + } + }, + "ruleset": { + "title": "Regulation Ruleset Versions", + "subtitle": "Ruleset version history and rule items for regulation revisions.", + "readonly": "Read only", + "readonlyNote": "Rulesets are version-controlled as deployment resources (rulesets/*.json). Editing and version creation are done by replacing resources when regulations are revised; this screen is view-only.", + "listError": "Failed to load the ruleset list.", + "noRulesets": "No deployed rulesets", + "versionHistory": "Version history", + "rulesCount": "{{n}} rules", + "effectiveDate": "Effective {{date}}", + "ruleList": "Rule list", + "rulesOf": "{{name}} rules ({{n}})", + "detailError": "Failed to load the full rules.", + "noRulesTitle": "Ruleset has no rule array", + "noRulesDesc": "A ruleset that defines only rates/unit prices without rules (e.g. rate master).", + "colRuleName": "Rule name", + "colValue": "Value/setting", + "colLogic": "Logic / Requirement", + "colSeverity": "Violation level", + "block": "Block", + "warn": "Warn", + "required": "Required", + "perHall": "Per hall {{unit}}", + "docNeeded": "Document required: {{doc}}" + }, + "audit": { + "title": "Audit Log", + "subtitle": "Tracks all admin actions (approvals, awards, setting changes) and key domain events. (Integrity preserved)", + "exportCsv": "Export CSV", + "filterAria": "Audit log filter", + "actionType": "Action type", + "allActions": "All actions", + "actorId": "Actor ID", + "actorIdPh": "e.g. user-102", + "eventId": "Event ID", + "eventIdPh": "e.g. EVT-2026-01", + "reset": "Reset", + "applyFilter": "Apply filter", + "loadError": "Failed to load the audit log.", + "tableAria": "Audit log table", + "emptyTitle": "No audit records match the criteria", + "emptyDesc": "Adjust or reset filters and search again.", + "colTime": "Time", + "colUser": "User", + "colAction": "Action", + "colTarget": "Target", + "colEvent": "Event", + "colResult": "Result", + "showing": "Showing <0>{{from}}-{{to}}0> / <1>{{total}}1> total", + "prev": "Prev", + "next": "Next", + "rowsShow": "Rows", + "rowsPerPage": "Rows per page", + "rowsN": "{{n}}", + "success": "Success", + "failure": "Failure", + "csvTime": "Time", + "csvActorId": "ActorID", + "csvActorName": "ActorName", + "csvAction": "Action", + "csvTargetType": "TargetType", + "csvTargetId": "TargetID", + "csvEventId": "EventID", + "csvResult": "Result", + "csvSummary": "Summary" + }, + "role": { + "title": "Roles & Permissions (RBAC)", + "subtitle": "Assign resource permissions per role (Organizer, Exhibitor, Contractor, Hall Manager, Admin, Visitor) in a matrix.", + "addRole": "Add role", + "loadError": "Failed to load roles/permissions.", + "noRolesTitle": "No roles defined", + "noRolesDesc": "Add a role to configure RBAC.", + "roleList": "Role list", + "role": "Role", + "codePerm": "{{code}} · {{n}} permissions", + "unused": "Unused", + "permMatrix": "Permission matrix", + "rolePerm": "{{name}} permissions", + "roleInfo": "Role info", + "deleteConfirm": "Delete the role '{{name}}'?", + "delete": "Delete", + "noPerms": "No permissions defined", + "selectRole": "Select a role", + "saved": "Role saved.", + "deleted": "Role deleted.", + "addRoleModal": "Add role", + "editRole": "Edit role info", + "modalDesc": "Permission assignment is managed by checkboxes in the matrix after saving.", + "roleCode": "Role code *", + "roleCodePh": "e.g. ORGANIZER", + "roleName": "Role name *", + "description": "Description", + "useYn": "In use", + "use": "In use", + "noUse": "Unused", + "cancel": "Cancel", + "save": "Save", + "saving": "Saving…", + "errRequired": "Role code and name are required.", + "errDupCode": "This role code already exists." + }, + "menu": { + "title": "Menu Management", + "subtitle": "Manage the per-role navigation menu tree with paths and access roles.", + "addMenu": "Add menu", + "loadError": "Failed to load menus.", + "treeAria": "Menu tree", + "emptyTitle": "No registered menus", + "emptyDesc": "Add a menu.", + "colName": "Menu name", + "colPath": "Path", + "colType": "Type", + "colRole": "Access role", + "colSort": "Order", + "colUse": "In use", + "colManage": "Manage", + "all": "All", + "unused": "Unused", + "used": "In use", + "edit": "Edit", + "delete": "Delete", + "deleteConfirm": "Delete the menu '{{name}}'? Sub-menus, if any, will be cleaned up together.", + "saved": "Menu saved.", + "deleted": "Menu deleted.", + "editMenu": "Edit menu", + "menuId": "Menu ID (unique key) *", + "menuIdPh": "e.g. admin-users", + "menuName": "Menu name *", + "parent": "Parent menu", + "topLevel": "(Top level)", + "path": "Path", + "pathPh": "e.g. /admin/users", + "type": "Type", + "accessRole": "Access role", + "accessRolePh": "e.g. ADMIN (empty = all)", + "sort": "Order", + "useYn": "In use", + "cancel": "Cancel", + "save": "Save", + "saving": "Saving…", + "errName": "Menu name is required.", + "errId": "Menu ID (unique key) is required.", + "errDupId": "This menu ID already exists." + }, + "code": { + "title": "Common Code Management", + "subtitle": "Manage common code groups and code values used in dropdowns and classifications.", + "addGroup": "Add group", + "groupLoadError": "Failed to load code groups.", + "noGroupsTitle": "No code groups", + "noGroupsDesc": "Add a group to configure common codes.", + "codeGroup": "Code group", + "codeValues": "Code values", + "valuesOf": "{{name}} values", + "editGroup": "Edit code group", + "deleteGroup": "Delete group", + "deleteGroupConfirm": "Delete group '{{name}}' and its codes?", + "addCode": "Add code", + "codeLoadError": "Failed to load code values.", + "noCodesTitle": "No codes", + "noCodesDesc": "Add a code.", + "code": "Code", + "name": "Name", + "value": "Value", + "sort": "Order", + "use": "In use", + "manage": "Manage", + "unused": "Unused", + "used": "In use", + "edit": "Edit", + "delete": "Delete", + "deleteCodeConfirm": "Delete code '{{name}}'?", + "groupSaved": "Code group saved.", + "groupDeleted": "Code group deleted.", + "codeSaved": "Code saved.", + "codeDeleted": "Code deleted.", + "groupCode": "Group code *", + "groupName": "Group name *", + "description": "Description", + "useYn": "In use", + "cancel": "Cancel", + "save": "Save", + "saving": "Saving…", + "errGroupRequired": "Group code and name are required.", + "errDupGroup": "This group code already exists.", + "editCode": "Edit code", + "group": "Group", + "codeReq": "Code *", + "nameReq": "Name *", + "attr1": "Attribute 1 (attr1)", + "errCodeRequired": "Code and name are required.", + "errDupCode": "This code already exists." + }, + "sysuser": { + "title": "User Management", + "subtitle": "Manage account creation/editing, role assignment, status (active/locked/disabled), and OTP reset. Passwords are masked and not displayed.", + "addUser": "Add user", + "searchAria": "User search", + "keyword": "Keyword", + "keywordPh": "Name·Email", + "role": "Role", + "status": "Status", + "all": "All", + "reset": "Reset", + "search": "Search", + "loadError": "Failed to load the user list.", + "listAria": "User list", + "emptyTitle": "No users", + "emptyDesc": "Adjust search criteria or add a user.", + "colUser": "User", + "colEmail": "Email", + "colRole": "Role", + "colDept": "Dept", + "colStatus": "Status", + "colLastLogin": "Last login", + "colManage": "Manage", + "hallManagerTag": "Hall Mgr", + "otpSet": "Set", + "otpUnset": "Not set", + "edit": "Edit", + "lock": "Lock", + "unlock": "Unlock", + "otpResetBtn": "Reset OTP", + "otpResetConfirm": "Reset OTP for {{name}}?", + "saved": "User saved.", + "statusChanged": "Status changed.", + "otpReset": "OTP reset.", + "active": "Active", + "locked": "Locked", + "disabled": "Disabled", + "editUser": "Edit user", + "newDesc": "The initial password is not displayed on screen after saving.", + "editDesc": "Email cannot be edited. Password reset follows a separate policy.", + "email": "Email *", + "displayName": "Display name *", + "dept": "Department", + "initPassword": "Initial password *", + "hallManagerCheck": "Hall Manager (internal operator)", + "cancel": "Cancel", + "save": "Save", + "saving": "Saving…", + "errRequired": "Email and display name are required.", + "errPassword": "New users require an initial password.", + "totalUsers": "<0>{{total}}0> users total", + "prev": "Prev", + "next": "Next" + }, + "settings": { + "title": "System Settings", + "subtitle": "Manage system parameters such as global policies, feature toggles, and AI gates. Secret values are masked.", + "loadError": "Failed to load system settings.", + "emptyTitle": "No settings registered", + "emptyDesc": "Add your first setting item in the form below.", + "saved": "Setting saved.", + "deleted": "Setting deleted.", + "deleteConfirm": "Delete the setting '{{key}}'?", + "general": "General", + "groupAria": "Settings group {{name}}", + "secret": "Secret", + "secretTitle": "Secret — value masked", + "on": "On", + "off": "Off", + "secretPh": "Set (masked) — enter new value to change", + "save": "Save", + "delete": "Delete", + "deleteKey": "Delete {{key}}", + "secretHint": "The secret value is never exposed. Saving without entering a value keeps the existing value.", + "addAria": "Add setting", + "addTitle": "Add / override setting", + "settingKey": "Setting key", + "settingKeyPh": "e.g. deadline.utility.days", + "value": "Value", + "type": "Type", + "description": "Description", + "secretCheck": "Secret (mask value)" + }, + "slide": { + "title": "Login Slide Management", + "subtitle": "Login screen left carousel images/text — the <0>latest 5 by registration order0> among active slides rotate.", + "addAria": "Add new slide", + "addTitle": "Add new slide", + "imageLabel": "Image (PNG/JPG/WebP, up to 8MB)", + "preview": "Preview", + "titleLabel": "Title text", + "titlePh": "e.g. KINTEX, Asia's leading exhibition & convention center", + "captionLabel": "Caption (subtext)", + "captionPh": "e.g. Discover business opportunities across 100,000 sqm of exhibition space.", + "submitting": "Registering…", + "submit": "Register slide", + "listAria": "Slide list", + "registered": "Registered slides", + "countActive": "{{total}} total · {{active}} active", + "emptyTitle": "No registered slides", + "emptyDesc": "Registered images and text appear in the login carousel. (5 defaults shown when none registered)", + "live": "Live", + "inactive": "Inactive", + "registeredAt": "Registered {{date}}", + "active": "Active", + "delete": "Delete", + "listError": "Failed to load the list.", + "errImage": "Please select a slide image.", + "errTitle": "Please enter the title text.", + "createFail": "Registration failed.", + "updateFail": "Update failed.", + "deleteFail": "Deletion failed.", + "deleteConfirm": "Delete the slide \"{{title}}\"?" } } diff --git a/src/frontend/src/i18n/locales/ja.json b/src/frontend/src/i18n/locales/ja.json index 51f60e4..7fc2571 100644 --- a/src/frontend/src/i18n/locales/ja.json +++ b/src/frontend/src/i18n/locales/ja.json @@ -14,6 +14,17 @@ "states": { "loading": "読み込み中…", "empty": "表示する内容がありません。" + }, + "aiWatermark": "AI 生成の予想画像 — 実際の施工結果と異なる場合があります", + "aiNotice": "AI 生成画像は契約・審査書類には使用できません — 施工基準は図面です", + "weekday": { + "0": "日", + "1": "月", + "2": "火", + "3": "水", + "4": "木", + "5": "金", + "6": "土" } }, "lang": { @@ -27,7 +38,9 @@ "transport": "交通" }, "searchPlaceholder": "イベントを検索", - "cta": { "register": "来場事前登録" }, + "cta": { + "register": "来場事前登録" + }, "menuOpen": "メニューを開く", "menuClose": "メニューを閉じる", "homeAria": "{{brand}} ホーム", @@ -156,7 +169,12 @@ "fabCta": "事前登録へ" }, "register": { - "steps": { "type": "タイプ", "info": "情報", "interest": "関心分野", "done": "完了" }, + "steps": { + "type": "タイプ", + "info": "情報", + "interest": "関心分野", + "done": "完了" + }, "stepsAria": "事前登録ステップ", "type": { "general": "一般来場者", @@ -358,7 +376,12 @@ "vipDesc": "展示会オールパス + VIP ラウンジ利用券", "vipPrice": "₩50,000" }, - "steps": { "s1": "イベント·券種", "s2": "予約者", "s3": "決済", "s4": "完了" }, + "steps": { + "s1": "イベント·券種", + "s2": "予約者", + "s3": "決済", + "s4": "完了" + }, "stepsAria": "予約ステップ", "bannerTitle": "スマートファクトリー・コリア 2026", "bannerDate": "2026.09.05 – 09.08", @@ -517,5 +540,743 @@ "errRequest": "リクエスト中にエラーが発生しました。", "errReset": "再設定中にエラーが発生しました。", "cancel": "キャンセル" + }, + "dashboard": { + "countdown": "開場 D-{{n}}", + "degradedTag": "オフライン — サンプル", + "degradedMsg": "集計 API に接続できず、デモ用サンプルを表示しています。", + "loadError": "ダッシュボード集計を読み込めませんでした。", + "timelineAria": "マイルストーンタイムライン", + "milestoneEmptyTitle": "派生スケジュールがありません", + "milestoneEmptyDesc": "イベント開始日が確定するとマイルストーンが表示されます。", + "mstate": { + "done": "完了", + "current": "進行中", + "upcoming": "予定" + }, + "kpisAria": "主要指標", + "kpiEmpty": "集計された指標がありません", + "exhibitorStatus": "出展社状況", + "sampleTag": "サンプル", + "exhibitorEmptyTitle": "出展社ブースがありません", + "exhibitorEmptyDesc": "ブース配置・設計案が登録されるとここに表示されます。", + "colBoothNo": "ブース番号", + "colCompany": "会社名", + "colStatus": "設計状態", + "feedAria": "通知フィード", + "feedTitle": "通知", + "feedEmpty": "新しい通知はありません", + "aerialAria": "ホール全景俯瞰", + "aerialTitle": "ホール全景俯瞰", + "aerialShot": "S7 ホール全景", + "aerialAlt": "ホール全景俯瞰の予想画像" + }, + "schedule": { + "title": "展示スケジュール", + "subtitle": "KINTEX 全ホールのイベント日程管理・モニタリング — {{n}}件", + "crawlFallback": "(kintex.com クロールフォールバック)", + "yearFilter": "年度フィルター", + "allYears": "全年度", + "yearN": "{{y}}年", + "hallFilter": "ホールフィルター", + "allHalls": "全ホール (1-10)", + "hall1": "第1展示場 (ホール 1-5)", + "hall2": "第2展示場 (ホール 6-10)", + "catFilter": "カテゴリーフィルター", + "allCats": "全カテゴリー", + "statusFilter": "ステータスフィルター", + "allStatus": "全ステータス", + "cat": { + "exhibition": "展示", + "culture": "文化・芸術", + "trade": "貿易・産業", + "tech": "技術" + }, + "status": { + "ongoing": "開催中", + "upcoming": "予定", + "ended": "終了" + }, + "catExhibit": "{{cat}}展", + "apiWait": "イベント API 待機 (クロールデータ)", + "viewSwitch": "表示切替", + "viewList": "リスト", + "viewCalendar": "カレンダー", + "listLoading": "イベント一覧を読み込み中", + "listAria": "イベント一覧", + "loadError": "展示スケジュールを読み込めませんでした。", + "count": "{{n}}件", + "emptyTitle": "登録されたイベントがありません", + "emptyDesc": "フィルターを調整するか新規イベントを作成してください。", + "newEvent": "新規イベント作成", + "period": "期間", + "assignedHall": "割当ホール", + "estVisitors": "推定人数", + "editDetail": "詳細編集", + "viewLogistics": "搬入/物流を見る", + "monthCal": "月カレンダー", + "prev": "前へ", + "next": "次へ", + "today": "今日", + "yearMonth": "{{y}}年{{m}}月", + "miniCal": "ミニカレンダー", + "prevMonth": "前月", + "nextMonth": "翌月", + "offlineSample": "オフライン — サンプル", + "day": "{{d}}日", + "dayHasEvent": "{{d}}日 (イベントあり)", + "hallUtil": "ホール稼働率", + "hallUtilMonth": "ホール稼働率 ({{y}}年{{m}}月)" + }, + "settlement": { + "title": "精算・決済", + "subtitle": "イベント出展社の請求・入金状況を管理", + "generate": "請求書生成", + "generating": "生成中…", + "noEventTitle": "イベントを選択してください", + "noEventDesc": "精算状況を照会するイベントが指定されていません。", + "genDone": "請求書生成完了 — 新規 {{created}}件、既存維持 {{skipped}}件。", + "genError": "請求書の生成に失敗しました。権限または対象データを確認してください。", + "dashError": "精算状況の集計を読み込めませんでした。", + "kpisAria": "精算主要指標", + "kpiBilled": "総請求額", + "kpiCollected": "入金額", + "kpiOutstanding": "未収金", + "kpiOverdue": "延滞請求", + "count": "{{n}}件", + "collectionRate": "入金率 {{rate}}%", + "outstandingSub": "未収残高", + "overdueSub": "納付期限超過", + "distTitle": "請求ステータス分布", + "distEmpty": "集計された請求がありません", + "listTitle": "請求書一覧", + "invoices": "請求書", + "statusFilter": "ステータスフィルター", + "status": { + "all": "全て", + "pending": "未発行", + "invoiced": "請求済", + "paid": "完納", + "overdue": "延滞" + }, + "listError": "請求書一覧を読み込めませんでした。", + "emptyTitle": "請求書がありません", + "emptyManage": "上部の請求書生成で出展社別に請求を集計してください。", + "emptyView": "まだ発行された請求書がありません。", + "colNo": "請求番号", + "colExhibitor": "出展社", + "colBilled": "請求額", + "colPaid": "入金", + "colOutstanding": "未収", + "colDue": "納付期限", + "colStatus": "ステータス", + "detail": "詳細", + "prev": "前へ", + "next": "次へ", + "pageInfo": "{{page}} / {{total}} · 全 {{cnt}}件", + "modalAria": "請求書詳細", + "invoice": "請求書", + "close": "閉じる", + "detailError": "請求書詳細を読み込めませんでした。", + "liItem": "項目", + "liQty": "数量", + "liUnit": "単価", + "liAmount": "金額", + "noLines": "明細項目なし", + "supplyAmount": "供給価額", + "vat": "消費税(10%)", + "grandTotal": "請求総額", + "paidOutstanding": "入金 / 未収", + "payHistory": "入金履歴", + "noPayments": "入金記録がありません。", + "won": "{{v}}ウォン", + "issue": "請求書発行", + "issuing": "発行中…", + "payAmount": "入金額", + "payAmountPh": "入金額(ウォン)", + "payMethod": "入金方法", + "methodTransfer": "口座振込", + "methodCard": "カード", + "methodOther": "その他", + "memo": "メモ", + "memoPh": "メモ(任意)", + "recordPay": "入金記録", + "recording": "記録中…", + "payFail": "入金記録に失敗しました。金額(総額以内)・権限を確認してください。", + "errPositive": "0より大きい金額を入力してください。", + "errExceed": "未収残高を超えることはできません。", + "pgNote": "実際の決済は外部 PG に委任されます。本画面は入金確認記録専用です。", + "managerOnly": "入金・発行は主催者/ホールマネージャーのみ実行できます。" + }, + "hallassign": { + "title": "ホール割当 · 自動見積", + "subtitle": "ホール×期間の割当状況と料率ルールセットに基づく即時見積(M1)。", + "start": "開始", + "end": "終了", + "newAssign": "新規割当", + "board": "割当ボード", + "boardHint": "重複する割当は競合として表示されます。", + "forbiddenTitle": "ホールマネージャー専用ボード", + "forbiddenDesc": "割当状況の照会は KINTEX ホールマネージャーアカウントのみ可能です。自動見積は下部をご利用ください。", + "noHalls": "ホールマスターがありません。", + "noAssign": "割当なし", + "release": "割当解除", + "releaseConfirm": "{{event}} の {{hall}} 割当を解除しますか?", + "assignedToast": "ホールが割り当てられました。", + "releasedToast": "割当が解除されました。", + "releaseFail": "解除に失敗しました(権限確認)。", + "conflictErr": "割り当てるホール・期間が他のイベントと重複します。", + "assignFail": "割当に失敗しました。", + "pickEventHall": "イベントとホールを選択してください。", + "modalTitle": "ホール割当", + "close": "閉じる", + "event": "イベント", + "pickEvent": "イベント選択…", + "hall": "ホール", + "pickHall": "ホール選択…", + "occupyStart": "占有開始", + "occupyEnd": "占有終了", + "primary": "主割当ホール(primary)", + "note": "備考", + "optional": "任意入力", + "cancel": "キャンセル", + "assign": "割当", + "assigning": "割当中…", + "autoQuote": "自動見積", + "quoteHint": "料率ルールセット基準 · 最終金額は KINTEX 確定時に案内", + "startDate": "開始日", + "endDate": "終了日", + "boothSize": "ブース規模(任意)", + "countPh": "数量", + "calcQuote": "見積計算", + "calculating": "計算中…", + "quoteFail": "見積計算に失敗しました。", + "quoteReq": "ホール・開始日・終了日を入力してください。", + "qItem": "項目", + "qBasis": "算出根拠", + "qAmount": "金額", + "rentalSubtotal": "賃料小計", + "rentalTotal": "賃料合計 ({{days}}日)", + "deposit": "管理費予託金(別途)", + "ruleset": "(ルールセット {{v}})" + }, + "analytics": { + "title": "経営分析", + "subtitle": "KINTEX 運営収益性 · 出展社インサイト", + "perspective": "分析視点", + "operator": "運営社", + "exhibitor": "出展社", + "periodAria": "分析期間", + "periodEvent": "今回のイベント", + "periodD90": "直近 90 日", + "periodAnnual": "年間", + "exportPdf": "PDF 出力", + "exportExcel": "Excel 出力", + "noEventTitle": "イベントを選択してください", + "noEventDesc": "分析するイベントが指定されていません。", + "offlineSample": "オフライン — サンプル", + "degradedMsg": "集計 API に接続できず、デモ用サンプルを表示しています。", + "loadError": "経営分析集計を読み込めませんでした。", + "kpisAria": "主要指標", + "kpiEmpty": "集計された指標がありません", + "trendAria": "トレンドチャート", + "trendTitle": "売上 · ブース推移", + "monthlyAgg": "月別集計", + "trendHint": "月別の賃貸・ユーティリティ売上とブース数の推移です(売上単位:ウォン)。", + "trendEmptyTitle": "推移データがありません", + "trendEmptyDesc": "集計可能な期間の売上・ブースレコードがありません。", + "revenue": "売上", + "booth": "ブース", + "boothsUnit": "{{n}} ブース", + "sectorsAria": "業種別構成", + "sectorsTitle": "業種別構成", + "sectorsEmptyTitle": "業種分類未集計", + "sectorsEmptyDesc": "業種分類ソース(M13)が連携されると表示されます。", + "perfTable": "成果テーブル", + "pnlTitle": "損益(P&L)構成", + "exhibitorPerfTitle": "出展社成果", + "sampleTag": "サンプル", + "pnlEmptyTitle": "損益項目がありません", + "pnlEmptyDesc": "ブース賃貸・ユーティリティ売上レコードが集計されると表示されます。", + "colItem": "項目", + "colAmount": "金額", + "colGrade": "等級", + "perfEmptyTitle": "出展社成果未集計", + "perfEmptyDesc": "リード・転換率ソース(M13 来場者)が連携されると表示されます。", + "colCompany": "会社名", + "colLeads": "リード", + "colConversion": "転換率" + }, + "global": { + "sectionAria": "テナント全域経営分析", + "title": "全社経営分析 (イベント横断)", + "subtitle": "イベント間売上比較 · ホール稼働率推移 · 出展社リテンション · 需要予測", + "yearsAria": "分析年数", + "years3": "直近 3 年", + "years5": "直近 5 年", + "years10": "直近 10 年", + "loadErrorTitle": "全域集計を読み込めませんでした", + "loadErrorDesc": "しばらくしてから再試行してください。", + "kpisAria": "全域主要指標", + "kpiEmpty": "集計された指標がありません", + "evComp": "イベント間売上比較", + "top8": "上位 8 件", + "evCompHint": "ブースが配置されたイベントの賃貸+ユーティリティ売上ランキングです(単位:ウォン)。", + "evCompEmptyTitle": "比較するイベントがありません", + "evCompEmptyDesc": "ブースが配置されたイベント売上が集計されると表示されます。", + "revenue": "売上", + "retention": "出展社リテンション", + "retentionCum": "累積再出展率", + "returning": "再出展", + "retentionEmptyTitle": "年度別リテンション未集計", + "retentionEmptyDesc": "ブース割当社ソースが連携されると表示されます。", + "retentionRate": "再出展率", + "occupancy": "ホール稼働率推移 (月別)", + "occupancyHint": "開催イベント-日数 ÷ (ホール数 × その月の日数) 基準の月別稼働率です。", + "occupancyEmpty": "稼働率データがありません", + "occupancyRate": "稼働率", + "forecast": "需要予測", + "forecastTitle": "需要予測 (開催トレンド)", + "trendProjection": "トレンド投影", + "forecastHint": "年度別開催イベント数の実測 + 最小二乗線形トレンドで今後 2 年を投影(点線)。", + "forecastEmptyTitle": "予測する履歴がありません", + "forecastEmptyDesc": "年度別開催履歴が集計されると表示されます。", + "casesUnit": "{{n}}件", + "actual": "実測", + "projected": "予測" + }, + "ops": { + "title": "ホール現場運営", + "subtitle": "チェックイン・ユーティリティ実集計に基づく状況", + "modeAria": "照会モード", + "live": "リアルタイム", + "history": "履歴照会", + "noEventTitle": "イベントを選択してください", + "noEventDesc": "現場運営を照会するイベントが指定されていません。", + "offlineSample": "オフライン — サンプル", + "degradedMsg": "集計 API に接続できず、デモ用サンプルを表示しています。", + "loadError": "現場運営状況を読み込めませんでした。", + "hallStatus": "ホール別状況", + "checkinDerived": "チェックイン(M10)派生 · 面積比重で分配", + "sensorNoneHalls": "センサー未連携 · 割当ホール一覧", + "noHallsTitle": "割り当てられたホールがありません", + "noHallsDesc": "イベントにホールが割り当てられると表示されます。", + "busy": "混雑", + "smooth": "円滑", + "moderate": "普通", + "people": "{{n}}名", + "onSiteCheckin": "現場チェックイン <0>{{n}}0>名", + "noCheckinAgg": "チェックイン集計なし", + "powerLoad": "電力負荷", + "totalKw": "計 <0>{{v}}0>kW", + "utilLoadAgg": "ユーティリティ申請(M4)負荷集計", + "noLoadTitle": "申請負荷なし", + "noLoadDesc": "電気ユーティリティ申請が受け付けられるとホール別負荷が表示されます。", + "kw": "{{v}}kW", + "hourlyCheckin": "時間帯別チェックイン", + "hourlyAgg": "チェックイン(M10)時間帯集計", + "noSeriesTitle": "チェックイン推移なし", + "noSeriesDesc": "現場チェックインが記録されると時間帯別推移が表示されます。", + "facilitySensor": "施設センサー連携", + "facilitySensorTitle": "施設センサー (空調·照明·駐車)", + "sensorNoneTitle": "施設センサー未連携", + "sensorNoneDesc": "空調(HVAC)·照明 IoT·駐車(iparking)連携はセンサー(M14)連携時に表示されます。", + "temp": "温度", + "humidity": "湿度", + "parkingSlots": "{{zone}}: {{occ}}/{{cap}}台" + }, + "admin": { + "deniedTitle": "アクセス権限がありません", + "deniedAuthed": "このページはシステム管理者(ホールマネージャー·ADMIN)のみ利用できます。アクセスが必要な場合は管理者にお問い合わせください。", + "deniedGuest": "管理バックオフィスはログイン後に利用できます。", + "overviewTitle": "システム概要", + "overviewSub": "リアルタイム指標と運用状態", + "loginSlides": "ログインスライド管理", + "tenantSwitch": "テナント切替", + "systemNormal": "システム正常", + "moduleShortcuts": "管理モジュールショートカット", + "offlineSample": "オフライン — サンプル", + "degradedMsg": "集計 API に接続できず、デモ用サンプルを表示しています。", + "dashLoadError": "管理者ダッシュボードを読み込めませんでした。", + "kpisAria": "主要指標", + "kpiEmpty": "集計された指標がありません", + "visitorTrend": "来場者推移", + "hourlyVisitors": "時間帯別来場者", + "visitors": "来場者", + "visitorTrendEmptyTitle": "来場者推移未集計", + "visitorTrendEmptyDesc": "来場者計測センサー(M14)が連携されると時間帯別推移が表示されます。", + "liveEvents": "開催中イベント", + "noLiveEvents": "開催中のイベントがありません", + "hallTbd": "ホール未割当", + "occupancy": "占有 {{n}}%", + "modUsers": "ユーザー", + "modRoles": "役割·権限", + "modCodes": "共通コード", + "modMenus": "メニュー", + "modAudit": "監査ログ", + "modSettings": "システム設定", + "modRulesets": "ルールセット", + "modTenants": "テナント" + }, + "tenant": { + "title": "テナント管理·オンボーディング", + "subtitle": "プラットフォームスーパー管理者専用 · コード配備なしにデータオンボーディングで展示館を追加します。", + "sampleTag": "サンプル · 未実装", + "sampleNote": "マルチテナンシー(テナント CRUD·オンボーディング·分離)がバックエンドにまだ無いため、画面は目標 IA を表示します。実データ連携はマルチテナンシー導入後に配線されます。", + "listAria": "テナント一覧", + "venue": "展示館", + "notImpl": "未実装", + "addVenue": "展示館追加", + "code": "コード", + "domain": "ドメイン", + "status": "ステータス", + "events": "イベント", + "users": "ユーザー", + "wizardAria": "オンボーディングウィザード", + "onboardingOf": "{{name}} オンボーディング", + "dataOnboarding": "コード配備なしのデータオンボーディング", + "wiz": { + "create": { + "label": "テナント作成", + "desc": "コード·表示名の登録" + }, + "domain": { + "label": "サブドメイン", + "desc": "DNS·ブランディングドメイン割当" + }, + "brand": { + "label": "ブランディング", + "desc": "ロゴ·色·公開サイトテーマ" + }, + "master": { + "label": "マスターデータ入力", + "desc": "ホール·料率·ルールセットシード" + }, + "admin": { + "label": "管理者アカウント", + "desc": "テナント管理者を招待" + }, + "smoke": { + "label": "分離スモーク", + "desc": "クロステナント分離検証" + }, + "activate": { + "label": "有効化", + "desc": "運用へ切替" + } + }, + "tstatus": { + "active": "有効", + "onboarding": "オンボーディング中", + "suspended": "停止" + } + }, + "ruleset": { + "title": "規定ルールセットバージョン管理", + "subtitle": "規定改定対応のためのルールセットバージョン履歴と規則項目です。", + "readonly": "読み取り専用", + "readonlyNote": "ルールセットはデプロイリソース(rulesets/*.json)でバージョン管理されます。編集・バージョン作成は規定改定時のリソース差し替えで行われ、画面では照会のみ可能です。", + "listError": "ルールセット一覧を読み込めませんでした。", + "noRulesets": "デプロイされたルールセットがありません", + "versionHistory": "バージョン履歴", + "rulesCount": "規則 {{n}}", + "effectiveDate": "発効日 {{date}}", + "ruleList": "規則一覧", + "rulesOf": "{{name}} 規則 ({{n}}件)", + "detailError": "規則全文を読み込めませんでした。", + "noRulesTitle": "規則配列がないルールセットです", + "noRulesDesc": "料率マスターなど、規則(rules)なしで料率・単価のみ定義されたルールセットです。", + "colRuleName": "規則名", + "colValue": "数値/設定値", + "colLogic": "論理 / 要件", + "colSeverity": "違反レベル", + "block": "遮断", + "warn": "警告", + "required": "必須", + "perHall": "ホール別 {{unit}}", + "docNeeded": "文書必要: {{doc}}" + }, + "audit": { + "title": "監査ログ", + "subtitle": "承認·落札·設定変更などすべての管理アクションと重要なドメインイベントを追跡します。(整合性維持)", + "exportCsv": "CSV 出力", + "filterAria": "監査ログフィルター", + "actionType": "アクション種別", + "allActions": "全アクション", + "actorId": "担当者 ID", + "actorIdPh": "例: user-102", + "eventId": "イベント ID", + "eventIdPh": "例: EVT-2026-01", + "reset": "リセット", + "applyFilter": "フィルター適用", + "loadError": "監査ログを読み込めませんでした。", + "tableAria": "監査ログテーブル", + "emptyTitle": "条件に一致する監査記録がありません", + "emptyDesc": "フィルターを調整またはリセットして再照会してください。", + "colTime": "時刻", + "colUser": "ユーザー", + "colAction": "アクション", + "colTarget": "対象", + "colEvent": "イベント", + "colResult": "結果", + "showing": "表示中 <0>{{from}}-{{to}}0> / 全 <1>{{total}}1>件", + "prev": "前へ", + "next": "次へ", + "rowsShow": "行表示", + "rowsPerPage": "ページあたり行数", + "rowsN": "{{n}}件", + "success": "成功", + "failure": "失敗", + "csvTime": "時刻", + "csvActorId": "担当者ID", + "csvActorName": "担当者名", + "csvAction": "アクション", + "csvTargetType": "対象種別", + "csvTargetId": "対象ID", + "csvEventId": "イベントID", + "csvResult": "結果", + "csvSummary": "要約" + }, + "role": { + "title": "役割 · 権限 (RBAC)", + "subtitle": "役割(主催者·出展社·装置業者·ホールマネージャー·管理者·来場者)ごとにリソース権限をマトリックスで割り当てます。", + "addRole": "役割追加", + "loadError": "役割·権限情報を読み込めませんでした。", + "noRolesTitle": "定義された役割がありません", + "noRolesDesc": "役割を追加して RBAC を構成してください。", + "roleList": "役割一覧", + "role": "役割", + "codePerm": "{{code}} · 権限 {{n}}", + "unused": "未使用", + "permMatrix": "権限マトリックス", + "rolePerm": "{{name}} 権限", + "roleInfo": "役割情報", + "deleteConfirm": "役割 '{{name}}' を削除しますか?", + "delete": "削除", + "noPerms": "定義された権限がありません", + "selectRole": "役割を選択してください", + "saved": "役割を保存しました。", + "deleted": "役割を削除しました。", + "addRoleModal": "役割追加", + "editRole": "役割情報の編集", + "modalDesc": "権限割当は保存後にマトリックスでチェックにより管理します。", + "roleCode": "役割コード *", + "roleCodePh": "例: ORGANIZER", + "roleName": "役割名 *", + "description": "説明", + "useYn": "使用可否", + "use": "使用", + "noUse": "未使用", + "cancel": "キャンセル", + "save": "保存", + "saving": "保存中…", + "errRequired": "役割コードと名前は必須です。", + "errDupCode": "既に存在する役割コードです。" + }, + "menu": { + "title": "メニュー管理", + "subtitle": "役割別ナビゲーションメニューツリーとパス·アクセス役割を管理します。", + "addMenu": "メニュー追加", + "loadError": "メニューを読み込めませんでした。", + "treeAria": "メニューツリー", + "emptyTitle": "登録されたメニューがありません", + "emptyDesc": "メニューを追加してください。", + "colName": "メニュー名", + "colPath": "パス", + "colType": "種別", + "colRole": "アクセス役割", + "colSort": "並び順", + "colUse": "使用", + "colManage": "管理", + "all": "全体", + "unused": "未使用", + "used": "使用", + "edit": "編集", + "delete": "削除", + "deleteConfirm": "メニュー '{{name}}' を削除しますか?下位メニューがある場合は一緒に整理されます。", + "saved": "メニューを保存しました。", + "deleted": "メニューを削除しました。", + "editMenu": "メニュー編集", + "menuId": "メニュー ID (一意キー) *", + "menuIdPh": "例: admin-users", + "menuName": "メニュー名 *", + "parent": "上位メニュー", + "topLevel": "(最上位)", + "path": "パス(path)", + "pathPh": "例: /admin/users", + "type": "種別", + "accessRole": "アクセス役割", + "accessRolePh": "例: ADMIN (空欄で全体)", + "sort": "並び順", + "useYn": "使用可否", + "cancel": "キャンセル", + "save": "保存", + "saving": "保存中…", + "errName": "メニュー名は必須です。", + "errId": "メニュー ID(一意キー)は必須です。", + "errDupId": "既に存在するメニュー ID です。" + }, + "code": { + "title": "共通コード管理", + "subtitle": "ドロップダウン·分類に使う共通コードグループとコード値を管理します。", + "addGroup": "グループ追加", + "groupLoadError": "コードグループを読み込めませんでした。", + "noGroupsTitle": "コードグループがありません", + "noGroupsDesc": "グループを追加して共通コードを構成してください。", + "codeGroup": "コードグループ", + "codeValues": "コード値", + "valuesOf": "{{name}} 値", + "editGroup": "コードグループ編集", + "deleteGroup": "グループ削除", + "deleteGroupConfirm": "グループ '{{name}}' と下位コードを削除しますか?", + "addCode": "コード追加", + "codeLoadError": "コード値を読み込めませんでした。", + "noCodesTitle": "コードがありません", + "noCodesDesc": "コードを追加してください。", + "code": "コード", + "name": "名前", + "value": "値", + "sort": "並び順", + "use": "使用", + "manage": "管理", + "unused": "未使用", + "used": "使用", + "edit": "編集", + "delete": "削除", + "deleteCodeConfirm": "コード '{{name}}' を削除しますか?", + "groupSaved": "コードグループを保存しました。", + "groupDeleted": "コードグループを削除しました。", + "codeSaved": "コードを保存しました。", + "codeDeleted": "コードを削除しました。", + "groupCode": "グループコード *", + "groupName": "グループ名 *", + "description": "説明", + "useYn": "使用可否", + "cancel": "キャンセル", + "save": "保存", + "saving": "保存中…", + "errGroupRequired": "グループコードと名前は必須です。", + "errDupGroup": "既に存在するグループコードです。", + "editCode": "コード編集", + "group": "グループ", + "codeReq": "コード *", + "nameReq": "名前 *", + "attr1": "属性1(attr1)", + "errCodeRequired": "コードと名前は必須です。", + "errDupCode": "既に存在するコードです。" + }, + "sysuser": { + "title": "ユーザー管理", + "subtitle": "アカウント作成·編集、役割割当、ステータス(有効/ロック/無効)と OTP 初期化を管理します。パスワードはマスクされ表示されません。", + "addUser": "ユーザー追加", + "searchAria": "ユーザー検索", + "keyword": "検索語", + "keywordPh": "名前·メール", + "role": "役割", + "status": "ステータス", + "all": "全体", + "reset": "リセット", + "search": "検索", + "loadError": "ユーザー一覧を読み込めませんでした。", + "listAria": "ユーザー一覧", + "emptyTitle": "ユーザーがいません", + "emptyDesc": "検索条件を調整するかユーザーを追加してください。", + "colUser": "ユーザー", + "colEmail": "メール", + "colRole": "役割", + "colDept": "部署", + "colStatus": "ステータス", + "colLastLogin": "最終ログイン", + "colManage": "管理", + "hallManagerTag": "ホールMgr", + "otpSet": "設定済", + "otpUnset": "未設定", + "edit": "編集", + "lock": "ロック", + "unlock": "ロック解除", + "otpResetBtn": "OTP初期化", + "otpResetConfirm": "{{name}} の OTP を初期化しますか?", + "saved": "ユーザーを保存しました。", + "statusChanged": "ステータスを変更しました。", + "otpReset": "OTP を初期化しました。", + "active": "有効", + "locked": "ロック", + "disabled": "無効", + "editUser": "ユーザー編集", + "newDesc": "初期パスワードは保存後に画面に表示されません。", + "editDesc": "メールは編集できません。パスワード再設定は別途ポリシーに従います。", + "email": "メール *", + "displayName": "表示名 *", + "dept": "部署", + "initPassword": "初期パスワード *", + "hallManagerCheck": "ホールマネージャー(内部運営者)", + "cancel": "キャンセル", + "save": "保存", + "saving": "保存中…", + "errRequired": "メールと表示名は必須です。", + "errPassword": "新規ユーザーは初期パスワードが必要です。", + "totalUsers": "全 <0>{{total}}0>名", + "prev": "前へ", + "next": "次へ" + }, + "settings": { + "title": "システム設定", + "subtitle": "グローバルポリシー·機能トグル·AI ゲートなどのシステムパラメータを管理します。シークレット値はマスキング表示されます。", + "loadError": "システム設定を読み込めませんでした。", + "emptyTitle": "登録された設定がありません", + "emptyDesc": "下のフォームで最初の設定項目を追加してください。", + "saved": "設定を保存しました。", + "deleted": "設定を削除しました。", + "deleteConfirm": "設定 '{{key}}' を削除しますか?", + "general": "一般", + "groupAria": "設定グループ {{name}}", + "secret": "シークレット", + "secretTitle": "シークレット — 値マスキング", + "on": "オン", + "off": "オフ", + "secretPh": "設定済(マスキング) — 変更時に新しい値を入力", + "save": "保存", + "delete": "削除", + "deleteKey": "{{key}} 削除", + "secretHint": "シークレット原文は表示されません。値を入力せず保存すると既存の値が維持されます。", + "addAria": "設定追加", + "addTitle": "設定追加 / 再定義", + "settingKey": "設定キー", + "settingKeyPh": "例: deadline.utility.days", + "value": "値", + "type": "タイプ", + "description": "説明", + "secretCheck": "シークレット(値マスキング)" + }, + "slide": { + "title": "ログインスライド管理", + "subtitle": "ログイン画面左側カルーセルの画像·文言 — 有効なスライドのうち<0>最新登録順5件0>がローテーションされます。", + "addAria": "新規スライド追加", + "addTitle": "新規スライド追加", + "imageLabel": "画像 (PNG/JPG/WebP, 8MB 以下)", + "preview": "プレビュー", + "titleLabel": "タイトル文言", + "titlePh": "例) アジアを代表する展示コンベンションセンター、KINTEX", + "captionLabel": "キャプション(補助文言)", + "captionPh": "例) 10万㎡ の展示スペースで広がるビジネスチャンスをご体験ください。", + "submitting": "登録中…", + "submit": "スライド登録", + "listAria": "スライド一覧", + "registered": "登録されたスライド", + "countActive": "全 {{total}}件 · 有効 {{active}}件", + "emptyTitle": "登録されたスライドがありません", + "emptyDesc": "画像と文言を登録するとログイン画面のカルーセルに表示されます。(未登録時はデフォルト5種を表示)", + "live": "表示中", + "inactive": "無効", + "registeredAt": "登録 {{date}}", + "active": "有効", + "delete": "削除", + "listError": "一覧を読み込めませんでした。", + "errImage": "スライド画像を選択してください。", + "errTitle": "タイトル文言を入力してください。", + "createFail": "登録に失敗しました。", + "updateFail": "変更に失敗しました。", + "deleteFail": "削除に失敗しました。", + "deleteConfirm": "\"{{title}}\" スライドを削除しますか?" } } diff --git a/src/frontend/src/i18n/locales/ko.json b/src/frontend/src/i18n/locales/ko.json index fe7fdb6..51855cc 100644 --- a/src/frontend/src/i18n/locales/ko.json +++ b/src/frontend/src/i18n/locales/ko.json @@ -14,6 +14,17 @@ "states": { "loading": "불러오는 중입니다…", "empty": "표시할 내용이 없습니다." + }, + "aiWatermark": "AI 생성 예상 이미지 — 실제 시공 결과와 다를 수 있습니다", + "aiNotice": "AI 생성 이미지는 계약·심사 서류에 사용할 수 없습니다 — 시공 기준은 도면입니다", + "weekday": { + "0": "일", + "1": "월", + "2": "화", + "3": "수", + "4": "목", + "5": "금", + "6": "토" } }, "lang": { @@ -27,7 +38,9 @@ "transport": "교통" }, "searchPlaceholder": "행사 검색", - "cta": { "register": "관람 사전등록" }, + "cta": { + "register": "관람 사전등록" + }, "menuOpen": "메뉴 열기", "menuClose": "메뉴 닫기", "homeAria": "{{brand}} 홈", @@ -156,7 +169,12 @@ "fabCta": "사전등록 바로가기" }, "register": { - "steps": { "type": "유형", "info": "정보", "interest": "관심분야", "done": "완료" }, + "steps": { + "type": "유형", + "info": "정보", + "interest": "관심분야", + "done": "완료" + }, "stepsAria": "사전등록 단계", "type": { "general": "일반 관람객", @@ -358,7 +376,12 @@ "vipDesc": "전시회 올패스 + VIP 라운지 이용권", "vipPrice": "₩50,000" }, - "steps": { "s1": "행사·권종", "s2": "예매자", "s3": "결제", "s4": "완료" }, + "steps": { + "s1": "행사·권종", + "s2": "예매자", + "s3": "결제", + "s4": "완료" + }, "stepsAria": "예매 단계", "bannerTitle": "스마트팩토리 코리아 2026", "bannerDate": "2026.09.05 – 09.08", @@ -517,5 +540,743 @@ "errRequest": "요청 중 오류가 발생했습니다.", "errReset": "재설정 중 오류가 발생했습니다.", "cancel": "취소" + }, + "dashboard": { + "countdown": "개장 D-{{n}}", + "degradedTag": "오프라인 — 샘플", + "degradedMsg": "집계 API에 연결하지 못해 시연용 샘플을 표시합니다.", + "loadError": "대시보드 집계를 불러오지 못했습니다.", + "timelineAria": "마일스톤 타임라인", + "milestoneEmptyTitle": "파생 일정이 없습니다", + "milestoneEmptyDesc": "행사 시작일이 확정되면 마일스톤이 표시됩니다.", + "mstate": { + "done": "완료", + "current": "진행 중", + "upcoming": "예정" + }, + "kpisAria": "핵심 지표", + "kpiEmpty": "집계된 지표가 없습니다", + "exhibitorStatus": "참가업체 현황", + "sampleTag": "샘플", + "exhibitorEmptyTitle": "참가업체 부스가 없습니다", + "exhibitorEmptyDesc": "부스 배치·설계안이 등록되면 여기에 표시됩니다.", + "colBoothNo": "부스번호", + "colCompany": "업체명", + "colStatus": "설계 상태", + "feedAria": "알림 피드", + "feedTitle": "알림", + "feedEmpty": "새 알림이 없습니다", + "aerialAria": "홀 전경 조감", + "aerialTitle": "홀 전경 조감", + "aerialShot": "S7 홀 전경", + "aerialAlt": "홀 전경 조감 예상 이미지" + }, + "schedule": { + "title": "전시 일정", + "subtitle": "킨텍스 전 홀 행사 일정 관리·모니터링 — {{n}}건", + "crawlFallback": "(kintex.com 크롤링 폴백)", + "yearFilter": "연도 필터", + "allYears": "전체 연도", + "yearN": "{{y}}년", + "hallFilter": "홀 필터", + "allHalls": "전체 홀 (1-10)", + "hall1": "제1전시장 (홀 1-5)", + "hall2": "제2전시장 (홀 6-10)", + "catFilter": "카테고리 필터", + "allCats": "전체 카테고리", + "statusFilter": "상태 필터", + "allStatus": "전체 상태", + "cat": { + "exhibition": "전시", + "culture": "문화·예술", + "trade": "무역·산업", + "tech": "기술" + }, + "status": { + "ongoing": "진행중", + "upcoming": "예정", + "ended": "종료" + }, + "catExhibit": "{{cat}} 전시", + "apiWait": "행사 API 대기 (크롤링 데이터)", + "viewSwitch": "보기 전환", + "viewList": "목록", + "viewCalendar": "캘린더", + "listLoading": "행사 목록 로딩 중", + "listAria": "행사 목록", + "loadError": "전시 일정을 불러오지 못했습니다.", + "count": "{{n}}건", + "emptyTitle": "등록된 행사가 없습니다", + "emptyDesc": "필터를 조정하거나 새 행사를 생성하세요.", + "newEvent": "새 행사 생성", + "period": "기간", + "assignedHall": "배정 홀", + "estVisitors": "추정 인원", + "editDetail": "상세 편집", + "viewLogistics": "반입/물류 보기", + "monthCal": "월 캘린더", + "prev": "이전", + "next": "다음", + "today": "오늘", + "yearMonth": "{{y}}년 {{m}}월", + "miniCal": "미니 캘린더", + "prevMonth": "이전 달", + "nextMonth": "다음 달", + "offlineSample": "오프라인 — 샘플", + "day": "{{d}}일", + "dayHasEvent": "{{d}}일 (행사 있음)", + "hallUtil": "홀 가동률", + "hallUtilMonth": "홀 가동률 ({{y}}년 {{m}}월)" + }, + "settlement": { + "title": "정산·결제", + "subtitle": "행사 참가업체 청구 · 수납 현황 관리", + "generate": "청구서 생성", + "generating": "생성 중…", + "noEventTitle": "행사를 선택해 주세요", + "noEventDesc": "정산 현황을 조회할 행사가 지정되지 않았습니다.", + "genDone": "청구서 생성 완료 — 신규 {{created}}건, 기존 유지 {{skipped}}건.", + "genError": "청구서 생성에 실패했습니다. 권한 또는 대상 데이터를 확인해 주세요.", + "dashError": "정산 현황 집계를 불러오지 못했습니다.", + "kpisAria": "정산 핵심 지표", + "kpiBilled": "총 청구액", + "kpiCollected": "수납액", + "kpiOutstanding": "미수금", + "kpiOverdue": "연체 청구", + "count": "{{n}}건", + "collectionRate": "수납률 {{rate}}%", + "outstandingSub": "미수 잔액", + "overdueSub": "납부 기한 경과", + "distTitle": "청구 상태 분포", + "distEmpty": "집계된 청구가 없습니다", + "listTitle": "청구서 목록", + "invoices": "청구서", + "statusFilter": "상태 필터", + "status": { + "all": "전체", + "pending": "미발행", + "invoiced": "청구됨", + "paid": "완납", + "overdue": "연체" + }, + "listError": "청구서 목록을 불러오지 못했습니다.", + "emptyTitle": "청구서가 없습니다", + "emptyManage": "상단의 청구서 생성으로 참가업체별 청구를 집계하세요.", + "emptyView": "아직 발행된 청구서가 없습니다.", + "colNo": "청구번호", + "colExhibitor": "참가업체", + "colBilled": "청구액", + "colPaid": "수납", + "colOutstanding": "미수", + "colDue": "납부기한", + "colStatus": "상태", + "detail": "상세", + "prev": "이전", + "next": "다음", + "pageInfo": "{{page}} / {{total}} · 총 {{cnt}}건", + "modalAria": "청구서 상세", + "invoice": "청구서", + "close": "닫기", + "detailError": "청구서 상세를 불러오지 못했습니다.", + "liItem": "항목", + "liQty": "수량", + "liUnit": "단가", + "liAmount": "금액", + "noLines": "라인 항목 없음", + "supplyAmount": "공급가액", + "vat": "부가세(10%)", + "grandTotal": "청구 총액", + "paidOutstanding": "수납 / 미수", + "payHistory": "수납 이력", + "noPayments": "수납 기록이 없습니다.", + "won": "{{v}}원", + "issue": "청구서 발행", + "issuing": "발행 중…", + "payAmount": "수납 금액", + "payAmountPh": "수납 금액(원)", + "payMethod": "수납 방법", + "methodTransfer": "계좌이체", + "methodCard": "카드", + "methodOther": "기타", + "memo": "메모", + "memoPh": "메모(선택)", + "recordPay": "수납 기록", + "recording": "기록 중…", + "payFail": "수납 기록에 실패했습니다. 금액(총액 이내)·권한을 확인해 주세요.", + "errPositive": "0보다 큰 금액을 입력해 주세요.", + "errExceed": "미수 잔액을 초과할 수 없습니다.", + "pgNote": "실제 결제는 외부 PG로 위임됩니다. 본 화면은 수납 확인 기록 전용입니다.", + "managerOnly": "수납·발행은 주최자/홀매니저만 수행할 수 있습니다." + }, + "hallassign": { + "title": "홀 배정 · 자동 견적", + "subtitle": "홀×기간 배정 현황과 요율 룰셋 기반 즉시 견적(M1).", + "start": "시작", + "end": "종료", + "newAssign": "새 배정", + "board": "배정 보드", + "boardHint": "겹치는 배정은 충돌로 표시됩니다.", + "forbiddenTitle": "홀매니저 전용 보드", + "forbiddenDesc": "배정 현황 조회는 킨텍스 홀매니저 계정만 가능합니다. 자동 견적은 아래에서 이용하세요.", + "noHalls": "홀 마스터가 없습니다.", + "noAssign": "배정 없음", + "release": "배정 해제", + "releaseConfirm": "{{event}}의 {{hall}} 배정을 해제할까요?", + "assignedToast": "홀이 배정되었습니다.", + "releasedToast": "배정이 해제되었습니다.", + "releaseFail": "해제에 실패했습니다(권한 확인).", + "conflictErr": "배정하려는 홀·기간이 다른 행사와 겹칩니다.", + "assignFail": "배정에 실패했습니다.", + "pickEventHall": "행사와 홀을 선택하세요.", + "modalTitle": "홀 배정", + "close": "닫기", + "event": "행사", + "pickEvent": "행사 선택…", + "hall": "홀", + "pickHall": "홀 선택…", + "occupyStart": "점유 시작", + "occupyEnd": "점유 종료", + "primary": "주 배정 홀(primary)", + "note": "비고", + "optional": "선택 입력", + "cancel": "취소", + "assign": "배정", + "assigning": "배정 중…", + "autoQuote": "자동 견적", + "quoteHint": "요율 룰셋 기준 · 최종 금액은 킨텍스 확정 시 안내", + "startDate": "시작일", + "endDate": "종료일", + "boothSize": "부스 규모(선택)", + "countPh": "개수", + "calcQuote": "견적 계산", + "calculating": "계산 중…", + "quoteFail": "견적 계산에 실패했습니다.", + "quoteReq": "홀·시작일·종료일을 입력하세요.", + "qItem": "항목", + "qBasis": "산출 근거", + "qAmount": "금액", + "rentalSubtotal": "임대료 소계", + "rentalTotal": "임대료 합계 ({{days}}일)", + "deposit": "관리비 예치금(별도)", + "ruleset": "(룰셋 {{v}})" + }, + "analytics": { + "title": "경영분석", + "subtitle": "킨텍스 운영 수익성 · 참가업체 인사이트", + "perspective": "분석 관점", + "operator": "운영사", + "exhibitor": "참가업체", + "periodAria": "분석 기간", + "periodEvent": "이번 행사", + "periodD90": "최근 90일", + "periodAnnual": "연간", + "exportPdf": "PDF 내보내기", + "exportExcel": "Excel 내보내기", + "noEventTitle": "행사를 선택해 주세요", + "noEventDesc": "분석할 행사가 지정되지 않았습니다.", + "offlineSample": "오프라인 — 샘플", + "degradedMsg": "집계 API에 연결하지 못해 시연용 샘플을 표시합니다.", + "loadError": "경영분석 집계를 불러오지 못했습니다.", + "kpisAria": "핵심 지표", + "kpiEmpty": "집계된 지표가 없습니다", + "trendAria": "추세 차트", + "trendTitle": "매출 · 부스 추이", + "monthlyAgg": "월별 집계", + "trendHint": "월별 임대·유틸리티 매출과 부스 수 추이입니다 (매출 단위: 원).", + "trendEmptyTitle": "추이 데이터가 없습니다", + "trendEmptyDesc": "집계 가능한 기간의 매출·부스 레코드가 없습니다.", + "revenue": "매출", + "booth": "부스", + "boothsUnit": "{{n}} 부스", + "sectorsAria": "업종별 구성", + "sectorsTitle": "업종별 구성", + "sectorsEmptyTitle": "업종 분류 미집계", + "sectorsEmptyDesc": "업종 분류 원천(M13)이 연동되면 표시됩니다.", + "perfTable": "성과 테이블", + "pnlTitle": "손익(P&L) 구성", + "exhibitorPerfTitle": "참가사 성과", + "sampleTag": "샘플", + "pnlEmptyTitle": "손익 항목이 없습니다", + "pnlEmptyDesc": "부스 임대·유틸리티 매출 레코드가 집계되면 표시됩니다.", + "colItem": "항목", + "colAmount": "금액", + "colGrade": "등급", + "perfEmptyTitle": "참가사 성과 미집계", + "perfEmptyDesc": "리드·전환율 원천(M13 방문자)이 연동되면 표시됩니다.", + "colCompany": "업체명", + "colLeads": "리드", + "colConversion": "전환율" + }, + "global": { + "sectionAria": "테넌트 전역 경영분석", + "title": "전사 경영분석 (행사 횡단)", + "subtitle": "행사 간 매출 비교 · 홀 가동률 추이 · 참가사 리텐션 · 수요예측", + "yearsAria": "분석 연수", + "years3": "최근 3년", + "years5": "최근 5년", + "years10": "최근 10년", + "loadErrorTitle": "전역 집계를 불러오지 못했습니다", + "loadErrorDesc": "잠시 후 다시 시도해 주세요.", + "kpisAria": "전역 핵심 지표", + "kpiEmpty": "집계된 지표가 없습니다", + "evComp": "행사 간 매출 비교", + "top8": "상위 8개", + "evCompHint": "부스가 배치된 행사의 임대+유틸리티 매출 순위입니다 (단위: 원).", + "evCompEmptyTitle": "비교할 행사가 없습니다", + "evCompEmptyDesc": "부스가 배치된 행사 매출이 집계되면 표시됩니다.", + "revenue": "매출", + "retention": "참가사 리텐션", + "retentionCum": "누적 재참가율", + "returning": "재참가", + "retentionEmptyTitle": "연도별 리텐션 미집계", + "retentionEmptyDesc": "부스 배정사 원천이 연동되면 표시됩니다.", + "retentionRate": "재참가율", + "occupancy": "홀 가동률 추이 (월별)", + "occupancyHint": "개최 행사-일수 ÷ (홀수 × 그달 일수) 기준 월별 가동률입니다.", + "occupancyEmpty": "가동률 데이터가 없습니다", + "occupancyRate": "가동률", + "forecast": "수요예측", + "forecastTitle": "수요예측 (개최 추세)", + "trendProjection": "추세 투영", + "forecastHint": "연도별 개최 행사 수 실측 + 최소자승 선형 추세로 향후 2년 투영(점선).", + "forecastEmptyTitle": "예측할 이력이 없습니다", + "forecastEmptyDesc": "연도별 개최 이력이 집계되면 표시됩니다.", + "casesUnit": "{{n}}건", + "actual": "실측", + "projected": "예측" + }, + "ops": { + "title": "홀 현장운영", + "subtitle": "체크인·유틸리티 실집계 기반 현황", + "modeAria": "조회 모드", + "live": "실시간", + "history": "이력 조회", + "noEventTitle": "행사를 선택해 주세요", + "noEventDesc": "현장 운영을 조회할 행사가 지정되지 않았습니다.", + "offlineSample": "오프라인 — 샘플", + "degradedMsg": "집계 API에 연결하지 못해 시연용 샘플을 표시합니다.", + "loadError": "현장 운영 현황을 불러오지 못했습니다.", + "hallStatus": "홀별 현황", + "checkinDerived": "체크인(M10) 파생 · 면적 비중 분배", + "sensorNoneHalls": "센서 미연동 · 배정 홀 목록", + "noHallsTitle": "배정된 홀이 없습니다", + "noHallsDesc": "행사에 홀이 배정되면 표시됩니다.", + "busy": "혼잡", + "smooth": "원활", + "moderate": "보통", + "people": "{{n}}명", + "onSiteCheckin": "현장 체크인 <0>{{n}}0>명", + "noCheckinAgg": "체크인 집계 없음", + "powerLoad": "전력 부하", + "totalKw": "총 <0>{{v}}0>kW", + "utilLoadAgg": "유틸리티 신청(M4) 부하 집계", + "noLoadTitle": "신청 부하 없음", + "noLoadDesc": "전기 유틸리티 신청이 접수되면 홀별 부하가 표시됩니다.", + "kw": "{{v}}kW", + "hourlyCheckin": "시간대별 체크인", + "hourlyAgg": "체크인(M10) 시간대 집계", + "noSeriesTitle": "체크인 추이 없음", + "noSeriesDesc": "현장 체크인이 기록되면 시간대별 추이가 표시됩니다.", + "facilitySensor": "시설 센서 연동", + "facilitySensorTitle": "시설 센서 (공조·조명·주차)", + "sensorNoneTitle": "시설 센서 미연동", + "sensorNoneDesc": "공조(HVAC)·조명 IoT·주차(iparking) 연계는 센서(M14) 연동 시 표시됩니다.", + "temp": "온도", + "humidity": "습도", + "parkingSlots": "{{zone}}: {{occ}}/{{cap}}면" + }, + "admin": { + "deniedTitle": "접근 권한이 없습니다", + "deniedAuthed": "이 페이지는 시스템 관리자(홀매니저·ADMIN)만 이용할 수 있습니다. 접근이 필요하면 관리자에게 문의하세요.", + "deniedGuest": "관리자 백오피스는 로그인 후 이용할 수 있습니다.", + "overviewTitle": "시스템 개요", + "overviewSub": "실시간 지표 및 운영 상태", + "loginSlides": "로그인 슬라이드 관리", + "tenantSwitch": "테넌트 전환", + "systemNormal": "시스템 정상", + "moduleShortcuts": "관리 모듈 바로가기", + "offlineSample": "오프라인 — 샘플", + "degradedMsg": "집계 API에 연결하지 못해 시연용 샘플을 표시합니다.", + "dashLoadError": "관리자 대시보드를 불러오지 못했습니다.", + "kpisAria": "핵심 지표", + "kpiEmpty": "집계된 지표가 없습니다", + "visitorTrend": "방문객 추이", + "hourlyVisitors": "시간대별 관람객", + "visitors": "관람객", + "visitorTrendEmptyTitle": "관람객 추이 미집계", + "visitorTrendEmptyDesc": "관람객 계측 센서(M14)가 연동되면 시간대별 추이가 표시됩니다.", + "liveEvents": "진행 중 이벤트", + "noLiveEvents": "진행 중인 행사가 없습니다", + "hallTbd": "홀 미배정", + "occupancy": "점유 {{n}}%", + "modUsers": "사용자", + "modRoles": "역할·권한", + "modCodes": "공통코드", + "modMenus": "메뉴", + "modAudit": "감사로그", + "modSettings": "시스템설정", + "modRulesets": "룰셋", + "modTenants": "테넌트" + }, + "tenant": { + "title": "테넌트 관리·온보딩", + "subtitle": "플랫폼 슈퍼관리자 전용 · 코드 배포 없이 데이터 온보딩으로 전시관을 추가합니다.", + "sampleTag": "샘플 · 미구현", + "sampleNote": "멀티테넌시(테넌트 CRUD·온보딩·격리)가 백엔드에 아직 없어 화면은 목표 IA 를 표시합니다. 실 데이터 연동은 멀티테넌시 도입 후 배선됩니다.", + "listAria": "테넌트 목록", + "venue": "전시관", + "notImpl": "미구현", + "addVenue": "전시관 추가", + "code": "코드", + "domain": "도메인", + "status": "상태", + "events": "행사", + "users": "사용자", + "wizardAria": "온보딩 위저드", + "onboardingOf": "{{name}} 온보딩", + "dataOnboarding": "코드 배포 없이 데이터 온보딩", + "wiz": { + "create": { + "label": "테넌트 생성", + "desc": "코드·표시명 등록" + }, + "domain": { + "label": "서브도메인", + "desc": "DNS·브랜딩 도메인 배정" + }, + "brand": { + "label": "브랜딩", + "desc": "로고·색상·공개사이트 테마" + }, + "master": { + "label": "마스터데이터 입력", + "desc": "홀·요율·룰셋 시드" + }, + "admin": { + "label": "관리자 계정", + "desc": "테넌트 관리자 초대" + }, + "smoke": { + "label": "격리 스모크", + "desc": "크로스-테넌트 격리 검증" + }, + "activate": { + "label": "활성화", + "desc": "운영 전환" + } + }, + "tstatus": { + "active": "활성", + "onboarding": "온보딩중", + "suspended": "중지" + } + }, + "ruleset": { + "title": "규정 룰셋 버전 관리", + "subtitle": "규정 개정 대응을 위한 룰셋 버전 이력과 규칙 항목입니다.", + "readonly": "읽기 전용", + "readonlyNote": "룰셋은 배포 리소스(rulesets/*.json)로 버전 관리됩니다. 편집·버전 생성은 규정 개정 시 리소스 교체로 수행되며 화면에서는 조회만 가능합니다.", + "listError": "룰셋 목록을 불러오지 못했습니다.", + "noRulesets": "배포된 룰셋이 없습니다", + "versionHistory": "버전 히스토리", + "rulesCount": "규칙 {{n}}", + "effectiveDate": "발효일 {{date}}", + "ruleList": "규칙 목록", + "rulesOf": "{{name}} 규칙 ({{n}}개)", + "detailError": "규칙 전문을 불러오지 못했습니다.", + "noRulesTitle": "규칙 배열이 없는 룰셋입니다", + "noRulesDesc": "요율 마스터 등 규칙(rules) 없이 요율·단가만 정의된 룰셋입니다.", + "colRuleName": "규칙명", + "colValue": "수치/설정값", + "colLogic": "논리 / 요구사항", + "colSeverity": "위반 수준", + "block": "차단", + "warn": "경고", + "required": "필수", + "perHall": "홀별 {{unit}}", + "docNeeded": "문서 필요: {{doc}}" + }, + "audit": { + "title": "감사 로그", + "subtitle": "승인·낙찰·설정변경 등 모든 관리 액션과 중요 도메인 이벤트를 추적합니다. (무결성 유지)", + "exportCsv": "CSV 내보내기", + "filterAria": "감사 로그 필터", + "actionType": "액션 유형", + "allActions": "전체 액션", + "actorId": "담당자 ID", + "actorIdPh": "예: user-102", + "eventId": "행사 ID", + "eventIdPh": "예: EVT-2026-01", + "reset": "초기화", + "applyFilter": "필터 적용", + "loadError": "감사 로그를 불러오지 못했습니다.", + "tableAria": "감사 로그 테이블", + "emptyTitle": "조건에 맞는 감사 기록이 없습니다", + "emptyDesc": "필터를 조정하거나 초기화해 다시 조회하세요.", + "colTime": "시각", + "colUser": "사용자", + "colAction": "액션", + "colTarget": "대상", + "colEvent": "행사", + "colResult": "결과", + "showing": "표시 중 <0>{{from}}-{{to}}0> / 총 <1>{{total}}1>개", + "prev": "이전", + "next": "다음", + "rowsShow": "행 표시", + "rowsPerPage": "페이지당 행 수", + "rowsN": "{{n}}개", + "success": "성공", + "failure": "실패", + "csvTime": "시각", + "csvActorId": "담당자ID", + "csvActorName": "담당자명", + "csvAction": "액션", + "csvTargetType": "대상유형", + "csvTargetId": "대상ID", + "csvEventId": "행사ID", + "csvResult": "결과", + "csvSummary": "요약" + }, + "role": { + "title": "역할 · 권한 (RBAC)", + "subtitle": "역할(주최자·참가업체·장치업체·홀매니저·관리자·관람객)별 리소스 권한을 매트릭스로 배정합니다.", + "addRole": "역할 추가", + "loadError": "역할·권한 정보를 불러오지 못했습니다.", + "noRolesTitle": "정의된 역할이 없습니다", + "noRolesDesc": "역할을 추가해 RBAC 를 구성하세요.", + "roleList": "역할 목록", + "role": "역할", + "codePerm": "{{code}} · 권한 {{n}}", + "unused": "미사용", + "permMatrix": "권한 매트릭스", + "rolePerm": "{{name}} 권한", + "roleInfo": "역할 정보", + "deleteConfirm": "역할 '{{name}}' 을(를) 삭제하시겠습니까?", + "delete": "삭제", + "noPerms": "정의된 권한이 없습니다", + "selectRole": "역할을 선택하세요", + "saved": "역할을 저장했습니다.", + "deleted": "역할을 삭제했습니다.", + "addRoleModal": "역할 추가", + "editRole": "역할 정보 수정", + "modalDesc": "권한 배정은 저장 후 매트릭스에서 체크로 관리합니다.", + "roleCode": "역할 코드 *", + "roleCodePh": "예: ORGANIZER", + "roleName": "역할 이름 *", + "description": "설명", + "useYn": "사용 여부", + "use": "사용", + "noUse": "미사용", + "cancel": "취소", + "save": "저장", + "saving": "저장 중…", + "errRequired": "역할 코드와 이름은 필수입니다.", + "errDupCode": "이미 존재하는 역할 코드입니다." + }, + "menu": { + "title": "메뉴 관리", + "subtitle": "역할별 내비게이션 메뉴 트리와 경로·접근 역할을 관리합니다.", + "addMenu": "메뉴 추가", + "loadError": "메뉴를 불러오지 못했습니다.", + "treeAria": "메뉴 트리", + "emptyTitle": "등록된 메뉴가 없습니다", + "emptyDesc": "메뉴를 추가하세요.", + "colName": "메뉴명", + "colPath": "경로", + "colType": "유형", + "colRole": "접근 역할", + "colSort": "정렬", + "colUse": "사용", + "colManage": "관리", + "all": "전체", + "unused": "미사용", + "used": "사용", + "edit": "수정", + "delete": "삭제", + "deleteConfirm": "메뉴 '{{name}}' 을(를) 삭제하시겠습니까? 하위 메뉴가 있으면 함께 정리됩니다.", + "saved": "메뉴를 저장했습니다.", + "deleted": "메뉴를 삭제했습니다.", + "editMenu": "메뉴 수정", + "menuId": "메뉴 ID (고유 키) *", + "menuIdPh": "예: admin-users", + "menuName": "메뉴명 *", + "parent": "상위 메뉴", + "topLevel": "(최상위)", + "path": "경로(path)", + "pathPh": "예: /admin/users", + "type": "유형", + "accessRole": "접근 역할", + "accessRolePh": "예: ADMIN (비우면 전체)", + "sort": "정렬", + "useYn": "사용 여부", + "cancel": "취소", + "save": "저장", + "saving": "저장 중…", + "errName": "메뉴명은 필수입니다.", + "errId": "메뉴 ID(고유 키)는 필수입니다.", + "errDupId": "이미 존재하는 메뉴 ID 입니다." + }, + "code": { + "title": "공통코드 관리", + "subtitle": "드롭다운·분류에 쓰이는 공통코드 그룹과 코드 값을 관리합니다.", + "addGroup": "그룹 추가", + "groupLoadError": "코드 그룹을 불러오지 못했습니다.", + "noGroupsTitle": "코드 그룹이 없습니다", + "noGroupsDesc": "그룹을 추가해 공통코드를 구성하세요.", + "codeGroup": "코드 그룹", + "codeValues": "코드 값", + "valuesOf": "{{name}} 값", + "editGroup": "코드 그룹 수정", + "deleteGroup": "그룹 삭제", + "deleteGroupConfirm": "그룹 '{{name}}' 과 하위 코드를 삭제하시겠습니까?", + "addCode": "코드 추가", + "codeLoadError": "코드 값을 불러오지 못했습니다.", + "noCodesTitle": "코드가 없습니다", + "noCodesDesc": "코드를 추가하세요.", + "code": "코드", + "name": "이름", + "value": "값", + "sort": "정렬", + "use": "사용", + "manage": "관리", + "unused": "미사용", + "used": "사용", + "edit": "수정", + "delete": "삭제", + "deleteCodeConfirm": "코드 '{{name}}' 을(를) 삭제하시겠습니까?", + "groupSaved": "코드 그룹을 저장했습니다.", + "groupDeleted": "코드 그룹을 삭제했습니다.", + "codeSaved": "코드를 저장했습니다.", + "codeDeleted": "코드를 삭제했습니다.", + "groupCode": "그룹 코드 *", + "groupName": "그룹 이름 *", + "description": "설명", + "useYn": "사용 여부", + "cancel": "취소", + "save": "저장", + "saving": "저장 중…", + "errGroupRequired": "그룹 코드와 이름은 필수입니다.", + "errDupGroup": "이미 존재하는 그룹 코드입니다.", + "editCode": "코드 수정", + "group": "그룹", + "codeReq": "코드 *", + "nameReq": "이름 *", + "attr1": "속성1(attr1)", + "errCodeRequired": "코드와 이름은 필수입니다.", + "errDupCode": "이미 존재하는 코드입니다." + }, + "sysuser": { + "title": "사용자 관리", + "subtitle": "계정 생성·수정, 역할 배정, 상태(활성/잠금/비활성)와 OTP 초기화를 관리합니다. 비밀번호는 마스킹되어 표시되지 않습니다.", + "addUser": "사용자 추가", + "searchAria": "사용자 검색", + "keyword": "검색어", + "keywordPh": "이름·이메일", + "role": "역할", + "status": "상태", + "all": "전체", + "reset": "초기화", + "search": "검색", + "loadError": "사용자 목록을 불러오지 못했습니다.", + "listAria": "사용자 목록", + "emptyTitle": "사용자가 없습니다", + "emptyDesc": "검색 조건을 조정하거나 사용자를 추가하세요.", + "colUser": "사용자", + "colEmail": "이메일", + "colRole": "역할", + "colDept": "부서", + "colStatus": "상태", + "colLastLogin": "최근 로그인", + "colManage": "관리", + "hallManagerTag": "홀매니저", + "otpSet": "설정됨", + "otpUnset": "미설정", + "edit": "수정", + "lock": "잠금", + "unlock": "잠금해제", + "otpResetBtn": "OTP초기화", + "otpResetConfirm": "{{name}} 의 OTP 를 초기화하시겠습니까?", + "saved": "사용자를 저장했습니다.", + "statusChanged": "상태를 변경했습니다.", + "otpReset": "OTP 를 초기화했습니다.", + "active": "활성", + "locked": "잠금", + "disabled": "비활성", + "editUser": "사용자 수정", + "newDesc": "초기 비밀번호는 저장 후 화면에 표시되지 않습니다.", + "editDesc": "이메일은 수정할 수 없습니다. 비밀번호 재설정은 별도 정책을 따릅니다.", + "email": "이메일 *", + "displayName": "표시 이름 *", + "dept": "부서", + "initPassword": "초기 비밀번호 *", + "hallManagerCheck": "홀매니저(내부 운영자)", + "cancel": "취소", + "save": "저장", + "saving": "저장 중…", + "errRequired": "이메일과 표시 이름은 필수입니다.", + "errPassword": "신규 사용자는 초기 비밀번호가 필요합니다.", + "totalUsers": "총 <0>{{total}}0>명", + "prev": "이전", + "next": "다음" + }, + "settings": { + "title": "시스템 설정", + "subtitle": "전역 정책·기능 토글·AI 게이트 등 시스템 파라미터를 관리합니다. 시크릿 값은 마스킹 표시됩니다.", + "loadError": "시스템 설정을 불러오지 못했습니다.", + "emptyTitle": "등록된 설정이 없습니다", + "emptyDesc": "아래 폼에서 첫 설정 항목을 추가하세요.", + "saved": "설정을 저장했습니다.", + "deleted": "설정을 삭제했습니다.", + "deleteConfirm": "설정 '{{key}}' 을(를) 삭제하시겠습니까?", + "general": "일반", + "groupAria": "설정 그룹 {{name}}", + "secret": "시크릿", + "secretTitle": "시크릿 — 값 마스킹", + "on": "켜짐", + "off": "꺼짐", + "secretPh": "설정됨(마스킹) — 변경 시 새 값 입력", + "save": "저장", + "delete": "삭제", + "deleteKey": "{{key}} 삭제", + "secretHint": "시크릿 원문은 노출되지 않습니다. 값을 입력하지 않고 저장하면 기존 값이 유지됩니다.", + "addAria": "설정 추가", + "addTitle": "설정 추가 / 재정의", + "settingKey": "설정 키", + "settingKeyPh": "예: deadline.utility.days", + "value": "값", + "type": "타입", + "description": "설명", + "secretCheck": "시크릿(값 마스킹)" + }, + "slide": { + "title": "로그인 슬라이드 관리", + "subtitle": "로그인 화면 좌측 캐러셀 이미지·문구 — 활성 슬라이드 중 <0>최신 등록순 5건0>이 로테이션됩니다.", + "addAria": "새 슬라이드 추가", + "addTitle": "새 슬라이드 추가", + "imageLabel": "이미지 (PNG/JPG/WebP, 8MB 이하)", + "preview": "미리보기", + "titleLabel": "타이틀 문구", + "titlePh": "예) 아시아를 대표하는 전시컨벤션센터, KINTEX", + "captionLabel": "캡션(보조 문구)", + "captionPh": "예) 10만㎡ 전시 공간에서 펼쳐지는 비즈니스 기회를 만나보세요.", + "submitting": "등록 중…", + "submit": "슬라이드 등록", + "listAria": "슬라이드 목록", + "registered": "등록된 슬라이드", + "countActive": "총 {{total}}건 · 활성 {{active}}건", + "emptyTitle": "등록된 슬라이드가 없습니다", + "emptyDesc": "이미지와 문구를 등록하면 로그인 화면 캐러셀에 노출됩니다. (미등록 시 기본 5종 표시)", + "live": "노출 중", + "inactive": "비활성", + "registeredAt": "등록 {{date}}", + "active": "활성", + "delete": "삭제", + "listError": "목록을 불러오지 못했습니다.", + "errImage": "슬라이드 이미지를 선택해 주세요.", + "errTitle": "타이틀 문구를 입력해 주세요.", + "createFail": "등록에 실패했습니다.", + "updateFail": "변경에 실패했습니다.", + "deleteFail": "삭제에 실패했습니다.", + "deleteConfirm": "\"{{title}}\" 슬라이드를 삭제할까요?" } } diff --git a/src/frontend/src/i18n/locales/zh.json b/src/frontend/src/i18n/locales/zh.json index c43f6d7..460f8b4 100644 --- a/src/frontend/src/i18n/locales/zh.json +++ b/src/frontend/src/i18n/locales/zh.json @@ -14,6 +14,17 @@ "states": { "loading": "加载中…", "empty": "暂无内容。" + }, + "aiWatermark": "AI 生成的预览图 — 实际施工效果可能不同", + "aiNotice": "AI 生成图像不可用于合同或审核文件 — 施工以图纸为准", + "weekday": { + "0": "日", + "1": "一", + "2": "二", + "3": "三", + "4": "四", + "5": "五", + "6": "六" } }, "lang": { @@ -27,7 +38,9 @@ "transport": "交通" }, "searchPlaceholder": "搜索展会", - "cta": { "register": "观众预登记" }, + "cta": { + "register": "观众预登记" + }, "menuOpen": "打开菜单", "menuClose": "关闭菜单", "homeAria": "{{brand}} 首页", @@ -156,7 +169,12 @@ "fabCta": "前往预登记" }, "register": { - "steps": { "type": "类型", "info": "信息", "interest": "兴趣领域", "done": "完成" }, + "steps": { + "type": "类型", + "info": "信息", + "interest": "兴趣领域", + "done": "完成" + }, "stepsAria": "预登记步骤", "type": { "general": "普通观众", @@ -358,7 +376,12 @@ "vipDesc": "展会通票 + VIP 休息室使用权", "vipPrice": "₩50,000" }, - "steps": { "s1": "展会·票种", "s2": "购票人", "s3": "支付", "s4": "完成" }, + "steps": { + "s1": "展会·票种", + "s2": "购票人", + "s3": "支付", + "s4": "完成" + }, "stepsAria": "预订步骤", "bannerTitle": "智能工厂韩国 2026", "bannerDate": "2026.09.05 – 09.08", @@ -517,5 +540,743 @@ "errRequest": "请求时发生错误。", "errReset": "重置时发生错误。", "cancel": "取消" + }, + "dashboard": { + "countdown": "开展 D-{{n}}", + "degradedTag": "离线 — 示例", + "degradedMsg": "无法连接汇总接口,正在显示演示示例数据。", + "loadError": "无法加载仪表盘汇总数据。", + "timelineAria": "里程碑时间轴", + "milestoneEmptyTitle": "暂无派生日程", + "milestoneEmptyDesc": "确认活动开始日期后将显示里程碑。", + "mstate": { + "done": "已完成", + "current": "进行中", + "upcoming": "待进行" + }, + "kpisAria": "核心指标", + "kpiEmpty": "暂无汇总指标", + "exhibitorStatus": "参展商现状", + "sampleTag": "示例", + "exhibitorEmptyTitle": "暂无参展商展位", + "exhibitorEmptyDesc": "登记展位布局与设计后将在此显示。", + "colBoothNo": "展位号", + "colCompany": "公司名称", + "colStatus": "设计状态", + "feedAria": "通知动态", + "feedTitle": "通知", + "feedEmpty": "暂无新通知", + "aerialAria": "展厅鸟瞰", + "aerialTitle": "展厅鸟瞰", + "aerialShot": "S7 展厅鸟瞰", + "aerialAlt": "展厅鸟瞰预览图" + }, + "schedule": { + "title": "展览日程", + "subtitle": "KINTEX 全展厅活动日程管理与监控 — {{n}} 项", + "crawlFallback": "(kintex.com 采集回退)", + "yearFilter": "年份筛选", + "allYears": "全部年份", + "yearN": "{{y}}年", + "hallFilter": "展厅筛选", + "allHalls": "全部展厅 (1-10)", + "hall1": "第一展馆 (1-5 厅)", + "hall2": "第二展馆 (6-10 厅)", + "catFilter": "类别筛选", + "allCats": "全部类别", + "statusFilter": "状态筛选", + "allStatus": "全部状态", + "cat": { + "exhibition": "展览", + "culture": "文化艺术", + "trade": "贸易产业", + "tech": "技术" + }, + "status": { + "ongoing": "进行中", + "upcoming": "待举办", + "ended": "已结束" + }, + "catExhibit": "{{cat}}展", + "apiWait": "等待活动接口 (采集数据)", + "viewSwitch": "切换视图", + "viewList": "列表", + "viewCalendar": "日历", + "listLoading": "正在加载活动列表", + "listAria": "活动列表", + "loadError": "无法加载展览日程。", + "count": "{{n}} 项", + "emptyTitle": "暂无已登记活动", + "emptyDesc": "请调整筛选条件或创建新活动。", + "newEvent": "新建活动", + "period": "期间", + "assignedHall": "分配展厅", + "estVisitors": "预计人数", + "editDetail": "编辑详情", + "viewLogistics": "查看进场/物流", + "monthCal": "月历", + "prev": "上一个", + "next": "下一个", + "today": "今天", + "yearMonth": "{{y}}年{{m}}月", + "miniCal": "迷你日历", + "prevMonth": "上个月", + "nextMonth": "下个月", + "offlineSample": "离线 — 示例", + "day": "{{d}}日", + "dayHasEvent": "{{d}}日 (有活动)", + "hallUtil": "展厅使用率", + "hallUtilMonth": "展厅使用率 ({{y}}年{{m}}月)" + }, + "settlement": { + "title": "结算·付款", + "subtitle": "管理参展商的账单与收款状况", + "generate": "生成账单", + "generating": "生成中…", + "noEventTitle": "请选择活动", + "noEventDesc": "尚未指定要查询结算状况的活动。", + "genDone": "账单生成完成 — 新增 {{created}} 项,保留 {{skipped}} 项。", + "genError": "账单生成失败。请检查权限或目标数据。", + "dashError": "无法加载结算汇总数据。", + "kpisAria": "结算核心指标", + "kpiBilled": "总账单额", + "kpiCollected": "收款额", + "kpiOutstanding": "未收款", + "kpiOverdue": "逾期账单", + "count": "{{n}} 项", + "collectionRate": "收款率 {{rate}}%", + "outstandingSub": "未收余额", + "overdueSub": "已过缴款期限", + "distTitle": "账单状态分布", + "distEmpty": "暂无汇总账单", + "listTitle": "账单列表", + "invoices": "账单", + "statusFilter": "状态筛选", + "status": { + "all": "全部", + "pending": "未开具", + "invoiced": "已开具", + "paid": "已付清", + "overdue": "逾期" + }, + "listError": "无法加载账单列表。", + "emptyTitle": "暂无账单", + "emptyManage": "请使用上方的生成账单,按参展商汇总账单。", + "emptyView": "尚未开具任何账单。", + "colNo": "账单编号", + "colExhibitor": "参展商", + "colBilled": "账单额", + "colPaid": "收款", + "colOutstanding": "未收", + "colDue": "缴款期限", + "colStatus": "状态", + "detail": "详情", + "prev": "上一页", + "next": "下一页", + "pageInfo": "{{page}} / {{total}} · 共 {{cnt}} 项", + "modalAria": "账单详情", + "invoice": "账单", + "close": "关闭", + "detailError": "无法加载账单详情。", + "liItem": "项目", + "liQty": "数量", + "liUnit": "单价", + "liAmount": "金额", + "noLines": "无明细项", + "supplyAmount": "供货金额", + "vat": "增值税(10%)", + "grandTotal": "账单总额", + "paidOutstanding": "收款 / 未收", + "payHistory": "收款记录", + "noPayments": "暂无收款记录。", + "won": "{{v}}韩元", + "issue": "开具账单", + "issuing": "开具中…", + "payAmount": "收款金额", + "payAmountPh": "收款金额(韩元)", + "payMethod": "收款方式", + "methodTransfer": "银行转账", + "methodCard": "刷卡", + "methodOther": "其他", + "memo": "备注", + "memoPh": "备注(可选)", + "recordPay": "记录收款", + "recording": "记录中…", + "payFail": "记录收款失败。请检查金额(不超过总额)和权限。", + "errPositive": "请输入大于 0 的金额。", + "errExceed": "不能超过未收余额。", + "pgNote": "实际付款委托外部支付网关处理。本页面仅用于记录收款确认。", + "managerOnly": "仅主办方/展厅经理可记录或开具收款。" + }, + "hallassign": { + "title": "展厅分配 · 自动报价", + "subtitle": "展厅×期间分配现状与基于费率规则集的即时报价(M1)。", + "start": "开始", + "end": "结束", + "newAssign": "新分配", + "board": "分配看板", + "boardHint": "重叠的分配将显示为冲突。", + "forbiddenTitle": "展厅经理专用看板", + "forbiddenDesc": "分配现状查询仅限 KINTEX 展厅经理账户。自动报价请在下方使用。", + "noHalls": "暂无展厅主数据。", + "noAssign": "无分配", + "release": "解除分配", + "releaseConfirm": "解除 {{event}} 的 {{hall}} 分配吗?", + "assignedToast": "展厅已分配。", + "releasedToast": "分配已解除。", + "releaseFail": "解除失败(请检查权限)。", + "conflictErr": "所选展厅·期间与其他活动重叠。", + "assignFail": "分配失败。", + "pickEventHall": "请选择活动和展厅。", + "modalTitle": "展厅分配", + "close": "关闭", + "event": "活动", + "pickEvent": "选择活动…", + "hall": "展厅", + "pickHall": "选择展厅…", + "occupyStart": "占用开始", + "occupyEnd": "占用结束", + "primary": "主分配展厅(primary)", + "note": "备注", + "optional": "可选输入", + "cancel": "取消", + "assign": "分配", + "assigning": "分配中…", + "autoQuote": "自动报价", + "quoteHint": "按费率规则集 · 最终金额以 KINTEX 确定为准", + "startDate": "开始日期", + "endDate": "结束日期", + "boothSize": "展位规模(可选)", + "countPh": "数量", + "calcQuote": "计算报价", + "calculating": "计算中…", + "quoteFail": "报价计算失败。", + "quoteReq": "请输入展厅·开始日期·结束日期。", + "qItem": "项目", + "qBasis": "计算依据", + "qAmount": "金额", + "rentalSubtotal": "租金小计", + "rentalTotal": "租金合计 ({{days}}天)", + "deposit": "管理费押金(另计)", + "ruleset": "(规则集 {{v}})" + }, + "analytics": { + "title": "经营分析", + "subtitle": "KINTEX 运营盈利能力 · 参展商洞察", + "perspective": "分析视角", + "operator": "运营方", + "exhibitor": "参展商", + "periodAria": "分析期间", + "periodEvent": "本次活动", + "periodD90": "最近 90 天", + "periodAnnual": "年度", + "exportPdf": "导出 PDF", + "exportExcel": "导出 Excel", + "noEventTitle": "请选择活动", + "noEventDesc": "尚未指定要分析的活动。", + "offlineSample": "离线 — 示例", + "degradedMsg": "无法连接汇总接口,正在显示演示示例数据。", + "loadError": "无法加载经营分析汇总。", + "kpisAria": "核心指标", + "kpiEmpty": "暂无汇总指标", + "trendAria": "趋势图表", + "trendTitle": "营收 · 展位趋势", + "monthlyAgg": "按月汇总", + "trendHint": "按月租赁/公用事业营收与展位数量趋势(营收单位:韩元)。", + "trendEmptyTitle": "暂无趋势数据", + "trendEmptyDesc": "可汇总期间内暂无营收/展位记录。", + "revenue": "营收", + "booth": "展位", + "boothsUnit": "{{n}} 个展位", + "sectorsAria": "行业构成", + "sectorsTitle": "行业构成", + "sectorsEmptyTitle": "行业分类未汇总", + "sectorsEmptyDesc": "接入行业分类来源(M13)后将显示。", + "perfTable": "绩效表", + "pnlTitle": "损益(P&L)构成", + "exhibitorPerfTitle": "参展商绩效", + "sampleTag": "示例", + "pnlEmptyTitle": "暂无损益项", + "pnlEmptyDesc": "汇总展位租赁/公用事业营收记录后将显示。", + "colItem": "项目", + "colAmount": "金额", + "colGrade": "等级", + "perfEmptyTitle": "参展商绩效未汇总", + "perfEmptyDesc": "接入潜客/转化率来源(M13 访客)后将显示。", + "colCompany": "公司名称", + "colLeads": "潜客", + "colConversion": "转化率" + }, + "global": { + "sectionAria": "租户全域经营分析", + "title": "全公司经营分析 (跨活动)", + "subtitle": "跨活动营收对比 · 展厅使用率趋势 · 参展商留存 · 需求预测", + "yearsAria": "分析年数", + "years3": "最近 3 年", + "years5": "最近 5 年", + "years10": "最近 10 年", + "loadErrorTitle": "无法加载全域汇总", + "loadErrorDesc": "请稍后再试。", + "kpisAria": "全域核心指标", + "kpiEmpty": "暂无汇总指标", + "evComp": "跨活动营收对比", + "top8": "前 8 名", + "evCompHint": "已布展活动的租赁+公用事业营收排名(单位:韩元)。", + "evCompEmptyTitle": "暂无可对比的活动", + "evCompEmptyDesc": "汇总已布展活动营收后将显示。", + "revenue": "营收", + "retention": "参展商留存", + "retentionCum": "累计再参展率", + "returning": "再参展", + "retentionEmptyTitle": "按年度留存未汇总", + "retentionEmptyDesc": "接入展位分配企业来源后将显示。", + "retentionRate": "再参展率", + "occupancy": "展厅使用率趋势 (按月)", + "occupancyHint": "按 举办活动-天数 ÷ (展厅数 × 当月天数) 计算的月度使用率。", + "occupancyEmpty": "暂无使用率数据", + "occupancyRate": "使用率", + "forecast": "需求预测", + "forecastTitle": "需求预测 (举办趋势)", + "trendProjection": "趋势投影", + "forecastHint": "按年度举办活动数实测 + 最小二乘线性趋势,向未来 2 年投影(虚线)。", + "forecastEmptyTitle": "暂无可预测的历史", + "forecastEmptyDesc": "汇总年度举办历史后将显示。", + "casesUnit": "{{n}} 项", + "actual": "实测", + "projected": "预测" + }, + "ops": { + "title": "展厅现场运营", + "subtitle": "基于签到·公用事业实时汇总的现状", + "modeAria": "查看模式", + "live": "实时", + "history": "历史查询", + "noEventTitle": "请选择活动", + "noEventDesc": "尚未指定要查询现场运营的活动。", + "offlineSample": "离线 — 示例", + "degradedMsg": "无法连接汇总接口,正在显示演示示例数据。", + "loadError": "无法加载现场运营状况。", + "hallStatus": "各展厅现状", + "checkinDerived": "由签到(M10)派生 · 按面积占比分配", + "sensorNoneHalls": "无传感器 · 分配展厅列表", + "noHallsTitle": "暂无已分配展厅", + "noHallsDesc": "为活动分配展厅后将显示。", + "busy": "拥挤", + "smooth": "顺畅", + "moderate": "一般", + "people": "{{n}}人", + "onSiteCheckin": "现场签到 <0>{{n}}0>人", + "noCheckinAgg": "无签到汇总", + "powerLoad": "电力负荷", + "totalKw": "共 <0>{{v}}0>kW", + "utilLoadAgg": "公用事业申请(M4)负荷汇总", + "noLoadTitle": "无申请负荷", + "noLoadDesc": "受理电力公用事业申请后将按展厅显示负荷。", + "kw": "{{v}}kW", + "hourlyCheckin": "分时段签到", + "hourlyAgg": "签到(M10)分时段汇总", + "noSeriesTitle": "无签到趋势", + "noSeriesDesc": "记录现场签到后将显示分时段趋势。", + "facilitySensor": "设施传感器接入", + "facilitySensorTitle": "设施传感器 (空调·照明·停车)", + "sensorNoneTitle": "设施传感器未接入", + "sensorNoneDesc": "空调(HVAC)·照明 IoT·停车(iparking)联动将在接入传感器(M14)后显示。", + "temp": "温度", + "humidity": "湿度", + "parkingSlots": "{{zone}}: {{occ}}/{{cap}} 车位" + }, + "admin": { + "deniedTitle": "无访问权限", + "deniedAuthed": "本页面仅系统管理员(展厅经理·ADMIN)可使用。如需访问请联系管理员。", + "deniedGuest": "管理后台需登录后使用。", + "overviewTitle": "系统概览", + "overviewSub": "实时指标与运营状态", + "loginSlides": "登录轮播管理", + "tenantSwitch": "切换租户", + "systemNormal": "系统正常", + "moduleShortcuts": "管理模块快捷入口", + "offlineSample": "离线 — 示例", + "degradedMsg": "无法连接汇总接口,正在显示演示示例数据。", + "dashLoadError": "无法加载管理员仪表盘。", + "kpisAria": "核心指标", + "kpiEmpty": "暂无汇总指标", + "visitorTrend": "访客趋势", + "hourlyVisitors": "分时段访客", + "visitors": "访客", + "visitorTrendEmptyTitle": "访客趋势未汇总", + "visitorTrendEmptyDesc": "接入访客计量传感器(M14)后将显示分时段趋势。", + "liveEvents": "进行中活动", + "noLiveEvents": "暂无进行中的活动", + "hallTbd": "展厅未分配", + "occupancy": "占用 {{n}}%", + "modUsers": "用户", + "modRoles": "角色·权限", + "modCodes": "通用代码", + "modMenus": "菜单", + "modAudit": "审计日志", + "modSettings": "系统设置", + "modRulesets": "规则集", + "modTenants": "租户" + }, + "tenant": { + "title": "租户管理·上线", + "subtitle": "仅平台超级管理员 · 无需部署代码,通过数据上线添加展馆。", + "sampleTag": "示例 · 未实现", + "sampleNote": "多租户(租户 CRUD·上线·隔离)后端尚未实现,本页面展示目标信息架构。多租户引入后将接入实数据。", + "listAria": "租户列表", + "venue": "展馆", + "notImpl": "未实现", + "addVenue": "添加展馆", + "code": "代码", + "domain": "域名", + "status": "状态", + "events": "活动", + "users": "用户", + "wizardAria": "上线向导", + "onboardingOf": "{{name}} 上线", + "dataOnboarding": "无需部署代码的数据上线", + "wiz": { + "create": { + "label": "创建租户", + "desc": "登记代码·显示名" + }, + "domain": { + "label": "子域名", + "desc": "分配 DNS·品牌域名" + }, + "brand": { + "label": "品牌", + "desc": "Logo·颜色·公开站主题" + }, + "master": { + "label": "录入主数据", + "desc": "展厅·费率·规则集种子" + }, + "admin": { + "label": "管理员账户", + "desc": "邀请租户管理员" + }, + "smoke": { + "label": "隔离冒烟测试", + "desc": "验证跨租户隔离" + }, + "activate": { + "label": "激活", + "desc": "转入运营" + } + }, + "tstatus": { + "active": "活跃", + "onboarding": "上线中", + "suspended": "已停用" + } + }, + "ruleset": { + "title": "规则集版本管理", + "subtitle": "应对法规修订的规则集版本历史与规则项。", + "readonly": "只读", + "readonlyNote": "规则集以部署资源(rulesets/*.json)进行版本管理。编辑与版本创建通过在法规修订时替换资源完成,本页面仅供查询。", + "listError": "无法加载规则集列表。", + "noRulesets": "暂无已部署的规则集", + "versionHistory": "版本历史", + "rulesCount": "规则 {{n}}", + "effectiveDate": "生效日 {{date}}", + "ruleList": "规则列表", + "rulesOf": "{{name}} 规则 ({{n}} 项)", + "detailError": "无法加载规则全文。", + "noRulesTitle": "该规则集无规则数组", + "noRulesDesc": "仅定义费率/单价而无规则(rules)的规则集,如费率主数据。", + "colRuleName": "规则名", + "colValue": "数值/设定值", + "colLogic": "逻辑 / 要求", + "colSeverity": "违规级别", + "block": "阻断", + "warn": "警告", + "required": "必填", + "perHall": "各展厅 {{unit}}", + "docNeeded": "需文件: {{doc}}" + }, + "audit": { + "title": "审计日志", + "subtitle": "追踪审批·中标·设置变更等所有管理操作及重要领域事件。(保持完整性)", + "exportCsv": "导出 CSV", + "filterAria": "审计日志筛选", + "actionType": "操作类型", + "allActions": "全部操作", + "actorId": "操作人 ID", + "actorIdPh": "例: user-102", + "eventId": "活动 ID", + "eventIdPh": "例: EVT-2026-01", + "reset": "重置", + "applyFilter": "应用筛选", + "loadError": "无法加载审计日志。", + "tableAria": "审计日志表", + "emptyTitle": "无符合条件的审计记录", + "emptyDesc": "请调整或重置筛选条件后重新查询。", + "colTime": "时间", + "colUser": "用户", + "colAction": "操作", + "colTarget": "对象", + "colEvent": "活动", + "colResult": "结果", + "showing": "显示 <0>{{from}}-{{to}}0> / 共 <1>{{total}}1> 条", + "prev": "上一页", + "next": "下一页", + "rowsShow": "行显示", + "rowsPerPage": "每页行数", + "rowsN": "{{n}} 条", + "success": "成功", + "failure": "失败", + "csvTime": "时间", + "csvActorId": "操作人ID", + "csvActorName": "操作人姓名", + "csvAction": "操作", + "csvTargetType": "对象类型", + "csvTargetId": "对象ID", + "csvEventId": "活动ID", + "csvResult": "结果", + "csvSummary": "摘要" + }, + "role": { + "title": "角色 · 权限 (RBAC)", + "subtitle": "按角色(主办方·参展商·装置商·展厅经理·管理员·访客)以矩阵方式分配资源权限。", + "addRole": "添加角色", + "loadError": "无法加载角色·权限信息。", + "noRolesTitle": "尚未定义角色", + "noRolesDesc": "添加角色以配置 RBAC。", + "roleList": "角色列表", + "role": "角色", + "codePerm": "{{code}} · 权限 {{n}}", + "unused": "未使用", + "permMatrix": "权限矩阵", + "rolePerm": "{{name}} 权限", + "roleInfo": "角色信息", + "deleteConfirm": "确定删除角色 '{{name}}' 吗?", + "delete": "删除", + "noPerms": "尚未定义权限", + "selectRole": "请选择角色", + "saved": "角色已保存。", + "deleted": "角色已删除。", + "addRoleModal": "添加角色", + "editRole": "编辑角色信息", + "modalDesc": "权限分配在保存后于矩阵中通过勾选管理。", + "roleCode": "角色代码 *", + "roleCodePh": "例: ORGANIZER", + "roleName": "角色名称 *", + "description": "说明", + "useYn": "是否使用", + "use": "使用", + "noUse": "未使用", + "cancel": "取消", + "save": "保存", + "saving": "保存中…", + "errRequired": "角色代码和名称为必填。", + "errDupCode": "该角色代码已存在。" + }, + "menu": { + "title": "菜单管理", + "subtitle": "管理按角色的导航菜单树及路径·访问角色。", + "addMenu": "添加菜单", + "loadError": "无法加载菜单。", + "treeAria": "菜单树", + "emptyTitle": "暂无已登记菜单", + "emptyDesc": "请添加菜单。", + "colName": "菜单名", + "colPath": "路径", + "colType": "类型", + "colRole": "访问角色", + "colSort": "排序", + "colUse": "使用", + "colManage": "管理", + "all": "全部", + "unused": "未使用", + "used": "使用", + "edit": "编辑", + "delete": "删除", + "deleteConfirm": "确定删除菜单 '{{name}}' 吗?如有子菜单将一并清理。", + "saved": "菜单已保存。", + "deleted": "菜单已删除。", + "editMenu": "编辑菜单", + "menuId": "菜单 ID (唯一键) *", + "menuIdPh": "例: admin-users", + "menuName": "菜单名 *", + "parent": "上级菜单", + "topLevel": "(最上级)", + "path": "路径(path)", + "pathPh": "例: /admin/users", + "type": "类型", + "accessRole": "访问角色", + "accessRolePh": "例: ADMIN (留空=全部)", + "sort": "排序", + "useYn": "是否使用", + "cancel": "取消", + "save": "保存", + "saving": "保存中…", + "errName": "菜单名为必填。", + "errId": "菜单 ID(唯一键)为必填。", + "errDupId": "该菜单 ID 已存在。" + }, + "code": { + "title": "通用代码管理", + "subtitle": "管理下拉·分类所用的通用代码组与代码值。", + "addGroup": "添加组", + "groupLoadError": "无法加载代码组。", + "noGroupsTitle": "暂无代码组", + "noGroupsDesc": "添加组以配置通用代码。", + "codeGroup": "代码组", + "codeValues": "代码值", + "valuesOf": "{{name}} 值", + "editGroup": "编辑代码组", + "deleteGroup": "删除组", + "deleteGroupConfirm": "确定删除组 '{{name}}' 及其下代码吗?", + "addCode": "添加代码", + "codeLoadError": "无法加载代码值。", + "noCodesTitle": "暂无代码", + "noCodesDesc": "请添加代码。", + "code": "代码", + "name": "名称", + "value": "值", + "sort": "排序", + "use": "使用", + "manage": "管理", + "unused": "未使用", + "used": "使用", + "edit": "编辑", + "delete": "删除", + "deleteCodeConfirm": "确定删除代码 '{{name}}' 吗?", + "groupSaved": "代码组已保存。", + "groupDeleted": "代码组已删除。", + "codeSaved": "代码已保存。", + "codeDeleted": "代码已删除。", + "groupCode": "组代码 *", + "groupName": "组名称 *", + "description": "说明", + "useYn": "是否使用", + "cancel": "取消", + "save": "保存", + "saving": "保存中…", + "errGroupRequired": "组代码和名称为必填。", + "errDupGroup": "该组代码已存在。", + "editCode": "编辑代码", + "group": "组", + "codeReq": "代码 *", + "nameReq": "名称 *", + "attr1": "属性1(attr1)", + "errCodeRequired": "代码和名称为必填。", + "errDupCode": "该代码已存在。" + }, + "sysuser": { + "title": "用户管理", + "subtitle": "管理账户创建·编辑、角色分配、状态(激活/锁定/停用)及 OTP 重置。密码以掩码显示,不予展示。", + "addUser": "添加用户", + "searchAria": "用户搜索", + "keyword": "搜索词", + "keywordPh": "姓名·邮箱", + "role": "角色", + "status": "状态", + "all": "全部", + "reset": "重置", + "search": "搜索", + "loadError": "无法加载用户列表。", + "listAria": "用户列表", + "emptyTitle": "暂无用户", + "emptyDesc": "请调整搜索条件或添加用户。", + "colUser": "用户", + "colEmail": "邮箱", + "colRole": "角色", + "colDept": "部门", + "colStatus": "状态", + "colLastLogin": "最近登录", + "colManage": "管理", + "hallManagerTag": "展厅经理", + "otpSet": "已设置", + "otpUnset": "未设置", + "edit": "编辑", + "lock": "锁定", + "unlock": "解锁", + "otpResetBtn": "重置OTP", + "otpResetConfirm": "确定重置 {{name}} 的 OTP 吗?", + "saved": "用户已保存。", + "statusChanged": "状态已变更。", + "otpReset": "OTP 已重置。", + "active": "激活", + "locked": "锁定", + "disabled": "停用", + "editUser": "编辑用户", + "newDesc": "初始密码在保存后不会显示在界面上。", + "editDesc": "邮箱不可编辑。密码重置遵循单独策略。", + "email": "邮箱 *", + "displayName": "显示名称 *", + "dept": "部门", + "initPassword": "初始密码 *", + "hallManagerCheck": "展厅经理(内部运营者)", + "cancel": "取消", + "save": "保存", + "saving": "保存中…", + "errRequired": "邮箱和显示名称为必填。", + "errPassword": "新用户需设置初始密码。", + "totalUsers": "共 <0>{{total}}0> 人", + "prev": "上一页", + "next": "下一页" + }, + "settings": { + "title": "系统设置", + "subtitle": "管理全局策略·功能开关·AI 门控等系统参数。密钥值以掩码显示。", + "loadError": "无法加载系统设置。", + "emptyTitle": "暂无已登记设置", + "emptyDesc": "请在下方表单中添加第一个设置项。", + "saved": "设置已保存。", + "deleted": "设置已删除。", + "deleteConfirm": "确定删除设置 '{{key}}' 吗?", + "general": "常规", + "groupAria": "设置组 {{name}}", + "secret": "密钥", + "secretTitle": "密钥 — 值掩码", + "on": "开", + "off": "关", + "secretPh": "已设置(掩码) — 更改时输入新值", + "save": "保存", + "delete": "删除", + "deleteKey": "删除 {{key}}", + "secretHint": "密钥原文不会展示。不输入值直接保存则保留原值。", + "addAria": "添加设置", + "addTitle": "添加 / 覆盖设置", + "settingKey": "设置键", + "settingKeyPh": "例: deadline.utility.days", + "value": "值", + "type": "类型", + "description": "说明", + "secretCheck": "密钥(值掩码)" + }, + "slide": { + "title": "登录轮播管理", + "subtitle": "登录界面左侧轮播图片·文案 — 活跃幻灯片中<0>按登记顺序最新 5 张0>进行轮播。", + "addAria": "添加新幻灯片", + "addTitle": "添加新幻灯片", + "imageLabel": "图片 (PNG/JPG/WebP, 8MB 以内)", + "preview": "预览", + "titleLabel": "标题文案", + "titlePh": "例) 代表亚洲的展览会议中心 KINTEX", + "captionLabel": "副标题(辅助文案)", + "captionPh": "例) 在 10 万㎡ 展览空间中探索商机。", + "submitting": "登记中…", + "submit": "登记幻灯片", + "listAria": "幻灯片列表", + "registered": "已登记幻灯片", + "countActive": "共 {{total}} 张 · 活跃 {{active}} 张", + "emptyTitle": "暂无已登记幻灯片", + "emptyDesc": "登记图片和文案后将在登录界面轮播中展示。(未登记时显示默认 5 种)", + "live": "展示中", + "inactive": "未激活", + "registeredAt": "登记 {{date}}", + "active": "激活", + "delete": "删除", + "listError": "无法加载列表。", + "errImage": "请选择幻灯片图片。", + "errTitle": "请输入标题文案。", + "createFail": "登记失败。", + "updateFail": "变更失败。", + "deleteFail": "删除失败。", + "deleteConfirm": "确定删除幻灯片 \"{{title}}\" 吗?" } } diff --git a/src/frontend/src/screens/admin/AdminDashboardPage.tsx b/src/frontend/src/screens/admin/AdminDashboardPage.tsx index 4b5af6b..a810ca9 100644 --- a/src/frontend/src/screens/admin/AdminDashboardPage.tsx +++ b/src/frontend/src/screens/admin/AdminDashboardPage.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { Link } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; import { Bar, BarChart, @@ -25,6 +26,7 @@ import './admin.css'; * 폴백: NETWORK/NOT_FOUND 시에만 sampleAdmin 로 강등. 관람객 추이는 센서 부재로 백엔드 빈배열 → 빈 상태. */ export function AdminDashboardPage() { + const { t } = useTranslation(); const [tenant, setTenant] = useState(undefined); const q = useQuery({ @@ -43,53 +45,53 @@ export function AdminDashboardPage() { - 시스템 개요 - 실시간 지표 및 운영 상태 + {t('admin.overviewTitle')} + {t('admin.overviewSub')} - 로그인 슬라이드 관리 + {t('admin.loginSlides')} setTenant(e.target.value)} > - {tenants.map((t) => ( - {t} + {tenants.map((tn) => ( + {tn} ))} - 시스템 정상 + {t('admin.systemNormal')} - - {ADMIN_MODULES.map(([label, to]) => ( - {label} + + {ADMIN_MODULES.map(([labelKey, to]) => ( + {t(labelKey)} ))} {degraded && ( - 오프라인 — 샘플 - 집계 API에 연결하지 못해 시연용 샘플을 표시합니다. + {t('admin.offlineSample')} + {t('admin.degradedMsg')} )} {q.isLoading && } {hardError && ( - q.refetch()} /> + q.refetch()} /> )} {data && ( <> - + {data.kpis.length === 0 ? ( - + ) : ( data.kpis.map((k, i) => ( @@ -102,15 +104,15 @@ export function AdminDashboardPage() { - + - 시간대별 관람객 + {t('admin.hourlyVisitors')} {data.visitorTrend.length === 0 ? ( ) : ( @@ -119,19 +121,19 @@ export function AdminDashboardPage() { - + )} - @@ -210,14 +212,14 @@ const FALLBACK: AdminDashboardData = { tenants: TENANTS, }; -/** 관리 모듈 바로가기(M18) — 라우트는 App.tsx AdminGuard 배선과 동일. */ +/** 관리 모듈 바로가기(M18) — 라우트는 App.tsx AdminGuard 배선과 동일. 첫 항목은 i18n 키. */ const ADMIN_MODULES: [string, string][] = [ - ['사용자', '/admin/users'], - ['역할·권한', '/admin/roles'], - ['공통코드', '/admin/codes'], - ['메뉴', '/admin/menus'], - ['감사로그', '/admin/audit'], - ['시스템설정', '/admin/settings'], - ['룰셋', '/admin/masterdata/rulesets'], - ['테넌트', '/admin/tenants'], + ['admin.modUsers', '/admin/users'], + ['admin.modRoles', '/admin/roles'], + ['admin.modCodes', '/admin/codes'], + ['admin.modMenus', '/admin/menus'], + ['admin.modAudit', '/admin/audit'], + ['admin.modSettings', '/admin/settings'], + ['admin.modRulesets', '/admin/masterdata/rulesets'], + ['admin.modTenants', '/admin/tenants'], ]; diff --git a/src/frontend/src/screens/admin/AdminGuard.tsx b/src/frontend/src/screens/admin/AdminGuard.tsx index 0dac5c6..a148a5b 100644 --- a/src/frontend/src/screens/admin/AdminGuard.tsx +++ b/src/frontend/src/screens/admin/AdminGuard.tsx @@ -13,6 +13,7 @@ * 사용(통합자 배선): 관리자 라우트 element 를 로 감싼다. App.tsx 는 이 트랙에서 수정하지 않는다. */ import type { ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; import { useAuthStore } from '../../store/authStore'; import { IconBlock } from '../../components/ui/icons'; import './admin-modules.css'; @@ -29,6 +30,7 @@ export function useIsAdmin(): boolean { } export function AdminGuard({ children }: { children: ReactNode }) { + const { t } = useTranslation(); const authed = useAuthStore((s) => s.isAuthenticated); const isAdmin = useIsAdmin(); @@ -40,11 +42,9 @@ export function AdminGuard({ children }: { children: ReactNode }) { - 접근 권한이 없습니다 + {t('admin.deniedTitle')} - {authed - ? '이 페이지는 시스템 관리자(홀매니저·ADMIN)만 이용할 수 있습니다. 접근이 필요하면 관리자에게 문의하세요.' - : '관리자 백오피스는 로그인 후 이용할 수 있습니다.'} + {authed ? t('admin.deniedAuthed') : t('admin.deniedGuest')} 403 · FORBIDDEN diff --git a/src/frontend/src/screens/admin/AuditLogPage.tsx b/src/frontend/src/screens/admin/AuditLogPage.tsx index 5065226..70f2ab2 100644 --- a/src/frontend/src/screens/admin/AuditLogPage.tsx +++ b/src/frontend/src/screens/admin/AuditLogPage.tsx @@ -7,6 +7,8 @@ */ import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; +import { Trans, useTranslation } from 'react-i18next'; +import type { TFunction } from 'i18next'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { Button } from '../../components/ui/Button'; import { IconDownload, IconSearch } from '../../components/ui/icons'; @@ -16,6 +18,7 @@ import './admin-modules.css'; const PAGE_SIZES = [20, 50, 100]; export function AuditLogPage() { + const { t } = useTranslation(); const [action, setAction] = useState(''); const [actorId, setActorId] = useState(''); const [eventId, setEventId] = useState(''); @@ -63,30 +66,30 @@ export function AuditLogPage() { - 감사 로그 + {t('audit.title')} - 승인·낙찰·설정변경 등 모든 관리 액션과 중요 도메인 이벤트를 추적합니다. (무결성 유지) + {t('audit.subtitle')} } - onClick={() => exportCsv(rows)} + onClick={() => exportCsv(t, rows)} disabled={rows.length === 0} > - CSV 내보내기 + {t('audit.exportCsv')} {/* 필터 바 */} - + - 액션 유형 + {t('audit.actionType')} setAction(e.target.value)} /> @@ -97,29 +100,29 @@ export function AuditLogPage() { - 담당자 ID + {t('audit.actorId')} setActorId(e.target.value)} /> - 행사 ID + {t('audit.eventId')} setEventId(e.target.value)} /> - 초기화 + {t('audit.reset')} } onClick={applyFilters}> - 필터 적용 + {t('audit.applyFilter')} @@ -129,15 +132,15 @@ export function AuditLogPage() { {q.isLoading && } {q.isError && !q.isLoading && ( - q.refetch()} /> + q.refetch()} /> )} {!q.isLoading && !q.isError && ( - + {rows.length === 0 ? ( ) : ( <> @@ -145,12 +148,12 @@ export function AuditLogPage() { - 시각 - 사용자 - 액션 - 대상 - 행사 - 결과 + {t('audit.colTime')} + {t('audit.colUser')} + {t('audit.colAction')} + {t('audit.colTarget')} + {t('audit.colEvent')} + {t('audit.colResult')} @@ -198,8 +201,7 @@ export function AuditLogPage() { - 표시 중 {from}-{to} / 총{' '} - {total.toLocaleString()}개 + , ]} /> setPage((p) => Math.max(0, p - 1))} disabled={page === 0} > - 이전 + {t('audit.prev')} {page + 1} / {totalPages} @@ -217,11 +219,11 @@ export function AuditLogPage() { onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))} disabled={page >= totalPages - 1} > - 다음 + {t('audit.next')} - 행 표시 + {t('audit.rowsShow')} {PAGE_SIZES.map((s) => ( - {s}개 + {t('audit.rowsN', { n: s })} ))} @@ -248,6 +250,7 @@ export function AuditLogPage() { } function ResultPill({ value }: { value: string | null }) { + const { t } = useTranslation(); const v = (value ?? '').toUpperCase(); const tone = v === 'SUCCESS' || v === 'OK' @@ -255,7 +258,7 @@ function ResultPill({ value }: { value: string | null }) { : v === 'FAILURE' || v === 'FAIL' || v === 'ERROR' || v === 'DENIED' ? 'error' : 'neutral'; - const label = tone === 'success' ? '성공' : tone === 'error' ? '실패' : value ?? '—'; + const label = tone === 'success' ? t('audit.success') : tone === 'error' ? t('audit.failure') : value ?? '—'; return {label}; } @@ -283,9 +286,12 @@ function fmtTs(s: string | null | undefined): string { } /** 현재 로드된 페이지 rows 를 CSV 로 내보낸다. IP/자격증명 컬럼은 애초에 존재하지 않는다. */ -function exportCsv(rows: AuditLogRow[]): void { +function exportCsv(t: TFunction, rows: AuditLogRow[]): void { if (rows.length === 0) return; - const headers = ['시각', '담당자ID', '담당자명', '액션', '대상유형', '대상ID', '행사ID', '결과', '요약']; + const headers = [ + t('audit.csvTime'), t('audit.csvActorId'), t('audit.csvActorName'), t('audit.csvAction'), + t('audit.csvTargetType'), t('audit.csvTargetId'), t('audit.csvEventId'), t('audit.csvResult'), t('audit.csvSummary'), + ]; const esc = (v: unknown) => { const s = v == null ? '' : String(v); return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; diff --git a/src/frontend/src/screens/admin/CommonCodeAdminPage.tsx b/src/frontend/src/screens/admin/CommonCodeAdminPage.tsx index 7cade49..feef8a6 100644 --- a/src/frontend/src/screens/admin/CommonCodeAdminPage.tsx +++ b/src/frontend/src/screens/admin/CommonCodeAdminPage.tsx @@ -6,6 +6,7 @@ */ import { useEffect, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { Button } from '../../components/ui/Button'; import { IconPlus } from '../../components/ui/icons'; @@ -14,6 +15,7 @@ import { codeApi, type Code, type CodeGroup } from './adminCrudApi'; import './admin-modules.css'; export function CommonCodeAdminPage() { + const { t } = useTranslation(); const qc = useQueryClient(); const { show, node: toast } = useToast(); @@ -39,7 +41,7 @@ export function CommonCodeAdminPage() { const saveGroupM = useMutation({ mutationFn: (g: CodeGroup) => codeApi.saveGroup(g), onSuccess: (_d, g) => { - show('코드 그룹을 저장했습니다.'); + show(t('code.groupSaved')); setGroupModal(undefined); setSelected(g.grpCode); qc.invalidateQueries({ queryKey: ['admin-code-groups'] }); @@ -49,7 +51,7 @@ export function CommonCodeAdminPage() { const delGroupM = useMutation({ mutationFn: (grpCode: string) => codeApi.removeGroup(grpCode), onSuccess: () => { - show('코드 그룹을 삭제했습니다.'); + show(t('code.groupDeleted')); setSelected(null); qc.invalidateQueries({ queryKey: ['admin-code-groups'] }); }, @@ -58,7 +60,7 @@ export function CommonCodeAdminPage() { const saveCodeM = useMutation({ mutationFn: (c: Code) => codeApi.saveCode(c), onSuccess: () => { - show('코드를 저장했습니다.'); + show(t('code.codeSaved')); setCodeModal(undefined); qc.invalidateQueries({ queryKey: ['admin-codes', selected] }); }, @@ -67,7 +69,7 @@ export function CommonCodeAdminPage() { const delCodeM = useMutation({ mutationFn: ({ grpCode, code }: { grpCode: string; code: string }) => codeApi.removeCode(grpCode, code), onSuccess: () => { - show('코드를 삭제했습니다.'); + show(t('code.codeDeleted')); qc.invalidateQueries({ queryKey: ['admin-codes', selected] }); }, onError: (e) => show(errMessage(e)), @@ -79,26 +81,26 @@ export function CommonCodeAdminPage() { - 공통코드 관리 - 드롭다운·분류에 쓰이는 공통코드 그룹과 코드 값을 관리합니다. + {t('code.title')} + {t('code.subtitle')} - } onClick={() => setGroupModal(null)}>그룹 추가 + } onClick={() => setGroupModal(null)}>{t('code.addGroup')} {groupsQ.isLoading && } {groupsQ.isError && !groupsQ.isLoading && ( - groupsQ.refetch()} /> + groupsQ.refetch()} /> )} {!groupsQ.isLoading && !groupsQ.isError && ( groups.length === 0 ? ( - + ) : ( {/* 좌: 그룹 */} - + - 코드 그룹 + {t('code.codeGroup')} {groups.map((g) => ( @@ -109,7 +111,7 @@ export function CommonCodeAdminPage() { onClick={() => setSelected(g.grpCode)} > {g.grpName} - {g.grpCode}{g.useYn === 'N' ? ' · 미사용' : ''} + {g.grpCode}{g.useYn === 'N' ? ` · ${t('code.unused')}` : ''} ))} @@ -117,28 +119,28 @@ export function CommonCodeAdminPage() { {/* 우: 코드 값 */} - + - {activeGroup?.grpName ?? '코드'} 값 + {t('code.valuesOf', { name: activeGroup?.grpName ?? t('code.code') })} {activeGroup?.description && {activeGroup.description}} {activeGroup && ( <> - setGroupModal(activeGroup)}>그룹 수정 + setGroupModal(activeGroup)}>{t('code.editGroup')} { - if (window.confirm(`그룹 '${activeGroup.grpName}' 과 하위 코드를 삭제하시겠습니까?`)) + if (window.confirm(t('code.deleteGroupConfirm', { name: activeGroup.grpName }))) delGroupM.mutate(activeGroup.grpCode); }} > - 그룹 삭제 + {t('code.deleteGroup')} - } onClick={() => setCodeModal(null)}>코드 추가 + } onClick={() => setCodeModal(null)}>{t('code.addCode')} > )} @@ -147,25 +149,25 @@ export function CommonCodeAdminPage() { {codesQ.isLoading && } {codesQ.isError && !codesQ.isLoading && ( - codesQ.refetch()} /> + codesQ.refetch()} /> )} {!codesQ.isLoading && !codesQ.isError && ( codes.length === 0 ? ( - + ) : ( - 코드 - 이름 - 값 - 정렬 - 사용 - 관리 + {t('code.code')} + {t('code.name')} + {t('code.value')} + {t('code.sort')} + {t('code.use')} + {t('code.manage')} @@ -177,22 +179,22 @@ export function CommonCodeAdminPage() { {c.sortOrder} - {c.useYn === 'N' ? '미사용' : '사용'} + {c.useYn === 'N' ? t('code.unused') : t('code.used')} - setCodeModal(c)}>수정 + setCodeModal(c)}>{t('code.edit')} { - if (window.confirm(`코드 '${c.codeName}' 을(를) 삭제하시겠습니까?`)) + if (window.confirm(t('code.deleteCodeConfirm', { name: c.codeName }))) delCodeM.mutate({ grpCode: c.grpCode, code: c.code }); }} > - 삭제 + {t('code.delete')} @@ -245,6 +247,7 @@ function GroupModal({ onClose: () => void; onSubmit: (g: CodeGroup) => void; }) { + const { t } = useTranslation(); const isNew = group === null; const [grpCode, setGrpCode] = useState(group?.grpCode ?? ''); const [grpName, setGrpName] = useState(group?.grpName ?? ''); @@ -257,11 +260,11 @@ function GroupModal({ e.preventDefault(); setError(null); if (!grpCode.trim() || !grpName.trim()) { - setError('그룹 코드와 이름은 필수입니다.'); + setError(t('code.errGroupRequired')); return; } if (isNew && existingCodes.includes(grpCode.trim())) { - setError('이미 존재하는 그룹 코드입니다.'); + setError(t('code.errDupGroup')); return; } onSubmit({ @@ -277,36 +280,36 @@ function GroupModal({ - {isNew ? '코드 그룹 추가' : '코드 그룹 수정'} + {isNew ? t('code.addGroup') : t('code.editGroup')} - 그룹 코드 * + {t('code.groupCode')} setGrpCode(e.target.value)} /> - 그룹 이름 * + {t('code.groupName')} setGrpName(e.target.value)} /> - 설명 + {t('code.description')} setDescription(e.target.value)} /> - 정렬 + {t('code.sort')} setSortOrder(e.target.value)} /> - 사용 여부 + {t('code.useYn')} setUseYn(e.target.value)}> - 사용 - 미사용 + {t('code.used')} + {t('code.unused')} {error && {error}} - 취소 - {saving ? '저장 중…' : '저장'} + {t('code.cancel')} + {saving ? t('code.saving') : t('code.save')} @@ -328,6 +331,7 @@ function CodeModal({ onClose: () => void; onSubmit: (c: Code) => void; }) { + const { t } = useTranslation(); const isNew = code === null; const [codeVal, setCodeVal] = useState(code?.code ?? ''); const [codeName, setCodeName] = useState(code?.codeName ?? ''); @@ -341,11 +345,11 @@ function CodeModal({ e.preventDefault(); setError(null); if (!codeVal.trim() || !codeName.trim()) { - setError('코드와 이름은 필수입니다.'); + setError(t('code.errCodeRequired')); return; } if (isNew && existingCodes.includes(codeVal.trim())) { - setError('이미 존재하는 코드입니다.'); + setError(t('code.errDupCode')); return; } onSubmit({ @@ -363,41 +367,41 @@ function CodeModal({ - {isNew ? '코드 추가' : '코드 수정'} - 그룹 {grpCode} + {isNew ? t('code.addCode') : t('code.editCode')} + {t('code.group')} {grpCode} - 코드 * + {t('code.codeReq')} setCodeVal(e.target.value)} /> - 이름 * + {t('code.nameReq')} setCodeName(e.target.value)} /> - 값 + {t('code.value')} setCodeValue(e.target.value)} /> - 속성1(attr1) + {t('code.attr1')} setAttr1(e.target.value)} /> - 정렬 + {t('code.sort')} setSortOrder(e.target.value)} /> - 사용 여부 + {t('code.useYn')} setUseYn(e.target.value)}> - 사용 - 미사용 + {t('code.used')} + {t('code.unused')} {error && {error}} - 취소 - {saving ? '저장 중…' : '저장'} + {t('code.cancel')} + {saving ? t('code.saving') : t('code.save')} diff --git a/src/frontend/src/screens/admin/LoginSlideAdminPage.tsx b/src/frontend/src/screens/admin/LoginSlideAdminPage.tsx index b8e87ac..993a1bb 100644 --- a/src/frontend/src/screens/admin/LoginSlideAdminPage.tsx +++ b/src/frontend/src/screens/admin/LoginSlideAdminPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; import { loginSlideApi } from '../../api/endpoints'; import { ApiRequestError } from '../../api/client'; import type { LoginSlideDto } from '../../api/types'; @@ -12,6 +13,7 @@ import './admin.css'; * 공개 노출 규칙: 활성 슬라이드 중 최신 등록순(created_at DESC) 최대 5건. */ export function LoginSlideAdminPage() { + const { t } = useTranslation(); const [slides, setSlides] = useState(null); const [loadError, setLoadError] = useState(null); @@ -32,7 +34,7 @@ export function LoginSlideAdminPage() { setSlides(await loginSlideApi.adminList()); } catch (err) { setSlides([]); - setLoadError(err instanceof ApiRequestError ? err.message : '목록을 불러오지 못했습니다.'); + setLoadError(err instanceof ApiRequestError ? err.message : t('slide.listError')); } } @@ -55,11 +57,11 @@ export function LoginSlideAdminPage() { e.preventDefault(); setFormError(null); if (!file) { - setFormError('슬라이드 이미지를 선택해 주세요.'); + setFormError(t('slide.errImage')); return; } if (!title.trim()) { - setFormError('타이틀 문구를 입력해 주세요.'); + setFormError(t('slide.errTitle')); return; } setSubmitting(true); @@ -71,7 +73,7 @@ export function LoginSlideAdminPage() { setCaption(''); if (fileInputRef.current) fileInputRef.current.value = ''; } catch (err) { - setFormError(err instanceof ApiRequestError ? err.message : '등록에 실패했습니다.'); + setFormError(err instanceof ApiRequestError ? err.message : t('slide.createFail')); } finally { setSubmitting(false); } @@ -88,20 +90,20 @@ export function LoginSlideAdminPage() { }); setSlides((prev) => (prev ?? []).map((s) => (s.id === slide.id ? updated : s))); } catch (err) { - setLoadError(err instanceof ApiRequestError ? err.message : '변경에 실패했습니다.'); + setLoadError(err instanceof ApiRequestError ? err.message : t('slide.updateFail')); } finally { setBusyId(null); } } async function handleDelete(slide: LoginSlideDto) { - if (!window.confirm(`"${slide.title}" 슬라이드를 삭제할까요?`)) return; + if (!window.confirm(t('slide.deleteConfirm', { title: slide.title }))) return; setBusyId(slide.id); try { await loginSlideApi.remove(slide.id); setSlides((prev) => (prev ?? []).filter((s) => s.id !== slide.id)); } catch (err) { - setLoadError(err instanceof ApiRequestError ? err.message : '삭제에 실패했습니다.'); + setLoadError(err instanceof ApiRequestError ? err.message : t('slide.deleteFail')); } finally { setBusyId(null); } @@ -113,24 +115,23 @@ export function LoginSlideAdminPage() { - 로그인 슬라이드 관리 + {t('slide.title')} - 로그인 화면 좌측 캐러셀 이미지·문구 — 활성 슬라이드 중 최신 등록순 5건이 - 로테이션됩니다. + ]} /> {/* 새 슬라이드 추가 */} - + - 새 슬라이드 추가 + {t('slide.addTitle')} - 이미지 (PNG/JPG/WebP, 8MB 이하) + {t('slide.imageLabel')} - {!preview && 미리보기} + {!preview && {t('slide.preview')}} - 타이틀 문구 + {t('slide.titleLabel')} setTitle(e.target.value)} required /> - 캡션(보조 문구) + {t('slide.captionLabel')} setCaption(e.target.value)} /> @@ -177,7 +178,7 @@ export function LoginSlideAdminPage() { )} - {submitting ? '등록 중…' : '슬라이드 등록'} + {submitting ? t('slide.submitting') : t('slide.submit')} @@ -186,13 +187,13 @@ export function LoginSlideAdminPage() { {/* 목록 */} - + - 등록된 슬라이드{' '} + {t('slide.registered')}{' '} {slides && ( - 총 {slides.length}건 · 활성 {activeCount}건 + {t('slide.countActive', { total: slides.length, active: activeCount })} )} @@ -208,8 +209,8 @@ export function LoginSlideAdminPage() { ) : slides.length === 0 && !loadError ? ( ) : ( @@ -223,11 +224,11 @@ export function LoginSlideAdminPage() { {s.title} - {s.active && i < 5 && 노출 중} - {!s.active && 비활성} + {s.active && i < 5 && {t('slide.live')}} + {!s.active && {t('slide.inactive')}} {s.caption && {s.caption}} - 등록 {s.createdAt} + {t('slide.registeredAt', { date: s.createdAt })} @@ -237,7 +238,7 @@ export function LoginSlideAdminPage() { disabled={busyId === s.id} onChange={() => void handleToggleActive(s)} /> - 활성 + {t('slide.active')} void handleDelete(s)} > - 삭제 + {t('slide.delete')} diff --git a/src/frontend/src/screens/admin/MenuAdminPage.tsx b/src/frontend/src/screens/admin/MenuAdminPage.tsx index abb7ac5..36815ad 100644 --- a/src/frontend/src/screens/admin/MenuAdminPage.tsx +++ b/src/frontend/src/screens/admin/MenuAdminPage.tsx @@ -5,6 +5,7 @@ */ import { useMemo, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { Button } from '../../components/ui/Button'; import { IconPlus } from '../../components/ui/icons'; @@ -48,6 +49,7 @@ function toDepthList(nodes: MenuNode[]): FlatMenu[] { } export function MenuAdminPage() { + const { t } = useTranslation(); const qc = useQueryClient(); const { show, node: toast } = useToast(); @@ -59,7 +61,7 @@ export function MenuAdminPage() { const saveM = useMutation({ mutationFn: (body: MenuSaveBody) => menuApi.save(body), onSuccess: () => { - show('메뉴를 저장했습니다.'); + show(t('menu.saved')); setEditing(undefined); qc.invalidateQueries({ queryKey: ['admin-menus'] }); }, @@ -68,7 +70,7 @@ export function MenuAdminPage() { const delM = useMutation({ mutationFn: (id: string) => menuApi.remove(id), onSuccess: () => { - show('메뉴를 삭제했습니다.'); + show(t('menu.deleted')); qc.invalidateQueries({ queryKey: ['admin-menus'] }); }, onError: (e) => show(errMessage(e)), @@ -78,33 +80,33 @@ export function MenuAdminPage() { - 메뉴 관리 - 역할별 내비게이션 메뉴 트리와 경로·접근 역할을 관리합니다. + {t('menu.title')} + {t('menu.subtitle')} - } onClick={() => setEditing(null)}>메뉴 추가 + } onClick={() => setEditing(null)}>{t('menu.addMenu')} {q.isLoading && } {q.isError && !q.isLoading && ( - q.refetch()} /> + q.refetch()} /> )} {!q.isLoading && !q.isError && ( - + {flat.length === 0 ? ( - + ) : ( - 메뉴명 - 경로 - 유형 - 접근 역할 - 정렬 - 사용 - 관리 + {t('menu.colName')} + {t('menu.colPath')} + {t('menu.colType')} + {t('menu.colRole')} + {t('menu.colSort')} + {t('menu.colUse')} + {t('menu.colManage')} @@ -122,26 +124,26 @@ export function MenuAdminPage() { {m.menuPath ?? '—'} {m.prgType ?? '—'} - {m.requiredRole ?? '전체'} + {m.requiredRole ?? t('menu.all')} {m.sortOrder} - {m.useYn === 'N' ? '미사용' : '사용'} + {m.useYn === 'N' ? t('menu.unused') : t('menu.used')} - setEditing(m)}>수정 + setEditing(m)}>{t('menu.edit')} { - if (window.confirm(`메뉴 '${m.menuName}' 을(를) 삭제하시겠습니까? 하위 메뉴가 있으면 함께 정리됩니다.`)) + if (window.confirm(t('menu.deleteConfirm', { name: m.menuName }))) delM.mutate(m.id); }} > - 삭제 + {t('menu.delete')} @@ -181,6 +183,7 @@ function MenuModal({ onClose: () => void; onSubmit: (body: MenuSaveBody) => void; }) { + const { t } = useTranslation(); const isNew = menu === null; const [id, setId] = useState(menu?.id ?? ''); const [menuName, setMenuName] = useState(menu?.menuName ?? ''); @@ -199,15 +202,15 @@ function MenuModal({ e.preventDefault(); setError(null); if (!menuName.trim()) { - setError('메뉴명은 필수입니다.'); + setError(t('menu.errName')); return; } if (isNew && !id.trim()) { - setError('메뉴 ID(고유 키)는 필수입니다.'); + setError(t('menu.errId')); return; } if (isNew && options.some((o) => o.id === id.trim())) { - setError('이미 존재하는 메뉴 ID 입니다.'); + setError(t('menu.errDupId')); return; } onSubmit({ @@ -226,59 +229,59 @@ function MenuModal({ - {isNew ? '메뉴 추가' : '메뉴 수정'} + {isNew ? t('menu.addMenu') : t('menu.editMenu')} {isNew && ( - 메뉴 ID (고유 키) * - setId(e.target.value)} /> + {t('menu.menuId')} + setId(e.target.value)} /> )} - 메뉴명 * + {t('menu.menuName')} setMenuName(e.target.value)} /> - 상위 메뉴 + {t('menu.parent')} setParentId(e.target.value)}> - (최상위) + {t('menu.topLevel')} {parentOptions.map((o) => ( {'—'.repeat(o.depth)} {o.menuName} ))} - 경로(path) - setMenuPath(e.target.value)} /> + {t('menu.path')} + setMenuPath(e.target.value)} /> - 유형 + {t('menu.type')} setPrgType(e.target.value)}> - {MENU_PRG_TYPES.map((t) => ( - {t} + {MENU_PRG_TYPES.map((pt) => ( + {pt} ))} - 접근 역할 - setRequiredRole(e.target.value)} /> + {t('menu.accessRole')} + setRequiredRole(e.target.value)} /> - 정렬 + {t('menu.sort')} setSortOrder(e.target.value)} /> - 사용 여부 + {t('menu.useYn')} setUseYn(e.target.value)}> - 사용 - 미사용 + {t('menu.used')} + {t('menu.unused')} {error && {error}} - 취소 - {saving ? '저장 중…' : '저장'} + {t('menu.cancel')} + {saving ? t('menu.saving') : t('menu.save')} diff --git a/src/frontend/src/screens/admin/RoleAdminPage.tsx b/src/frontend/src/screens/admin/RoleAdminPage.tsx index 6dc0a78..441e85b 100644 --- a/src/frontend/src/screens/admin/RoleAdminPage.tsx +++ b/src/frontend/src/screens/admin/RoleAdminPage.tsx @@ -6,6 +6,7 @@ */ import { useEffect, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { Button } from '../../components/ui/Button'; import { IconPlus } from '../../components/ui/icons'; @@ -14,6 +15,7 @@ import { roleApi, type Role } from './adminCrudApi'; import './admin-modules.css'; export function RoleAdminPage() { + const { t } = useTranslation(); const qc = useQueryClient(); const { show, node: toast } = useToast(); @@ -35,7 +37,7 @@ export function RoleAdminPage() { const saveM = useMutation({ mutationFn: (role: Role) => roleApi.save(role), onSuccess: () => { - show('역할을 저장했습니다.'); + show(t('role.saved')); setEditing(undefined); qc.invalidateQueries({ queryKey: ['admin-roles'] }); }, @@ -44,7 +46,7 @@ export function RoleAdminPage() { const delM = useMutation({ mutationFn: (roleCode: string) => roleApi.remove(roleCode), onSuccess: () => { - show('역할을 삭제했습니다.'); + show(t('role.deleted')); setSelected(null); qc.invalidateQueries({ queryKey: ['admin-roles'] }); }, @@ -66,18 +68,18 @@ export function RoleAdminPage() { - 역할 · 권한 (RBAC) + {t('role.title')} - 역할(주최자·참가업체·장치업체·홀매니저·관리자·관람객)별 리소스 권한을 매트릭스로 배정합니다. + {t('role.subtitle')} - } onClick={() => setEditing(null)}>역할 추가 + } onClick={() => setEditing(null)}>{t('role.addRole')} {loading && } {errored && !loading && ( { rolesQ.refetch(); permsQ.refetch(); @@ -87,13 +89,13 @@ export function RoleAdminPage() { {!loading && !errored && ( roles.length === 0 ? ( - + ) : ( {/* 좌: 역할 목록 */} - + - 역할 + {t('role.role')} {roles.map((r) => ( @@ -105,8 +107,8 @@ export function RoleAdminPage() { > {r.roleName} - {r.roleCode} · 권한 {r.permissions.length} - {r.useYn === 'N' ? ' · 미사용' : ''} + {t('role.codePerm', { code: r.roleCode, n: r.permissions.length })} + {r.useYn === 'N' ? ` · ${t('role.unused')}` : ''} @@ -115,32 +117,32 @@ export function RoleAdminPage() { {/* 우: 권한 매트릭스 */} - + {activeRole ? ( <> - {activeRole.roleName} 권한 + {t('role.rolePerm', { name: activeRole.roleName })} {activeRole.description ?? activeRole.roleCode} - setEditing(activeRole)}>역할 정보 + setEditing(activeRole)}>{t('role.roleInfo')} { - if (window.confirm(`역할 '${activeRole.roleName}' 을(를) 삭제하시겠습니까?`)) + if (window.confirm(t('role.deleteConfirm', { name: activeRole.roleName }))) delM.mutate(activeRole.roleCode); }} > - 삭제 + {t('role.delete')} {perms.length === 0 ? ( - + ) : ( {perms.map((p) => { @@ -167,7 +169,7 @@ export function RoleAdminPage() { )} > ) : ( - + )} @@ -201,6 +203,7 @@ function RoleModal({ onClose: () => void; onSubmit: (role: Role) => void; }) { + const { t } = useTranslation(); const isNew = role === null; const [roleCode, setRoleCode] = useState(role?.roleCode ?? ''); const [roleName, setRoleName] = useState(role?.roleName ?? ''); @@ -212,11 +215,11 @@ function RoleModal({ e.preventDefault(); setError(null); if (!roleCode.trim() || !roleName.trim()) { - setError('역할 코드와 이름은 필수입니다.'); + setError(t('role.errRequired')); return; } if (isNew && existingCodes.includes(roleCode.trim().toUpperCase())) { - setError('이미 존재하는 역할 코드입니다.'); + setError(t('role.errDupCode')); return; } onSubmit({ @@ -232,39 +235,39 @@ function RoleModal({ - {isNew ? '역할 추가' : '역할 정보 수정'} - 권한 배정은 저장 후 매트릭스에서 체크로 관리합니다. + {isNew ? t('role.addRole') : t('role.editRole')} + {t('role.modalDesc')} - 역할 코드 * + {t('role.roleCode')} setRoleCode(e.target.value)} /> - 역할 이름 * + {t('role.roleName')} setRoleName(e.target.value)} /> - 설명 + {t('role.description')} setDescription(e.target.value)} /> - 사용 여부 + {t('role.useYn')} setUseYn(e.target.value)}> - 사용 - 미사용 + {t('role.use')} + {t('role.noUse')} {error && {error}} - 취소 - {saving ? '저장 중…' : '저장'} + {t('role.cancel')} + {saving ? t('role.saving') : t('role.save')} diff --git a/src/frontend/src/screens/admin/RulesetVersionsPage.tsx b/src/frontend/src/screens/admin/RulesetVersionsPage.tsx index a4796c5..a7431e5 100644 --- a/src/frontend/src/screens/admin/RulesetVersionsPage.tsx +++ b/src/frontend/src/screens/admin/RulesetVersionsPage.tsx @@ -5,11 +5,14 @@ */ import { useEffect, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import type { TFunction } from 'i18next'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { rulesetApi, type RulesetRule } from './adminModulesApi'; import './admin-modules.css'; export function RulesetVersionsPage() { + const { t } = useTranslation(); const listQ = useQuery({ queryKey: ['admin-rulesets'], queryFn: () => rulesetApi.list(), @@ -42,32 +45,31 @@ export function RulesetVersionsPage() { - 규정 룰셋 버전 관리 - 규정 개정 대응을 위한 룰셋 버전 이력과 규칙 항목입니다. + {t('ruleset.title')} + {t('ruleset.subtitle')} - 읽기 전용 - 룰셋은 배포 리소스(rulesets/*.json)로 버전 관리됩니다. 편집·버전 생성은 규정 개정 시 리소스 교체로 - 수행되며 화면에서는 조회만 가능합니다. + {t('ruleset.readonly')} + {t('ruleset.readonlyNote')} {listQ.isLoading && } {listQ.isError && ( - listQ.refetch()} /> + listQ.refetch()} /> )} {!listQ.isLoading && !listQ.isError && versions.length === 0 && ( - + )} {versions.length > 0 && ( {/* 좌: 버전 히스토리 */} -
실시간 지표 및 운영 상태
{t('admin.overviewSub')}
- {authed - ? '이 페이지는 시스템 관리자(홀매니저·ADMIN)만 이용할 수 있습니다. 접근이 필요하면 관리자에게 문의하세요.' - : '관리자 백오피스는 로그인 후 이용할 수 있습니다.'} + {authed ? t('admin.deniedAuthed') : t('admin.deniedGuest')}
403 · FORBIDDEN
- 승인·낙찰·설정변경 등 모든 관리 액션과 중요 도메인 이벤트를 추적합니다. (무결성 유지) + {t('audit.subtitle')}
- 표시 중 {from}-{to} / 총{' '} - {total.toLocaleString()}개 + , ]} />
드롭다운·분류에 쓰이는 공통코드 그룹과 코드 값을 관리합니다.
{t('code.subtitle')}
{activeGroup.description}
{error}
그룹 {grpCode}
{t('code.group')} {grpCode}
- 로그인 화면 좌측 캐러셀 이미지·문구 — 활성 슬라이드 중 최신 등록순 5건이 - 로테이션됩니다. + ]} />
{s.caption}
등록 {s.createdAt}
{t('slide.registeredAt', { date: s.createdAt })}
역할별 내비게이션 메뉴 트리와 경로·접근 역할을 관리합니다.
{t('menu.subtitle')}
- 역할(주최자·참가업체·장치업체·홀매니저·관리자·관람객)별 리소스 권한을 매트릭스로 배정합니다. + {t('role.subtitle')}
{activeRole.description ?? activeRole.roleCode}
권한 배정은 저장 후 매트릭스에서 체크로 관리합니다.
{t('role.modalDesc')}
규정 개정 대응을 위한 룰셋 버전 이력과 규칙 항목입니다.
{t('ruleset.subtitle')}