generated from gc/template-java-maven
- 디자인 시스템 가이드 문서 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>
284 lines
12 KiB
TypeScript
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;
|