generated from gc/template-java-maven
- 디자인 시스템 CSS 변수 토큰 적용 (success/warning/danger/info) - PeriodFilter 공통 컴포넌트 생성 및 통계 페이지 적용 - SERVICE_BADGE_VARIANTS 공통 상수 추출 - 통계/요청로그/키관리/관리자 페이지 레퍼런스 디자인 반영 - 테이블 규격 통일 (h-8/h-7, px-3 py-1, text-xs, Button xs) - 타이틀 아이콘 전체 페이지 통일 - 카드 테두리 디자인 통일 (border + rounded-xl) - FHD 1920x1080 최적화
246 lines
10 KiB
TypeScript
246 lines
10 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import type { Tenant, CreateTenantRequest, UpdateTenantRequest } from '../../types/tenant';
|
|
import { getTenants, createTenant, updateTenant } from '../../services/tenantService';
|
|
import Button from '../../components/ui/Button';
|
|
import Badge from '../../components/ui/Badge';
|
|
|
|
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-[var(--color-text-secondary)]">로딩 중...</div>;
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-7xl mx-auto">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div className="flex items-center gap-3 mb-1">
|
|
<div className="w-10 h-10 rounded-xl bg-[var(--color-primary-subtle)] flex items-center justify-center">
|
|
<svg className="w-5 h-5 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}>
|
|
<rect x="3" y="3" width="18" height="18" rx="2" /><path d="M3 9h18" /><path d="M9 21V9" />
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<h1 className="text-xl font-bold text-[var(--color-text-primary)]">Tenants</h1>
|
|
<p className="text-sm text-[var(--color-text-secondary)]">부서 관리</p>
|
|
</div>
|
|
</div>
|
|
<Button onClick={handleOpenCreate} size="sm">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-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl">
|
|
<table className="w-full divide-y divide-[var(--color-border)] text-xs">
|
|
<thead className="bg-[var(--color-bg-base)]">
|
|
<tr className="h-8">
|
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Code</th>
|
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Name</th>
|
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Description</th>
|
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Active</th>
|
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Created</th>
|
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-[var(--color-border)]">
|
|
{tenants.map((tenant) => (
|
|
<tr key={tenant.tenantId} className="h-7 hover:bg-[var(--color-bg-base)]">
|
|
<td className="px-3 py-1 text-[var(--color-text-primary)]">{tenant.tenantCode}</td>
|
|
<td className="px-3 py-1 text-[var(--color-text-primary)]">{tenant.tenantName}</td>
|
|
<td className="px-3 py-1 text-[var(--color-text-secondary)]">{tenant.description || '-'}</td>
|
|
<td className="px-3 py-1">
|
|
<Badge variant={tenant.isActive ? 'success' : 'danger'} size="sm">
|
|
{tenant.isActive ? 'Active' : 'Inactive'}
|
|
</Badge>
|
|
</td>
|
|
<td className="px-3 py-1 text-[var(--color-text-secondary)]">
|
|
{new Date(tenant.createdAt).toLocaleDateString()}
|
|
</td>
|
|
<td className="px-3 py-1">
|
|
<Button variant="outline" size="xs" onClick={() => handleOpenEdit(tenant)}>
|
|
수정
|
|
</Button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{tenants.length === 0 && (
|
|
<tr>
|
|
<td colSpan={6} className="px-3 py-8 text-center text-[var(--color-text-tertiary)]">
|
|
등록된 테넌트가 없습니다.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{isModalOpen && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow-xl w-full max-w-md mx-4">
|
|
<div className="px-6 py-4 border-b border-[var(--color-border)]">
|
|
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
|
{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-[var(--color-text-primary)] mb-1">
|
|
Tenant Code
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={tenantCode}
|
|
onChange={(e) => setTenantCode(e.target.value)}
|
|
disabled={!!editingTenant}
|
|
required
|
|
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] 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-[var(--color-text-primary)] mb-1">
|
|
Tenant Name
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={tenantName}
|
|
onChange={(e) => setTenantName(e.target.value)}
|
|
required
|
|
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
|
|
Description
|
|
</label>
|
|
<textarea
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
rows={3}
|
|
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] 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-[var(--color-text-primary)]">
|
|
Active
|
|
</label>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="px-6 py-4 border-t border-[var(--color-border)] flex justify-end gap-2">
|
|
<Button type="button" variant="outline" onClick={handleCloseModal}>
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit">Save</Button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default TenantsPage;
|