diff --git a/docs/GAP_AUDIT.md b/docs/GAP_AUDIT.md index b80fb26..d47aec5 100644 --- a/docs/GAP_AUDIT.md +++ b/docs/GAP_AUDIT.md @@ -46,11 +46,12 @@ ### P1 — 주요 미배선/백엔드 전무 -**G-01 [P1] 테넌트 관리 화면 미배선 (frontend)** +**G-01 [P1] 테넌트 관리 화면 미배선 (frontend)** — ✅해소(2026-07-13) - 파일: `src/frontend/src/screens/admin/TenantAdminPage.tsx:20`(하드코딩 `TENANTS`), `:53`(추가 버튼 `disabled title=notImpl`) - 유형: 미배선(백엔드 존재, 프론트 sample 고정) — CRUD 누락(목록·생성 미연결) - 근거: 백엔드 `TenantAdminController`(`/api/admin/tenants` GET list + POST create) **실존**. 화면 주석 "백엔드 미구현"은 **STALE**. - 정본: PLANNING M18 §1A(테넌트 온보딩), design.md SCR-A9. 기획에 있음. +- **해소:** `tenantApi`(list/create) 라이브 배선 — react-query 목록(로딩/에러/빈 상태), 등록 모달(서버 권위 검증+클라 미러), KxDataTable(검색·정렬·CSV), `tenant.*` i18n 17키×4로케일. 하드코딩·disabled·샘플 배너 제거. **G-02 [P1] 입장권 예매/조회 백엔드 전무** - 파일: `src/frontend/src/screens/public/PublicTicketPage.tsx:5`(주석 "티켓·재고·결제 API 부재 → 샘플"), `:36`(하드코딩 `TICKETS`) @@ -93,7 +94,7 @@ | 순위 | ID | 작업 | 담당 | |------|-----|------|------| -| 1 | G-01 | TenantAdminPage → `/api/admin/tenants` GET/POST 배선(하드코딩·disabled 제거) | **kintex-frontend-dev** | +| 1 | ~~G-01~~ ✅ | TenantAdminPage → `/api/admin/tenants` GET/POST 배선(하드코딩·disabled 제거) — 2026-07-13 해소 | **kintex-frontend-dev** | | 2 | G-02 | 티켓 도메인 백엔드(티켓·재고·예매·조회) + PG 어댑터 + 프론트 배선 | **kintex-backend-dev**(+visitor-dev) → frontend-dev | | 3 | G-04 | 캠페인 실발송(SMTP, 수신동의·상한) | kintex-backend-dev(marketing) | | 4 | G-03 | CMS AI 자동번역 배선(AiTextRouter) | kintex-ai-dev | diff --git a/src/frontend/src/i18n/locales/en.json b/src/frontend/src/i18n/locales/en.json index 86b57ab..ea99438 100644 --- a/src/frontend/src/i18n/locales/en.json +++ b/src/frontend/src/i18n/locales/en.json @@ -1375,7 +1375,24 @@ "active": "Active", "onboarding": "Onboarding", "suspended": "Suspended" - } + }, + "loadError": "Failed to load tenants.", + "emptyTitle": "No tenants yet", + "emptyDesc": "Add a venue to onboard your first tenant.", + "modalTitle": "Register Tenant", + "codeHint": "Lowercase letters, digits and hyphens, 2–40 chars (used as subdomain/identifier)", + "codePlaceholder": "e.g. coex", + "nameLabel": "Display name", + "namePlaceholder": "e.g. COEX", + "domainPlaceholder": "e.g. coex.wise.ai.kr", + "cancel": "Cancel", + "save": "Register", + "saving": "Registering…", + "created": "Tenant registered.", + "errCodeRequired": "Tenant code is required.", + "errCodePattern": "Code must be 2–40 lowercase letters, digits or hyphens.", + "errDupCode": "This tenant code already exists.", + "errNameRequired": "Display name is required." }, "ruleset": { "title": "Regulation Ruleset Versions", diff --git a/src/frontend/src/i18n/locales/ja.json b/src/frontend/src/i18n/locales/ja.json index 1173336..c4d559c 100644 --- a/src/frontend/src/i18n/locales/ja.json +++ b/src/frontend/src/i18n/locales/ja.json @@ -1375,7 +1375,24 @@ "active": "有効", "onboarding": "オンボーディング中", "suspended": "停止" - } + }, + "loadError": "テナント一覧を読み込めませんでした。", + "emptyTitle": "登録されたテナントがありません", + "emptyDesc": "展示館を追加して最初のテナントをオンボーディングしましょう。", + "modalTitle": "テナント登録", + "codeHint": "小文字・数字・ハイフン 2~40 文字(サブドメイン・識別子として使用)", + "codePlaceholder": "例: coex", + "nameLabel": "表示名", + "namePlaceholder": "例: COEX", + "domainPlaceholder": "例: coex.wise.ai.kr", + "cancel": "キャンセル", + "save": "登録", + "saving": "登録中…", + "created": "テナントを登録しました。", + "errCodeRequired": "テナントコードは必須です。", + "errCodePattern": "コードは小文字・数字・ハイフン 2~40 文字にしてください。", + "errDupCode": "既に存在するテナントコードです。", + "errNameRequired": "表示名は必須です。" }, "ruleset": { "title": "規定ルールセットバージョン管理", diff --git a/src/frontend/src/i18n/locales/ko.json b/src/frontend/src/i18n/locales/ko.json index 3789651..f5f10f9 100644 --- a/src/frontend/src/i18n/locales/ko.json +++ b/src/frontend/src/i18n/locales/ko.json @@ -1375,7 +1375,24 @@ "active": "활성", "onboarding": "온보딩중", "suspended": "중지" - } + }, + "loadError": "테넌트 목록을 불러오지 못했습니다.", + "emptyTitle": "등록된 테넌트가 없습니다", + "emptyDesc": "전시관 추가로 첫 테넌트를 온보딩하세요.", + "modalTitle": "테넌트 등록", + "codeHint": "소문자·숫자·하이픈 2~40자 (서브도메인·식별자로 사용)", + "codePlaceholder": "예: coex", + "nameLabel": "표시명", + "namePlaceholder": "예: 코엑스", + "domainPlaceholder": "예: coex.wise.ai.kr", + "cancel": "취소", + "save": "등록", + "saving": "등록 중…", + "created": "테넌트가 등록되었습니다.", + "errCodeRequired": "테넌트 코드는 필수입니다.", + "errCodePattern": "코드는 소문자·숫자·하이픈 2~40자여야 합니다.", + "errDupCode": "이미 존재하는 테넌트 코드입니다.", + "errNameRequired": "표시명은 필수입니다." }, "ruleset": { "title": "규정 룰셋 버전 관리", diff --git a/src/frontend/src/i18n/locales/zh.json b/src/frontend/src/i18n/locales/zh.json index e91b6b9..d2f1d09 100644 --- a/src/frontend/src/i18n/locales/zh.json +++ b/src/frontend/src/i18n/locales/zh.json @@ -1375,7 +1375,24 @@ "active": "活跃", "onboarding": "上线中", "suspended": "已停用" - } + }, + "loadError": "无法加载租户列表。", + "emptyTitle": "尚无租户", + "emptyDesc": "点击添加展馆以引导首个租户。", + "modalTitle": "注册租户", + "codeHint": "小写字母、数字和连字符,2–40 个字符(用作子域名/标识符)", + "codePlaceholder": "例如 coex", + "nameLabel": "显示名称", + "namePlaceholder": "例如 COEX", + "domainPlaceholder": "例如 coex.wise.ai.kr", + "cancel": "取消", + "save": "注册", + "saving": "注册中…", + "created": "租户已注册。", + "errCodeRequired": "租户代码为必填项。", + "errCodePattern": "代码必须为 2–40 位小写字母、数字或连字符。", + "errDupCode": "该租户代码已存在。", + "errNameRequired": "显示名称为必填项。" }, "ruleset": { "title": "规则集版本管理", diff --git a/src/frontend/src/screens/admin/TenantAdminPage.tsx b/src/frontend/src/screens/admin/TenantAdminPage.tsx index 527af6e..85e5b44 100644 --- a/src/frontend/src/screens/admin/TenantAdminPage.tsx +++ b/src/frontend/src/screens/admin/TenantAdminPage.tsx @@ -1,36 +1,67 @@ /* * SCR-A9 테넌트(전시관) 관리·온보딩 (M18 · §1A · 플랫폼 슈퍼관리자 전용). Stitch scr_a9 이식. - * 데이터 원천: 샘플. 멀티테넌시(테넌트 CRUD·온보딩·격리)가 백엔드 미구현 → 실배선 대상 없음. 갭 문서화. - * 화면은 온보딩 위저드 IA 와 테넌트 목록 레이아웃을 확정해 후속 구현의 계약 참조로 남긴다. + * ★ 실배선(G-01 해소): GET /api/admin/tenants(목록) · POST /api/admin/tenants(등록). + * 백엔드 TenantAdminController 실존 — 종전 하드코딩 배열·disabled 버튼(STALE "미구현")을 제거하고 + * react-query 목록 + 등록 모달로 배선. 상태·수치는 서버 권위. 코드 규칙은 서버 재검증(클라 사전검증만). + * 우측 온보딩 위저드는 선택 테넌트 상태 기반 IA 표시(2단계 브랜딩·시드·격리 스모크는 후속). */ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; -import { IconCheck } from '../../components/ui/icons'; +import type { TFunction } from 'i18next'; +import { Button } from '../../components/ui/Button'; +import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; +import { KxDataTable, type KxColumn } from '../../components/KxDataTable'; +import { IconCheck, IconPlus } from '../../components/ui/icons'; +import { errMessage, useToast } from '../work/workShared'; +import { tenantApi, TENANT_STATUSES, type Tenant, type TenantCreateBody } from './adminCrudApi'; import './admin-modules.css'; -interface TenantRow { - code: string; - name: string; - domain: string; - status: 'active' | 'onboarding' | 'suspended'; - events: number; - users: number; -} - -const TENANTS: TenantRow[] = [ - { code: 'KINTEX', name: '킨텍스', domain: 'kintex.wise.ai.kr', status: 'active', events: 24, users: 1280 }, - { code: 'COEX', name: '코엑스', domain: 'coex.wise.ai.kr', status: 'onboarding', events: 0, users: 3 }, - { code: 'BEXCO', name: '벡스코', domain: 'bexco.wise.ai.kr', status: 'suspended', events: 5, users: 210 }, -]; - const WIZARD_STEPS = ['create', 'domain', 'brand', 'master', 'admin', 'smoke', 'activate']; +/** 코드 규칙(백엔드 CODE 패턴과 동일) — 사전 검증용. 최종 검증은 서버 권위. */ +const CODE_RE = /^[a-z0-9-]{2,40}$/; + export function TenantAdminPage() { const { t } = useTranslation(); - const [selected, setSelected] = useState(TENANTS[1].code); - const tenant = TENANTS.find((tn) => tn.code === selected) ?? TENANTS[0]; - // 온보딩 진행 단계(샘플): active=완료 / onboarding=4단계 진행 / suspended=완료 후 중지 - const currentStep = tenant.status === 'onboarding' ? 3 : WIZARD_STEPS.length; + const qc = useQueryClient(); + const { show, node: toast } = useToast(); + const [selected, setSelected] = useState(null); + const [modalOpen, setModalOpen] = useState(false); + + const listQ = useQuery({ queryKey: ['admin-tenants'], queryFn: tenantApi.list, retry: false }); + const tenants = listQ.data ?? []; + + const createM = useMutation({ + mutationFn: (body: TenantCreateBody) => tenantApi.create(body), + onSuccess: (created) => { + show(t('tenant.created')); + setModalOpen(false); + setSelected(created.code); + qc.invalidateQueries({ queryKey: ['admin-tenants'] }); + }, + onError: (e) => show(errMessage(e)), + }); + + // 선택 테넌트(미선택 시 첫 행). 위저드 진행 단계는 상태 기반. + const tenant = tenants.find((tn) => tn.code === selected) ?? tenants[0] ?? null; + const currentStep = !tenant ? 0 : tenant.status === 'onboarding' ? 3 : WIZARD_STEPS.length; + + const columns = useMemo[]>( + () => [ + { key: 'name', header: t('tenant.venue'), value: (tn) => tn.name, + render: (tn) => {tn.name} }, + { key: 'code', header: t('tenant.code'), value: (tn) => tn.code, + render: (tn) => {tn.code} }, + { key: 'domain', header: t('tenant.domain'), value: (tn) => tn.domain ?? '', + render: (tn) => {tn.domain ?? '—'} }, + { key: 'status', header: t('tenant.status'), value: (tn) => t(`tenant.tstatus.${tn.status}`, { defaultValue: tn.status }), + render: (tn) => }, + { key: 'events', header: t('tenant.events'), numeric: true, value: (tn) => tn.events }, + { key: 'users', header: t('tenant.users'), numeric: true, value: (tn) => tn.users }, + ], + [t], + ); return (
@@ -39,81 +70,180 @@ export function TenantAdminPage() {

{t('tenant.title')}

{t('tenant.subtitle')}

+ -
- {t('tenant.sampleTag')} - {t('tenant.sampleNote')} -
-

{t('tenant.venue')}

- -
-
- - - - - - - - - - - - - {TENANTS.map((tn) => ( - setSelected(tn.code)} - style={{ cursor: 'pointer' }} - > - - - - - - - - ))} - -
{t('tenant.venue')}{t('tenant.code')}{t('tenant.domain')}{t('tenant.status')}{t('tenant.events')}{t('tenant.users')}
{tn.name}{tn.code}{tn.domain}{tn.events}{tn.users.toLocaleString()}
+ + {listQ.isLoading ? ( +
+ ) : listQ.isError ? ( +
+ listQ.refetch()} /> +
+ ) : ( +
+ tn.code} + onRowClick={(tn) => setSelected(tn.code)} + isRowSelected={(tn) => tn.code === tenant?.code} + csvFileName="tenants" + ariaLabel={t('tenant.listAria')} + emptyTitle={t('tenant.emptyTitle')} + emptyDescription={t('tenant.emptyDesc')} + /> +
+ )}
+ + {modalOpen && ( + tn.code)} + saving={createM.isPending} + onClose={() => setModalOpen(false)} + onSubmit={(body) => createM.mutate(body)} + /> + )} + {toast} ); } -function TenantStatus({ status }: { status: TenantRow['status'] }) { +function TenantStatus({ status }: { status: string }) { const { t } = useTranslation(); const cls = status === 'active' ? 'success' : status === 'onboarding' ? 'warn' : 'error'; - return {t(`tenant.tstatus.${status}`)}; + return {t(`tenant.tstatus.${status}`, { defaultValue: status })}; +} + +function TenantModal({ + t, + existingCodes, + saving, + onClose, + onSubmit, +}: { + t: TFunction; + existingCodes: string[]; + saving: boolean; + onClose: () => void; + onSubmit: (body: TenantCreateBody) => void; +}) { + const [code, setCode] = useState(''); + const [name, setName] = useState(''); + const [domain, setDomain] = useState(''); + const [status, setStatus] = useState('onboarding'); + const [error, setError] = useState(null); + + function submit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + const c = code.trim().toLowerCase(); + if (!c) { + setError(t('tenant.errCodeRequired')); + return; + } + if (!CODE_RE.test(c)) { + setError(t('tenant.errCodePattern')); + return; + } + if (existingCodes.includes(c)) { + setError(t('tenant.errDupCode')); + return; + } + if (!name.trim()) { + setError(t('tenant.errNameRequired')); + return; + } + onSubmit({ code: c, name: name.trim(), domain: domain.trim() || null, status }); + } + + return ( +
+
+
+

{t('tenant.modalTitle')}

+
+ + + + +
+ {error &&

{error}

} +
+ + +
+
+
+ ); } diff --git a/src/frontend/src/screens/admin/admin-modules.css b/src/frontend/src/screens/admin/admin-modules.css index 15caa13..c26d26c 100644 --- a/src/frontend/src/screens/admin/admin-modules.css +++ b/src/frontend/src/screens/admin/admin-modules.css @@ -37,6 +37,10 @@ font-weight: var(--fw-semibold); color: var(--color-neutral-500); } +.kx-adm-field__hint { + font-size: var(--fs-caption); + color: var(--color-neutral-500); +} .kx-adm-field__input { height: 36px; padding: 0 var(--space-3); @@ -346,6 +350,8 @@ border-bottom: var(--border-card); } .kx-adm-tenant__lhead h2 { font-size: var(--fs-h3); font-weight: var(--fw-bold); color: var(--color-neutral-900); } +/* 테넌트 목록 KxDataTable 래퍼 — 카드 padding:0 상쇄(툴바·표·페이저 여백) */ +.kx-adm-tenant__tbl { padding: var(--space-3) var(--space-4) var(--space-4); } .kx-adm-tenants { width: 100%; border-collapse: collapse; } .kx-adm-tenants th, .kx-adm-tenants td { padding: 10px 12px; text-align: left; font-size: var(--fs-body); } .kx-adm-tenants thead th { diff --git a/src/frontend/src/screens/admin/adminCrudApi.ts b/src/frontend/src/screens/admin/adminCrudApi.ts index 5def19a..e35ae03 100644 --- a/src/frontend/src/screens/admin/adminCrudApi.ts +++ b/src/frontend/src/screens/admin/adminCrudApi.ts @@ -152,3 +152,29 @@ export const menuApi = { }; export const MENU_PRG_TYPES = ['GROUP', 'SCREEN', 'LINK'] as const; + +// ══════════ 테넌트 (TenantAdminController · 플랫폼 슈퍼관리자) ══════════ +/** 관리자 테넌트 목록 행 — 백엔드 TenantAdminDto 와 정합(code=tenant_id 불변 코드). */ +export interface Tenant { + code: string; + name: string; + domain: string | null; + status: string; // 'active' | 'onboarding' | 'suspended' + events: number; + users: number; + createdAt: string | null; +} +/** 테넌트 등록(온보딩 1단계) 요청 — 백엔드 TenantCreateRequest. status 생략 시 서버가 'onboarding'. */ +export interface TenantCreateBody { + code: string; + name: string; + domain?: string | null; + status?: string; +} + +export const tenantApi = { + list: () => api.get('/api/admin/tenants'), + create: (body: TenantCreateBody) => api.post('/api/admin/tenants', body), +}; + +export const TENANT_STATUSES = ['onboarding', 'active', 'suspended'] as const;