snp-connection-monitoring/frontend/src/pages/admin/TenantsPage.tsx
HYOJIN d4aa982e1a feat(ui): 피드백 반영 - 다크모드, API Key UX, 레이아웃 개선
공통:
- 다크/라이트 모드 (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>
2026-04-08 16:54:29 +09:00

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;