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:
parent
6f2629e50c
commit
a6098ef002
@ -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 |
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "規定ルールセットバージョン管理",
|
||||
|
||||
@ -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": "규정 룰셋 버전 관리",
|
||||
|
||||
@ -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": "规则集版本管理",
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user