snp-connection-monitoring/frontend/src/pages/admin/DomainsPage.tsx
HYOJIN c2a71c1b77 feat(design): 디자인 시스템 적용 (CSS 토큰, Button/Badge, 차트, 다크모드) (#48)
- 디자인 시스템 가이드 문서 11개 생성 (docs/design/)
- CSS 변수 토큰 시스템 (@theme + :root/.dark 전환)
- cn() 유틸리티 (clsx + tailwind-merge)
- Button/Badge 공통 컴포넌트 (variant/size, 다크모드 대응)
- 하드코딩 Tailwind 색상 → CSS 변수 토큰 리팩토링 (30개 파일)
- 차트 팔레트 다크모드 색상 업데이트 (CHART_COLORS_HEX)
- 버튼 다크모드 채도/대비 강화 (primary-600 기반)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 16:38:00 +09:00

284 lines
12 KiB
TypeScript

import { useState, useEffect } from 'react';
import type { ApiDomainInfo } from '../../types/apihub';
import type { CreateDomainRequest, UpdateDomainRequest } from '../../services/serviceService';
import { getDomains, createDomain, updateDomain, deleteDomain } from '../../services/serviceService';
import Button from '../../components/ui/Button';
const DEFAULT_ICON_PATHS = ['M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 00-1.883 2.542l.857 6a2.25 2.25 0 002.227 1.932H19.05a2.25 2.25 0 002.227-1.932l.857-6a2.25 2.25 0 00-1.883-2.542m-16.5 0V6A2.25 2.25 0 016 3.75h3.879a1.5 1.5 0 011.06.44l2.122 2.12a1.5 1.5 0 001.06.44H18A2.25 2.25 0 0120.25 9v.776'];
const parseIconPaths = (iconPath: string | null): string[] => {
if (!iconPath) return DEFAULT_ICON_PATHS;
const pathRegex = /d="([^"]+)"/g;
const matches: string[] = [];
let m;
while ((m = pathRegex.exec(iconPath)) !== null) {
matches.push(m[1]);
}
return matches.length > 0 ? matches : [iconPath];
};
const DomainsPage = () => {
const [domains, setDomains] = useState<ApiDomainInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingDomain, setEditingDomain] = useState<ApiDomainInfo | null>(null);
const [domainName, setDomainName] = useState('');
const [iconPath, setIconPath] = useState('');
const [sortOrder, setSortOrder] = useState(0);
const fetchDomains = async () => {
try {
setLoading(true);
const res = await getDomains();
if (res.success && res.data) {
setDomains(res.data);
} else {
setError(res.message || '도메인 목록을 불러오는데 실패했습니다.');
}
} catch {
setError('도메인 목록을 불러오는데 실패했습니다.');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchDomains();
}, []);
const handleOpenCreate = () => {
setEditingDomain(null);
setDomainName('');
setIconPath('');
setSortOrder(domains.length > 0 ? Math.max(...domains.map((d) => d.sortOrder)) + 1 : 0);
setIsModalOpen(true);
};
const handleOpenEdit = (domain: ApiDomainInfo) => {
setEditingDomain(domain);
setDomainName(domain.domainName);
setIconPath(domain.iconPath ?? '');
setSortOrder(domain.sortOrder);
setIsModalOpen(true);
};
const handleCloseModal = () => {
setIsModalOpen(false);
setEditingDomain(null);
setError(null);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
try {
if (editingDomain) {
const req: UpdateDomainRequest = {
domainName,
iconPath: iconPath || null,
sortOrder,
};
const res = await updateDomain(editingDomain.domainId, req);
if (!res.success) {
setError(res.message || '도메인 수정에 실패했습니다.');
return;
}
} else {
const req: CreateDomainRequest = {
domainName,
iconPath: iconPath || null,
sortOrder,
};
const res = await createDomain(req);
if (!res.success) {
setError(res.message || '도메인 생성에 실패했습니다.');
return;
}
}
handleCloseModal();
await fetchDomains();
} catch {
setError(editingDomain ? '도메인 수정에 실패했습니다.' : '도메인 생성에 실패했습니다.');
}
};
const handleDelete = async (domain: ApiDomainInfo) => {
if (!window.confirm(`'${domain.domainName}' 도메인을 삭제하시겠습니까?`)) return;
try {
const res = await deleteDomain(domain.domainId);
if (!res.success) {
setError(res.message || '도메인 삭제에 실패했습니다.');
return;
}
await fetchDomains();
} catch {
setError('도메인 삭제에 실패했습니다.');
}
};
const previewPath = iconPath.trim() || null;
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">
<h1 className="text-2xl font-bold text-[var(--color-text-primary)]">Domains</h1>
<Button onClick={handleOpenCreate}> </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)] rounded-lg shadow">
<table className="w-full divide-y divide-[var(--color-border)] text-sm">
<thead className="bg-[var(--color-bg-base)]">
<tr>
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">#</th>
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]"></th>
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]"></th>
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]"></th>
<th className="px-4 py-3 text-left font-medium text-[var(--color-text-secondary)]">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--color-border)]">
{domains.map((domain, index) => (
<tr key={domain.domainId} className="hover:bg-[var(--color-bg-base)]">
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{index + 1}</td>
<td className="px-4 py-3">
<svg
className="h-5 w-5 text-[var(--color-text-secondary)]"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
{parseIconPaths(domain.iconPath).map((d, i) => (
<path key={i} d={d} />
))}
</svg>
</td>
<td className="px-4 py-3 text-[var(--color-text-primary)] font-medium">{domain.domainName}</td>
<td className="px-4 py-3 text-[var(--color-text-secondary)]">{domain.sortOrder}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => handleOpenEdit(domain)}>
</Button>
<Button variant="danger" size="sm" onClick={() => handleDelete(domain)}>
</Button>
</div>
</td>
</tr>
))}
{domains.length === 0 && (
<tr>
<td colSpan={5} className="px-4 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)]">
{editingDomain ? '도메인 수정' : '도메인 추가'}
</h2>
</div>
<form onSubmit={handleSubmit}>
<div className="px-6 py-4 space-y-4 max-h-[70vh] overflow-y-auto">
{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">
</label>
<input
type="text"
value={domainName}
onChange={(e) => setDomainName(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="flex items-center justify-between text-sm font-medium text-[var(--color-text-primary)] mb-1">
<span>SVG Path</span>
<span className="text-xs font-normal text-[var(--color-text-tertiary)]">
:
<a href="https://heroicons.com" target="_blank" rel="noopener noreferrer" className="ml-1 text-blue-500 hover:text-blue-600 underline">Heroicons</a>
<a href="https://lucide.dev" target="_blank" rel="noopener noreferrer" className="ml-1 text-blue-500 hover:text-blue-600 underline">Lucide</a>
</span>
</label>
<textarea
value={iconPath}
onChange={(e) => setIconPath(e.target.value)}
rows={3}
placeholder="M4 6a2 2 0 012-2h8..."
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 font-mono text-xs"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-2">
</label>
<div className="flex items-center justify-center w-16 h-16 bg-[var(--color-bg-base)] rounded-lg">
<svg
className="h-8 w-8 text-[var(--color-text-secondary)]"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
{parseIconPaths(previewPath).map((d, i) => (
<path key={i} d={d} />
))}
</svg>
</div>
</div>
<div>
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">
</label>
<input
type="number"
value={sortOrder}
onChange={(e) => setSortOrder(Number(e.target.value))}
min={0}
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>
<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 DomainsPage;