feat(web): wire TenantAdminPage to live tenant API (GAP G-01)

Replace hardcoded TENANTS array and disabled add button with live
GET/POST /api/admin/tenants via react-query: list states, create modal
(server-authoritative validation mirrored client-side), KxDataTable
search/sort/CSV. tenant.* i18n 17 keys x 4 locales (parity kept).
Mark G-01 resolved in GAP_AUDIT.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zio 2026-07-13 23:25:35 +09:00
parent 6f2629e50c
commit a6098ef002
8 changed files with 319 additions and 88 deletions

View File

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

View File

@ -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, 240 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 240 lowercase letters, digits or hyphens.",
"errDupCode": "This tenant code already exists.",
"errNameRequired": "Display name is required."
},
"ruleset": {
"title": "Regulation Ruleset Versions",

View File

@ -1375,7 +1375,24 @@
"active": "有効",
"onboarding": "オンボーディング中",
"suspended": "停止"
}
},
"loadError": "テナント一覧を読み込めませんでした。",
"emptyTitle": "登録されたテナントがありません",
"emptyDesc": "展示館を追加して最初のテナントをオンボーディングしましょう。",
"modalTitle": "テナント登録",
"codeHint": "小文字・数字・ハイフン 240 文字(サブドメイン・識別子として使用)",
"codePlaceholder": "例: coex",
"nameLabel": "表示名",
"namePlaceholder": "例: COEX",
"domainPlaceholder": "例: coex.wise.ai.kr",
"cancel": "キャンセル",
"save": "登録",
"saving": "登録中…",
"created": "テナントを登録しました。",
"errCodeRequired": "テナントコードは必須です。",
"errCodePattern": "コードは小文字・数字・ハイフン 240 文字にしてください。",
"errDupCode": "既に存在するテナントコードです。",
"errNameRequired": "表示名は必須です。"
},
"ruleset": {
"title": "規定ルールセットバージョン管理",

View File

@ -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": "규정 룰셋 버전 관리",

View File

@ -1375,7 +1375,24 @@
"active": "活跃",
"onboarding": "上线中",
"suspended": "已停用"
}
},
"loadError": "无法加载租户列表。",
"emptyTitle": "尚无租户",
"emptyDesc": "点击添加展馆以引导首个租户。",
"modalTitle": "注册租户",
"codeHint": "小写字母、数字和连字符240 个字符(用作子域名/标识符)",
"codePlaceholder": "例如 coex",
"nameLabel": "显示名称",
"namePlaceholder": "例如 COEX",
"domainPlaceholder": "例如 coex.wise.ai.kr",
"cancel": "取消",
"save": "注册",
"saving": "注册中…",
"created": "租户已注册。",
"errCodeRequired": "租户代码为必填项。",
"errCodePattern": "代码必须为 240 位小写字母、数字或连字符。",
"errDupCode": "该租户代码已存在。",
"errNameRequired": "显示名称为必填项。"
},
"ruleset": {
"title": "规则集版本管理",

View File

@ -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<string>(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<string | null>(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<KxColumn<Tenant>[]>(
() => [
{ key: 'name', header: t('tenant.venue'), value: (tn) => tn.name,
render: (tn) => <span className="kx-adm-tenants__name">{tn.name}</span> },
{ key: 'code', header: t('tenant.code'), value: (tn) => tn.code,
render: (tn) => <span className="kx-adm-mono">{tn.code}</span> },
{ key: 'domain', header: t('tenant.domain'), value: (tn) => tn.domain ?? '',
render: (tn) => <span className="kx-adm-muted">{tn.domain ?? '—'}</span> },
{ key: 'status', header: t('tenant.status'), value: (tn) => t(`tenant.tstatus.${tn.status}`, { defaultValue: tn.status }),
render: (tn) => <TenantStatus status={tn.status} /> },
{ 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 (
<div className="kx-page kx-adm">
@ -39,81 +70,180 @@ export function TenantAdminPage() {
<h1 className="kx-adm__title">{t('tenant.title')}</h1>
<p className="kx-adm__subtitle">{t('tenant.subtitle')}</p>
</div>
<Button leadingIcon={<IconPlus size={16} />} onClick={() => setModalOpen(true)}>
{t('tenant.addVenue')}
</Button>
</header>
<div className="kx-adm-sample-banner" role="status">
<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={t('tenant.listAria')}>
<div className="kx-adm-tenant__lhead">
<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>{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((tn) => (
<tr
key={tn.code}
className={tn.code === selected ? 'is-selected' : ''}
onClick={() => setSelected(tn.code)}
style={{ cursor: 'pointer' }}
>
<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>
</table>
</div>
{listQ.isLoading ? (
<div style={{ padding: 16 }}><Skeleton height={200} radius={8} /></div>
) : listQ.isError ? (
<div style={{ padding: 16 }}>
<ErrorState message={t('tenant.loadError')} onRetry={() => listQ.refetch()} />
</div>
) : (
<div className="kx-adm-tenant__tbl">
<KxDataTable
columns={columns}
rows={tenants}
rowKey={(tn) => 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')}
/>
</div>
)}
</section>
<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((key, i) => {
const state = i < currentStep ? 'done' : i === currentStep ? 'current' : 'todo';
return (
<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">{t(`tenant.wiz.${key}.label`)}</span>
<span className="kx-adm-wiz__desc">{t(`tenant.wiz.${key}.desc`)}</span>
</span>
</li>
);
})}
</ol>
{tenant ? (
<>
<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((key, i) => {
const state = i < currentStep ? 'done' : i === currentStep ? 'current' : 'todo';
return (
<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">{t(`tenant.wiz.${key}.label`)}</span>
<span className="kx-adm-wiz__desc">{t(`tenant.wiz.${key}.desc`)}</span>
</span>
</li>
);
})}
</ol>
</>
) : (
<p className="kx-adm-muted">{t('tenant.emptyDesc')}</p>
)}
</aside>
</div>
{modalOpen && (
<TenantModal
t={t}
existingCodes={tenants.map((tn) => tn.code)}
saving={createM.isPending}
onClose={() => setModalOpen(false)}
onSubmit={(body) => createM.mutate(body)}
/>
)}
{toast}
</div>
);
}
function TenantStatus({ status }: { status: TenantRow['status'] }) {
function TenantStatus({ status }: { status: string }) {
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>;
return <span className={`kx-adm-pill kx-adm-pill--${cls}`}>{t(`tenant.tstatus.${status}`, { defaultValue: status })}</span>;
}
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<string>('onboarding');
const [error, setError] = useState<string | null>(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 (
<div className="kx-adm-modal" role="dialog" aria-modal="true" aria-labelledby="tenant-modal-title">
<div className="kx-adm-modal__backdrop" onClick={onClose} />
<form className="kx-adm-modal__panel" onSubmit={submit}>
<h2 id="tenant-modal-title" className="kx-adm-modal__title">{t('tenant.modalTitle')}</h2>
<div className="kx-adm-modal__grid">
<label className="kx-adm-field">
<span className="kx-adm-field__label">{t('tenant.code')}</span>
<input
className="kx-adm-field__input kx-adm-mono"
value={code}
placeholder={t('tenant.codePlaceholder')}
onChange={(e) => setCode(e.target.value)}
autoFocus
/>
<span className="kx-adm-field__hint">{t('tenant.codeHint')}</span>
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label">{t('tenant.nameLabel')}</span>
<input
className="kx-adm-field__input"
value={name}
placeholder={t('tenant.namePlaceholder')}
onChange={(e) => setName(e.target.value)}
/>
</label>
<label className="kx-adm-field kx-adm-field--full">
<span className="kx-adm-field__label">{t('tenant.domain')}</span>
<input
className="kx-adm-field__input kx-adm-mono"
value={domain}
placeholder={t('tenant.domainPlaceholder')}
onChange={(e) => setDomain(e.target.value)}
/>
</label>
<label className="kx-adm-field">
<span className="kx-adm-field__label">{t('tenant.status')}</span>
<select className="kx-adm-field__select" value={status} onChange={(e) => setStatus(e.target.value)}>
{TENANT_STATUSES.map((s) => (
<option key={s} value={s}>{t(`tenant.tstatus.${s}`)}</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}>{t('tenant.cancel')}</Button>
<Button type="submit" disabled={saving}>{saving ? t('tenant.saving') : t('tenant.save')}</Button>
</div>
</form>
</div>
);
}

View File

@ -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 {

View File

@ -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<Tenant[]>('/api/admin/tenants'),
create: (body: TenantCreateBody) => api.post<Tenant>('/api/admin/tenants', body),
};
export const TENANT_STATUSES = ['onboarding', 'active', 'suspended'] as const;