feat(shell,tenant,i18n): wave 7 — MDI tab shell, multitenancy phase 2 (JWT tid + fail-closed guard), i18n sweep 2

- MDI: additive tab layer over existing routing (MdiTabBar + mdiStore, sessionStorage,
  dedup, max 10, keyboard a11y, dark-theme tokens); route tree untouched
- tenant p2: JWT tid claim with kintex fallback (backward compatible), EventAccessGuard
  cross-tenant 403 (EventTenantMapper + isolation tests), explicit tenant scoping on
  analytics/admin/sysuser aggregates; login threads app_user.tenant_id into tokens
- i18n sweep 2: 17 core+admin screens extracted to 30 namespaces x 4 locales (ko/en/zh/ja),
  zero ko-render regression; P2/P3 sweep list documented
- verified: tsc -b --force, vite build, compileJava, test (75/75) all EXIT 0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zio 2026-07-12 11:51:00 +09:00
parent 14efc37ce6
commit 9d47f9373f
43 changed files with 4522 additions and 819 deletions

View File

@ -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 `<script>` XML 위반(AuctionMapper `deadline < now()`·VisitorMapper `<=`)으로 부팅 크래시 루프→롤백. 매퍼 교정 + `MapperAnnotationParseTest` 회귀 가드 + `checkin.css` 누락 작성(서버 프론트 빌드 실패 원인) ②M16 BI 전역: `GET /api/analytics/overview`(행사 간 매출·홀 가동률 추이·리텐션·선형회귀 예측, 홀매니저 전용, 마이그레이션 無) + /analytics 인라인 섹션 ③M18 관리자: 사용자·역할/권한·공통코드·메뉴 CRUD 화면 4종(백엔드 system 라이브 소비, 신규 백엔드 0)+`AdminGuard`(전 /admin/* 라우트 가드 배선)+랜딩 모듈 바로가기. A8 룰셋은 기왕 실배선 확인 ④SCR-15 일정: V23(category·est_visitors 백필 1,098건)+홀 가동률/캘린더 API 2종+실 전환 ⑤QA minor: ✓글리프 SVG화(kx-field·★는 기왕 해소). 검증: compileJava·test(매퍼 파싱 포함)·tsc -b --force·vite build 전부 EXIT 0. ⚠AdminGuard 판정 한계: 전역 role_code 미노출로 hallManager 기준 — 정확 판정은 AuthUser.roleCode 노출 필요(후속) |

View File

@ -1,6 +1,7 @@
package com.zioinfo.kintex.admin;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@ -9,25 +10,32 @@ import java.util.Map;
/**
* 관리자 대시보드 집계 매퍼 event·hall_assignment·app_user·company read-only.
* 진행 여부는 오늘 날짜(start오늘end) 기준. 마이그레이션 불변.
*
* <p>멀티테넌시 2단계: 전역 KPI·라이브 행사는 tenant_id(event/app_user) 선행 필터로 격리(§1A-2 · §8-2).
* {@code company} tenant_id 컬럼이 없어(1단계 범위 유지·신규 컬럼 금지) 킨텍스 등록업체 공용 레지스트리로
* 스코프 제외 등록업체 수는 테넌트 무관 집계(설계 노트 기록).
*/
@Mapper
public interface AdminDashboardMapper {
/** KPI 원자 집계 — 진행 행사·사용 홀·활성 사용자·등록업체 수. */
/** KPI 원자 집계 — 진행 행사·사용 홀·활성 사용자(테넌트 스코프)·등록업체 수(공용). */
@Select("""
SELECT
(SELECT count(*) FROM event
WHERE start_date <= CURRENT_DATE AND end_date >= CURRENT_DATE) AS "ongoingEvents",
WHERE tenant_id = #{tenantId}
AND start_date <= CURRENT_DATE AND end_date >= CURRENT_DATE) AS "ongoingEvents",
(SELECT count(DISTINCT ha.hall_id) FROM hall_assignment ha JOIN event e ON e.id = ha.event_id
WHERE e.start_date <= CURRENT_DATE AND e.end_date >= CURRENT_DATE) AS "hallsInUse",
(SELECT count(*) FROM app_user WHERE status = 'ACTIVE') AS "activeUsers",
WHERE e.tenant_id = #{tenantId}
AND e.start_date <= CURRENT_DATE AND e.end_date >= CURRENT_DATE) AS "hallsInUse",
(SELECT count(*) FROM app_user
WHERE tenant_id = #{tenantId} AND status = 'ACTIVE') AS "activeUsers",
(SELECT count(*) FROM company) AS companies,
(SELECT count(*) FROM event
WHERE start_date > CURRENT_DATE) AS "upcomingEvents"
WHERE tenant_id = #{tenantId} AND start_date > CURRENT_DATE) AS "upcomingEvents"
""")
Map<String, Object> findKpiCounts();
Map<String, Object> findKpiCounts(@Param("tenantId") String tenantId);
/** 진행 중(라이브) 행사 — 대표 홀·부스 수·홀 수용량. 최대 20건. */
/** 진행 중(라이브) 행사 — 대표 홀·부스 수·홀 수용량. 테넌트 스코프. 최대 20건. */
@Select("""
SELECT e.id AS "eventId",
e.name,
@ -43,9 +51,10 @@ public interface AdminDashboardMapper {
WHERE ha.event_id = e.id ORDER BY is_primary DESC LIMIT 1
) pa ON true
LEFT JOIN hall h ON h.id = pa.hall_id
WHERE e.start_date <= CURRENT_DATE AND e.end_date >= CURRENT_DATE
WHERE e.tenant_id = #{tenantId}
AND e.start_date <= CURRENT_DATE AND e.end_date >= CURRENT_DATE
ORDER BY e.start_date DESC
LIMIT 20
""")
List<Map<String, Object>> findLiveEvents();
List<Map<String, Object>> findLiveEvents(@Param("tenantId") String tenantId);
}

View File

@ -1,6 +1,7 @@
package com.zioinfo.kintex.admin;
import com.zioinfo.kintex.admin.dto.AdminDashboardDto;
import com.zioinfo.kintex.tenant.TenantContext;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
@ -21,7 +22,9 @@ public class AdminDashboardService {
}
public AdminDashboardDto getDashboard(String tenant) {
Map<String, Object> c = mapper.findKpiCounts();
// 집계는 현재 해소된 테넌트로 격리(§8-2). 기준 테넌트('kintex') 단일 운영 회귀 0.
String tenantId = TenantContext.currentTenantId();
Map<String, Object> c = mapper.findKpiCounts(tenantId);
long ongoing = lng(c == null ? null : c.get("ongoingEvents"));
long halls = lng(c == null ? null : c.get("hallsInUse"));
long users = lng(c == null ? null : c.get("activeUsers"));
@ -35,7 +38,7 @@ public class AdminDashboardService {
kpis.add(new AdminDashboardDto.Kpi("등록업체", String.valueOf(companies), null));
List<AdminDashboardDto.LiveEvent> live = new ArrayList<>();
List<Map<String, Object>> rows = mapper.findLiveEvents();
List<Map<String, Object>> rows = mapper.findLiveEvents(tenantId);
if (rows != null) {
for (Map<String, Object> r : rows) {
long booths = lng(r.get("boothCount"));

View File

@ -54,31 +54,35 @@ public interface AnalyticsMapper {
Double findUtilityRevenue(@Param("eventId") String eventId);
// 테넌트 전역(행사 횡단) BI 집계 M16 잔여
// 멀티테넌시 2단계: 행사 횡단 집계는 tenant_id(event/hall) 선행 필터로 격리(§1A-2 · §8-2).
// booth/layout/utility_order는 tenant_id 컬럼이 없으므로 event 조인으로 간접 스코프(신규 컬럼 금지).
/** 전체 전시홀 수(가동률 분모 산정용). */
@Select("SELECT count(*) FROM hall")
long countHalls();
/** 전체 전시홀 수(가동률 분모 산정용) — 테넌트 스코프. */
@Select("SELECT count(*) FROM hall WHERE tenant_id = #{tenantId}")
long countHalls(@Param("tenantId") String tenantId);
/** 전체 개최 행사 수(기간 무관, KPI용). */
@Select("SELECT count(*) FROM event")
long countEvents();
/** 전체 개최 행사 수(기간 무관, KPI용) — 테넌트 스코프. */
@Select("SELECT count(*) FROM event WHERE tenant_id = #{tenantId}")
long countEvents(@Param("tenantId") String tenantId);
/**
* 월별 개최 행사-일수(booked hall-days 근사) 최근 N개월.
* 월별 개최 행사-일수(booked hall-days 근사) 최근 N개월. 테넌트 스코프.
* 행사 기간(end-start+1, 최소 1일) 시작월에 합산. 가동률은 서비스가 홀수·그달 일수로 환산.
*/
@Select("""
SELECT to_char(e.start_date, 'YYYY-MM') AS "period",
COALESCE(sum(GREATEST((e.end_date - e.start_date) + 1, 1)), 0) AS "bookedDays"
FROM event e
WHERE e.start_date IS NOT NULL
WHERE e.tenant_id = #{tenantId}
AND e.start_date IS NOT NULL
AND e.start_date >= (date_trunc('month', now()) - make_interval(months => #{months}))
GROUP BY to_char(e.start_date, 'YYYY-MM')
ORDER BY 1
""")
List<Map<String, Object>> findMonthlyEventDays(@Param("months") int months);
List<Map<String, Object>> findMonthlyEventDays(@Param("tenantId") String tenantId,
@Param("months") int months);
/** 부스가 배치된 행사의 판매면적·부스 수 랭킹(매출 상위 N). 임대 매출은 서비스가 요율로 환산. */
/** 부스가 배치된 행사의 판매면적·부스 수 랭킹(매출 상위 N) — 테넌트 스코프(event 조인). */
@Select("""
SELECT l.event_id AS "eventId",
e.name AS "eventName",
@ -87,26 +91,30 @@ public interface AnalyticsMapper {
FROM booth b
JOIN layout l ON l.id = b.layout_id
JOIN event e ON e.id = l.event_id
WHERE e.tenant_id = #{tenantId}
GROUP BY l.event_id, e.name
HAVING count(b.id) > 0
ORDER BY sum(ST_Area(b.geom)) DESC
LIMIT #{limit}
""")
List<Map<String, Object>> findEventAreaRanking(@Param("limit") int limit);
List<Map<String, Object>> findEventAreaRanking(@Param("tenantId") String tenantId,
@Param("limit") int limit);
/** 행사별 유틸리티 매출 합(매출 비교 조립용, N+1 회피). */
/** 행사별 유틸리티 매출 합(매출 비교 조립용, N+1 회피) — 테넌트 스코프(event 조인). */
@Select("""
SELECT event_id AS "eventId",
COALESCE(sum((quote->>'total')::numeric), 0) AS "utility"
FROM utility_order
WHERE quote->>'total' ~ '^[0-9]+(\\.[0-9]+)?$'
GROUP BY event_id
SELECT uo.event_id AS "eventId",
COALESCE(sum((uo.quote->>'total')::numeric), 0) AS "utility"
FROM utility_order uo
JOIN event e ON e.id = uo.event_id
WHERE e.tenant_id = #{tenantId}
AND uo.quote->>'total' ~ '^[0-9]+(\\.[0-9]+)?$'
GROUP BY uo.event_id
""")
List<Map<String, Object>> findUtilityRevenueByEvent();
List<Map<String, Object>> findUtilityRevenueByEvent(@Param("tenantId") String tenantId);
/**
* 참가사 리텐션 요약 부스 배정사(booth.assigned_company_name) 기준 고유 참가사 수와
* 2개 이상 행사에 참가한 재참가사 .
* 2개 이상 행사에 참가한 재참가사 . 테넌트 스코프(event 조인).
*/
@Select("""
SELECT COUNT(*) AS "totalCompanies",
@ -116,13 +124,15 @@ public interface AnalyticsMapper {
count(DISTINCT l.event_id) AS ec
FROM booth b
JOIN layout l ON l.id = b.layout_id
WHERE b.assigned_company_name IS NOT NULL AND btrim(b.assigned_company_name) <> ''
JOIN event e ON e.id = l.event_id
WHERE e.tenant_id = #{tenantId}
AND b.assigned_company_name IS NOT NULL AND btrim(b.assigned_company_name) <> ''
GROUP BY lower(btrim(b.assigned_company_name))
) t
""")
Map<String, Object> findRetentionSummary();
Map<String, Object> findRetentionSummary(@Param("tenantId") String tenantId);
/** 연도별 리텐션 — 해당 연도 참가사 중 이전 연도에도 참가한 참가사 수. */
/** 연도별 리텐션 — 해당 연도 참가사 중 이전 연도에도 참가한 참가사 수. 테넌트 스코프. */
@Select("""
WITH ce AS (
SELECT DISTINCT lower(btrim(b.assigned_company_name)) AS company,
@ -130,7 +140,8 @@ public interface AnalyticsMapper {
FROM booth b
JOIN layout l ON l.id = b.layout_id
JOIN event e ON e.id = l.event_id
WHERE b.assigned_company_name IS NOT NULL AND btrim(b.assigned_company_name) <> ''
WHERE e.tenant_id = #{tenantId}
AND b.assigned_company_name IS NOT NULL AND btrim(b.assigned_company_name) <> ''
AND e.start_date IS NOT NULL
)
SELECT c.yr::text AS "period",
@ -142,16 +153,17 @@ public interface AnalyticsMapper {
GROUP BY c.yr
ORDER BY c.yr
""")
List<Map<String, Object>> findRetentionByYear();
List<Map<String, Object>> findRetentionByYear(@Param("tenantId") String tenantId);
/** 연도별 개최 행사 수(수요예측 추세 입력). */
/** 연도별 개최 행사 수(수요예측 추세 입력) — 테넌트 스코프. */
@Select("""
SELECT EXTRACT(YEAR FROM start_date)::int AS "yr",
count(*) AS "eventCount"
FROM event
WHERE start_date IS NOT NULL
WHERE tenant_id = #{tenantId}
AND start_date IS NOT NULL
GROUP BY EXTRACT(YEAR FROM start_date)
ORDER BY 1
""")
List<Map<String, Object>> findYearlyEventCounts();
List<Map<String, Object>> findYearlyEventCounts(@Param("tenantId") String tenantId);
}

View File

@ -2,6 +2,7 @@ package com.zioinfo.kintex.analytics;
import com.zioinfo.kintex.analytics.dto.AnalyticsDto;
import com.zioinfo.kintex.analytics.dto.AnalyticsOverviewDto;
import com.zioinfo.kintex.tenant.TenantContext;
import org.springframework.stereotype.Service;
import java.time.YearMonth;
@ -77,21 +78,24 @@ public class AnalyticsService {
int y = Math.max(1, Math.min(20, years));
int months = y * 12;
// 행사 횡단 집계는 현재 테넌트로 격리(§1A-2 · §8-2) 기준 테넌트('kintex') 단일 운영 회귀 0.
String tenantId = TenantContext.currentTenantId();
Double rate = mapper.findRentalRatePerM2();
double ratePerM2 = rate == null ? 0.0 : rate;
long hallCount = mapper.countHalls();
long totalEvents = mapper.countEvents();
long hallCount = mapper.countHalls(tenantId);
long totalEvents = mapper.countEvents(tenantId);
// 행사 매출 비교(상위 8) 임대(면적×요율)+유틸리티.
Map<String, Long> utilByEvent = new HashMap<>();
List<Map<String, Object>> utilRows = mapper.findUtilityRevenueByEvent();
List<Map<String, Object>> utilRows = mapper.findUtilityRevenueByEvent(tenantId);
if (utilRows != null) {
for (Map<String, Object> r : utilRows) {
utilByEvent.put(str(r.get("eventId")), Math.round(dbl(r.get("utility"))));
}
}
List<AnalyticsOverviewDto.EventRevenue> eventComparison = new ArrayList<>();
List<Map<String, Object>> ranking = mapper.findEventAreaRanking(8);
List<Map<String, Object>> ranking = mapper.findEventAreaRanking(tenantId, 8);
if (ranking != null) {
for (Map<String, Object> r : ranking) {
String eventId = str(r.get("eventId"));
@ -108,7 +112,7 @@ public class AnalyticsService {
// 가동률 추이(월별) bookedDays / (홀수 × 그달 일수) × 100.
List<AnalyticsOverviewDto.OccupancyPoint> occupancy = new ArrayList<>();
List<Map<String, Object>> occRows = mapper.findMonthlyEventDays(months);
List<Map<String, Object>> occRows = mapper.findMonthlyEventDays(tenantId, months);
if (occRows != null) {
for (Map<String, Object> r : occRows) {
String period = str(r.get("period"));
@ -121,13 +125,13 @@ public class AnalyticsService {
}
// 참가사 리텐션(재참가).
Map<String, Object> retSum = mapper.findRetentionSummary();
Map<String, Object> retSum = mapper.findRetentionSummary(tenantId);
long totalCompanies = lng(retSum == null ? null : retSum.get("totalCompanies"));
long returningCompanies = lng(retSum == null ? null : retSum.get("returningCompanies"));
double retentionPct = totalCompanies > 0
? round1((returningCompanies * 100.0) / totalCompanies) : 0.0;
List<AnalyticsOverviewDto.RetentionYear> retByYear = new ArrayList<>();
List<Map<String, Object>> retRows = mapper.findRetentionByYear();
List<Map<String, Object>> retRows = mapper.findRetentionByYear(tenantId);
if (retRows != null) {
for (Map<String, Object> r : retRows) {
long companies = lng(r.get("companies"));
@ -141,7 +145,7 @@ public class AnalyticsService {
totalCompanies, returningCompanies, retentionPct, retByYear);
// 수요예측(단순 추세) 연도별 개최 행사 실측 + 최소자승 2년 투영.
AnalyticsOverviewDto.Forecast forecast = buildForecast(mapper.findYearlyEventCounts(), y);
AnalyticsOverviewDto.Forecast forecast = buildForecast(mapper.findYearlyEventCounts(tenantId), y);
// 요약 KPI.
List<AnalyticsOverviewDto.Kpi> kpis = new ArrayList<>();

View File

@ -66,12 +66,12 @@ public class AuthServiceImpl implements AuthService {
// 멤버십 반영 갱신된 역할로 토큰 재발급(현재 역할 기준 재조립).
// 초대코드행사/부스/역할 매핑(invite 테이블) db-engineer 인수 대기 매핑 매퍼 제공 역할 upsert 배선.
return buildLoginResponse(principal.userId(), principal.displayName(), principal.hallManager(),
principal.roleCode());
principal.roleCode(), principal.tenantId());
}
/** 사용자 역할을 조회해 토큰 + 워크스페이스 카드를 조립한다. globalRoleCode=app_user.role_code(null 허용). */
private LoginResponse buildLoginResponse(String userId, String displayName, boolean hallManager,
String globalRoleCode) {
String globalRoleCode, String tenantId) {
List<Map<String, Object>> roleRows = userMapper.findEventRoles(userId);
Map<String, EventRole> eventRoles = new LinkedHashMap<>();
if (roleRows != null) {
@ -83,7 +83,7 @@ public class AuthServiceImpl implements AuthService {
}
}
}
String token = jwtService.issue(userId, displayName, eventRoles, hallManager, globalRoleCode);
String token = jwtService.issue(userId, displayName, eventRoles, hallManager, globalRoleCode, tenantId);
List<WorkspaceDto> workspaces = toWorkspaces(roleRows);
return new LoginResponse(token, jwtService.ttlSeconds(),
new LoginResponse.UserSummary(userId, displayName, hallManager, globalRoleCode), workspaces);

View File

@ -2,6 +2,7 @@ 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.springframework.stereotype.Component;
import java.util.Set;
@ -9,10 +10,21 @@ import java.util.Set;
/**
* 행사 단위 RBAC 접근 가드. 컨트롤러가 도메인 액션 전에 호출한다.
* 홀매니저(킨텍스 내부) 행사 열람+승인이 허용된다.
*
* <p>멀티테넌시 2단계(§1A-3-2): 행사 접근은 <b>테넌트 소속 일치</b> 추가로 강제한다(fail-closed).
* 대상 행사가 요청자({@code principal.tenantId()}) 다른 테넌트에 속하면 거부한다 홀매니저처럼
* 행사 접근권이 넓은 주체의 교차-테넌트 열람을 차단하는 최종 방어선. 대상 행사의 테넌트가
* 미확인(행사 미존재)이거나 요청자와 동일하면 기존 동작을 그대로 유지한다(기준 테넌트 회귀 0).
*/
@Component
public class EventAccessGuard {
private final EventTenantMapper eventTenantMapper;
public EventAccessGuard(EventTenantMapper eventTenantMapper) {
this.eventTenantMapper = eventTenantMapper;
}
/** 인증 필수 — principal 없으면 401. */
public KintexPrincipal require(KintexPrincipal principal) {
if (principal == null) {
@ -21,15 +33,30 @@ public class EventAccessGuard {
return principal;
}
/** 해당 행사 접근 권한 확인(멤버 또는 홀매니저). 없으면 403. */
/** 해당 행사 접근 권한 확인(멤버 또는 홀매니저) + 테넌트 소속 일치. 없으면 403. */
public KintexPrincipal requireEventAccess(KintexPrincipal principal, String eventId) {
require(principal);
if (!principal.hasAccess(eventId)) {
throw new ApiException(ErrorCode.FORBIDDEN);
}
assertTenantOwnsEvent(principal, eventId);
return principal;
}
/**
* 대상 행사가 요청자의 테넌트에 속하는지 검증(fail-closed, §1A-3-2).
* 행사 미존재(null) 또는 동일 테넌트면 통과 기준 테넌트('kintex') 단일 운영 회귀 0.
*/
private void assertTenantOwnsEvent(KintexPrincipal principal, String eventId) {
if (eventId == null) {
return;
}
String eventTenant = eventTenantMapper.findEventTenant(eventId);
if (eventTenant != null && !eventTenant.equals(principal.tenantId())) {
throw new ApiException(ErrorCode.FORBIDDEN);
}
}
/** 해당 행사에서 허용된 역할 중 하나여야 함. 없으면 403. */
public KintexPrincipal requireRole(KintexPrincipal principal, String eventId, EventRole... allowed) {
requireEventAccess(principal, eventId);

View File

@ -31,9 +31,16 @@ public class JwtService {
this.ttlSeconds = props.getAccessTokenTtlSeconds();
}
/** 액세스 토큰 발급. eventRoles: 행사ID→역할. roleCode: 전역 역할(app_user.role_code, null 허용). */
/** 기준 테넌트(#1 KINTEX) — TenantContext.DEFAULT_TENANT와 일치. tid 미지정·공백 시 폴백(회귀 0). */
private static final String DEFAULT_TENANT = "kintex";
/**
* 액세스 토큰 발급. eventRoles: 행사ID역할. roleCode: 전역 역할(app_user.role_code, null 허용).
* tenantId: 소속 테넌트(멀티테넌시 2단계) {@code tid} 클레임으로 순증(§1A-3-2). null/공백이면 기준 테넌트.
*/
public String issue(String userId, String displayName,
Map<String, EventRole> eventRoles, boolean hallManager, String roleCode) {
Map<String, EventRole> eventRoles, boolean hallManager, String roleCode,
String tenantId) {
Instant now = Instant.now();
Map<String, String> rolesClaim = new LinkedHashMap<>();
if (eventRoles != null) {
@ -43,7 +50,8 @@ public class JwtService {
.subject(userId)
.claim("name", displayName)
.claim("roles", rolesClaim)
.claim("hm", hallManager);
.claim("hm", hallManager)
.claim("tid", (tenantId == null || tenantId.isBlank()) ? DEFAULT_TENANT : tenantId);
if (roleCode != null && !roleCode.isBlank()) {
builder.claim("rc", roleCode); // 순증 클레임 /me 전역 역할 복원용
}
@ -54,10 +62,16 @@ public class JwtService {
.compact();
}
/** 하위호환 오버로드 — 전역 역할 미상 경로용(roleCode=null). */
/** 하위호환 오버로드 — 테넌트 미지정 경로용(tid=기준 테넌트). */
public String issue(String userId, String displayName,
Map<String, EventRole> eventRoles, boolean hallManager, String roleCode) {
return issue(userId, displayName, eventRoles, hallManager, roleCode, DEFAULT_TENANT);
}
/** 하위호환 오버로드 — 전역 역할·테넌트 미상 경로용(roleCode=null). */
public String issue(String userId, String displayName,
Map<String, EventRole> eventRoles, boolean hallManager) {
return issue(userId, displayName, eventRoles, hallManager, null);
return issue(userId, displayName, eventRoles, hallManager, null, DEFAULT_TENANT);
}
/** 토큰 검증 후 principal 복원. 실패 시 401. */
@ -73,7 +87,10 @@ public class JwtService {
}
boolean hm = Boolean.TRUE.equals(c.get("hm", Boolean.class));
String roleCode = c.get("rc", String.class);
return new KintexPrincipal(c.getSubject(), c.get("name", String.class), roles, hm, roleCode);
// tid 부재(2단계 이전 발급 토큰) = 기준 테넌트 'kintex' 복원 하위호환·회귀 0.
String tid = c.get("tid", String.class);
String tenantId = (tid == null || tid.isBlank()) ? DEFAULT_TENANT : tid;
return new KintexPrincipal(c.getSubject(), c.get("name", String.class), roles, hm, roleCode, tenantId);
} catch (JwtException | IllegalArgumentException e) {
throw new ApiException(ErrorCode.UNAUTHORIZED, "토큰이 유효하지 않거나 만료되었습니다.");
}

View File

@ -10,19 +10,31 @@ import java.util.Map;
* @param eventRoles 행사ID 역할 (행사 단위 RBAC). 홀매니저는 전체 열람이므로 별도 플래그.
* @param hallManager 킨텍스 내부 홀매니저 여부(전체 행사 열람+승인)
* @param roleCode 전역 역할(app_user.role_code ADMIN/MANAGER/, 없으면 null). 순증 필드(하위호환).
* @param tenantId 소속 테넌트(전시관) JWT {@code tid} 클레임(멀티테넌시 2단계, §1A-3-2).
* 기존(tid 부재) 토큰·비인증 경로는 기준 테넌트 {@code 'kintex'} 복원(회귀 0).
*/
public record KintexPrincipal(
String userId,
String displayName,
Map<String, EventRole> eventRoles,
boolean hallManager,
String roleCode
String roleCode,
String tenantId
) {
/** 하위호환 보조 생성자 — roleCode 미상(전역 역할 정보 없음) 경로용. 기존 4-인자 호출 유지. */
/** 기준 테넌트(#1 KINTEX) — {@code com.zioinfo.kintex.tenant.TenantContext.DEFAULT_TENANT}와 일치. */
private static final String DEFAULT_TENANT = "kintex";
/** 하위호환 보조 생성자 — 테넌트 미상(tid 없는 기존 토큰) 경로용. 기존 5-인자 호출 유지·테넌트는 기준값. */
public KintexPrincipal(String userId, String displayName,
Map<String, EventRole> eventRoles, boolean hallManager, String roleCode) {
this(userId, displayName, eventRoles, hallManager, roleCode, DEFAULT_TENANT);
}
/** 하위호환 보조 생성자 — roleCode·테넌트 미상 경로용. 기존 4-인자 호출 유지(테스트 회귀 방지). */
public KintexPrincipal(String userId, String displayName,
Map<String, EventRole> eventRoles, boolean hallManager) {
this(userId, displayName, eventRoles, hallManager, null);
this(userId, displayName, eventRoles, hallManager, null, DEFAULT_TENANT);
}
/** 해당 행사에서 사용자의 역할(없으면 null). 홀매니저는 항상 HALL_MANAGER로 간주. */

View File

@ -19,7 +19,8 @@ public interface AccountSecurityMapper {
@Select("""
SELECT id AS "userId", display_name AS "displayName", password_hash AS "passwordHash",
hall_manager AS "hallManager", otp_enabled AS "otpEnabled", otp_secret AS "otpSecret",
role_code AS "roleCode", failed_login_count AS "failedCount", locked_until AS "lockedUntil"
role_code AS "roleCode", tenant_id AS "tenantId",
failed_login_count AS "failedCount", locked_until AS "lockedUntil"
FROM app_user WHERE email = #{email} AND status = 'ACTIVE'
""")
Map<String, Object> findAuthSecurityByEmail(@Param("email") String email);
@ -27,7 +28,8 @@ public interface AccountSecurityMapper {
/** userId → 보안 요약(마이페이지 OTP 상태·발급 라벨). */
@Select("""
SELECT id AS "userId", email, display_name AS "displayName", hall_manager AS "hallManager",
otp_enabled AS "otpEnabled", otp_secret AS "otpSecret", role_code AS "roleCode"
otp_enabled AS "otpEnabled", otp_secret AS "otpSecret", role_code AS "roleCode",
tenant_id AS "tenantId"
FROM app_user WHERE id = #{userId}
""")
Map<String, Object> findSecurityById(@Param("userId") String userId);

View File

@ -74,7 +74,7 @@ public class TwoFactorService {
return SecureLoginResponse.otpEnroll(challengeStore.issue(userId));
}
return SecureLoginResponse.ok(buildLoginResponse(userId, str(row.get("displayName")),
hallManager, str(row.get("roleCode"))));
hallManager, str(row.get("roleCode")), str(row.get("tenantId"))));
}
/**
@ -96,7 +96,7 @@ public class TwoFactorService {
}
challengeStore.consume(challengeToken);
return buildLoginResponse(userId, str(sec.get("displayName")), bool(sec.get("hallManager")),
str(sec.get("roleCode")));
str(sec.get("roleCode")), str(sec.get("tenantId")));
}
/** 로그인 중 등록 강제 — setup 단계(프리오스 챌린지 유지). 시크릿 발급·QR 반환. */
@ -126,7 +126,7 @@ public class TwoFactorService {
challengeStore.consume(challengeToken);
securityMapper.enableOtp(userId);
return buildLoginResponse(userId, str(sec.get("displayName")), bool(sec.get("hallManager")),
str(sec.get("roleCode")));
str(sec.get("roleCode")), str(sec.get("tenantId")));
}
/** OTP 등록 시작 — 시크릿 발급(봉인 저장)·QR 반환(미활성, confirm 필요). */
@ -183,7 +183,7 @@ public class TwoFactorService {
* event_member.role_code(행사 역할)이며 전역 role_code와 별개 혼동 금지.
*/
private LoginResponse buildLoginResponse(String userId, String displayName, boolean hallManager,
String globalRoleCode) {
String globalRoleCode, String tenantId) {
List<Map<String, Object>> roleRows = userMapper.findEventRoles(userId);
Map<String, EventRole> eventRoles = new LinkedHashMap<>();
if (roleRows != null) {
@ -195,7 +195,7 @@ public class TwoFactorService {
}
}
}
String token = jwtService.issue(userId, displayName, eventRoles, hallManager, globalRoleCode);
String token = jwtService.issue(userId, displayName, eventRoles, hallManager, globalRoleCode, tenantId);
List<WorkspaceDto> workspaces = new ArrayList<>();
if (roleRows != null) {
for (Map<String, Object> r : roleRows) {

View File

@ -18,7 +18,7 @@ public interface SysUserMapper {
@Select("SELECT role_code FROM app_user WHERE id = #{userId}")
String findRoleCode(@Param("userId") String userId);
/** 사용자 목록(검색·페이징) — 민감 컬럼 제외. */
/** 사용자 목록(검색·페이징) — 민감 컬럼 제외. 테넌트 스코프(§8-2). */
@Select("""
<script>
SELECT id AS "userId", email, display_name AS "displayName", hall_manager AS "hallManager",
@ -26,6 +26,7 @@ public interface SysUserMapper {
to_char(last_login_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "lastLoginAt"
FROM app_user
<where>
AND tenant_id = #{tenantId}
<if test="keyword != null and keyword != ''">
AND (email ILIKE '%'||#{keyword}||'%' OR display_name ILIKE '%'||#{keyword}||'%')
</if>
@ -36,7 +37,8 @@ public interface SysUserMapper {
LIMIT #{size} OFFSET #{offset}
</script>
""")
List<Map<String, Object>> search(@Param("keyword") String keyword, @Param("status") String status,
List<Map<String, Object>> 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 {
<script>
SELECT count(*) FROM app_user
<where>
AND tenant_id = #{tenantId}
<if test="keyword != null and keyword != ''">
AND (email ILIKE '%'||#{keyword}||'%' OR display_name ILIKE '%'||#{keyword}||'%')
</if>
@ -52,7 +55,8 @@ public interface SysUserMapper {
</where>
</script>
""")
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<String, Object> p);

View File

@ -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<SysUserDto> 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<Map<String, Object>> 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<Map<String, Object>> 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);
}

View File

@ -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).
* <p>{@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);
}

View File

@ -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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> y1 = new HashMap<>();
@ -99,7 +102,7 @@ class AnalyticsServiceTest {
y2.put("yr", 2025); y2.put("eventCount", 103L);
Map<String, Object> 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);

View File

@ -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"));
}
}

View File

@ -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() {
</div>
</header>
<MdiTabBar />
<main className="kx-shell__content" id="kx-main-content">
<Outlet />
</main>

View File

@ -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 (
<div className="kx-mdi" role="tablist" aria-label="열린 문서 탭">
{tabs.map((tab, i) => {
const isActive = tab.path === activePath;
return (
<div
key={tab.path}
className={`kx-mdi__tab ${isActive ? 'is-active' : ''}`}
role="presentation"
>
<button
type="button"
role="tab"
aria-selected={isActive}
tabIndex={isActive ? 0 : -1}
ref={(el) => {
btnRefs.current[i] = el;
}}
className="kx-mdi__tab-btn"
title={tab.label}
onClick={() => activate(tab.path)}
onKeyDown={(e) => handleKeyDown(e, i)}
>
<span className="kx-mdi__tab-label">{tab.label}</span>
</button>
<button
type="button"
className="kx-mdi__tab-close"
aria-label={`${tab.label} 탭 닫기`}
tabIndex={-1}
onClick={(e) => handleClose(e, tab.path)}
>
<IconClose size={13} />
</button>
</div>
);
})}
</div>
);
}

View File

@ -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;
}
}

View File

@ -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<string, string> = {
// 주 메뉴
'/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<string, string> = {
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 '문서';
}

View File

@ -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 order</0> 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}}\"?"
}
}

View File

@ -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}}\" スライドを削除しますか?"
}
}

View File

@ -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}}\" 슬라이드를 삭제할까요?"
}
}

View File

@ -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}}\" 吗?"
}
}

View File

@ -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<string | undefined>(undefined);
const q = useQuery({
@ -43,53 +45,53 @@ export function AdminDashboardPage() {
<div className="kx-admin">
<header className="kx-admin__head">
<div>
<h1 className="kx-admin__title"> </h1>
<p className="kx-admin__subtitle"> </p>
<h1 className="kx-admin__title">{t('admin.overviewTitle')}</h1>
<p className="kx-admin__subtitle">{t('admin.overviewSub')}</p>
</div>
<div className="kx-admin__head-actions">
<Link to="/admin/login-slides" className="kx-btn kx-btn--secondary">
{t('admin.loginSlides')}
</Link>
<select
className="kx-select"
aria-label="테넌트 전환"
aria-label={t('admin.tenantSwitch')}
value={activeTenant}
onChange={(e) => setTenant(e.target.value)}
>
{tenants.map((t) => (
<option key={t} value={t}>{t}</option>
{tenants.map((tn) => (
<option key={tn} value={tn}>{tn}</option>
))}
</select>
<span className="kx-admin__status" role="status">
<span className="kx-admin__status-dot" aria-hidden="true" />
<span className="kx-admin__status-dot" aria-hidden="true" /> {t('admin.systemNormal')}
</span>
</div>
</header>
<nav className="kx-admin__modules" aria-label="관리 모듈 바로가기">
{ADMIN_MODULES.map(([label, to]) => (
<Link key={to} to={to} className="kx-btn kx-btn--secondary">{label}</Link>
<nav className="kx-admin__modules" aria-label={t('admin.moduleShortcuts')}>
{ADMIN_MODULES.map(([labelKey, to]) => (
<Link key={to} to={to} className="kx-btn kx-btn--secondary">{t(labelKey)}</Link>
))}
</nav>
{degraded && (
<div className="kx-admin__degraded-bar">
<span className="kx-bi__degraded"> </span>
API에 .
<span className="kx-bi__degraded">{t('admin.offlineSample')}</span>
{t('admin.degradedMsg')}
</div>
)}
{q.isLoading && <AdminSkeleton />}
{hardError && (
<ErrorState message="관리자 대시보드를 불러오지 못했습니다." onRetry={() => q.refetch()} />
<ErrorState message={t('admin.dashLoadError')} onRetry={() => q.refetch()} />
)}
{data && (
<>
<section className="kx-admin__kpis" aria-label="핵심 지표">
<section className="kx-admin__kpis" aria-label={t('admin.kpisAria')}>
{data.kpis.length === 0 ? (
<EmptyState title="집계된 지표가 없습니다" />
<EmptyState title={t('admin.kpiEmpty')} />
) : (
data.kpis.map((k, i) => (
<div key={`${k.label}-${i}`} className="kx-kpi kx-kpi--normal">
@ -102,15 +104,15 @@ export function AdminDashboardPage() {
</section>
<div className="kx-admin__split">
<section className="kx-card kx-admin__chart-card" aria-label="방문객 추이">
<section className="kx-card kx-admin__chart-card" aria-label={t('admin.visitorTrend')}>
<div className="kx-card__head">
<h2> </h2>
<h2>{t('admin.hourlyVisitors')}</h2>
</div>
<div className="kx-admin__chart">
{data.visitorTrend.length === 0 ? (
<EmptyState
title="관람객 추이 미집계"
description="관람객 계측 센서(M14)가 연동되면 시간대별 추이가 표시됩니다."
title={t('admin.visitorTrendEmptyTitle')}
description={t('admin.visitorTrendEmptyDesc')}
/>
) : (
<ResponsiveContainer width="100%" height={300}>
@ -119,19 +121,19 @@ export function AdminDashboardPage() {
<XAxis dataKey="hour" tick={{ fontSize: 12, fill: CHART.slate }} tickLine={false} axisLine={{ stroke: CHART.neutral200 }} />
<YAxis tick={{ fontSize: 12, fill: CHART.slate }} tickLine={false} axisLine={false} width={44} />
<Tooltip cursor={{ fill: CHART.primary050 }} contentStyle={TOOLTIP_STYLE} />
<Bar dataKey="count" name="관람객" fill={CHART.primary600} radius={[4, 4, 0, 0]} maxBarSize={34} />
<Bar dataKey="count" name={t('admin.visitors')} fill={CHART.primary600} radius={[4, 4, 0, 0]} maxBarSize={34} />
</BarChart>
</ResponsiveContainer>
)}
</div>
</section>
<aside className="kx-admin__live" aria-label="진행 중 이벤트">
<aside className="kx-admin__live" aria-label={t('admin.liveEvents')}>
<div className="kx-card__head">
<h2> </h2>
<h2>{t('admin.liveEvents')}</h2>
</div>
{data.liveEvents.length === 0 ? (
<EmptyState title="진행 중인 행사가 없습니다" />
<EmptyState title={t('admin.noLiveEvents')} />
) : (
<div className="kx-admin__live-list">
{data.liveEvents.map((e) => (
@ -142,12 +144,12 @@ export function AdminDashboardPage() {
<StatusPill value={e.status} />
</div>
<h3 className="kx-livecard__title">{e.name}</h3>
<p className="kx-livecard__meta">{e.hall ?? '홀 미배정'}</p>
<p className="kx-livecard__meta">{e.hall ?? t('admin.hallTbd')}</p>
<div className="kx-livecard__occ">
<div className="kx-barlist__track">
<span className="kx-barlist__fill is-warning" style={{ width: `${clamp(e.occupancy)}%` }} />
</div>
<span className="kx-livecard__occ-val tnum"> {clamp(e.occupancy)}%</span>
<span className="kx-livecard__occ-val tnum">{t('admin.occupancy', { n: clamp(e.occupancy) })}</span>
</div>
</div>
</article>
@ -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'],
];

View File

@ -13,6 +13,7 @@
* ( ): element <AdminGuard> . 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 }) {
<span className="kx-adm-denied__icon" aria-hidden="true">
<IconBlock size={40} />
</span>
<h1 className="kx-adm-denied__title"> </h1>
<h1 className="kx-adm-denied__title">{t('admin.deniedTitle')}</h1>
<p className="kx-adm-denied__desc">
{authed
? '이 페이지는 시스템 관리자(홀매니저·ADMIN)만 이용할 수 있습니다. 접근이 필요하면 관리자에게 문의하세요.'
: '관리자 백오피스는 로그인 후 이용할 수 있습니다.'}
{authed ? t('admin.deniedAuthed') : t('admin.deniedGuest')}
</p>
<p className="kx-adm-denied__code">403 · FORBIDDEN</p>
</div>

View File

@ -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() {
<div className="kx-page kx-adm">
<header className="kx-adm__head">
<div>
<h1 className="kx-adm__title"> </h1>
<h1 className="kx-adm__title">{t('audit.title')}</h1>
<p className="kx-adm__subtitle">
·· . ( )
{t('audit.subtitle')}
</p>
</div>
<Button
variant="secondary"
leadingIcon={<IconDownload size={16} />}
onClick={() => exportCsv(rows)}
onClick={() => exportCsv(t, rows)}
disabled={rows.length === 0}
>
CSV
{t('audit.exportCsv')}
</Button>
</header>
{/* 필터 바 */}
<section className="kx-card kx-adm-filter" aria-label="감사 로그 필터">
<section className="kx-card kx-adm-filter" aria-label={t('audit.filterAria')}>
<div className="kx-adm-filter__grid">
<label className="kx-adm-field">
<span className="kx-adm-field__label"> </span>
<span className="kx-adm-field__label">{t('audit.actionType')}</span>
<input
className="kx-adm-field__input"
list="audit-actions"
placeholder="전체 액션"
placeholder={t('audit.allActions')}
value={action}
onChange={(e) => setAction(e.target.value)}
/>
@ -97,29 +100,29 @@ export function AuditLogPage() {
</datalist>
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label"> ID</span>
<span className="kx-adm-field__label">{t('audit.actorId')}</span>
<input
className="kx-adm-field__input"
placeholder="예: user-102"
placeholder={t('audit.actorIdPh')}
value={actorId}
onChange={(e) => setActorId(e.target.value)}
/>
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label"> ID</span>
<span className="kx-adm-field__label">{t('audit.eventId')}</span>
<input
className="kx-adm-field__input"
placeholder="예: EVT-2026-01"
placeholder={t('audit.eventIdPh')}
value={eventId}
onChange={(e) => setEventId(e.target.value)}
/>
</label>
<div className="kx-adm-filter__actions">
<Button variant="ghost" onClick={resetFilters}>
{t('audit.reset')}
</Button>
<Button leadingIcon={<IconSearch size={16} />} onClick={applyFilters}>
{t('audit.applyFilter')}
</Button>
</div>
</div>
@ -129,15 +132,15 @@ export function AuditLogPage() {
{q.isLoading && <TableSkeleton />}
{q.isError && !q.isLoading && (
<ErrorState message="감사 로그를 불러오지 못했습니다." onRetry={() => q.refetch()} />
<ErrorState message={t('audit.loadError')} onRetry={() => q.refetch()} />
)}
{!q.isLoading && !q.isError && (
<section className="kx-card kx-adm-table-card" aria-label="감사 로그 테이블">
<section className="kx-card kx-adm-table-card" aria-label={t('audit.tableAria')}>
{rows.length === 0 ? (
<EmptyState
title="조건에 맞는 감사 기록이 없습니다"
description="필터를 조정하거나 초기화해 다시 조회하세요."
title={t('audit.emptyTitle')}
description={t('audit.emptyDesc')}
/>
) : (
<>
@ -145,12 +148,12 @@ export function AuditLogPage() {
<table className="kx-table kx-table--zebra kx-adm-audit">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th>{t('audit.colTime')}</th>
<th>{t('audit.colUser')}</th>
<th>{t('audit.colAction')}</th>
<th>{t('audit.colTarget')}</th>
<th>{t('audit.colEvent')}</th>
<th>{t('audit.colResult')}</th>
</tr>
</thead>
<tbody>
@ -198,8 +201,7 @@ export function AuditLogPage() {
<div className="kx-adm-pager">
<p className="kx-adm-pager__info">
<strong className="tnum">{from}-{to}</strong> / {' '}
<strong className="tnum">{total.toLocaleString()}</strong>
<Trans i18nKey="audit.showing" values={{ from, to, total: total.toLocaleString() }} components={[<strong className="tnum" key="a" />, <strong className="tnum" key="b" />]} />
</p>
<div className="kx-adm-pager__nav">
<Button
@ -207,7 +209,7 @@ export function AuditLogPage() {
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
>
{t('audit.prev')}
</Button>
<span className="kx-adm-pager__page tnum">
{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')}
</Button>
</div>
<label className="kx-adm-pager__size">
<span> </span>
<span>{t('audit.rowsShow')}</span>
<select
className="kx-select"
value={size}
@ -229,11 +231,11 @@ export function AuditLogPage() {
setSize(Number(e.target.value));
setPage(0);
}}
aria-label="페이지당 행 수"
aria-label={t('audit.rowsPerPage')}
>
{PAGE_SIZES.map((s) => (
<option key={s} value={s}>
{s}
{t('audit.rowsN', { n: s })}
</option>
))}
</select>
@ -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 <span className={`kx-adm-pill kx-adm-pill--${tone}`}>{label}</span>;
}
@ -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;

View File

@ -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() {
<div className="kx-page kx-adm">
<header className="kx-adm__head">
<div>
<h1 className="kx-adm__title"> </h1>
<p className="kx-adm__subtitle">· .</p>
<h1 className="kx-adm__title">{t('code.title')}</h1>
<p className="kx-adm__subtitle">{t('code.subtitle')}</p>
</div>
<Button leadingIcon={<IconPlus size={16} />} onClick={() => setGroupModal(null)}> </Button>
<Button leadingIcon={<IconPlus size={16} />} onClick={() => setGroupModal(null)}>{t('code.addGroup')}</Button>
</header>
{groupsQ.isLoading && <Skeleton height={320} radius={12} />}
{groupsQ.isError && !groupsQ.isLoading && (
<ErrorState message="코드 그룹을 불러오지 못했습니다." onRetry={() => groupsQ.refetch()} />
<ErrorState message={t('code.groupLoadError')} onRetry={() => groupsQ.refetch()} />
)}
{!groupsQ.isLoading && !groupsQ.isError && (
groups.length === 0 ? (
<EmptyState title="코드 그룹이 없습니다" description="그룹을 추가해 공통코드를 구성하세요." />
<EmptyState title={t('code.noGroupsTitle')} description={t('code.noGroupsDesc')} />
) : (
<div className="kx-adm-codes">
{/* 좌: 그룹 */}
<section className="kx-card kx-adm-codes__groups" aria-label="코드 그룹">
<section className="kx-card kx-adm-codes__groups" aria-label={t('code.codeGroup')}>
<div className="kx-adm-codes__head">
<h2> </h2>
<h2>{t('code.codeGroup')}</h2>
</div>
<ul className="kx-adm-glist">
{groups.map((g) => (
@ -109,7 +111,7 @@ export function CommonCodeAdminPage() {
onClick={() => setSelected(g.grpCode)}
>
<span className="kx-adm-gitem__name">{g.grpName}</span>
<span className="kx-adm-gitem__code">{g.grpCode}{g.useYn === 'N' ? ' · 미사용' : ''}</span>
<span className="kx-adm-gitem__code">{g.grpCode}{g.useYn === 'N' ? ` · ${t('code.unused')}` : ''}</span>
</button>
</li>
))}
@ -117,28 +119,28 @@ export function CommonCodeAdminPage() {
</section>
{/* 우: 코드 값 */}
<section className="kx-card kx-adm-codes__values" aria-label="코드 값">
<section className="kx-card kx-adm-codes__values" aria-label={t('code.codeValues')}>
<div className="kx-adm-codes__head">
<div>
<h2>{activeGroup?.grpName ?? '코드'} </h2>
<h2>{t('code.valuesOf', { name: activeGroup?.grpName ?? t('code.code') })}</h2>
{activeGroup?.description && <p className="kx-adm-muted">{activeGroup.description}</p>}
</div>
<div className="kx-adm-tbl__actions">
{activeGroup && (
<>
<Button variant="ghost" onClick={() => setGroupModal(activeGroup)}> </Button>
<Button variant="ghost" onClick={() => setGroupModal(activeGroup)}>{t('code.editGroup')}</Button>
<Button
variant="ghost"
className="kx-adm-del"
disabled={delGroupM.isPending}
onClick={() => {
if (window.confirm(`그룹 '${activeGroup.grpName}' 과 하위 코드를 삭제하시겠습니까?`))
if (window.confirm(t('code.deleteGroupConfirm', { name: activeGroup.grpName })))
delGroupM.mutate(activeGroup.grpCode);
}}
>
{t('code.deleteGroup')}
</Button>
<Button leadingIcon={<IconPlus size={16} />} onClick={() => setCodeModal(null)}> </Button>
<Button leadingIcon={<IconPlus size={16} />} onClick={() => setCodeModal(null)}>{t('code.addCode')}</Button>
</>
)}
</div>
@ -147,25 +149,25 @@ export function CommonCodeAdminPage() {
{codesQ.isLoading && <div style={{ padding: 16 }}><Skeleton height={160} radius={8} /></div>}
{codesQ.isError && !codesQ.isLoading && (
<div style={{ padding: 16 }}>
<ErrorState message="코드 값을 불러오지 못했습니다." onRetry={() => codesQ.refetch()} />
<ErrorState message={t('code.codeLoadError')} onRetry={() => codesQ.refetch()} />
</div>
)}
{!codesQ.isLoading && !codesQ.isError && (
codes.length === 0 ? (
<div style={{ padding: 16 }}>
<EmptyState title="코드가 없습니다" description="코드를 추가하세요." />
<EmptyState title={t('code.noCodesTitle')} description={t('code.noCodesDesc')} />
</div>
) : (
<div className="kx-table-scroll">
<table className="kx-table kx-table--zebra kx-adm-tbl">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th className="kx-num"></th>
<th></th>
<th style={{ textAlign: 'right' }}></th>
<th>{t('code.code')}</th>
<th>{t('code.name')}</th>
<th>{t('code.value')}</th>
<th className="kx-num">{t('code.sort')}</th>
<th>{t('code.use')}</th>
<th style={{ textAlign: 'right' }}>{t('code.manage')}</th>
</tr>
</thead>
<tbody>
@ -177,22 +179,22 @@ export function CommonCodeAdminPage() {
<td className="kx-num tnum">{c.sortOrder}</td>
<td>
<span className={`kx-adm-pill kx-adm-pill--${c.useYn === 'N' ? 'neutral' : 'success'}`}>
{c.useYn === 'N' ? '미사용' : '사용'}
{c.useYn === 'N' ? t('code.unused') : t('code.used')}
</span>
</td>
<td>
<div className="kx-adm-tbl__actions">
<Button variant="ghost" onClick={() => setCodeModal(c)}></Button>
<Button variant="ghost" onClick={() => setCodeModal(c)}>{t('code.edit')}</Button>
<Button
variant="ghost"
className="kx-adm-del"
disabled={delCodeM.isPending}
onClick={() => {
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')}
</Button>
</div>
</td>
@ -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({
<div className="kx-adm-modal" role="dialog" aria-modal="true" aria-labelledby="group-modal-title">
<div className="kx-adm-modal__backdrop" onClick={onClose} />
<form className="kx-adm-modal__panel" onSubmit={submit}>
<h2 id="group-modal-title" className="kx-adm-modal__title">{isNew ? '코드 그룹 추가' : '코드 그룹 수정'}</h2>
<h2 id="group-modal-title" className="kx-adm-modal__title">{isNew ? t('code.addGroup') : t('code.editGroup')}</h2>
<div className="kx-adm-modal__grid">
<label className="kx-adm-field">
<span className="kx-adm-field__label"> *</span>
<span className="kx-adm-field__label">{t('code.groupCode')}</span>
<input className="kx-adm-field__input kx-adm-mono" value={grpCode} disabled={!isNew} onChange={(e) => setGrpCode(e.target.value)} />
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label"> *</span>
<span className="kx-adm-field__label">{t('code.groupName')}</span>
<input className="kx-adm-field__input" value={grpName} onChange={(e) => setGrpName(e.target.value)} />
</label>
<label className="kx-adm-field kx-adm-field--full">
<span className="kx-adm-field__label"></span>
<span className="kx-adm-field__label">{t('code.description')}</span>
<input className="kx-adm-field__input" value={description} onChange={(e) => setDescription(e.target.value)} />
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label"></span>
<span className="kx-adm-field__label">{t('code.sort')}</span>
<input className="kx-adm-field__input" type="number" value={sortOrder} onChange={(e) => setSortOrder(e.target.value)} />
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label"> </span>
<span className="kx-adm-field__label">{t('code.useYn')}</span>
<select className="kx-adm-field__select" value={useYn} onChange={(e) => setUseYn(e.target.value)}>
<option value="Y"></option>
<option value="N"></option>
<option value="Y">{t('code.used')}</option>
<option value="N">{t('code.unused')}</option>
</select>
</label>
</div>
{error && <p className="kx-adm-modal__error" role="alert">{error}</p>}
<div className="kx-adm-modal__actions">
<Button variant="ghost" type="button" onClick={onClose}></Button>
<Button type="submit" disabled={saving}>{saving ? '저장 중…' : '저장'}</Button>
<Button variant="ghost" type="button" onClick={onClose}>{t('code.cancel')}</Button>
<Button type="submit" disabled={saving}>{saving ? t('code.saving') : t('code.save')}</Button>
</div>
</form>
</div>
@ -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({
<div className="kx-adm-modal" role="dialog" aria-modal="true" aria-labelledby="code-modal-title">
<div className="kx-adm-modal__backdrop" onClick={onClose} />
<form className="kx-adm-modal__panel" onSubmit={submit}>
<h2 id="code-modal-title" className="kx-adm-modal__title">{isNew ? '코드 추가' : '코드 수정'}</h2>
<p className="kx-adm-modal__desc"> <span className="kx-adm-mono">{grpCode}</span></p>
<h2 id="code-modal-title" className="kx-adm-modal__title">{isNew ? t('code.addCode') : t('code.editCode')}</h2>
<p className="kx-adm-modal__desc">{t('code.group')} <span className="kx-adm-mono">{grpCode}</span></p>
<div className="kx-adm-modal__grid">
<label className="kx-adm-field">
<span className="kx-adm-field__label"> *</span>
<span className="kx-adm-field__label">{t('code.codeReq')}</span>
<input className="kx-adm-field__input kx-adm-mono" value={codeVal} disabled={!isNew} onChange={(e) => setCodeVal(e.target.value)} />
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label"> *</span>
<span className="kx-adm-field__label">{t('code.nameReq')}</span>
<input className="kx-adm-field__input" value={codeName} onChange={(e) => setCodeName(e.target.value)} />
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label"></span>
<span className="kx-adm-field__label">{t('code.value')}</span>
<input className="kx-adm-field__input" value={codeValue} onChange={(e) => setCodeValue(e.target.value)} />
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label">1(attr1)</span>
<span className="kx-adm-field__label">{t('code.attr1')}</span>
<input className="kx-adm-field__input" value={attr1} onChange={(e) => setAttr1(e.target.value)} />
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label"></span>
<span className="kx-adm-field__label">{t('code.sort')}</span>
<input className="kx-adm-field__input" type="number" value={sortOrder} onChange={(e) => setSortOrder(e.target.value)} />
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label"> </span>
<span className="kx-adm-field__label">{t('code.useYn')}</span>
<select className="kx-adm-field__select" value={useYn} onChange={(e) => setUseYn(e.target.value)}>
<option value="Y"></option>
<option value="N"></option>
<option value="Y">{t('code.used')}</option>
<option value="N">{t('code.unused')}</option>
</select>
</label>
</div>
{error && <p className="kx-adm-modal__error" role="alert">{error}</p>}
<div className="kx-adm-modal__actions">
<Button variant="ghost" type="button" onClick={onClose}></Button>
<Button type="submit" disabled={saving}>{saving ? '저장 중…' : '저장'}</Button>
<Button variant="ghost" type="button" onClick={onClose}>{t('code.cancel')}</Button>
<Button type="submit" disabled={saving}>{saving ? t('code.saving') : t('code.save')}</Button>
</div>
</form>
</div>

View File

@ -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<LoginSlideDto[] | null>(null);
const [loadError, setLoadError] = useState<string | null>(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() {
<div className="kx-admin">
<header className="kx-admin__head">
<div>
<h1 className="kx-admin__title"> </h1>
<h1 className="kx-admin__title">{t('slide.title')}</h1>
<p className="kx-admin__subtitle">
· <strong> 5</strong>
.
<Trans i18nKey="slide.subtitle" components={[<strong key="s" />]} />
</p>
</div>
</header>
{/* 새 슬라이드 추가 */}
<section className="kx-card kx-slideadm__form-card" aria-label="새 슬라이드 추가">
<section className="kx-card kx-slideadm__form-card" aria-label={t('slide.addAria')}>
<div className="kx-card__head">
<h2> </h2>
<h2>{t('slide.addTitle')}</h2>
</div>
<form className="kx-slideadm__form" onSubmit={handleCreate}>
<div className="kx-slideadm__form-grid">
<div className="kx-slideadm__upload">
<label className="kx-slideadm__label" htmlFor="slide-image">
(PNG/JPG/WebP, 8MB )
{t('slide.imageLabel')}
</label>
<input
id="slide-image"
@ -143,29 +144,29 @@ export function LoginSlideAdminPage() {
className={`kx-slideadm__preview ${preview ? 'has-image' : ''}`}
style={preview ? { backgroundImage: `url(${preview})` } : undefined}
>
{!preview && <span></span>}
{!preview && <span>{t('slide.preview')}</span>}
</div>
</div>
<div className="kx-slideadm__fields">
<div className="kx-adm-field">
<label htmlFor="slide-title"> </label>
<label htmlFor="slide-title">{t('slide.titleLabel')}</label>
<input
id="slide-title"
type="text"
maxLength={200}
placeholder="예) 아시아를 대표하는 전시컨벤션센터, KINTEX"
placeholder={t('slide.titlePh')}
value={title}
onChange={(e) => setTitle(e.target.value)}
required
/>
</div>
<div className="kx-adm-field">
<label htmlFor="slide-caption">( )</label>
<label htmlFor="slide-caption">{t('slide.captionLabel')}</label>
<input
id="slide-caption"
type="text"
maxLength={500}
placeholder="예) 10만㎡ 전시 공간에서 펼쳐지는 비즈니스 기회를 만나보세요."
placeholder={t('slide.captionPh')}
value={caption}
onChange={(e) => setCaption(e.target.value)}
/>
@ -177,7 +178,7 @@ export function LoginSlideAdminPage() {
)}
<div className="kx-slideadm__form-actions">
<Button type="submit" disabled={submitting}>
{submitting ? '등록 중…' : '슬라이드 등록'}
{submitting ? t('slide.submitting') : t('slide.submit')}
</Button>
</div>
</div>
@ -186,13 +187,13 @@ export function LoginSlideAdminPage() {
</section>
{/* 목록 */}
<section className="kx-card kx-slideadm__list-card" aria-label="슬라이드 목록">
<section className="kx-card kx-slideadm__list-card" aria-label={t('slide.listAria')}>
<div className="kx-card__head">
<h2>
{' '}
{t('slide.registered')}{' '}
{slides && (
<span className="kx-slideadm__count tnum">
{slides.length} · {activeCount}
{t('slide.countActive', { total: slides.length, active: activeCount })}
</span>
)}
</h2>
@ -208,8 +209,8 @@ export function LoginSlideAdminPage() {
</div>
) : slides.length === 0 && !loadError ? (
<EmptyState
title="등록된 슬라이드가 없습니다"
description="이미지와 문구를 등록하면 로그인 화면 캐러셀에 노출됩니다. (미등록 시 기본 5종 표시)"
title={t('slide.emptyTitle')}
description={t('slide.emptyDesc')}
/>
) : (
<ul className="kx-slideadm__list">
@ -223,11 +224,11 @@ export function LoginSlideAdminPage() {
<div className="kx-slideadm__body">
<div className="kx-slideadm__row-top">
<strong className="kx-slideadm__row-title">{s.title}</strong>
{s.active && i < 5 && <span className="kx-slideadm__live-badge"> </span>}
{!s.active && <span className="kx-slideadm__off-badge"></span>}
{s.active && i < 5 && <span className="kx-slideadm__live-badge">{t('slide.live')}</span>}
{!s.active && <span className="kx-slideadm__off-badge">{t('slide.inactive')}</span>}
</div>
{s.caption && <p className="kx-slideadm__row-caption">{s.caption}</p>}
<p className="kx-slideadm__row-meta tnum"> {s.createdAt}</p>
<p className="kx-slideadm__row-meta tnum">{t('slide.registeredAt', { date: s.createdAt })}</p>
</div>
<div className="kx-slideadm__actions">
<label className="kx-slideadm__toggle">
@ -237,7 +238,7 @@ export function LoginSlideAdminPage() {
disabled={busyId === s.id}
onChange={() => void handleToggleActive(s)}
/>
<span></span>
<span>{t('slide.active')}</span>
</label>
<Button
variant="danger"
@ -245,7 +246,7 @@ export function LoginSlideAdminPage() {
disabled={busyId === s.id}
onClick={() => void handleDelete(s)}
>
{t('slide.delete')}
</Button>
</div>
</li>

View File

@ -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() {
<div className="kx-page kx-adm">
<header className="kx-adm__head">
<div>
<h1 className="kx-adm__title"> </h1>
<p className="kx-adm__subtitle"> · .</p>
<h1 className="kx-adm__title">{t('menu.title')}</h1>
<p className="kx-adm__subtitle">{t('menu.subtitle')}</p>
</div>
<Button leadingIcon={<IconPlus size={16} />} onClick={() => setEditing(null)}> </Button>
<Button leadingIcon={<IconPlus size={16} />} onClick={() => setEditing(null)}>{t('menu.addMenu')}</Button>
</header>
{q.isLoading && <Skeleton height={320} radius={12} />}
{q.isError && !q.isLoading && (
<ErrorState message="메뉴를 불러오지 못했습니다." onRetry={() => q.refetch()} />
<ErrorState message={t('menu.loadError')} onRetry={() => q.refetch()} />
)}
{!q.isLoading && !q.isError && (
<section className="kx-card kx-adm-table-card" aria-label="메뉴 트리">
<section className="kx-card kx-adm-table-card" aria-label={t('menu.treeAria')}>
{flat.length === 0 ? (
<EmptyState title="등록된 메뉴가 없습니다" description="메뉴를 추가하세요." />
<EmptyState title={t('menu.emptyTitle')} description={t('menu.emptyDesc')} />
) : (
<div className="kx-table-scroll">
<table className="kx-table kx-table--zebra kx-adm-tbl">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th> </th>
<th className="kx-num"></th>
<th></th>
<th style={{ textAlign: 'right' }}></th>
<th>{t('menu.colName')}</th>
<th>{t('menu.colPath')}</th>
<th>{t('menu.colType')}</th>
<th>{t('menu.colRole')}</th>
<th className="kx-num">{t('menu.colSort')}</th>
<th>{t('menu.colUse')}</th>
<th style={{ textAlign: 'right' }}>{t('menu.colManage')}</th>
</tr>
</thead>
<tbody>
@ -122,26 +124,26 @@ export function MenuAdminPage() {
</td>
<td className="kx-adm-mono">{m.menuPath ?? '—'}</td>
<td className="kx-adm-muted">{m.prgType ?? '—'}</td>
<td className="kx-adm-muted">{m.requiredRole ?? '전체'}</td>
<td className="kx-adm-muted">{m.requiredRole ?? t('menu.all')}</td>
<td className="kx-num tnum">{m.sortOrder}</td>
<td>
<span className={`kx-adm-pill kx-adm-pill--${m.useYn === 'N' ? 'neutral' : 'success'}`}>
{m.useYn === 'N' ? '미사용' : '사용'}
{m.useYn === 'N' ? t('menu.unused') : t('menu.used')}
</span>
</td>
<td>
<div className="kx-adm-tbl__actions">
<Button variant="ghost" onClick={() => setEditing(m)}></Button>
<Button variant="ghost" onClick={() => setEditing(m)}>{t('menu.edit')}</Button>
<Button
variant="ghost"
className="kx-adm-del"
disabled={delM.isPending}
onClick={() => {
if (window.confirm(`메뉴 '${m.menuName}' 을(를) 삭제하시겠습니까? 하위 메뉴가 있으면 함께 정리됩니다.`))
if (window.confirm(t('menu.deleteConfirm', { name: m.menuName })))
delM.mutate(m.id);
}}
>
{t('menu.delete')}
</Button>
</div>
</td>
@ -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({
<div className="kx-adm-modal" role="dialog" aria-modal="true" aria-labelledby="menu-modal-title">
<div className="kx-adm-modal__backdrop" onClick={onClose} />
<form className="kx-adm-modal__panel" onSubmit={submit}>
<h2 id="menu-modal-title" className="kx-adm-modal__title">{isNew ? '메뉴 추가' : '메뉴 수정'}</h2>
<h2 id="menu-modal-title" className="kx-adm-modal__title">{isNew ? t('menu.addMenu') : t('menu.editMenu')}</h2>
<div className="kx-adm-modal__grid">
{isNew && (
<label className="kx-adm-field kx-adm-field--full">
<span className="kx-adm-field__label"> ID ( ) *</span>
<input className="kx-adm-field__input kx-adm-mono" value={id} placeholder="예: admin-users" onChange={(e) => setId(e.target.value)} />
<span className="kx-adm-field__label">{t('menu.menuId')}</span>
<input className="kx-adm-field__input kx-adm-mono" value={id} placeholder={t('menu.menuIdPh')} onChange={(e) => setId(e.target.value)} />
</label>
)}
<label className="kx-adm-field">
<span className="kx-adm-field__label"> *</span>
<span className="kx-adm-field__label">{t('menu.menuName')}</span>
<input className="kx-adm-field__input" value={menuName} onChange={(e) => setMenuName(e.target.value)} />
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label"> </span>
<span className="kx-adm-field__label">{t('menu.parent')}</span>
<select className="kx-adm-field__select" value={parentId} onChange={(e) => setParentId(e.target.value)}>
<option value="">()</option>
<option value="">{t('menu.topLevel')}</option>
{parentOptions.map((o) => (
<option key={o.id} value={o.id}>{'—'.repeat(o.depth)} {o.menuName}</option>
))}
</select>
</label>
<label className="kx-adm-field kx-adm-field--full">
<span className="kx-adm-field__label">(path)</span>
<input className="kx-adm-field__input kx-adm-mono" value={menuPath} placeholder="예: /admin/users" onChange={(e) => setMenuPath(e.target.value)} />
<span className="kx-adm-field__label">{t('menu.path')}</span>
<input className="kx-adm-field__input kx-adm-mono" value={menuPath} placeholder={t('menu.pathPh')} onChange={(e) => setMenuPath(e.target.value)} />
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label"></span>
<span className="kx-adm-field__label">{t('menu.type')}</span>
<select className="kx-adm-field__select" value={prgType ?? 'SCREEN'} onChange={(e) => setPrgType(e.target.value)}>
{MENU_PRG_TYPES.map((t) => (
<option key={t} value={t}>{t}</option>
{MENU_PRG_TYPES.map((pt) => (
<option key={pt} value={pt}>{pt}</option>
))}
</select>
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label"> </span>
<input className="kx-adm-field__input kx-adm-mono" value={requiredRole} placeholder="예: ADMIN (비우면 전체)" onChange={(e) => setRequiredRole(e.target.value)} />
<span className="kx-adm-field__label">{t('menu.accessRole')}</span>
<input className="kx-adm-field__input kx-adm-mono" value={requiredRole} placeholder={t('menu.accessRolePh')} onChange={(e) => setRequiredRole(e.target.value)} />
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label"></span>
<span className="kx-adm-field__label">{t('menu.sort')}</span>
<input className="kx-adm-field__input" type="number" value={sortOrder} onChange={(e) => setSortOrder(e.target.value)} />
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label"> </span>
<span className="kx-adm-field__label">{t('menu.useYn')}</span>
<select className="kx-adm-field__select" value={useYn} onChange={(e) => setUseYn(e.target.value)}>
<option value="Y"></option>
<option value="N"></option>
<option value="Y">{t('menu.used')}</option>
<option value="N">{t('menu.unused')}</option>
</select>
</label>
</div>
{error && <p className="kx-adm-modal__error" role="alert">{error}</p>}
<div className="kx-adm-modal__actions">
<Button variant="ghost" type="button" onClick={onClose}></Button>
<Button type="submit" disabled={saving}>{saving ? '저장 중…' : '저장'}</Button>
<Button variant="ghost" type="button" onClick={onClose}>{t('menu.cancel')}</Button>
<Button type="submit" disabled={saving}>{saving ? t('menu.saving') : t('menu.save')}</Button>
</div>
</form>
</div>

View File

@ -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() {
<div className="kx-page kx-adm">
<header className="kx-adm__head">
<div>
<h1 className="kx-adm__title"> · (RBAC)</h1>
<h1 className="kx-adm__title">{t('role.title')}</h1>
<p className="kx-adm__subtitle">
(·····) .
{t('role.subtitle')}
</p>
</div>
<Button leadingIcon={<IconPlus size={16} />} onClick={() => setEditing(null)}> </Button>
<Button leadingIcon={<IconPlus size={16} />} onClick={() => setEditing(null)}>{t('role.addRole')}</Button>
</header>
{loading && <Skeleton height={320} radius={12} />}
{errored && !loading && (
<ErrorState
message="역할·권한 정보를 불러오지 못했습니다."
message={t('role.loadError')}
onRetry={() => {
rolesQ.refetch();
permsQ.refetch();
@ -87,13 +89,13 @@ export function RoleAdminPage() {
{!loading && !errored && (
roles.length === 0 ? (
<EmptyState title="정의된 역할이 없습니다" description="역할을 추가해 RBAC 를 구성하세요." />
<EmptyState title={t('role.noRolesTitle')} description={t('role.noRolesDesc')} />
) : (
<div className="kx-adm-matrix">
{/* 좌: 역할 목록 */}
<section className="kx-card kx-adm-matrix__roles" aria-label="역할 목록">
<section className="kx-card kx-adm-matrix__roles" aria-label={t('role.roleList')}>
<div className="kx-adm-codes__head">
<h2></h2>
<h2>{t('role.role')}</h2>
</div>
<ul className="kx-adm-glist">
{roles.map((r) => (
@ -105,8 +107,8 @@ export function RoleAdminPage() {
>
<span className="kx-adm-gitem__name">{r.roleName}</span>
<span className="kx-adm-gitem__code">
{r.roleCode} · {r.permissions.length}
{r.useYn === 'N' ? ' · 미사용' : ''}
{t('role.codePerm', { code: r.roleCode, n: r.permissions.length })}
{r.useYn === 'N' ? ` · ${t('role.unused')}` : ''}
</span>
</button>
</li>
@ -115,32 +117,32 @@ export function RoleAdminPage() {
</section>
{/* 우: 권한 매트릭스 */}
<section className="kx-card kx-adm-matrix__perms" aria-label="권한 매트릭스">
<section className="kx-card kx-adm-matrix__perms" aria-label={t('role.permMatrix')}>
{activeRole ? (
<>
<div className="kx-adm-codes__head" style={{ padding: 0, border: 'none', marginBottom: 8 }}>
<div>
<h2>{activeRole.roleName} </h2>
<h2>{t('role.rolePerm', { name: activeRole.roleName })}</h2>
<p className="kx-adm-muted">{activeRole.description ?? activeRole.roleCode}</p>
</div>
<div className="kx-adm-tbl__actions">
<Button variant="ghost" onClick={() => setEditing(activeRole)}> </Button>
<Button variant="ghost" onClick={() => setEditing(activeRole)}>{t('role.roleInfo')}</Button>
<Button
variant="ghost"
className="kx-adm-del"
disabled={delM.isPending}
onClick={() => {
if (window.confirm(`역할 '${activeRole.roleName}' 을(를) 삭제하시겠습니까?`))
if (window.confirm(t('role.deleteConfirm', { name: activeRole.roleName })))
delM.mutate(activeRole.roleCode);
}}
>
{t('role.delete')}
</Button>
</div>
</div>
{perms.length === 0 ? (
<EmptyState title="정의된 권한이 없습니다" />
<EmptyState title={t('role.noPerms')} />
) : (
<div className="kx-adm-permgrid">
{perms.map((p) => {
@ -167,7 +169,7 @@ export function RoleAdminPage() {
)}
</>
) : (
<EmptyState title="역할을 선택하세요" />
<EmptyState title={t('role.selectRole')} />
)}
</section>
</div>
@ -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({
<div className="kx-adm-modal" role="dialog" aria-modal="true" aria-labelledby="role-modal-title">
<div className="kx-adm-modal__backdrop" onClick={onClose} />
<form className="kx-adm-modal__panel" onSubmit={submit}>
<h2 id="role-modal-title" className="kx-adm-modal__title">{isNew ? '역할 추가' : '역할 정보 수정'}</h2>
<p className="kx-adm-modal__desc"> .</p>
<h2 id="role-modal-title" className="kx-adm-modal__title">{isNew ? t('role.addRole') : t('role.editRole')}</h2>
<p className="kx-adm-modal__desc">{t('role.modalDesc')}</p>
<div className="kx-adm-modal__grid">
<label className="kx-adm-field">
<span className="kx-adm-field__label"> *</span>
<span className="kx-adm-field__label">{t('role.roleCode')}</span>
<input
className="kx-adm-field__input kx-adm-mono"
value={roleCode}
disabled={!isNew}
placeholder="예: ORGANIZER"
placeholder={t('role.roleCodePh')}
onChange={(e) => setRoleCode(e.target.value)}
/>
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label"> *</span>
<span className="kx-adm-field__label">{t('role.roleName')}</span>
<input className="kx-adm-field__input" value={roleName} onChange={(e) => setRoleName(e.target.value)} />
</label>
<label className="kx-adm-field kx-adm-field--full">
<span className="kx-adm-field__label"></span>
<span className="kx-adm-field__label">{t('role.description')}</span>
<input className="kx-adm-field__input" value={description} onChange={(e) => setDescription(e.target.value)} />
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label"> </span>
<span className="kx-adm-field__label">{t('role.useYn')}</span>
<select className="kx-adm-field__select" value={useYn} onChange={(e) => setUseYn(e.target.value)}>
<option value="Y"></option>
<option value="N"></option>
<option value="Y">{t('role.use')}</option>
<option value="N">{t('role.noUse')}</option>
</select>
</label>
</div>
{error && <p className="kx-adm-modal__error" role="alert">{error}</p>}
<div className="kx-adm-modal__actions">
<Button variant="ghost" type="button" onClick={onClose}></Button>
<Button type="submit" disabled={saving}>{saving ? '저장 중…' : '저장'}</Button>
<Button variant="ghost" type="button" onClick={onClose}>{t('role.cancel')}</Button>
<Button type="submit" disabled={saving}>{saving ? t('role.saving') : t('role.save')}</Button>
</div>
</form>
</div>

View File

@ -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() {
<div className="kx-page kx-adm">
<header className="kx-adm__head">
<div>
<h1 className="kx-adm__title"> </h1>
<p className="kx-adm__subtitle"> .</p>
<h1 className="kx-adm__title">{t('ruleset.title')}</h1>
<p className="kx-adm__subtitle">{t('ruleset.subtitle')}</p>
</div>
</header>
<div className="kx-adm-sample-banner" role="status">
<span className="kx-bi__degraded"> </span>
(rulesets/*.json) . ·
.
<span className="kx-bi__degraded">{t('ruleset.readonly')}</span>
{t('ruleset.readonlyNote')}
</div>
{listQ.isLoading && <RulesetSkeleton />}
{listQ.isError && (
<ErrorState message="룰셋 목록을 불러오지 못했습니다." onRetry={() => listQ.refetch()} />
<ErrorState message={t('ruleset.listError')} onRetry={() => listQ.refetch()} />
)}
{!listQ.isLoading && !listQ.isError && versions.length === 0 && (
<EmptyState title="배포된 룰셋이 없습니다" />
<EmptyState title={t('ruleset.noRulesets')} />
)}
{versions.length > 0 && (
<div className="kx-adm-ruleset">
{/* 좌: 버전 히스토리 */}
<aside className="kx-adm-ruleset__versions" aria-label="버전 히스토리">
<aside className="kx-adm-ruleset__versions" aria-label={t('ruleset.versionHistory')}>
<div className="kx-adm-ruleset__vhead">
<h2> </h2>
<h2>{t('ruleset.versionHistory')}</h2>
</div>
<ul className="kx-adm-vlist">
{versions.map((v) => (
@ -79,10 +81,10 @@ export function RulesetVersionsPage() {
>
<span className="kx-adm-vitem__top">
<strong>{v.version}</strong>
<span className="kx-adm-pill kx-adm-pill--neutral"> {v.ruleCount}</span>
<span className="kx-adm-pill kx-adm-pill--neutral">{t('ruleset.rulesCount', { n: v.ruleCount })}</span>
</span>
<span className="kx-adm-vitem__date">
{v.effectiveDate ? `발효일 ${v.effectiveDate}` : v.name}
{v.effectiveDate ? t('ruleset.effectiveDate', { date: v.effectiveDate }) : v.name}
</span>
<ModuleDistribution dist={v.moduleDistribution} />
</button>
@ -92,15 +94,15 @@ export function RulesetVersionsPage() {
</aside>
{/* 우: 규칙 목록 */}
<section className="kx-card kx-adm-ruleset__editor" aria-label="규칙 목록">
<section className="kx-card kx-adm-ruleset__editor" aria-label={t('ruleset.ruleList')}>
<div className="kx-adm-ruleset__ehead">
<div>
<h2>
{current?.version ?? activeName} ({rules.length})
{t('ruleset.rulesOf', { name: current?.version ?? activeName, n: rules.length })}
</h2>
<p className="kx-adm-muted">
{detailQ.data?.effectiveDate
? `발효일 ${detailQ.data.effectiveDate}`
? t('ruleset.effectiveDate', { date: detailQ.data.effectiveDate })
: current?.name}
</p>
</div>
@ -108,13 +110,13 @@ export function RulesetVersionsPage() {
{detailQ.isLoading && <Skeleton height={240} radius={12} />}
{detailQ.isError && (
<ErrorState message="규칙 전문을 불러오지 못했습니다." onRetry={() => detailQ.refetch()} />
<ErrorState message={t('ruleset.detailError')} onRetry={() => detailQ.refetch()} />
)}
{!detailQ.isLoading && !detailQ.isError && rules.length === 0 && (
<EmptyState
title="규칙 배열이 없는 룰셋입니다"
description="요율 마스터 등 규칙(rules) 없이 요율·단가만 정의된 룰셋입니다."
title={t('ruleset.noRulesTitle')}
description={t('ruleset.noRulesDesc')}
/>
)}
@ -123,10 +125,10 @@ export function RulesetVersionsPage() {
<table className="kx-table kx-table--zebra kx-adm-rules">
<thead>
<tr>
<th></th>
<th>/</th>
<th> / </th>
<th> </th>
<th>{t('ruleset.colRuleName')}</th>
<th>{t('ruleset.colValue')}</th>
<th>{t('ruleset.colLogic')}</th>
<th>{t('ruleset.colSeverity')}</th>
</tr>
</thead>
<tbody>
@ -136,13 +138,13 @@ export function RulesetVersionsPage() {
<span className="kx-adm-rulename">{r.label ?? r.code}</span>
<span className="kx-adm-rulecode">{r.code}</span>
</td>
<td className="kx-adm-mono">{formatValue(r)}</td>
<td className="kx-adm-muted">{formatLogic(r)}</td>
<td className="kx-adm-mono">{formatValue(t, r)}</td>
<td className="kx-adm-muted">{formatLogic(t, r)}</td>
<td>
<span
className={`kx-adm-pill kx-adm-pill--${r.severity === 'block' ? 'error' : 'warn'}`}
>
{r.severity === 'block' ? '차단' : '경고'}
{r.severity === 'block' ? t('ruleset.block') : t('ruleset.warn')}
</span>
</td>
</tr>
@ -173,7 +175,7 @@ function ModuleDistribution({ dist }: { dist: Record<string, number> }) {
}
/** 원문 규칙에서 표시용 수치/설정값을 유도한다(operator + threshold/min/max/unit). */
function formatValue(r: RulesetRule): string {
function formatValue(t: TFunction, r: RulesetRule): string {
const unit = r.unit ?? '';
switch (r.operator) {
case 'lte':
@ -185,7 +187,7 @@ function formatValue(r: RulesetRule): string {
case 'between':
return `${fmtNum(r.min)}${fmtNum(r.max)}${unit}`;
case 'isTrue':
return '필수';
return t('ruleset.required');
case 'excludesAll':
return (r.forbidden ?? []).join(' · ');
case 'lteHall':
@ -193,17 +195,17 @@ function formatValue(r: RulesetRule): string {
? Object.entries(r.hallLimits)
.map(([h, v]) => `${h} ${v}`)
.join(' · ') + unit
: `홀별 ${unit}`;
: t('ruleset.perHall', { unit });
default:
return r.threshold != null ? `${fmtNum(r.threshold)}${unit}` : '—';
}
}
/** 원문 규칙에서 논리/요구사항 설명을 유도한다(metric·requiresDocument·note). */
function formatLogic(r: RulesetRule): string {
function formatLogic(t: TFunction, r: RulesetRule): string {
const parts: string[] = [];
if (r.metric) parts.push(r.metric);
if (r.requiresDocument) parts.push(`문서 필요: ${r.requiresDocument}`);
if (r.requiresDocument) parts.push(t('ruleset.docNeeded', { doc: r.requiresDocument }));
if (r.note) parts.push(r.note);
return parts.length > 0 ? parts.join(' · ') : (r.group ?? '—');
}

View File

@ -6,6 +6,7 @@
*/
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Trans, useTranslation } from 'react-i18next';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { Button } from '../../components/ui/Button';
import { IconPlus, IconSearch } from '../../components/ui/icons';
@ -23,6 +24,7 @@ import './admin-modules.css';
const PAGE_SIZE = 20;
export function SysUserAdminPage() {
const { t } = useTranslation();
const qc = useQueryClient();
const { show, node: toast } = useToast();
@ -43,7 +45,7 @@ export function SysUserAdminPage() {
mutationFn: ({ userId, body }: { userId: string | null; body: UserSaveBody }) =>
userId ? sysUserApi.update(userId, body) : sysUserApi.create(body),
onSuccess: () => {
show('사용자를 저장했습니다.');
show(t('sysuser.saved'));
setEditing(undefined);
qc.invalidateQueries({ queryKey: ['admin-users'] });
},
@ -54,7 +56,7 @@ export function SysUserAdminPage() {
mutationFn: ({ userId, value }: { userId: string; value: string }) =>
sysUserApi.changeStatus(userId, value),
onSuccess: () => {
show('상태를 변경했습니다.');
show(t('sysuser.statusChanged'));
qc.invalidateQueries({ queryKey: ['admin-users'] });
},
onError: (e) => show(errMessage(e)),
@ -62,7 +64,7 @@ export function SysUserAdminPage() {
const otpM = useMutation({
mutationFn: (userId: string) => sysUserApi.resetOtp(userId),
onSuccess: () => show('OTP 를 초기화했습니다.'),
onSuccess: () => show(t('sysuser.otpReset')),
onError: (e) => show(errMessage(e)),
});
@ -90,43 +92,43 @@ export function SysUserAdminPage() {
<div className="kx-page kx-adm">
<header className="kx-adm__head">
<div>
<h1 className="kx-adm__title"> </h1>
<h1 className="kx-adm__title">{t('sysuser.title')}</h1>
<p className="kx-adm__subtitle">
·, , (//) OTP . .
{t('sysuser.subtitle')}
</p>
</div>
<Button leadingIcon={<IconPlus size={16} />} onClick={() => setEditing(null)}>
{t('sysuser.addUser')}
</Button>
</header>
{/* 검색 툴바 */}
<section className="kx-card kx-adm-filter" aria-label="사용자 검색">
<section className="kx-card kx-adm-filter" aria-label={t('sysuser.searchAria')}>
<div className="kx-adm-toolbar">
<div className="kx-adm-toolbar__filters">
<label className="kx-adm-field">
<span className="kx-adm-field__label"></span>
<span className="kx-adm-field__label">{t('sysuser.keyword')}</span>
<input
className="kx-adm-field__input"
placeholder="이름·이메일"
placeholder={t('sysuser.keywordPh')}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && applyFilters()}
/>
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label"></span>
<span className="kx-adm-field__label">{t('sysuser.role')}</span>
<select className="kx-adm-field__select" value={roleCode} onChange={(e) => setRoleCode(e.target.value)}>
<option value=""></option>
<option value="">{t('sysuser.all')}</option>
{USER_ROLE_CODES.map((r) => (
<option key={r} value={r}>{r}</option>
))}
</select>
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label"></span>
<span className="kx-adm-field__label">{t('sysuser.status')}</span>
<select className="kx-adm-field__select" value={status} onChange={(e) => setStatus(e.target.value)}>
<option value=""></option>
<option value="">{t('sysuser.all')}</option>
{USER_STATUSES.map((s) => (
<option key={s} value={s}>{s}</option>
))}
@ -134,35 +136,35 @@ export function SysUserAdminPage() {
</label>
</div>
<div className="kx-adm-toolbar__actions">
<Button variant="ghost" onClick={resetFilters}></Button>
<Button leadingIcon={<IconSearch size={16} />} onClick={applyFilters}></Button>
<Button variant="ghost" onClick={resetFilters}>{t('sysuser.reset')}</Button>
<Button leadingIcon={<IconSearch size={16} />} onClick={applyFilters}>{t('sysuser.search')}</Button>
</div>
</div>
</section>
{q.isLoading && <TableSkeleton />}
{q.isError && !q.isLoading && (
<ErrorState message="사용자 목록을 불러오지 못했습니다." onRetry={() => q.refetch()} />
<ErrorState message={t('sysuser.loadError')} onRetry={() => q.refetch()} />
)}
{!q.isLoading && !q.isError && (
<section className="kx-card kx-adm-table-card" aria-label="사용자 목록">
<section className="kx-card kx-adm-table-card" aria-label={t('sysuser.listAria')}>
{rows.length === 0 ? (
<EmptyState title="사용자가 없습니다" description="검색 조건을 조정하거나 사용자를 추가하세요." />
<EmptyState title={t('sysuser.emptyTitle')} description={t('sysuser.emptyDesc')} />
) : (
<>
<div className="kx-table-scroll">
<table className="kx-table kx-table--zebra kx-adm-tbl">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th>{t('sysuser.colUser')}</th>
<th>{t('sysuser.colEmail')}</th>
<th>{t('sysuser.colRole')}</th>
<th>{t('sysuser.colDept')}</th>
<th>{t('sysuser.colStatus')}</th>
<th>OTP</th>
<th> </th>
<th style={{ textAlign: 'right' }}></th>
<th>{t('sysuser.colLastLogin')}</th>
<th style={{ textAlign: 'right' }}>{t('sysuser.colManage')}</th>
</tr>
</thead>
<tbody>
@ -171,7 +173,7 @@ export function SysUserAdminPage() {
<td>
<span className="kx-adm-tbl__strong">{u.displayName ?? '—'}</span>
{u.hallManager && (
<span className="kx-adm-typechip" style={{ marginLeft: 6 }}></span>
<span className="kx-adm-typechip" style={{ marginLeft: 6 }}>{t('sysuser.hallManagerTag')}</span>
)}
</td>
<td className="kx-adm-muted">{u.email ?? '—'}</td>
@ -180,20 +182,20 @@ export function SysUserAdminPage() {
<td><StatusCell status={u.status} /></td>
<td>
<span className={`kx-adm-pill kx-adm-pill--${u.otpEnabled ? 'success' : 'neutral'}`}>
{u.otpEnabled ? '설정됨' : '미설정'}
{u.otpEnabled ? t('sysuser.otpSet') : t('sysuser.otpUnset')}
</span>
</td>
<td className="kx-adm-mono">{u.lastLoginAt ? fmtDateTime(u.lastLoginAt) : '—'}</td>
<td>
<div className="kx-adm-tbl__actions">
<Button variant="ghost" onClick={() => setEditing(u)}></Button>
<Button variant="ghost" onClick={() => setEditing(u)}>{t('sysuser.edit')}</Button>
{u.status !== 'LOCKED' ? (
<Button
variant="ghost"
disabled={statusM.isPending}
onClick={() => statusM.mutate({ userId: u.userId, value: 'LOCKED' })}
>
{t('sysuser.lock')}
</Button>
) : (
<Button
@ -201,7 +203,7 @@ export function SysUserAdminPage() {
disabled={statusM.isPending}
onClick={() => statusM.mutate({ userId: u.userId, value: 'ACTIVE' })}
>
{t('sysuser.unlock')}
</Button>
)}
{u.otpEnabled && (
@ -209,11 +211,11 @@ export function SysUserAdminPage() {
variant="ghost"
disabled={otpM.isPending}
onClick={() => {
if (window.confirm(`${u.displayName ?? u.userId} 의 OTP 를 초기화하시겠습니까?`))
if (window.confirm(t('sysuser.otpResetConfirm', { name: u.displayName ?? u.userId })))
otpM.mutate(u.userId);
}}
>
OTP초기화
{t('sysuser.otpResetBtn')}
</Button>
)}
</div>
@ -249,9 +251,10 @@ function RoleChip({ value }: { value: string | null }) {
}
function StatusCell({ status }: { status: string | null }) {
const { t } = useTranslation();
const v = (status ?? 'ACTIVE').toUpperCase();
const tone = v === 'ACTIVE' ? 'success' : v === 'LOCKED' ? 'error' : 'neutral';
const label = v === 'ACTIVE' ? '활성' : v === 'LOCKED' ? '잠금' : v === 'DISABLED' ? '비활성' : v;
const label = v === 'ACTIVE' ? t('sysuser.active') : v === 'LOCKED' ? t('sysuser.locked') : v === 'DISABLED' ? t('sysuser.disabled') : v;
return <span className={`kx-adm-pill kx-adm-pill--${tone}`}>{label}</span>;
}
@ -266,6 +269,7 @@ function UserModal({
onClose: () => void;
onSubmit: (body: UserSaveBody) => void;
}) {
const { t } = useTranslation();
const isNew = user === null;
const [email, setEmail] = useState(user?.email ?? '');
const [displayName, setDisplayName] = useState(user?.displayName ?? '');
@ -279,11 +283,11 @@ function UserModal({
e.preventDefault();
setError(null);
if (!email.trim() || !displayName.trim()) {
setError('이메일과 표시 이름은 필수입니다.');
setError(t('sysuser.errRequired'));
return;
}
if (isNew && password.trim() === '') {
setError('신규 사용자는 초기 비밀번호가 필요합니다.');
setError(t('sysuser.errPassword'));
return;
}
const body: UserSaveBody = {
@ -301,13 +305,13 @@ function UserModal({
<div className="kx-adm-modal" role="dialog" aria-modal="true" aria-labelledby="user-modal-title">
<div className="kx-adm-modal__backdrop" onClick={onClose} />
<form className="kx-adm-modal__panel" onSubmit={submit}>
<h2 id="user-modal-title" className="kx-adm-modal__title">{isNew ? '사용자 추가' : '사용자 수정'}</h2>
<h2 id="user-modal-title" className="kx-adm-modal__title">{isNew ? t('sysuser.addUser') : t('sysuser.editUser')}</h2>
<p className="kx-adm-modal__desc">
{isNew ? '초기 비밀번호는 저장 후 화면에 표시되지 않습니다.' : '이메일은 수정할 수 없습니다. 비밀번호 재설정은 별도 정책을 따릅니다.'}
{isNew ? t('sysuser.newDesc') : t('sysuser.editDesc')}
</p>
<div className="kx-adm-modal__grid">
<label className="kx-adm-field kx-adm-field--full">
<span className="kx-adm-field__label"> *</span>
<span className="kx-adm-field__label">{t('sysuser.email')}</span>
<input
className="kx-adm-field__input"
type="email"
@ -318,16 +322,16 @@ function UserModal({
/>
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label"> *</span>
<span className="kx-adm-field__label">{t('sysuser.displayName')}</span>
<input className="kx-adm-field__input" value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label"></span>
<span className="kx-adm-field__label">{t('sysuser.dept')}</span>
<input className="kx-adm-field__input" value={deptId} onChange={(e) => setDeptId(e.target.value)} />
</label>
{isNew && (
<label className="kx-adm-field kx-adm-field--full">
<span className="kx-adm-field__label"> *</span>
<span className="kx-adm-field__label">{t('sysuser.initPassword')}</span>
<input
className="kx-adm-field__input"
type="password"
@ -338,7 +342,7 @@ function UserModal({
</label>
)}
<label className="kx-adm-field">
<span className="kx-adm-field__label"></span>
<span className="kx-adm-field__label">{t('sysuser.role')}</span>
<select className="kx-adm-field__select" value={roleCode ?? 'USER'} onChange={(e) => setRoleCode(e.target.value)}>
{USER_ROLE_CODES.map((r) => (
<option key={r} value={r}>{r}</option>
@ -347,13 +351,13 @@ function UserModal({
</label>
<label className="kx-adm-check" style={{ alignSelf: 'end', paddingBottom: 8 }}>
<input type="checkbox" checked={hallManager} onChange={(e) => setHallManager(e.target.checked)} />
( )
{t('sysuser.hallManagerCheck')}
</label>
</div>
{error && <p className="kx-adm-modal__error" role="alert">{error}</p>}
<div className="kx-adm-modal__actions">
<Button variant="ghost" type="button" onClick={onClose}></Button>
<Button type="submit" disabled={saving}>{saving ? '저장 중…' : '저장'}</Button>
<Button variant="ghost" type="button" onClick={onClose}>{t('sysuser.cancel')}</Button>
<Button type="submit" disabled={saving}>{saving ? t('sysuser.saving') : t('sysuser.save')}</Button>
</div>
</form>
</div>
@ -371,13 +375,14 @@ function Pager({
total: number;
onPage: (p: number) => void;
}) {
const { t } = useTranslation();
return (
<div className="kx-adm-pager">
<p className="kx-adm-pager__info"> <strong className="tnum">{total.toLocaleString()}</strong></p>
<p className="kx-adm-pager__info"><Trans i18nKey="sysuser.totalUsers" values={{ total: total.toLocaleString() }} components={[<strong className="tnum" key="a" />]} /></p>
<div className="kx-adm-pager__nav">
<Button variant="ghost" onClick={() => onPage(Math.max(0, page - 1))} disabled={page === 0}></Button>
<Button variant="ghost" onClick={() => onPage(Math.max(0, page - 1))} disabled={page === 0}>{t('sysuser.prev')}</Button>
<span className="kx-adm-pager__page tnum">{page + 1} / {totalPages}</span>
<Button variant="ghost" onClick={() => onPage(Math.min(totalPages - 1, page + 1))} disabled={page >= totalPages - 1}></Button>
<Button variant="ghost" onClick={() => onPage(Math.min(totalPages - 1, page + 1))} disabled={page >= totalPages - 1}>{t('sysuser.next')}</Button>
</div>
</div>
);

View File

@ -7,6 +7,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';
@ -15,8 +16,10 @@ import { settingApi, type SettingRow, type SettingSaveBody } from './adminModule
import './admin-modules.css';
const VALUE_TYPES = ['STRING', 'NUMBER', 'BOOLEAN', 'JSON'];
const GENERAL_GROUP = '__general__';
export function SystemSettingsPage() {
const { t } = useTranslation();
const qc = useQueryClient();
const { show, node: toast } = useToast();
@ -25,7 +28,7 @@ export function SystemSettingsPage() {
const saveM = useMutation({
mutationFn: (body: SettingSaveBody) => settingApi.save(body),
onSuccess: () => {
show('설정을 저장했습니다.');
show(t('settings.saved'));
qc.invalidateQueries({ queryKey: ['admin-settings'] });
},
onError: (e) => show(errMessage(e)),
@ -33,7 +36,7 @@ export function SystemSettingsPage() {
const delM = useMutation({
mutationFn: (key: string) => settingApi.remove(key),
onSuccess: () => {
show('설정을 삭제했습니다.');
show(t('settings.deleted'));
qc.invalidateQueries({ queryKey: ['admin-settings'] });
},
onError: (e) => show(errMessage(e)),
@ -45,9 +48,9 @@ export function SystemSettingsPage() {
<div className="kx-page kx-adm">
<header className="kx-adm__head">
<div>
<h1 className="kx-adm__title"> </h1>
<h1 className="kx-adm__title">{t('settings.title')}</h1>
<p className="kx-adm__subtitle">
· ·AI . 릿 .
{t('settings.subtitle')}
</p>
</div>
</header>
@ -55,15 +58,15 @@ export function SystemSettingsPage() {
{q.isLoading && <SettingsSkeleton />}
{q.isError && !q.isLoading && (
<ErrorState message="시스템 설정을 불러오지 못했습니다." onRetry={() => q.refetch()} />
<ErrorState message={t('settings.loadError')} onRetry={() => q.refetch()} />
)}
{!q.isLoading && !q.isError && (
<>
{(q.data ?? []).length === 0 ? (
<EmptyState
title="등록된 설정이 없습니다"
description="아래 폼에서 첫 설정 항목을 추가하세요."
title={t('settings.emptyTitle')}
description={t('settings.emptyDesc')}
/>
) : (
<div className="kx-adm-settings">
@ -76,7 +79,7 @@ export function SystemSettingsPage() {
deleting={delM.isPending}
onSave={(b) => saveM.mutate(b)}
onDelete={(k) => {
if (window.confirm(`설정 '${k}' 을(를) 삭제하시겠습니까?`)) delM.mutate(k);
if (window.confirm(t('settings.deleteConfirm', { key: k }))) delM.mutate(k);
}}
/>
))}
@ -106,11 +109,13 @@ function SettingGroupCard({
onSave: (b: SettingSaveBody) => void;
onDelete: (key: string) => void;
}) {
const { t } = useTranslation();
const displayName = name === GENERAL_GROUP ? t('settings.general') : name;
return (
<section className="kx-card kx-adm-group" aria-label={`설정 그룹 ${name}`}>
<section className="kx-card kx-adm-group" aria-label={t('settings.groupAria', { name: displayName })}>
<div className="kx-adm-group__accent" aria-hidden="true" />
<div className="kx-adm-group__body">
<h2 className="kx-adm-group__title">{name}</h2>
<h2 className="kx-adm-group__title">{displayName}</h2>
<ul className="kx-adm-setrows">
{rows.map((r) => (
<SettingRowItem
@ -141,6 +146,7 @@ function SettingRowItem({
onSave: (b: SettingSaveBody) => void;
onDelete: (key: string) => void;
}) {
const { t } = useTranslation();
const isSecret = row.secretYn === 'Y';
const isBool = (row.valueType ?? '').toUpperCase() === 'BOOLEAN';
// 시크릿: 입력 비움 상태에서 시작(마스킹 값을 재저장하지 않기 위함). 비-시크릿: 현재값 편집.
@ -165,7 +171,7 @@ function SettingRowItem({
<div className="kx-adm-setrow__meta">
<span className="kx-adm-setrow__key">
{row.settingKey}
{isSecret && <span className="kx-adm-secret" title="시크릿 — 값 마스킹">릿</span>}
{isSecret && <span className="kx-adm-secret" title={t('settings.secretTitle')}>{t('settings.secret')}</span>}
{row.valueType && <span className="kx-adm-typechip">{row.valueType}</span>}
</span>
{row.description && <span className="kx-adm-setrow__desc">{row.description}</span>}
@ -181,7 +187,7 @@ function SettingRowItem({
onChange={(e) => submit(e.target.checked ? 'true' : 'false')}
/>
<span className="kx-adm-switch__track" aria-hidden="true" />
<span className="kx-adm-switch__label">{boolChecked ? '켜짐' : '꺼짐'}</span>
<span className="kx-adm-switch__label">{boolChecked ? t('settings.on') : t('settings.off')}</span>
</label>
) : (
<>
@ -189,7 +195,7 @@ function SettingRowItem({
className="kx-adm-field__input kx-adm-setrow__input"
type={isSecret ? 'password' : 'text'}
value={val}
placeholder={isSecret ? '설정됨(마스킹) — 변경 시 새 값 입력' : ''}
placeholder={isSecret ? t('settings.secretPh') : ''}
autoComplete={isSecret ? 'new-password' : 'off'}
onChange={(e) => {
setVal(e.target.value);
@ -201,7 +207,7 @@ function SettingRowItem({
disabled={saving || !dirty || (isSecret && val.trim() === '')}
onClick={() => submit(val)}
>
{t('settings.save')}
</Button>
</>
)}
@ -210,14 +216,14 @@ function SettingRowItem({
className="kx-adm-del"
disabled={deleting}
onClick={() => onDelete(row.settingKey)}
aria-label={`${row.settingKey} 삭제`}
aria-label={t('settings.deleteKey', { key: row.settingKey })}
>
{t('settings.delete')}
</Button>
</div>
{isSecret && (
<p className="kx-adm-setrow__hint">
릿 . .
{t('settings.secretHint')}
</p>
)}
</li>
@ -231,6 +237,7 @@ function AddSettingCard({
saving: boolean;
onSave: (b: SettingSaveBody) => void;
}) {
const { t } = useTranslation();
const [key, setKey] = useState('');
const [value, setValue] = useState('');
const [type, setType] = useState('STRING');
@ -256,20 +263,20 @@ function AddSettingCard({
}
return (
<section className="kx-card kx-adm-add" aria-label="설정 추가">
<h2 className="kx-adm-add__title"> / </h2>
<section className="kx-card kx-adm-add" aria-label={t('settings.addAria')}>
<h2 className="kx-adm-add__title">{t('settings.addTitle')}</h2>
<div className="kx-adm-add__grid">
<label className="kx-adm-field">
<span className="kx-adm-field__label"> </span>
<span className="kx-adm-field__label">{t('settings.settingKey')}</span>
<input
className="kx-adm-field__input"
placeholder="예: deadline.utility.days"
placeholder={t('settings.settingKeyPh')}
value={key}
onChange={(e) => setKey(e.target.value)}
/>
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label"></span>
<span className="kx-adm-field__label">{t('settings.value')}</span>
<input
className="kx-adm-field__input"
type={secret ? 'password' : 'text'}
@ -278,17 +285,17 @@ function AddSettingCard({
/>
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label"></span>
<span className="kx-adm-field__label">{t('settings.type')}</span>
<select className="kx-select" value={type} onChange={(e) => setType(e.target.value)}>
{VALUE_TYPES.map((t) => (
<option key={t} value={t}>
{t}
{VALUE_TYPES.map((vt) => (
<option key={vt} value={vt}>
{vt}
</option>
))}
</select>
</label>
<label className="kx-adm-field kx-adm-add__desc">
<span className="kx-adm-field__label"></span>
<span className="kx-adm-field__label">{t('settings.description')}</span>
<input
className="kx-adm-field__input"
value={desc}
@ -297,11 +304,11 @@ function AddSettingCard({
</label>
<label className="kx-adm-check">
<input type="checkbox" checked={secret} onChange={(e) => setSecret(e.target.checked)} />
릿( )
{t('settings.secretCheck')}
</label>
<div className="kx-adm-add__actions">
<Button leadingIcon={<IconPlus size={16} />} disabled={saving || !canSave} onClick={submit}>
{t('settings.save')}
</Button>
</div>
</div>
@ -324,7 +331,7 @@ function groupByPrefix(rows: SettingRow[]): { name: string; rows: SettingRow[] }
const map = new Map<string, SettingRow[]>();
for (const r of rows) {
const dot = r.settingKey.indexOf('.');
const name = dot > 0 ? r.settingKey.slice(0, dot) : '일반';
const name = dot > 0 ? r.settingKey.slice(0, dot) : GENERAL_GROUP;
const arr = map.get(name) ?? [];
arr.push(r);
map.set(name, arr);

View File

@ -4,6 +4,7 @@
* IA .
*/
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { IconCheck } from '../../components/ui/icons';
import './admin-modules.css';
@ -22,19 +23,12 @@ const TENANTS: TenantRow[] = [
{ code: 'BEXCO', name: '벡스코', domain: 'bexco.wise.ai.kr', status: 'suspended', events: 5, users: 210 },
];
const WIZARD_STEPS = [
{ key: 'create', label: '테넌트 생성', desc: '코드·표시명 등록' },
{ key: 'domain', label: '서브도메인', desc: 'DNS·브랜딩 도메인 배정' },
{ key: 'brand', label: '브랜딩', desc: '로고·색상·공개사이트 테마' },
{ key: 'master', label: '마스터데이터 입력', desc: '홀·요율·룰셋 시드' },
{ key: 'admin', label: '관리자 계정', desc: '테넌트 관리자 초대' },
{ key: 'smoke', label: '격리 스모크', desc: '크로스-테넌트 격리 검증' },
{ key: 'activate', label: '활성화', desc: '운영 전환' },
];
const WIZARD_STEPS = ['create', 'domain', 'brand', 'master', 'admin', 'smoke', 'activate'];
export function TenantAdminPage() {
const { t } = useTranslation();
const [selected, setSelected] = useState<string>(TENANTS[1].code);
const tenant = TENANTS.find((t) => t.code === selected) ?? TENANTS[0];
const tenant = TENANTS.find((tn) => tn.code === selected) ?? TENANTS[0];
// 온보딩 진행 단계(샘플): active=완료 / onboarding=4단계 진행 / suspended=완료 후 중지
const currentStep = tenant.status === 'onboarding' ? 3 : WIZARD_STEPS.length;
@ -42,51 +36,50 @@ export function TenantAdminPage() {
<div className="kx-page kx-adm">
<header className="kx-adm__head">
<div>
<h1 className="kx-adm__title"> ·</h1>
<p className="kx-adm__subtitle"> · .</p>
<h1 className="kx-adm__title">{t('tenant.title')}</h1>
<p className="kx-adm__subtitle">{t('tenant.subtitle')}</p>
</div>
</header>
<div className="kx-adm-sample-banner" role="status">
<span className="kx-bi__degraded"> · </span>
( CRUD··) IA .
.
<span className="kx-bi__degraded">{t('tenant.sampleTag')}</span>
{t('tenant.sampleNote')}
</div>
<div className="kx-adm-tenant">
<section className="kx-card kx-adm-tenant__list" aria-label="테넌트 목록">
<section className="kx-card kx-adm-tenant__list" aria-label={t('tenant.listAria')}>
<div className="kx-adm-tenant__lhead">
<h2></h2>
<button className="kx-btn kx-btn--secondary" disabled title="미구현">
<h2>{t('tenant.venue')}</h2>
<button className="kx-btn kx-btn--secondary" disabled title={t('tenant.notImpl')}>
{t('tenant.addVenue')}
</button>
</div>
<div className="kx-table-scroll">
<table className="kx-table kx-table--zebra kx-adm-tenants">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th className="kx-num"></th>
<th className="kx-num"></th>
<th>{t('tenant.venue')}</th>
<th>{t('tenant.code')}</th>
<th>{t('tenant.domain')}</th>
<th>{t('tenant.status')}</th>
<th className="kx-num">{t('tenant.events')}</th>
<th className="kx-num">{t('tenant.users')}</th>
</tr>
</thead>
<tbody>
{TENANTS.map((t) => (
{TENANTS.map((tn) => (
<tr
key={t.code}
className={t.code === selected ? 'is-selected' : ''}
onClick={() => setSelected(t.code)}
key={tn.code}
className={tn.code === selected ? 'is-selected' : ''}
onClick={() => setSelected(tn.code)}
style={{ cursor: 'pointer' }}
>
<td className="kx-adm-tenants__name">{t.name}</td>
<td className="kx-adm-mono">{t.code}</td>
<td className="kx-adm-muted">{t.domain}</td>
<td><TenantStatus status={t.status} /></td>
<td className="kx-num tnum">{t.events}</td>
<td className="kx-num tnum">{t.users.toLocaleString()}</td>
<td className="kx-adm-tenants__name">{tn.name}</td>
<td className="kx-adm-mono">{tn.code}</td>
<td className="kx-adm-muted">{tn.domain}</td>
<td><TenantStatus status={tn.status} /></td>
<td className="kx-num tnum">{tn.events}</td>
<td className="kx-num tnum">{tn.users.toLocaleString()}</td>
</tr>
))}
</tbody>
@ -94,20 +87,20 @@ export function TenantAdminPage() {
</div>
</section>
<aside className="kx-card kx-adm-tenant__wizard" aria-label="온보딩 위저드">
<h2 className="kx-adm-wiz__title">{tenant.name} </h2>
<p className="kx-adm-muted"> </p>
<aside className="kx-card kx-adm-tenant__wizard" aria-label={t('tenant.wizardAria')}>
<h2 className="kx-adm-wiz__title">{t('tenant.onboardingOf', { name: tenant.name })}</h2>
<p className="kx-adm-muted">{t('tenant.dataOnboarding')}</p>
<ol className="kx-adm-wiz">
{WIZARD_STEPS.map((s, i) => {
{WIZARD_STEPS.map((key, i) => {
const state = i < currentStep ? 'done' : i === currentStep ? 'current' : 'todo';
return (
<li key={s.key} className={`kx-adm-wiz__step is-${state}`}>
<li key={key} className={`kx-adm-wiz__step is-${state}`}>
<span className="kx-adm-wiz__dot" aria-hidden="true">
{state === 'done' ? <IconCheck size={14} /> : i + 1}
</span>
<span className="kx-adm-wiz__body">
<span className="kx-adm-wiz__label">{s.label}</span>
<span className="kx-adm-wiz__desc">{s.desc}</span>
<span className="kx-adm-wiz__label">{t(`tenant.wiz.${key}.label`)}</span>
<span className="kx-adm-wiz__desc">{t(`tenant.wiz.${key}.desc`)}</span>
</span>
</li>
);
@ -120,11 +113,7 @@ export function TenantAdminPage() {
}
function TenantStatus({ status }: { status: TenantRow['status'] }) {
const map = {
active: { cls: 'success', label: '활성' },
onboarding: { cls: 'warn', label: '온보딩중' },
suspended: { cls: 'error', label: '중지' },
} as const;
const m = map[status];
return <span className={`kx-adm-pill kx-adm-pill--${m.cls}`}>{m.label}</span>;
const { t } = useTranslation();
const cls = status === 'active' ? 'success' : status === 'onboarding' ? 'warn' : 'error';
return <span className={`kx-adm-pill kx-adm-pill--${cls}`}>{t(`tenant.tstatus.${status}`)}</span>;
}

View File

@ -1,5 +1,6 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
Area,
AreaChart,
@ -39,6 +40,7 @@ import './analytics.css';
* 폴백: NETWORK/NOT_FOUND sampleAnalytics . sectors·exhibitorPerf .
*/
export function AnalyticsDashboardPage() {
const { t } = useTranslation();
const [perspective, setPerspective] = useState<AnalyticsPerspective>('operator');
const [period, setPeriod] = useState<AnalyticsPeriod>('event');
const eventId = useResolvedEventId();
@ -59,18 +61,18 @@ export function AnalyticsDashboardPage() {
<div className="kx-bi">
<header className="kx-bi__head">
<div>
<h1 className="kx-bi__title"></h1>
<p className="kx-bi__subtitle"> · </p>
<h1 className="kx-bi__title">{t('analytics.title')}</h1>
<p className="kx-bi__subtitle">{t('analytics.subtitle')}</p>
</div>
<div className="kx-bi__head-actions">
<div className="kx-seg" role="tablist" aria-label="분석 관점">
<div className="kx-seg" role="tablist" aria-label={t('analytics.perspective')}>
<button
role="tab"
aria-selected={isOperator}
className={`kx-seg__btn ${isOperator ? 'is-active' : ''}`}
onClick={() => setPerspective('operator')}
>
{t('analytics.operator')}
</button>
<button
role="tab"
@ -78,47 +80,47 @@ export function AnalyticsDashboardPage() {
className={`kx-seg__btn ${!isOperator ? 'is-active' : ''}`}
onClick={() => setPerspective('exhibitor')}
>
{t('analytics.exhibitor')}
</button>
</div>
<select
className="kx-select"
aria-label="분석 기간"
aria-label={t('analytics.periodAria')}
value={period}
onChange={(e) => setPeriod(e.target.value as AnalyticsPeriod)}
>
<option value="event"> </option>
<option value="d90"> 90</option>
<option value="annual"></option>
<option value="event">{t('analytics.periodEvent')}</option>
<option value="d90">{t('analytics.periodD90')}</option>
<option value="annual">{t('analytics.periodAnnual')}</option>
</select>
<Button variant="secondary">PDF </Button>
<Button variant="secondary">Excel </Button>
<Button variant="secondary">{t('analytics.exportPdf')}</Button>
<Button variant="secondary">{t('analytics.exportExcel')}</Button>
</div>
</header>
{!eventId && (
<EmptyState title="행사를 선택해 주세요" description="분석할 행사가 지정되지 않았습니다." />
<EmptyState title={t('analytics.noEventTitle')} description={t('analytics.noEventDesc')} />
)}
{degraded && (
<div className="kx-bi__degraded-bar">
<span className="kx-bi__degraded"> </span>
API에 .
<span className="kx-bi__degraded">{t('analytics.offlineSample')}</span>
{t('analytics.degradedMsg')}
</div>
)}
{q.isLoading && <BiSkeleton />}
{hardError && (
<ErrorState message="경영분석 집계를 불러오지 못했습니다." onRetry={() => q.refetch()} />
<ErrorState message={t('analytics.loadError')} onRetry={() => q.refetch()} />
)}
{data && (
<>
{/* KPI 밴드 */}
<section className="kx-bi__kpis" aria-label="핵심 지표">
<section className="kx-bi__kpis" aria-label={t('analytics.kpisAria')}>
{data.kpis.length === 0 ? (
<EmptyState title="집계된 지표가 없습니다" />
<EmptyState title={t('analytics.kpiEmpty')} />
) : (
data.kpis.map((k, i) => <KpiCard key={`${k.label}-${i}`} kpi={k} />)
)}
@ -126,15 +128,15 @@ export function AnalyticsDashboardPage() {
{/* 히어로 추세(2/3) + 업종 구성(1/3) */}
<div className="kx-bi__split">
<section className="kx-card kx-bi__trend" aria-label="추세 차트">
<section className="kx-card kx-bi__trend" aria-label={t('analytics.trendAria')}>
<div className="kx-card__head">
<h2> · </h2>
<AiLabel> </AiLabel>
<h2>{t('analytics.trendTitle')}</h2>
<AiLabel>{t('analytics.monthlyAgg')}</AiLabel>
</div>
<p className="kx-card__hint"> · ( 단위: ).</p>
<p className="kx-card__hint">{t('analytics.trendHint')}</p>
<div className="kx-bi__chart">
{data.trend.length === 0 ? (
<EmptyState title="추이 데이터가 없습니다" description="집계 가능한 기간의 매출·부스 레코드가 없습니다." />
<EmptyState title={t('analytics.trendEmptyTitle')} description={t('analytics.trendEmptyDesc')} />
) : (
<ResponsiveContainer width="100%" height={320}>
<AreaChart data={data.trend} margin={{ top: 8, right: 12, bottom: 0, left: -8 }}>
@ -150,26 +152,26 @@ export function AnalyticsDashboardPage() {
<YAxis yAxisId="booth" orientation="right" tick={{ fontSize: 12, fill: CHART.slate }} tickLine={false} axisLine={false} width={36} />
<Tooltip
contentStyle={TOOLTIP_STYLE}
formatter={(value, name) =>
name === '매출' ? formatWon(value as number) : `${(value as number).toLocaleString()} 부스`
formatter={(value, _name, item) =>
item?.dataKey === 'revenue' ? formatWon(value as number) : t('analytics.boothsUnit', { n: (value as number).toLocaleString() })
}
/>
<Area yAxisId="rev" type="monotone" dataKey="revenue" name="매출" stroke={CHART.primary600} fill="url(#gRevenue)" strokeWidth={2} />
<Area yAxisId="booth" type="monotone" dataKey="booths" name="부스" stroke={CHART.aiAccent} fill={CHART.aiAccent} fillOpacity={0.08} strokeWidth={2} />
<Area yAxisId="rev" type="monotone" dataKey="revenue" name={t('analytics.revenue')} stroke={CHART.primary600} fill="url(#gRevenue)" strokeWidth={2} />
<Area yAxisId="booth" type="monotone" dataKey="booths" name={t('analytics.booth')} stroke={CHART.aiAccent} fill={CHART.aiAccent} fillOpacity={0.08} strokeWidth={2} />
</AreaChart>
</ResponsiveContainer>
)}
</div>
</section>
<section className="kx-card kx-bi__sectors" aria-label="업종별 구성">
<section className="kx-card kx-bi__sectors" aria-label={t('analytics.sectorsAria')}>
<div className="kx-card__head">
<h2> </h2>
<h2>{t('analytics.sectorsTitle')}</h2>
</div>
{data.sectors.length === 0 ? (
<EmptyState
title="업종 분류 미집계"
description="업종 분류 원천(M13)이 연동되면 표시됩니다."
title={t('analytics.sectorsEmptyTitle')}
description={t('analytics.sectorsEmptyDesc')}
/>
) : (
<ul className="kx-barlist">
@ -190,22 +192,22 @@ export function AnalyticsDashboardPage() {
</div>
{/* 성과 테이블(전폭) */}
<section className="kx-card kx-bi__table-card" aria-label="성과 테이블">
<section className="kx-card kx-bi__table-card" aria-label={t('analytics.perfTable')}>
<div className="kx-card__head">
<h2>{isOperator ? '손익(P&L) 구성' : '참가사 성과'}</h2>
{degraded && <span className="kx-bi__degraded"></span>}
<h2>{isOperator ? t('analytics.pnlTitle') : t('analytics.exhibitorPerfTitle')}</h2>
{degraded && <span className="kx-bi__degraded">{t('analytics.sampleTag')}</span>}
</div>
<div className="kx-table-scroll">
{isOperator ? (
data.pnl.length === 0 ? (
<EmptyState title="손익 항목이 없습니다" description="부스 임대·유틸리티 매출 레코드가 집계되면 표시됩니다." />
<EmptyState title={t('analytics.pnlEmptyTitle')} description={t('analytics.pnlEmptyDesc')} />
) : (
<table className="kx-table kx-table--zebra">
<thead>
<tr>
<th></th>
<th className="kx-num"></th>
<th></th>
<th>{t('analytics.colItem')}</th>
<th className="kx-num">{t('analytics.colAmount')}</th>
<th>{t('analytics.colGrade')}</th>
</tr>
</thead>
<tbody>
@ -220,14 +222,14 @@ export function AnalyticsDashboardPage() {
</table>
)
) : data.exhibitorPerf.length === 0 ? (
<EmptyState title="참가사 성과 미집계" description="리드·전환율 원천(M13 방문자)이 연동되면 표시됩니다." />
<EmptyState title={t('analytics.perfEmptyTitle')} description={t('analytics.perfEmptyDesc')} />
) : (
<table className="kx-table kx-table--zebra">
<thead>
<tr>
<th></th>
<th className="kx-num"></th>
<th className="kx-num"></th>
<th>{t('analytics.colCompany')}</th>
<th className="kx-num">{t('analytics.colLeads')}</th>
<th className="kx-num">{t('analytics.colConversion')}</th>
</tr>
</thead>
<tbody>

View File

@ -1,5 +1,6 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
Bar,
BarChart,
@ -27,6 +28,7 @@ import {
* · () · · (, AI ).
*/
export function GlobalOverviewSection() {
const { t } = useTranslation();
const [years, setYears] = useState(5);
const q = useQuery({
queryKey: ['analytics-overview', years],
@ -38,27 +40,27 @@ export function GlobalOverviewSection() {
if (q.isError && isHideable(q.error)) return null;
return (
<section className="kx-bi__global" aria-label="테넌트 전역 경영분석">
<section className="kx-bi__global" aria-label={t('global.sectionAria')}>
<div className="kx-bi__global-head">
<div>
<h2 className="kx-bi__global-title"> ( )</h2>
<p className="kx-card__hint"> · · · </p>
<h2 className="kx-bi__global-title">{t('global.title')}</h2>
<p className="kx-card__hint">{t('global.subtitle')}</p>
</div>
<select
className="kx-select"
aria-label="분석 연수"
aria-label={t('global.yearsAria')}
value={years}
onChange={(e) => setYears(Number(e.target.value))}
>
<option value={3}> 3</option>
<option value={5}> 5</option>
<option value={10}> 10</option>
<option value={3}>{t('global.years3')}</option>
<option value={5}>{t('global.years5')}</option>
<option value={10}>{t('global.years10')}</option>
</select>
</div>
{q.isLoading && <Skeleton height={280} radius={12} />}
{q.isError && !isHideable(q.error) && (
<EmptyState title="전역 집계를 불러오지 못했습니다" description="잠시 후 다시 시도해 주세요." />
<EmptyState title={t('global.loadErrorTitle')} description={t('global.loadErrorDesc')} />
)}
{q.data && <OverviewBody data={q.data} />}
@ -67,12 +69,13 @@ export function GlobalOverviewSection() {
}
function OverviewBody({ data }: { data: AnalyticsOverview }) {
const { t } = useTranslation();
return (
<>
{/* 전역 KPI 밴드 */}
<div className="kx-bi__kpis" aria-label="전역 핵심 지표">
<div className="kx-bi__kpis" aria-label={t('global.kpisAria')}>
{data.kpis.length === 0 ? (
<EmptyState title="집계된 지표가 없습니다" />
<EmptyState title={t('global.kpiEmpty')} />
) : (
data.kpis.map((k, i) => (
<div key={`${k.label}-${i}`} className="kx-kpi kx-kpi--normal">
@ -86,14 +89,14 @@ function OverviewBody({ data }: { data: AnalyticsOverview }) {
<div className="kx-bi__split">
{/* 행사 간 매출 비교 */}
<section className="kx-card" aria-label="행사 간 매출 비교">
<section className="kx-card" aria-label={t('global.evComp')}>
<div className="kx-card__head">
<h2> </h2>
<AiLabel> 8</AiLabel>
<h2>{t('global.evComp')}</h2>
<AiLabel>{t('global.top8')}</AiLabel>
</div>
<p className="kx-card__hint"> + (단위: ).</p>
<p className="kx-card__hint">{t('global.evCompHint')}</p>
{data.eventComparison.length === 0 ? (
<EmptyState title="비교할 행사가 없습니다" description="부스가 배치된 행사 매출이 집계되면 표시됩니다." />
<EmptyState title={t('global.evCompEmptyTitle')} description={t('global.evCompEmptyDesc')} />
) : (
<div className="kx-bi__chart">
<ResponsiveContainer width="100%" height={280}>
@ -120,7 +123,7 @@ function OverviewBody({ data }: { data: AnalyticsOverview }) {
tickFormatter={(v) => truncate(v as string, 12)}
/>
<Tooltip contentStyle={TOOLTIP_STYLE} formatter={(v) => formatWon(v as number)} />
<Bar dataKey="revenue" name="매출" fill={CHART.primary600} radius={[0, 4, 4, 0]} />
<Bar dataKey="revenue" name={t('global.revenue')} fill={CHART.primary600} radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
@ -128,18 +131,18 @@ function OverviewBody({ data }: { data: AnalyticsOverview }) {
</section>
{/* 참가사 리텐션 */}
<section className="kx-card" aria-label="참가사 리텐션">
<section className="kx-card" aria-label={t('global.retention')}>
<div className="kx-card__head">
<h2> </h2>
<h2>{t('global.retention')}</h2>
</div>
<p className="kx-card__hint">
{' '}
{t('global.retentionCum')}{' '}
<strong className="tnum">{data.retention.retentionPercent.toFixed(1)}%</strong> (
{data.retention.returningCompanies.toLocaleString()}/
{data.retention.totalCompanies.toLocaleString()} )
{data.retention.totalCompanies.toLocaleString()} {t('global.returning')})
</p>
{data.retention.byYear.length === 0 ? (
<EmptyState title="연도별 리텐션 미집계" description="부스 배정사 원천이 연동되면 표시됩니다." />
<EmptyState title={t('global.retentionEmptyTitle')} description={t('global.retentionEmptyDesc')} />
) : (
<div className="kx-bi__chart">
<ResponsiveContainer width="100%" height={280}>
@ -148,7 +151,7 @@ function OverviewBody({ data }: { data: AnalyticsOverview }) {
<XAxis dataKey="period" tick={{ fontSize: 12, fill: CHART.slate }} tickLine={false} axisLine={{ stroke: CHART.neutral200 }} />
<YAxis tick={{ fontSize: 12, fill: CHART.slate }} tickLine={false} axisLine={false} width={40} tickFormatter={(v) => `${v}%`} />
<Tooltip contentStyle={TOOLTIP_STYLE} formatter={(v) => `${(v as number).toFixed(1)}%`} />
<Line type="monotone" dataKey="retentionPercent" name="재참가율" stroke={CHART.success} strokeWidth={2} dot={{ r: 3 }} />
<Line type="monotone" dataKey="retentionPercent" name={t('global.retentionRate')} stroke={CHART.success} strokeWidth={2} dot={{ r: 3 }} />
</LineChart>
</ResponsiveContainer>
</div>
@ -158,13 +161,13 @@ function OverviewBody({ data }: { data: AnalyticsOverview }) {
<div className="kx-bi__split">
{/* 홀 가동률 추이 */}
<section className="kx-card" aria-label="홀 가동률 추이">
<section className="kx-card" aria-label={t('global.occupancy')}>
<div className="kx-card__head">
<h2> ()</h2>
<h2>{t('global.occupancy')}</h2>
</div>
<p className="kx-card__hint"> - ÷ ( × ) .</p>
<p className="kx-card__hint">{t('global.occupancyHint')}</p>
{data.hallOccupancy.length === 0 ? (
<EmptyState title="가동률 데이터가 없습니다" />
<EmptyState title={t('global.occupancyEmpty')} />
) : (
<div className="kx-bi__chart">
<ResponsiveContainer width="100%" height={280}>
@ -173,7 +176,7 @@ function OverviewBody({ data }: { data: AnalyticsOverview }) {
<XAxis dataKey="period" tick={{ fontSize: 11, fill: CHART.slate }} tickLine={false} axisLine={{ stroke: CHART.neutral200 }} />
<YAxis tick={{ fontSize: 12, fill: CHART.slate }} tickLine={false} axisLine={false} width={40} tickFormatter={(v) => `${v}%`} />
<Tooltip contentStyle={TOOLTIP_STYLE} formatter={(v) => `${(v as number).toFixed(1)}%`} />
<Bar dataKey="occupancyPercent" name="가동률" fill={CHART.primary600} radius={[4, 4, 0, 0]} />
<Bar dataKey="occupancyPercent" name={t('global.occupancyRate')} fill={CHART.primary600} radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
@ -181,12 +184,12 @@ function OverviewBody({ data }: { data: AnalyticsOverview }) {
</section>
{/* 수요예측(추세) */}
<section className="kx-card" aria-label="수요예측">
<section className="kx-card" aria-label={t('global.forecast')}>
<div className="kx-card__head">
<h2> ( )</h2>
<AiLabel> </AiLabel>
<h2>{t('global.forecastTitle')}</h2>
<AiLabel>{t('global.trendProjection')}</AiLabel>
</div>
<p className="kx-card__hint"> + 2 ().</p>
<p className="kx-card__hint">{t('global.forecastHint')}</p>
<ForecastChart forecast={data.forecast} />
</section>
</div>
@ -195,9 +198,10 @@ function OverviewBody({ data }: { data: AnalyticsOverview }) {
}
function ForecastChart({ forecast }: { forecast: AnalyticsOverview['forecast'] }) {
const { t } = useTranslation();
const merged = mergeForecast(forecast.history, forecast.projection);
if (merged.length === 0) {
return <EmptyState title="예측할 이력이 없습니다" description="연도별 개최 이력이 집계되면 표시됩니다." />;
return <EmptyState title={t('global.forecastEmptyTitle')} description={t('global.forecastEmptyDesc')} />;
}
return (
<div className="kx-bi__chart">
@ -208,13 +212,13 @@ function ForecastChart({ forecast }: { forecast: AnalyticsOverview['forecast'] }
<YAxis tick={{ fontSize: 12, fill: CHART.slate }} tickLine={false} axisLine={false} width={40} />
<Tooltip
contentStyle={TOOLTIP_STYLE}
formatter={(v, name) => (v == null ? ['-', name as string] : [`${Math.round(v as number)}`, name as string])}
formatter={(v, name) => (v == null ? ['-', name as string] : [t('global.casesUnit', { n: Math.round(v as number) }), name as string])}
/>
<Line type="monotone" dataKey="actual" name="실측" stroke={CHART.primary600} strokeWidth={2} dot={{ r: 3 }} connectNulls />
<Line type="monotone" dataKey="actual" name={t('global.actual')} stroke={CHART.primary600} strokeWidth={2} dot={{ r: 3 }} connectNulls />
<Line
type="monotone"
dataKey="projected"
name="예측"
name={t('global.projected')}
stroke={CHART.aiAccent}
strokeWidth={2}
strokeDasharray="5 4"

View File

@ -1,5 +1,6 @@
import { useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../../store/authStore';
import { AiLabel } from '../../components/ui/Badge';
import { AiImage } from '../../components/ui/AiImage';
@ -28,6 +29,7 @@ const S7_AERIAL = `${MOCK}/img3.jpg`;
* ( ).
*/
export function OrganizerDashboardPage() {
const { t } = useTranslation();
const { eventId } = useParams();
const workspace = useAuthStore((s) => s.currentWorkspace());
@ -57,16 +59,16 @@ export function OrganizerDashboardPage() {
<div>
<h1 className="kx-dash__event-name">{eventName}</h1>
<p className="kx-dash__event-meta">
{hallLabel} · {period}
{t('common.brand')} {hallLabel} · {period}
</p>
</div>
<span className="kx-dash__countdown tnum"> D-{dday}</span>
<span className="kx-dash__countdown tnum">{t('dashboard.countdown', { n: dday })}</span>
</header>
{degraded && (
<div className="kx-dash__degraded-bar">
<span className="kx-dash__degraded"> </span>
API에 .
<span className="kx-dash__degraded">{t('dashboard.degradedTag')}</span>
{t('dashboard.degradedMsg')}
</div>
)}
@ -74,7 +76,7 @@ export function OrganizerDashboardPage() {
{hardError && (
<ErrorState
message="대시보드 집계를 불러오지 못했습니다."
message={t('dashboard.loadError')}
onRetry={() => q.refetch()}
/>
)}
@ -82,9 +84,9 @@ export function OrganizerDashboardPage() {
{data && (
<>
{/* 히어로: 마일스톤 타임라인 */}
<section className="kx-dash__hero" aria-label="마일스톤 타임라인">
<section className="kx-dash__hero" aria-label={t('dashboard.timelineAria')}>
{data.milestones.length === 0 ? (
<EmptyState title="파생 일정이 없습니다" description="행사 시작일이 확정되면 마일스톤이 표시됩니다." />
<EmptyState title={t('dashboard.milestoneEmptyTitle')} description={t('dashboard.milestoneEmptyDesc')} />
) : (
<ol className="kx-timeline">
{data.milestones.map((m, i) => (
@ -100,7 +102,7 @@ export function OrganizerDashboardPage() {
<span className="kx-timeline__title">{m.label}</span>
</div>
<div className="kx-timeline__card">
<span className="kx-timeline__muted">{stateLabel(m.state)}</span>
<span className="kx-timeline__muted">{t(`dashboard.mstate.${normState(m.state)}`)}</span>
</div>
</li>
))}
@ -109,9 +111,9 @@ export function OrganizerDashboardPage() {
</section>
{/* KPI 카드 */}
<section className="kx-dash__kpis" aria-label="핵심 지표">
<section className="kx-dash__kpis" aria-label={t('dashboard.kpisAria')}>
{data.kpis.length === 0 ? (
<EmptyState title="집계된 지표가 없습니다" />
<EmptyState title={t('dashboard.kpiEmpty')} />
) : (
data.kpis.map((k, i) => (
<div key={`${k.label}-${i}`} className={`kx-kpi kx-kpi--${kpiTone(k.trend)}`}>
@ -129,23 +131,23 @@ export function OrganizerDashboardPage() {
{/* 하단: 참가업체 테이블(2/3) + 알림·S7(1/3) */}
<div className="kx-dash__split">
<section className="kx-dash__table-wrap" aria-label="참가업체 현황">
<section className="kx-dash__table-wrap" aria-label={t('dashboard.exhibitorStatus')}>
<div className="kx-dash__section-head">
<h2> </h2>
{degraded && <span className="kx-dash__degraded"></span>}
<h2>{t('dashboard.exhibitorStatus')}</h2>
{degraded && <span className="kx-dash__degraded">{t('dashboard.sampleTag')}</span>}
</div>
{data.exhibitors.length === 0 ? (
<EmptyState
title="참가업체 부스가 없습니다"
description="부스 배치·설계안이 등록되면 여기에 표시됩니다."
title={t('dashboard.exhibitorEmptyTitle')}
description={t('dashboard.exhibitorEmptyDesc')}
/>
) : (
<table className="kx-table">
<thead>
<tr>
<th></th>
<th></th>
<th> </th>
<th>{t('dashboard.colBoothNo')}</th>
<th>{t('dashboard.colCompany')}</th>
<th>{t('dashboard.colStatus')}</th>
</tr>
</thead>
<tbody>
@ -164,10 +166,10 @@ export function OrganizerDashboardPage() {
</section>
<aside className="kx-dash__aside">
<section className="kx-feed" aria-label="알림 피드">
<h2 className="kx-feed__title"></h2>
<section className="kx-feed" aria-label={t('dashboard.feedAria')}>
<h2 className="kx-feed__title">{t('dashboard.feedTitle')}</h2>
{data.feed.length === 0 ? (
<EmptyState title="새 알림이 없습니다" />
<EmptyState title={t('dashboard.feedEmpty')} />
) : (
<ul className="kx-feed__list">
{data.feed.map((f) => (
@ -181,19 +183,19 @@ export function OrganizerDashboardPage() {
)}
</section>
<section className="kx-dash__s7" aria-label="홀 전경 조감">
<section className="kx-dash__s7" aria-label={t('dashboard.aerialAria')}>
<div className="kx-dash__s7-head">
<h2> </h2>
<h2>{t('dashboard.aerialTitle')}</h2>
<AiLabel>S7</AiLabel>
</div>
<AiImage
imageUrl={S7_AERIAL}
status="DONE"
sample
watermarkText="AI 생성 예상 이미지 — 실제 시공 결과와 다를 수 있습니다"
notice="AI 생성 이미지는 계약·심사 서류에 사용할 수 없습니다 — 시공 기준은 도면입니다"
shotLabel="S7 홀 전경"
alt="홀 전경 조감 예상 이미지"
watermarkText={t('common.aiWatermark')}
notice={t('common.aiNotice')}
shotLabel={t('dashboard.aerialShot')}
alt={t('dashboard.aerialAlt')}
/>
</section>
</aside>
@ -231,15 +233,10 @@ function milestoneClass(state: string): string {
if (state === 'current') return 'imminent';
return 'upcoming';
}
function stateLabel(state: string): string {
switch (state) {
case 'done':
return '완료';
case 'current':
return '진행 중';
default:
return '예정';
}
function normState(state: string): 'done' | 'current' | 'upcoming' {
if (state === 'done') return 'done';
if (state === 'current') return 'current';
return 'upcoming';
}
function kpiTone(trend: string): 'normal' | 'warn' | 'error' {
return trend === 'down' ? 'warn' : 'normal';

View File

@ -8,6 +8,7 @@
*/
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Button } from '../../components/ui/Button';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { IconWarning } from '../../components/ui/icons';
@ -51,6 +52,7 @@ function fmtKrw(n: number): string {
const EMPTY_FORM: AssignmentBody = { eventId: '', hallId: '', primary: true, occupyStart: '', occupyEnd: '', note: '' };
export function HallAssignmentPage() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [from, setFrom] = useState('');
const [to, setTo] = useState('');
@ -91,12 +93,12 @@ export function HallAssignmentPage() {
setForm(EMPTY_FORM);
setAssignError(null);
setConflictHits([]);
setToast('홀이 배정되었습니다.');
setToast(t('hallassign.assignedToast'));
queryClient.invalidateQueries({ queryKey: ['hall-assignments'] });
},
onError: async (err) => {
if (err instanceof ApiRequestError && err.code === 'CONFLICT') {
setAssignError('배정하려는 홀·기간이 다른 행사와 겹칩니다.');
setAssignError(t('hallassign.conflictErr'));
// 공용 client가 409 본문을 소거하므로 충돌 상세를 사전검사로 재조회.
try {
const hits = await hallAssignApi.conflicts(
@ -110,7 +112,7 @@ export function HallAssignmentPage() {
setConflictHits([]);
}
} else {
setAssignError(err instanceof ApiRequestError ? err.message : '배정에 실패했습니다.');
setAssignError(err instanceof ApiRequestError ? err.message : t('hallassign.assignFail'));
}
},
});
@ -118,10 +120,10 @@ export function HallAssignmentPage() {
const releaseMut = useMutation({
mutationFn: (id: string) => hallAssignApi.release(id),
onSuccess: () => {
setToast('배정이 해제되었습니다.');
setToast(t('hallassign.releasedToast'));
queryClient.invalidateQueries({ queryKey: ['hall-assignments'] });
},
onError: () => setToast('해제에 실패했습니다(권한 확인).'),
onError: () => setToast(t('hallassign.releaseFail')),
});
function openModal() {
@ -143,7 +145,7 @@ export function HallAssignmentPage() {
function submitAssign() {
if (!form.eventId || !form.hallId) {
setAssignError('행사와 홀을 선택하세요.');
setAssignError(t('hallassign.pickEventHall'));
return;
}
setAssignError(null);
@ -157,36 +159,36 @@ export function HallAssignmentPage() {
<header className="kx-hall__top">
<div>
<h1> · </h1>
<p className="kx-card__hint">× (M1).</p>
<h1>{t('hallassign.title')}</h1>
<p className="kx-card__hint">{t('hallassign.subtitle')}</p>
</div>
<div className="kx-hall__filter">
<label>
{t('hallassign.start')}
<input className="kx-select" type="date" value={from} onChange={(e) => setFrom(e.target.value)} />
</label>
<label>
{t('hallassign.end')}
<input className="kx-select" type="date" value={to} onChange={(e) => setTo(e.target.value)} />
</label>
<Button variant="primary" onClick={openModal}> </Button>
<Button variant="primary" onClick={openModal}>{t('hallassign.newAssign')}</Button>
</div>
</header>
{/* ── 배정 보드 ── */}
<section className="kx-card">
<div className="kx-card__head">
<h2> </h2>
<span className="kx-card__hint"> .</span>
<h2>{t('hallassign.board')}</h2>
<span className="kx-card__hint">{t('hallassign.boardHint')}</span>
</div>
{assignQ.isLoading || hallsQ.isLoading ? (
<div className="kx-hall__rows"><Skeleton height={40} /><Skeleton height={40} /><Skeleton height={40} /></div>
) : boardForbidden ? (
<EmptyState title="홀매니저 전용 보드" description="배정 현황 조회는 킨텍스 홀매니저 계정만 가능합니다. 자동 견적은 아래에서 이용하세요." />
<EmptyState title={t('hallassign.forbiddenTitle')} description={t('hallassign.forbiddenDesc')} />
) : assignQ.error ? (
<ErrorState onRetry={() => assignQ.refetch()} />
) : halls.length === 0 ? (
<EmptyState title="홀 마스터가 없습니다." />
<EmptyState title={t('hallassign.noHalls')} />
) : (
<div className="kx-hall__board">
{halls.map((h: Hall) => {
@ -199,7 +201,7 @@ export function HallAssignmentPage() {
</div>
<div className="kx-hall__chips">
{rows.length === 0 ? (
<span className="kx-hall__idle"> </span>
<span className="kx-hall__idle">{t('hallassign.noAssign')}</span>
) : (
rows.map((a) => {
const conflict = conflictSet.has(a.id);
@ -217,9 +219,9 @@ export function HallAssignmentPage() {
<button
type="button"
className="kx-hall__chipx"
aria-label="배정 해제"
aria-label={t('hallassign.release')}
onClick={() => {
if (window.confirm(`${a.eventName}${a.hallLabel} 배정을 해제할까요?`)) {
if (window.confirm(t('hallassign.releaseConfirm', { event: a.eventName, hall: a.hallLabel }))) {
releaseMut.mutate(a.id);
}
}}
@ -244,23 +246,23 @@ export function HallAssignmentPage() {
<div className="kx-hall__backdrop" onClick={() => setModalOpen(false)}>
<div className="kx-hall__modal" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
<div className="kx-card__head">
<h2> </h2>
<button type="button" className="kx-hall__chipx" aria-label="닫기" onClick={() => setModalOpen(false)}>×</button>
<h2>{t('hallassign.modalTitle')}</h2>
<button type="button" className="kx-hall__chipx" aria-label={t('hallassign.close')} onClick={() => setModalOpen(false)}>×</button>
</div>
<div className="kx-hall__form">
<label>
{t('hallassign.event')}
<select className="kx-select" value={form.eventId} onChange={(e) => onPickEvent(e.target.value)}>
<option value=""> </option>
<option value="">{t('hallassign.pickEvent')}</option>
{(eventsQ.data ?? []).map((e) => (
<option key={e.id} value={e.id}>{e.name}</option>
))}
</select>
</label>
<label>
{t('hallassign.hall')}
<select className="kx-select" value={form.hallId} onChange={(e) => setForm((f) => ({ ...f, hallId: e.target.value }))}>
<option value=""> </option>
<option value="">{t('hallassign.pickHall')}</option>
{halls.map((h) => (
<option key={h.id} value={h.id}>{h.label}</option>
))}
@ -268,21 +270,21 @@ export function HallAssignmentPage() {
</label>
<div className="kx-hall__form2">
<label>
{t('hallassign.occupyStart')}
<input className="kx-select" type="date" value={form.occupyStart ?? ''} onChange={(e) => setForm((f) => ({ ...f, occupyStart: e.target.value }))} />
</label>
<label>
{t('hallassign.occupyEnd')}
<input className="kx-select" type="date" value={form.occupyEnd ?? ''} onChange={(e) => setForm((f) => ({ ...f, occupyEnd: e.target.value }))} />
</label>
</div>
<label className="kx-hall__check">
<input type="checkbox" checked={form.primary} onChange={(e) => setForm((f) => ({ ...f, primary: e.target.checked }))} />
(primary)
{t('hallassign.primary')}
</label>
<label>
<input className="kx-select" type="text" value={form.note ?? ''} onChange={(e) => setForm((f) => ({ ...f, note: e.target.value }))} placeholder="선택 입력" />
{t('hallassign.note')}
<input className="kx-select" type="text" value={form.note ?? ''} onChange={(e) => setForm((f) => ({ ...f, note: e.target.value }))} placeholder={t('hallassign.optional')} />
</label>
{assignError && (
@ -299,9 +301,9 @@ export function HallAssignmentPage() {
)}
</div>
<div className="kx-hall__modalfoot">
<Button variant="ghost" onClick={() => setModalOpen(false)}></Button>
<Button variant="ghost" onClick={() => setModalOpen(false)}>{t('hallassign.cancel')}</Button>
<Button variant="primary" onClick={submitAssign} disabled={assignMut.isPending}>
{assignMut.isPending ? '배정 중…' : '배정'}
{assignMut.isPending ? t('hallassign.assigning') : t('hallassign.assign')}
</Button>
</div>
</div>
@ -313,6 +315,7 @@ export function HallAssignmentPage() {
/** 자동 견적 패널 — 홀 다중 선택 + 기간 + 부스 규모 → 요율 룰셋 라인. */
function QuotePanel({ halls }: { halls: Hall[] }) {
const { t } = useTranslation();
const [picked, setPicked] = useState<string[]>([]);
const [start, setStart] = useState('');
const [end, setEnd] = useState('');
@ -329,7 +332,7 @@ function QuotePanel({ halls }: { halls: Hall[] }) {
boothCount: boothCount ? Number(boothCount) : undefined,
}),
onSuccess: (r) => { setResult(r); setErr(null); },
onError: (e) => { setErr(e instanceof ApiRequestError ? e.message : '견적 계산에 실패했습니다.'); setResult(null); },
onError: (e) => { setErr(e instanceof ApiRequestError ? e.message : t('hallassign.quoteFail')); setResult(null); },
});
function toggleHall(id: string) {
@ -338,7 +341,7 @@ function QuotePanel({ halls }: { halls: Hall[] }) {
function run() {
if (picked.length === 0 || !start || !end) {
setErr('홀·시작일·종료일을 입력하세요.');
setErr(t('hallassign.quoteReq'));
return;
}
setErr(null);
@ -348,8 +351,8 @@ function QuotePanel({ halls }: { halls: Hall[] }) {
return (
<section className="kx-card">
<div className="kx-card__head">
<h2> </h2>
<span className="kx-card__hint"> · </span>
<h2>{t('hallassign.autoQuote')}</h2>
<span className="kx-card__hint">{t('hallassign.quoteHint')}</span>
</div>
<div className="kx-hall__quoteform">
<div className="kx-hall__hallpick">
@ -361,10 +364,10 @@ function QuotePanel({ halls }: { halls: Hall[] }) {
))}
</div>
<div className="kx-hall__quoteinputs">
<label><input className="kx-select" type="date" value={start} onChange={(e) => setStart(e.target.value)} /></label>
<label><input className="kx-select" type="date" value={end} onChange={(e) => setEnd(e.target.value)} /></label>
<label> ()<input className="kx-select" type="number" min={0} value={boothCount} onChange={(e) => setBoothCount(e.target.value)} placeholder="개수" /></label>
<Button variant="ai" onClick={run} disabled={quoteMut.isPending}>{quoteMut.isPending ? '계산 중…' : '견적 계산'}</Button>
<label>{t('hallassign.startDate')}<input className="kx-select" type="date" value={start} onChange={(e) => setStart(e.target.value)} /></label>
<label>{t('hallassign.endDate')}<input className="kx-select" type="date" value={end} onChange={(e) => setEnd(e.target.value)} /></label>
<label>{t('hallassign.boothSize')}<input className="kx-select" type="number" min={0} value={boothCount} onChange={(e) => setBoothCount(e.target.value)} placeholder={t('hallassign.countPh')} /></label>
<Button variant="ai" onClick={run} disabled={quoteMut.isPending}>{quoteMut.isPending ? t('hallassign.calculating') : t('hallassign.calcQuote')}</Button>
</div>
</div>
@ -374,7 +377,7 @@ function QuotePanel({ halls }: { halls: Hall[] }) {
<div className="kx-hall__quoteresult">
<table className="kx-table">
<thead>
<tr><th></th><th> </th><th className="kx-hall__num"></th></tr>
<tr><th>{t('hallassign.qItem')}</th><th>{t('hallassign.qBasis')}</th><th className="kx-hall__num">{t('hallassign.qAmount')}</th></tr>
</thead>
<tbody>
{result.lines.map((l, i) => (
@ -386,13 +389,13 @@ function QuotePanel({ halls }: { halls: Hall[] }) {
))}
</tbody>
<tfoot>
<tr><td colSpan={2}> </td><td className="kx-hall__num">{fmtKrw(result.rentalSubtotal)}</td></tr>
<tr className="kx-hall__total"><td colSpan={2}> ({result.days})</td><td className="kx-hall__num">{fmtKrw(result.total)}</td></tr>
<tr><td colSpan={2}> ()</td><td className="kx-hall__num">{fmtKrw(result.depositMin)} ~ {fmtKrw(result.depositMax)}</td></tr>
<tr><td colSpan={2}>{t('hallassign.rentalSubtotal')}</td><td className="kx-hall__num">{fmtKrw(result.rentalSubtotal)}</td></tr>
<tr className="kx-hall__total"><td colSpan={2}>{t('hallassign.rentalTotal', { days: result.days })}</td><td className="kx-hall__num">{fmtKrw(result.total)}</td></tr>
<tr><td colSpan={2}>{t('hallassign.deposit')}</td><td className="kx-hall__num">{fmtKrw(result.depositMin)} ~ {fmtKrw(result.depositMax)}</td></tr>
</tfoot>
</table>
{result.capacityNote && <p className="kx-hall__note"><IconWarning size={14} /> {result.capacityNote}</p>}
{result.disclaimer && <p className="kx-card__hint">{result.disclaimer} ( {result.rulesetVersion})</p>}
{result.disclaimer && <p className="kx-card__hint">{result.disclaimer} {t('hallassign.ruleset', { v: result.rulesetVersion })}</p>}
</div>
)}
</section>

View File

@ -1,5 +1,6 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Trans, useTranslation } from 'react-i18next';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { opsApiLocal, type OpsDataExt } from './opsApi';
import { ApiRequestError } from '../../api/client';
@ -22,6 +23,7 @@ import './ops.css';
* 폴백: NETWORK/NOT_FOUND/NOT_IMPLEMENTED sampleOps + "오프라인 — 샘플" .
*/
export function HallOperationsPage() {
const { t } = useTranslation();
const [mode, setMode] = useState<'live' | 'history'>('live');
const eventId = useResolvedEventId();
@ -50,73 +52,73 @@ export function HallOperationsPage() {
<div className="kx-ops">
<header className="kx-ops__head">
<div>
<h1 className="kx-ops__title"> </h1>
<p className="kx-ops__subtitle">· </p>
<h1 className="kx-ops__title">{t('ops.title')}</h1>
<p className="kx-ops__subtitle">{t('ops.subtitle')}</p>
</div>
<div className="kx-seg" role="tablist" aria-label="조회 모드">
<div className="kx-seg" role="tablist" aria-label={t('ops.modeAria')}>
<button role="tab" aria-selected={mode === 'live'} className={`kx-seg__btn ${mode === 'live' ? 'is-active' : ''}`} onClick={() => setMode('live')}>
{t('ops.live')}
</button>
<button role="tab" aria-selected={mode === 'history'} className={`kx-seg__btn ${mode === 'history' ? 'is-active' : ''}`} onClick={() => setMode('history')}>
{t('ops.history')}
</button>
</div>
</header>
{!eventId && (
<EmptyState title="행사를 선택해 주세요" description="현장 운영을 조회할 행사가 지정되지 않았습니다." />
<EmptyState title={t('ops.noEventTitle')} description={t('ops.noEventDesc')} />
)}
{degraded && (
<div className="kx-ops__degraded-bar">
<span className="kx-ops__degraded-badge"> </span>
API에 .
<span className="kx-ops__degraded-badge">{t('ops.offlineSample')}</span>
{t('ops.degradedMsg')}
</div>
)}
{q.isLoading && <OpsSkeleton />}
{hardError && (
<ErrorState message="현장 운영 현황을 불러오지 못했습니다." onRetry={() => q.refetch()} />
<ErrorState message={t('ops.loadError')} onRetry={() => q.refetch()} />
)}
{data && (
<div className="kx-ops__grid">
{/* 히어로: 홀 혼잡(체크인 파생) */}
<section className="kx-card kx-ops__heat" aria-label="홀별 현황">
<section className="kx-card kx-ops__heat" aria-label={t('ops.hallStatus')}>
<div className="kx-card__head">
<h2> </h2>
<h2>{t('ops.hallStatus')}</h2>
</div>
<span className="kx-ops__sensor-badge">
{hasPeople ? '체크인(M10) 파생 · 면적 비중 분배' : '센서 미연동 · 배정 홀 목록'}
{hasPeople ? t('ops.checkinDerived') : t('ops.sensorNoneHalls')}
</span>
{data.halls.length === 0 ? (
<EmptyState title="배정된 홀이 없습니다" description="행사에 홀이 배정되면 표시됩니다." />
<EmptyState title={t('ops.noHallsTitle')} description={t('ops.noHallsDesc')} />
) : (
<>
<div className="kx-ops__halls">
{data.halls.map((c) => (
<div key={c.hallId} className={`kx-ops__hall is-${heatClass(c.heat)}`}>
<span className="kx-ops__hall-label">{c.label}</span>
{c.heat === 'busy' && <span className="kx-ops__hall-tag"></span>}
{c.heat === 'busy' && <span className="kx-ops__hall-tag">{t('ops.busy')}</span>}
<span className="kx-ops__hall-people tnum">
{c.estPeople > 0 ? `${c.estPeople.toLocaleString()}` : '—'}
{c.estPeople > 0 ? t('ops.people', { n: c.estPeople.toLocaleString() }) : '—'}
</span>
</div>
))}
</div>
<div className="kx-ops__heat-foot">
<ul className="kx-ops__legend">
<li><span className="kx-dot is-smooth" /> </li>
<li><span className="kx-dot is-moderate" /> </li>
<li><span className="kx-dot is-busy" /> </li>
<li><span className="kx-dot is-smooth" /> {t('ops.smooth')}</li>
<li><span className="kx-dot is-moderate" /> {t('ops.moderate')}</li>
<li><span className="kx-dot is-busy" /> {t('ops.busy')}</li>
</ul>
<span className="kx-ops__total">
{hasPeople ? (
<> <strong className="tnum">{onSite.toLocaleString()}</strong></>
<Trans i18nKey="ops.onSiteCheckin" values={{ n: onSite.toLocaleString() }} components={[<strong className="tnum" key="s" />]} />
) : (
<> </>
<>{t('ops.noCheckinAgg')}</>
)}
</span>
</div>
@ -126,16 +128,16 @@ export function HallOperationsPage() {
{/* 우측: 전력 부하(M4 실집계) + 시간대 추이 */}
<aside className="kx-ops__aside">
<section className="kx-card" aria-label="전력 부하">
<section className="kx-card" aria-label={t('ops.powerLoad')}>
<div className="kx-card__head">
<h2> </h2>
<h2>{t('ops.powerLoad')}</h2>
{powerTotal != null && (
<span className="kx-ops__total"> <strong className="tnum">{formatKw(powerTotal)}</strong>kW</span>
<span className="kx-ops__total"><Trans i18nKey="ops.totalKw" values={{ v: formatKw(powerTotal) }} components={[<strong className="tnum" key="s" />]} /></span>
)}
</div>
<span className="kx-ops__sensor-badge"> (M4) </span>
<span className="kx-ops__sensor-badge">{t('ops.utilLoadAgg')}</span>
{powerHalls.length === 0 ? (
<EmptyState title="신청 부하 없음" description="전기 유틸리티 신청이 접수되면 홀별 부하가 표시됩니다." />
<EmptyState title={t('ops.noLoadTitle')} description={t('ops.noLoadDesc')} />
) : (
<ul className="kx-barlist">
{powerHalls.map((h) => {
@ -145,7 +147,7 @@ export function HallOperationsPage() {
<li key={h.hallId} className="kx-barlist__item">
<div className="kx-barlist__top">
<span>{h.label}</span>
<span className="tnum">{formatKw(kw)}kW</span>
<span className="tnum">{t('ops.kw', { v: formatKw(kw) })}</span>
</div>
<div className="kx-barlist__track">
<span className="kx-barlist__fill" style={{ width: `${pct}%` }} />
@ -157,13 +159,13 @@ export function HallOperationsPage() {
)}
</section>
<section className="kx-card" aria-label="시간대별 체크인">
<section className="kx-card" aria-label={t('ops.hourlyCheckin')}>
<div className="kx-card__head">
<h2> </h2>
<h2>{t('ops.hourlyCheckin')}</h2>
</div>
<span className="kx-ops__sensor-badge">(M10) </span>
<span className="kx-ops__sensor-badge">{t('ops.hourlyAgg')}</span>
{series.length === 0 ? (
<EmptyState title="체크인 추이 없음" description="현장 체크인이 기록되면 시간대별 추이가 표시됩니다." />
<EmptyState title={t('ops.noSeriesTitle')} description={t('ops.noSeriesDesc')} />
) : (
<ul className="kx-barlist kx-ops__series">
{series.map((p) => {
@ -186,25 +188,25 @@ export function HallOperationsPage() {
</aside>
{/* 하단: 시설 센서(미연동) — 정직한 빈 상태 */}
<section className="kx-card kx-ops__parking" aria-label="시설 센서 연동">
<section className="kx-card kx-ops__parking" aria-label={t('ops.facilitySensor')}>
<div className="kx-card__head">
<h2> (··)</h2>
<h2>{t('ops.facilitySensorTitle')}</h2>
</div>
{!hasHvac && data.lighting.length === 0 && data.parking.length === 0 ? (
<EmptyState
title="시설 센서 미연동"
description="공조(HVAC)·조명 IoT·주차(iparking) 연계는 센서(M14) 연동 시 표시됩니다."
title={t('ops.sensorNoneTitle')}
description={t('ops.sensorNoneDesc')}
/>
) : (
<div className="kx-ops__sensor-grid">
{hasHvac && (
<div className="kx-ops__hvac-meta">
<div>
<span className="kx-ops__meta-k"></span>
<span className="kx-ops__meta-k">{t('ops.temp')}</span>
<strong className="tnum">{hvac!.tempC != null ? `${hvac!.tempC}°C` : '—'}</strong>
</div>
<div>
<span className="kx-ops__meta-k"></span>
<span className="kx-ops__meta-k">{t('ops.humidity')}</span>
<strong className="tnum">{hvac!.humidityPercent != null ? `${hvac!.humidityPercent}%` : '—'}</strong>
</div>
<div>
@ -218,7 +220,7 @@ export function HallOperationsPage() {
))}
{data.parking.map((z) => (
<div key={z.zone} className="kx-ops__meta-k">
{z.zone}: {z.occupancy.toLocaleString()}/{z.capacity.toLocaleString()}
{t('ops.parkingSlots', { zone: z.zone, occ: z.occupancy.toLocaleString(), cap: z.capacity.toLocaleString() })}
</div>
))}
</div>

View File

@ -1,5 +1,6 @@
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { ApiRequestError } from '../../api/client';
import { exhibitionApi } from '../../api/endpoints';
import type { ExhibitionDto } from '../../api/types';
@ -97,6 +98,7 @@ function matchesHall(halls: string, range: string): boolean {
* 경계면: 행사 workspaces( ) , .
*/
export function ExhibitionSchedulePage() {
const { t } = useTranslation();
const [view, setView] = useState<'list' | 'calendar'>('list');
const [year, setYear] = useState('2026');
const [hall, setHall] = useState('all');
@ -150,49 +152,49 @@ export function ExhibitionSchedulePage() {
<div className="kx-sched">
<header className="kx-sched__head">
<div>
<h1 className="kx-sched__title"> </h1>
<h1 className="kx-sched__title">{t('schedule.title')}</h1>
<p className="kx-sched__subtitle">
· {events.length.toLocaleString()}
{degraded && ' (kintex.com 크롤링 폴백)'}
{t('schedule.subtitle', { n: events.length.toLocaleString() })}
{degraded && ` ${t('schedule.crawlFallback')}`}
</p>
</div>
</header>
<div className="kx-sched__filters">
<select className="kx-select" aria-label="연도 필터" value={year} onChange={(e) => setYear(e.target.value)}>
<option value="all"> </option>
<select className="kx-select" aria-label={t('schedule.yearFilter')} value={year} onChange={(e) => setYear(e.target.value)}>
<option value="all">{t('schedule.allYears')}</option>
{EVENT_YEARS.map((y) => (
<option key={y} value={String(y)}>{y}</option>
<option key={y} value={String(y)}>{t('schedule.yearN', { y })}</option>
))}
</select>
<select className="kx-select" aria-label="홀 필터" value={hall} onChange={(e) => setHall(e.target.value)}>
<option value="all"> (1-10)</option>
<option value="1-5">1 ( 1-5)</option>
<option value="6-10">2 ( 6-10)</option>
<select className="kx-select" aria-label={t('schedule.hallFilter')} value={hall} onChange={(e) => setHall(e.target.value)}>
<option value="all">{t('schedule.allHalls')}</option>
<option value="1-5">{t('schedule.hall1')}</option>
<option value="6-10">{t('schedule.hall2')}</option>
</select>
<select className="kx-select" aria-label="카테고리 필터" value={category} onChange={(e) => setCategory(e.target.value)}>
<option value="all"> </option>
{Object.entries(CATEGORY_LABEL).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
<select className="kx-select" aria-label={t('schedule.catFilter')} value={category} onChange={(e) => setCategory(e.target.value)}>
<option value="all">{t('schedule.allCats')}</option>
{Object.keys(CATEGORY_LABEL).map((k) => (
<option key={k} value={k}>{t(`schedule.cat.${k}`)}</option>
))}
</select>
<select className="kx-select" aria-label="상태 필터" value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="all"> </option>
{Object.entries(STATUS_LABEL).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
<select className="kx-select" aria-label={t('schedule.statusFilter')} value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="all">{t('schedule.allStatus')}</option>
{Object.keys(STATUS_LABEL).map((k) => (
<option key={k} value={k}>{t(`schedule.status.${k}`)}</option>
))}
</select>
<div className="kx-sched__filter-spacer" />
{degraded && <span className="kx-bi__degraded"> API ( )</span>}
<div className="kx-seg" role="tablist" aria-label="보기 전환">
<button role="tab" aria-selected={view === 'list'} className={`kx-seg__btn ${view === 'list' ? 'is-active' : ''}`} onClick={() => setView('list')}></button>
<button role="tab" aria-selected={view === 'calendar'} className={`kx-seg__btn ${view === 'calendar' ? 'is-active' : ''}`} onClick={() => setView('calendar')}></button>
{degraded && <span className="kx-bi__degraded">{t('schedule.apiWait')}</span>}
<div className="kx-seg" role="tablist" aria-label={t('schedule.viewSwitch')}>
<button role="tab" aria-selected={view === 'list'} className={`kx-seg__btn ${view === 'list' ? 'is-active' : ''}`} onClick={() => setView('list')}>{t('schedule.viewList')}</button>
<button role="tab" aria-selected={view === 'calendar'} className={`kx-seg__btn ${view === 'calendar' ? 'is-active' : ''}`} onClick={() => setView('calendar')}>{t('schedule.viewCalendar')}</button>
</div>
</div>
<div className="kx-sched__split">
{query.isLoading ? (
<section className="kx-sched__list" aria-label="행사 목록 로딩 중" aria-busy="true">
<section className="kx-sched__list" aria-label={t('schedule.listLoading')} aria-busy="true">
{Array.from({ length: 5 }).map((_, i) => (
<article key={i} className="kx-evcard" aria-hidden="true">
<Skeleton height={112} width={160} radius={8} />
@ -206,21 +208,21 @@ export function ExhibitionSchedulePage() {
))}
</section>
) : query.isError ? (
<section className="kx-sched__list" aria-label="행사 목록">
<section className="kx-sched__list" aria-label={t('schedule.listAria')}>
<ErrorState
message="전시 일정을 불러오지 못했습니다."
message={t('schedule.loadError')}
onRetry={() => query.refetch()}
/>
</section>
) : view === 'list' ? (
<section className="kx-sched__list" aria-label="행사 목록">
<p className="kx-sched__count tnum">{listed.length.toLocaleString()}</p>
<section className="kx-sched__list" aria-label={t('schedule.listAria')}>
<p className="kx-sched__count tnum">{t('schedule.count', { n: listed.length.toLocaleString() })}</p>
{listed.length === 0 ? (
<div className="kx-card">
<EmptyState
title="등록된 행사가 없습니다"
description="필터를 조정하거나 새 행사를 생성하세요."
action={<Button> </Button>}
title={t('schedule.emptyTitle')}
description={t('schedule.emptyDesc')}
action={<Button>{t('schedule.newEvent')}</Button>}
/>
</div>
) : (
@ -230,18 +232,18 @@ export function ExhibitionSchedulePage() {
<div className="kx-evcard__body">
<div className="kx-evcard__top">
<h2 className="kx-evcard__title">{e.title}</h2>
<span className={`kx-evstatus is-${e.status}`}>{STATUS_LABEL[e.status]}</span>
<span className={`kx-evstatus is-${e.status}`}>{t(`schedule.status.${e.status}`)}</span>
</div>
<p className="kx-evcard__cat">{CATEGORY_LABEL[e.category]} </p>
<p className="kx-evcard__cat">{t('schedule.catExhibit', { cat: t(`schedule.cat.${e.category}`) })}</p>
<ul className="kx-evcard__meta">
<li aria-label="기간"><IconCalendar size={14} /> <span className="tnum">{e.period}</span></li>
<li aria-label="배정 홀"><IconGrid size={14} /> {e.halls}</li>
<li aria-label="추정 인원"><IconUsers size={14} /> <span className="tnum">{e.estVisitors}</span></li>
<li aria-label={t('schedule.period')}><IconCalendar size={14} /> <span className="tnum">{e.period}</span></li>
<li aria-label={t('schedule.assignedHall')}><IconGrid size={14} /> {e.halls}</li>
<li aria-label={t('schedule.estVisitors')}><IconUsers size={14} /> <span className="tnum">{e.estVisitors}</span></li>
{e.designStatus && <li><StatusBadge status={e.designStatus} /></li>}
</ul>
<div className="kx-evcard__actions">
<Button variant="secondary"> </Button>
<Button variant="ghost">/ </Button>
<Button variant="secondary">{t('schedule.editDetail')}</Button>
<Button variant="ghost">{t('schedule.viewLogistics')}</Button>
</div>
</div>
</article>
@ -269,6 +271,7 @@ const MAX_CHIPS = 3; // 월 그리드 셀당 표시 칩 수 (초과분 +N)
* , ( ), +N. .
*/
function MonthCalendar({ events }: { events: ScheduleEvent[] }) {
const { t } = useTranslation();
const [cursor, setCursor] = useState(new Date(2026, 6, 1)); // 2026년 7월
const year = cursor.getFullYear();
const month = cursor.getMonth();
@ -286,22 +289,22 @@ function MonthCalendar({ events }: { events: ScheduleEvent[] }) {
const mm = String(month + 1).padStart(2, '0');
return (
<section className="kx-card kx-mcal" aria-label="월 캘린더">
<section className="kx-card kx-mcal" aria-label={t('schedule.monthCal')}>
<div className="kx-mcal__toolbar">
<button type="button" className="kx-mcal__nav" onClick={() => setCursor(new Date(year, month - 1, 1))}>
<IconChevronLeft size={14} />
<IconChevronLeft size={14} /> {t('schedule.prev')}
</button>
<strong className="kx-mcal__title tnum">{year} {month + 1}</strong>
<strong className="kx-mcal__title tnum">{t('schedule.yearMonth', { y: year, m: month + 1 })}</strong>
<button type="button" className="kx-mcal__nav" onClick={() => setCursor(new Date(year, month + 1, 1))}>
<IconChevronRight size={14} />
{t('schedule.next')} <IconChevronRight size={14} />
</button>
<button type="button" className="kx-mcal__nav" onClick={() => setCursor(new Date(TODAY.getFullYear(), TODAY.getMonth(), 1))}>
{t('schedule.today')}
</button>
</div>
<div className="kx-mcal__grid">
{WD.map((w, i) => (
<div key={w} className={`kx-mcal__dow${i === 0 ? ' is-sun' : i === 6 ? ' is-sat' : ''}`}>{w}</div>
<div key={w} className={`kx-mcal__dow${i === 0 ? ' is-sun' : i === 6 ? ' is-sat' : ''}`}>{t(`common.weekday.${i}`)}</div>
))}
{cells.map((d, i) => {
if (d === null) return <div key={`e${i}`} className="kx-mcal__cell is-out" />;
@ -335,6 +338,7 @@ function MonthCalendar({ events }: { events: ScheduleEvent[] }) {
}
function MiniCalendar({ cursor, onCursor }: { cursor: Date; onCursor: (d: Date) => void }) {
const { t } = useTranslation();
const year = cursor.getFullYear();
const month = cursor.getMonth();
const firstDay = new Date(year, month, 1).getDay();
@ -370,15 +374,15 @@ function MiniCalendar({ cursor, onCursor }: { cursor: Date; onCursor: (d: Date)
TODAY.getFullYear() === year && TODAY.getMonth() === month && TODAY.getDate() === d;
return (
<section className="kx-card kx-cal" aria-label="미니 캘린더">
<section className="kx-card kx-cal" aria-label={t('schedule.miniCal')}>
<div className="kx-cal__head">
<button className="kx-cal__nav" aria-label="이전 달" onClick={() => onCursor(new Date(year, month - 1, 1))}></button>
<strong>{year} {month + 1}{degraded && <span className="kx-bi__degraded"> </span>}</strong>
<button className="kx-cal__nav" aria-label="다음 달" onClick={() => onCursor(new Date(year, month + 1, 1))}></button>
<button className="kx-cal__nav" aria-label={t('schedule.prevMonth')} onClick={() => onCursor(new Date(year, month - 1, 1))}></button>
<strong>{t('schedule.yearMonth', { y: year, m: month + 1 })}{degraded && <span className="kx-bi__degraded"> {t('schedule.offlineSample')}</span>}</strong>
<button className="kx-cal__nav" aria-label={t('schedule.nextMonth')} onClick={() => onCursor(new Date(year, month + 1, 1))}></button>
</div>
<div className="kx-cal__grid" role="grid">
{WD.map((w) => (
<span key={w} className="kx-cal__wd" role="columnheader">{w}</span>
{WD.map((w, i) => (
<span key={w} className="kx-cal__wd" role="columnheader">{t(`common.weekday.${i}`)}</span>
))}
{cells.map((d, i) => {
if (d === null) return <span key={`e${i}`} className="kx-cal__cell is-empty" />;
@ -388,7 +392,7 @@ function MiniCalendar({ cursor, onCursor }: { cursor: Date; onCursor: (d: Date)
key={d}
className={`kx-cal__cell tnum ${isToday(d) ? 'is-today' : ''} ${hasEvent ? 'has-event' : ''}`}
role="gridcell"
aria-label={hasEvent ? `${d}일 (행사 있음)` : `${d}`}
aria-label={hasEvent ? t('schedule.dayHasEvent', { d }) : t('schedule.day', { d })}
>
{d}
</span>
@ -400,6 +404,7 @@ function MiniCalendar({ cursor, onCursor }: { cursor: Date; onCursor: (d: Date)
}
function HallUtilization({ cursor }: { cursor: Date }) {
const { t } = useTranslation();
const year = cursor.getFullYear();
const month = cursor.getMonth();
@ -423,10 +428,10 @@ function HallUtilization({ cursor }: { cursor: Date }) {
const degraded = query.data?.degraded ?? false;
return (
<section className="kx-card" aria-label="홀 가동률">
<section className="kx-card" aria-label={t('schedule.hallUtil')}>
<div className="kx-card__head">
<h2> ({year} {month + 1})</h2>
{degraded && <span className="kx-bi__degraded"> </span>}
<h2>{t('schedule.hallUtilMonth', { y: year, m: month + 1 })}</h2>
{degraded && <span className="kx-bi__degraded">{t('schedule.offlineSample')}</span>}
</div>
<ul className="kx-barlist">
{rows.map((h) => (

View File

@ -1,5 +1,7 @@
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts';
import { Button } from '../../components/ui/Button';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
@ -20,22 +22,22 @@ import './settlement.css';
const PAGE_SIZE = 20;
const STATUS_META: Record<InvoiceStatus, { label: string; color: string; tone: string }> = {
pending: { label: '미발행', color: CHART.slate, tone: 'muted' },
invoiced: { label: '청구됨', color: CHART.primary600, tone: 'info' },
paid: { label: '완납', color: CHART.success, tone: 'ok' },
overdue: { label: '연체', color: CHART.error, tone: 'error' },
const STATUS_META: Record<InvoiceStatus, { color: string; tone: string }> = {
pending: { color: CHART.slate, tone: 'muted' },
invoiced: { color: CHART.primary600, tone: 'info' },
paid: { color: CHART.success, tone: 'ok' },
overdue: { color: CHART.error, tone: 'error' },
};
const STATUS_FILTERS: Array<{ value: '' | InvoiceStatus; label: string }> = [
{ value: '', label: '전체' },
{ value: 'pending', label: '미발행' },
{ value: 'invoiced', label: '청구됨' },
{ value: 'paid', label: '완납' },
{ value: 'overdue', label: '연체' },
];
const STATUS_FILTER_VALUES: Array<'' | InvoiceStatus> = ['', 'pending', 'invoiced', 'paid', 'overdue'];
/** 청구 상태 → 현지화 라벨. '' 은 전체 필터. */
function statusLabel(t: TFunction, status: '' | InvoiceStatus): string {
return status === '' ? t('settlement.status.all') : t(`settlement.status.${status}`);
}
export function SettlementDashboardPage() {
const { t } = useTranslation();
const eventId = useResolvedEventId();
const role = useAuthStore((s) => s.roleForCurrent());
const canManage = role === 'ORGANIZER' || role === 'HALL_MANAGER';
@ -74,7 +76,7 @@ export function SettlementDashboardPage() {
return (
<div className="kx-settle">
<PageHeader canManage={false} onGenerate={() => {}} generating={false} />
<EmptyState title="행사를 선택해 주세요" description="정산 현황을 조회할 행사가 지정되지 않았습니다." />
<EmptyState title={t('settlement.noEventTitle')} description={t('settlement.noEventDesc')} />
</div>
);
}
@ -93,12 +95,12 @@ export function SettlementDashboardPage() {
{generateM.isSuccess && generateM.data && (
<div className="kx-settle__notice" role="status">
{generateM.data.created}, {generateM.data.skipped}.
{t('settlement.genDone', { created: generateM.data.created, skipped: generateM.data.skipped })}
</div>
)}
{generateM.isError && (
<div className="kx-settle__notice kx-settle__notice--error" role="alert">
. .
{t('settlement.genError')}
</div>
)}
@ -110,30 +112,30 @@ export function SettlementDashboardPage() {
))}
</div>
) : dashboardQ.isError ? (
<ErrorState message="정산 현황 집계를 불러오지 못했습니다." onRetry={() => dashboardQ.refetch()} />
<ErrorState message={t('settlement.dashError')} onRetry={() => dashboardQ.refetch()} />
) : dash ? (
<>
<section className="kx-settle__kpis" aria-label="정산 핵심 지표">
<KpiCard label="총 청구액" value={formatWon(dash.billed)} tone="info" sub={`${dash.invoiceCount}`} />
<section className="kx-settle__kpis" aria-label={t('settlement.kpisAria')}>
<KpiCard label={t('settlement.kpiBilled')} value={formatWon(dash.billed)} tone="info" sub={t('settlement.count', { n: dash.invoiceCount })} />
<KpiCard
label="수납액"
label={t('settlement.kpiCollected')}
value={formatWon(dash.collected)}
tone="ok"
sub={`수납률 ${dash.collectionRate.toFixed(1)}%`}
sub={t('settlement.collectionRate', { rate: dash.collectionRate.toFixed(1) })}
/>
<KpiCard label="미수금" value={formatWon(dash.outstanding)} tone="muted" sub="미수 잔액" />
<KpiCard label={t('settlement.kpiOutstanding')} value={formatWon(dash.outstanding)} tone="muted" sub={t('settlement.outstandingSub')} />
<KpiCard
label="연체 청구"
value={`${dash.overdueCount}`}
label={t('settlement.kpiOverdue')}
value={t('settlement.count', { n: dash.overdueCount })}
tone={dash.overdueCount > 0 ? 'error' : 'muted'}
sub="납부 기한 경과"
sub={t('settlement.overdueSub')}
/>
</section>
{/* 상태 분포 도넛 + 범례 */}
<section className="kx-card kx-settle__dist" aria-label="청구 상태 분포">
<section className="kx-card kx-settle__dist" aria-label={t('settlement.distTitle')}>
<div className="kx-card__head">
<h2> </h2>
<h2>{t('settlement.distTitle')}</h2>
</div>
<StatusDistribution buckets={dash.statusDistribution} />
<p className="kx-settle__paynote">{dash.paymentNote}</p>
@ -142,22 +144,22 @@ export function SettlementDashboardPage() {
) : null}
{/* 청구서 목록 */}
<section className="kx-card kx-settle__list-card" aria-label="청구서 목록">
<section className="kx-card kx-settle__list-card" aria-label={t('settlement.listTitle')}>
<div className="kx-card__head kx-settle__list-head">
<h2></h2>
<div className="kx-seg" role="tablist" aria-label="상태 필터">
{STATUS_FILTERS.map((f) => (
<h2>{t('settlement.invoices')}</h2>
<div className="kx-seg" role="tablist" aria-label={t('settlement.statusFilter')}>
{STATUS_FILTER_VALUES.map((v) => (
<button
key={f.value || 'all'}
key={v || 'all'}
role="tab"
aria-selected={statusFilter === f.value}
className={`kx-seg__btn ${statusFilter === f.value ? 'is-active' : ''}`}
aria-selected={statusFilter === v}
className={`kx-seg__btn ${statusFilter === v ? 'is-active' : ''}`}
onClick={() => {
setStatusFilter(f.value);
setStatusFilter(v);
setPage(0);
}}
>
{f.label}
{statusLabel(t, v)}
</button>
))}
</div>
@ -170,11 +172,11 @@ export function SettlementDashboardPage() {
))}
</div>
) : listQ.isError ? (
<ErrorState message="청구서 목록을 불러오지 못했습니다." onRetry={() => listQ.refetch()} />
<ErrorState message={t('settlement.listError')} onRetry={() => listQ.refetch()} />
) : !list || list.items.length === 0 ? (
<EmptyState
title="청구서가 없습니다"
description={canManage ? '상단의 청구서 생성으로 참가업체별 청구를 집계하세요.' : '아직 발행된 청구서가 없습니다.'}
title={t('settlement.emptyTitle')}
description={canManage ? t('settlement.emptyManage') : t('settlement.emptyView')}
/>
) : (
<>
@ -182,14 +184,14 @@ export function SettlementDashboardPage() {
<table className="kx-table kx-table--zebra">
<thead>
<tr>
<th></th>
<th></th>
<th className="kx-num"></th>
<th className="kx-num"></th>
<th className="kx-num"></th>
<th></th>
<th></th>
<th aria-label="상세" />
<th>{t('settlement.colNo')}</th>
<th>{t('settlement.colExhibitor')}</th>
<th className="kx-num">{t('settlement.colBilled')}</th>
<th className="kx-num">{t('settlement.colPaid')}</th>
<th className="kx-num">{t('settlement.colOutstanding')}</th>
<th>{t('settlement.colDue')}</th>
<th>{t('settlement.colStatus')}</th>
<th aria-label={t('settlement.detail')} />
</tr>
</thead>
<tbody>
@ -212,7 +214,7 @@ export function SettlementDashboardPage() {
setSelectedId(inv.id);
}}
>
{t('settlement.detail')}
</button>
</td>
</tr>
@ -226,17 +228,17 @@ export function SettlementDashboardPage() {
disabled={page <= 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
{t('settlement.prev')}
</Button>
<span className="kx-settle__pageinfo tnum">
{page + 1} / {totalPages} · {list.total}
{t('settlement.pageInfo', { page: page + 1, total: totalPages, cnt: list.total })}
</span>
<Button
variant="secondary"
disabled={page + 1 >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
{t('settlement.next')}
</Button>
</div>
</>
@ -264,16 +266,17 @@ function PageHeader({
onGenerate: () => void;
generating: boolean;
}) {
const { t } = useTranslation();
return (
<header className="kx-settle__head">
<div>
<h1 className="kx-settle__title">·</h1>
<p className="kx-settle__subtitle"> · </p>
<h1 className="kx-settle__title">{t('settlement.title')}</h1>
<p className="kx-settle__subtitle">{t('settlement.subtitle')}</p>
</div>
{canManage && (
<div className="kx-settle__head-actions">
<Button variant="primary" onClick={onGenerate} disabled={generating}>
{generating ? '생성 중…' : '청구서 생성'}
{generating ? t('settlement.generating') : t('settlement.generate')}
</Button>
</div>
)}
@ -304,22 +307,23 @@ function KpiCard({
// ── 상태 분포(도넛) ───────────────────────────────────────────────────────────
function StatusDistribution({ buckets }: { buckets: StatusBucket[] }) {
const { t } = useTranslation();
const data = useMemo(
() =>
buckets
.filter((b) => b.count > 0)
.map((b) => ({
key: b.status,
name: STATUS_META[b.status]?.label ?? b.status,
name: statusLabel(t, b.status),
value: b.totalAmount,
count: b.count,
color: STATUS_META[b.status]?.color ?? CHART.slate,
})),
[buckets],
[buckets, t],
);
if (data.length === 0) {
return <EmptyState title="집계된 청구가 없습니다" />;
return <EmptyState title={t('settlement.distEmpty')} />;
}
return (
@ -336,7 +340,7 @@ function StatusDistribution({ buckets }: { buckets: StatusBucket[] }) {
contentStyle={TOOLTIP_STYLE}
formatter={(value, _name, item) => {
const p = (item?.payload ?? {}) as { count?: number; name?: string };
return [`${formatWon(Number(value))} · ${p.count ?? 0}`, p.name ?? ''];
return [`${formatWon(Number(value))} · ${t('settlement.count', { n: p.count ?? 0 })}`, p.name ?? ''];
}}
/>
</PieChart>
@ -348,7 +352,7 @@ function StatusDistribution({ buckets }: { buckets: StatusBucket[] }) {
<span className="kx-settle__legend-dot" style={{ background: d.color }} aria-hidden="true" />
<span className="kx-settle__legend-name">{d.name}</span>
<span className="kx-settle__legend-val tnum">
{d.count} · {formatWon(d.value)}
{t('settlement.count', { n: d.count })} · {formatWon(d.value)}
</span>
</li>
))}
@ -359,8 +363,9 @@ function StatusDistribution({ buckets }: { buckets: StatusBucket[] }) {
// ── 상태 표시 ─────────────────────────────────────────────────────────────────
function StatusPill({ status }: { status: InvoiceStatus }) {
const meta = STATUS_META[status] ?? { label: status, tone: 'muted', color: CHART.slate };
return <span className={`kx-settle__pill kx-settle__pill--${meta.tone}`}>{meta.label}</span>;
const { t } = useTranslation();
const meta = STATUS_META[status] ?? { tone: 'muted', color: CHART.slate };
return <span className={`kx-settle__pill kx-settle__pill--${meta.tone}`}>{statusLabel(t, status)}</span>;
}
// ── 상세 모달(라인아이템 + 수납 이력 + 수납 기록) ─────────────────────────────
@ -373,6 +378,7 @@ function InvoiceDetailModal({
onClose: () => void;
onChanged: () => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const [amount, setAmount] = useState('');
const [method, setMethod] = useState<'manual' | 'transfer' | 'card'>('transfer');
@ -399,7 +405,7 @@ function InvoiceDetailModal({
setFormError(null);
invalidate();
},
onError: () => setFormError('수납 기록에 실패했습니다. 금액(총액 이내)·권한을 확인해 주세요.'),
onError: () => setFormError(t('settlement.payFail')),
});
const issueM = useMutation({
@ -412,11 +418,11 @@ function InvoiceDetailModal({
const submitPayment = () => {
const n = Number(amount);
if (!Number.isFinite(n) || n <= 0) {
setFormError('0보다 큰 금액을 입력해 주세요.');
setFormError(t('settlement.errPositive'));
return;
}
if (d && n > d.outstanding) {
setFormError('미수 잔액을 초과할 수 없습니다.');
setFormError(t('settlement.errExceed'));
return;
}
setFormError(null);
@ -424,11 +430,11 @@ function InvoiceDetailModal({
};
return (
<div className="kx-settle__overlay" role="dialog" aria-modal="true" aria-label="청구서 상세" onClick={onClose}>
<div className="kx-settle__overlay" role="dialog" aria-modal="true" aria-label={t('settlement.modalAria')} onClick={onClose}>
<div className="kx-settle__modal" onClick={(e) => e.stopPropagation()}>
<div className="kx-settle__modal-head">
<h2>{d ? d.invoiceNo : '청구서'}</h2>
<button className="kx-settle__close" aria-label="닫기" onClick={onClose}>
<h2>{d ? d.invoiceNo : t('settlement.invoice')}</h2>
<button className="kx-settle__close" aria-label={t('settlement.close')} onClick={onClose}>
</button>
</div>
@ -440,20 +446,20 @@ function InvoiceDetailModal({
<Skeleton height={80} radius={8} />
</div>
) : detailQ.isError || !d ? (
<ErrorState message="청구서 상세를 불러오지 못했습니다." onRetry={() => detailQ.refetch()} />
<ErrorState message={t('settlement.detailError')} onRetry={() => detailQ.refetch()} />
) : (
<div className="kx-settle__modal-body">
<div className="kx-settle__meta">
<div>
<span className="kx-settle__meta-k"></span>
<span className="kx-settle__meta-k">{t('settlement.colExhibitor')}</span>
<span className="kx-settle__meta-v">{d.exhibitorName}</span>
</div>
<div>
<span className="kx-settle__meta-k"></span>
<span className="kx-settle__meta-k">{t('settlement.colStatus')}</span>
<StatusPill status={d.status} />
</div>
<div>
<span className="kx-settle__meta-k"></span>
<span className="kx-settle__meta-k">{t('settlement.colDue')}</span>
<span className="kx-settle__meta-v">{d.dueDate ?? '-'}</span>
</div>
</div>
@ -462,17 +468,17 @@ function InvoiceDetailModal({
<table className="kx-table kx-settle__lines">
<thead>
<tr>
<th></th>
<th className="kx-num"></th>
<th className="kx-num"></th>
<th className="kx-num"></th>
<th>{t('settlement.liItem')}</th>
<th className="kx-num">{t('settlement.liQty')}</th>
<th className="kx-num">{t('settlement.liUnit')}</th>
<th className="kx-num">{t('settlement.liAmount')}</th>
</tr>
</thead>
<tbody>
{d.lineItems.length === 0 ? (
<tr>
<td colSpan={4} className="kx-settle__muted">
{t('settlement.noLines')}
</td>
</tr>
) : (
@ -490,19 +496,19 @@ function InvoiceDetailModal({
</tbody>
<tfoot>
<tr>
<td colSpan={3}></td>
<td colSpan={3}>{t('settlement.supplyAmount')}</td>
<td className="kx-num tnum">{d.subtotal.toLocaleString()}</td>
</tr>
<tr>
<td colSpan={3}>(10%)</td>
<td colSpan={3}>{t('settlement.vat')}</td>
<td className="kx-num tnum">{d.vat.toLocaleString()}</td>
</tr>
<tr className="kx-settle__total-row">
<td colSpan={3}> </td>
<td colSpan={3}>{t('settlement.grandTotal')}</td>
<td className="kx-num tnum">{d.total.toLocaleString()}</td>
</tr>
<tr>
<td colSpan={3}> / </td>
<td colSpan={3}>{t('settlement.paidOutstanding')}</td>
<td className="kx-num tnum">
{d.paidAmount.toLocaleString()} / {d.outstanding.toLocaleString()}
</td>
@ -512,14 +518,14 @@ function InvoiceDetailModal({
{/* 수납 이력 */}
<div className="kx-settle__pay-hist">
<h3> </h3>
<h3>{t('settlement.payHistory')}</h3>
{d.payments.length === 0 ? (
<p className="kx-settle__muted"> .</p>
<p className="kx-settle__muted">{t('settlement.noPayments')}</p>
) : (
<ul>
{d.payments.map((p) => (
<li key={p.id}>
<span className="tnum">{p.amount.toLocaleString()}</span>
<span className="tnum">{t('settlement.won', { v: p.amount.toLocaleString() })}</span>
<span className="kx-settle__pay-method">{p.method}</span>
{p.memo && <span className="kx-settle__pay-memo">{p.memo}</span>}
<span className="kx-settle__pay-date">{formatDate(p.recordedAt)}</span>
@ -534,7 +540,7 @@ function InvoiceDetailModal({
<div className="kx-settle__actions">
{d.status === 'pending' && (
<Button variant="secondary" onClick={() => issueM.mutate()} disabled={issueM.isPending}>
{issueM.isPending ? '발행 중…' : '청구서 발행'}
{issueM.isPending ? t('settlement.issuing') : t('settlement.issue')}
</Button>
)}
{d.outstanding > 0 && (
@ -544,42 +550,42 @@ function InvoiceDetailModal({
className="kx-input"
type="number"
min={1}
placeholder="수납 금액(원)"
placeholder={t('settlement.payAmountPh')}
value={amount}
onChange={(e) => setAmount(e.target.value)}
aria-label="수납 금액"
aria-label={t('settlement.payAmount')}
/>
<select
className="kx-select"
value={method}
onChange={(e) => setMethod(e.target.value as 'manual' | 'transfer' | 'card')}
aria-label="수납 방법"
aria-label={t('settlement.payMethod')}
>
<option value="transfer"></option>
<option value="card"></option>
<option value="manual"></option>
<option value="transfer">{t('settlement.methodTransfer')}</option>
<option value="card">{t('settlement.methodCard')}</option>
<option value="manual">{t('settlement.methodOther')}</option>
</select>
<input
className="kx-input"
type="text"
placeholder="메모(선택)"
placeholder={t('settlement.memoPh')}
value={memo}
onChange={(e) => setMemo(e.target.value)}
aria-label="메모"
aria-label={t('settlement.memo')}
/>
<Button variant="primary" onClick={submitPayment} disabled={payM.isPending}>
{payM.isPending ? '기록 중…' : '수납 기록'}
{payM.isPending ? t('settlement.recording') : t('settlement.recordPay')}
</Button>
</div>
{formError && <p className="kx-settle__form-error" role="alert">{formError}</p>}
<p className="kx-settle__muted">
PG로 . .
{t('settlement.pgNote')}
</p>
</div>
)}
</div>
) : (
<p className="kx-settle__muted">· / .</p>
<p className="kx-settle__muted">{t('settlement.managerOnly')}</p>
)}
</div>
)}

View File

@ -0,0 +1,140 @@
/*
* MDI( ) (zustand) design.md §2.7 MDI .
*
* W7 ( = "탭 = 라우트 북마크"):
* App.tsx (). ·
* react-router . DOM (display:none DocumentHost)
* , ( + ) .
*
* 지속: sessionStorage( , ). {path,label} · path.
* 패턴: themeStore( , set ).
*/
import { create } from 'zustand';
export interface MdiTab {
/** 문서 라우트(딥링크) — 문서키 겸 dedup 기준. */
path: string;
/** 탭에 표시할 화면명(라벨 매핑 테이블 해소값). */
label: string;
}
/** 동시 열림 상한 — 초과 시 가장 오래된 비활성 탭을 닫는다. */
export const MAX_TABS = 10;
const STORAGE_KEY = 'kx-mdi';
interface Persisted {
tabs: MdiTab[];
activePath: string | null;
}
function readStored(): Persisted {
if (typeof sessionStorage === 'undefined') return { tabs: [], activePath: null };
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) return { tabs: [], activePath: null };
const parsed = JSON.parse(raw) as Partial<Persisted>;
const tabs = Array.isArray(parsed.tabs)
? parsed.tabs.filter(
(t): t is MdiTab =>
!!t && typeof t.path === 'string' && typeof t.label === 'string',
)
: [];
const activePath =
typeof parsed.activePath === 'string' && tabs.some((t) => t.path === parsed.activePath)
? parsed.activePath
: (tabs[tabs.length - 1]?.path ?? null);
return { tabs, activePath };
} catch {
return { tabs: [], activePath: null };
}
}
function persist(state: Persisted) {
if (typeof sessionStorage === 'undefined') return;
try {
sessionStorage.setItem(
STORAGE_KEY,
JSON.stringify({ tabs: state.tabs, activePath: state.activePath }),
);
} catch {
/* 저장 실패(쿼터·프라이빗 모드)는 무시 — 세션 지속은 best-effort. */
}
}
interface MdiState {
tabs: MdiTab[];
activePath: string | null;
/**
* / .
* ( , §2.7-2).
* (: 딥링크로 ).
* .
*/
openTab: (path: string, label: string) => void;
/** 탭 활성 전환(경로가 목록에 있을 때만). */
activateTab: (path: string) => void;
/**
* . ( ) .
* @returns ( null) navigate .
*/
closeTab: (path: string) => string | null;
}
const initial = readStored();
export const useMdiStore = create<MdiState>((set, get) => ({
tabs: initial.tabs,
activePath: initial.activePath,
openTab: (path, label) => {
const { tabs } = get();
const existing = tabs.find((t) => t.path === path);
let next: MdiTab[];
if (existing) {
// 라벨만 갱신(위치 유지) — 중복 생성 금지.
next = existing.label === label
? tabs
: tabs.map((t) => (t.path === path ? { ...t, label } : t));
} else {
next = [...tabs, { path, label }];
// 상한 초과 → 가장 오래된 비활성 탭 제거(활성 대상·방금 연 탭 제외).
if (next.length > MAX_TABS) {
const victim = next.find((t) => t.path !== path);
if (victim) next = next.filter((t) => t.path !== victim.path);
}
}
const state = { tabs: next, activePath: path };
persist(state);
set(state);
},
activateTab: (path) => {
const { tabs } = get();
if (!tabs.some((t) => t.path === path)) return;
const state = { tabs, activePath: path };
persist(state);
set({ activePath: path });
},
closeTab: (path) => {
const { tabs, activePath } = get();
const idx = tabs.findIndex((t) => t.path === path);
if (idx === -1) return activePath;
const next = tabs.filter((t) => t.path !== path);
let nextActive = activePath;
if (activePath === path) {
// 오른쪽 우선, 없으면 왼쪽 인접.
const neighbor = tabs[idx + 1] ?? tabs[idx - 1] ?? null;
nextActive = neighbor ? neighbor.path : null;
}
const state = { tabs: next, activePath: nextActive };
persist(state);
set(state);
return nextActive;
},
}));