generated from gc/template-java-maven
공통:
- 다크/라이트 모드 (ThemeContext, Tailwind dark variant, 전체 페이지 적용)
- 사이드바 아이콘 링크체인 (#FF2E63), 헤더/사이드바 높이 통일
- 컨텐츠 영역 max-w-7xl 마진 통일 (대시보드 제외)
- 전체 Actions 버튼 bg-color-100 스타일 통일
- date input 달력 아이콘 다크모드 (filter invert)
API Keys:
- Request: 영구 사용 옵션 추가, 프리셋/영구 버튼 다크모드
- My Keys: ADMIN 직접 생성 제거 → Request 페이지 정식 폼으로 통일
- Admin: 키 관리 만료일 컬럼 추가, 권한 편집 제거 (승인 단계에서만 가능)
Gateway:
- API 경로 {변수} 패턴 매칭 지원
Closes #15
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
257 lines
10 KiB
TypeScript
257 lines
10 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import type { Tenant, CreateTenantRequest, UpdateTenantRequest } from '../../types/tenant';
|
|
import { getTenants, createTenant, updateTenant } from '../../services/tenantService';
|
|
|
|
const TenantsPage = () => {
|
|
const [tenants, setTenants] = useState<Tenant[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const [editingTenant, setEditingTenant] = useState<Tenant | null>(null);
|
|
|
|
const [tenantCode, setTenantCode] = useState('');
|
|
const [tenantName, setTenantName] = useState('');
|
|
const [description, setDescription] = useState('');
|
|
const [isActive, setIsActive] = useState(true);
|
|
|
|
const fetchTenants = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const res = await getTenants();
|
|
if (res.success && res.data) {
|
|
setTenants(res.data);
|
|
} else {
|
|
setError(res.message || '테넌트 목록을 불러오는데 실패했습니다.');
|
|
}
|
|
} catch {
|
|
setError('테넌트 목록을 불러오는데 실패했습니다.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchTenants();
|
|
}, []);
|
|
|
|
const handleOpenCreate = () => {
|
|
setEditingTenant(null);
|
|
setTenantCode('');
|
|
setTenantName('');
|
|
setDescription('');
|
|
setIsActive(true);
|
|
setIsModalOpen(true);
|
|
};
|
|
|
|
const handleOpenEdit = (tenant: Tenant) => {
|
|
setEditingTenant(tenant);
|
|
setTenantCode(tenant.tenantCode);
|
|
setTenantName(tenant.tenantName);
|
|
setDescription(tenant.description || '');
|
|
setIsActive(tenant.isActive);
|
|
setIsModalOpen(true);
|
|
};
|
|
|
|
const handleCloseModal = () => {
|
|
setIsModalOpen(false);
|
|
setEditingTenant(null);
|
|
setError(null);
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setError(null);
|
|
|
|
try {
|
|
if (editingTenant) {
|
|
const req: UpdateTenantRequest = {
|
|
tenantName,
|
|
description: description || undefined,
|
|
isActive,
|
|
};
|
|
const res = await updateTenant(editingTenant.tenantId, req);
|
|
if (!res.success) {
|
|
setError(res.message || '테넌트 수정에 실패했습니다.');
|
|
return;
|
|
}
|
|
} else {
|
|
const req: CreateTenantRequest = {
|
|
tenantCode,
|
|
tenantName,
|
|
description: description || undefined,
|
|
};
|
|
const res = await createTenant(req);
|
|
if (!res.success) {
|
|
setError(res.message || '테넌트 생성에 실패했습니다.');
|
|
return;
|
|
}
|
|
}
|
|
handleCloseModal();
|
|
await fetchTenants();
|
|
} catch {
|
|
setError(editingTenant ? '테넌트 수정에 실패했습니다.' : '테넌트 생성에 실패했습니다.');
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return <div className="max-w-7xl mx-auto text-center py-10 text-gray-500 dark:text-gray-400">로딩 중...</div>;
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-7xl mx-auto">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Tenants</h1>
|
|
<button
|
|
onClick={handleOpenCreate}
|
|
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
|
>
|
|
Create Tenant
|
|
</button>
|
|
</div>
|
|
|
|
{error && !isModalOpen && (
|
|
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
|
)}
|
|
|
|
<div className="overflow-x-auto bg-white dark:bg-gray-800 rounded-lg shadow">
|
|
<table className="w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm">
|
|
<thead className="bg-gray-50 dark:bg-gray-700">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Code</th>
|
|
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Name</th>
|
|
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Description</th>
|
|
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Active</th>
|
|
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Created</th>
|
|
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
|
{tenants.map((tenant) => (
|
|
<tr key={tenant.tenantId} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
|
<td className="px-4 py-3 font-mono text-gray-900 dark:text-gray-100">{tenant.tenantCode}</td>
|
|
<td className="px-4 py-3 text-gray-900 dark:text-gray-100">{tenant.tenantName}</td>
|
|
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{tenant.description || '-'}</td>
|
|
<td className="px-4 py-3">
|
|
<span
|
|
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
|
|
tenant.isActive
|
|
? 'bg-green-100 text-green-800'
|
|
: 'bg-red-100 text-red-800'
|
|
}`}
|
|
>
|
|
{tenant.isActive ? 'Active' : 'Inactive'}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">
|
|
{new Date(tenant.createdAt).toLocaleDateString()}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<button
|
|
onClick={() => handleOpenEdit(tenant)}
|
|
className="bg-blue-100 hover:bg-blue-200 text-blue-700 dark:bg-blue-900/30 dark:hover:bg-blue-800/40 dark:text-blue-400 px-3 py-1 rounded-lg text-sm font-medium"
|
|
>
|
|
수정
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{tenants.length === 0 && (
|
|
<tr>
|
|
<td colSpan={6} className="px-4 py-8 text-center text-gray-400 dark:text-gray-500">
|
|
등록된 테넌트가 없습니다.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{isModalOpen && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4">
|
|
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
|
{editingTenant ? '테넌트 수정' : '테넌트 생성'}
|
|
</h2>
|
|
</div>
|
|
<form onSubmit={handleSubmit}>
|
|
<div className="px-6 py-4 space-y-4">
|
|
{error && (
|
|
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
|
|
)}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Tenant Code
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={tenantCode}
|
|
onChange={(e) => setTenantCode(e.target.value)}
|
|
disabled={!!editingTenant}
|
|
required
|
|
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:bg-gray-100 disabled:text-gray-500 dark:disabled:bg-gray-600 dark:disabled:text-gray-400"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Tenant Name
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={tenantName}
|
|
onChange={(e) => setTenantName(e.target.value)}
|
|
required
|
|
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Description
|
|
</label>
|
|
<textarea
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
rows={3}
|
|
className="w-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
|
/>
|
|
</div>
|
|
{editingTenant && (
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
id="isActive"
|
|
checked={isActive}
|
|
onChange={(e) => setIsActive(e.target.checked)}
|
|
className="rounded"
|
|
/>
|
|
<label htmlFor="isActive" className="text-sm text-gray-700 dark:text-gray-300">
|
|
Active
|
|
</label>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={handleCloseModal}
|
|
className="bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 px-4 py-2 rounded-lg text-sm font-medium"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
|
|
>
|
|
Save
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default TenantsPage;
|