feat(WEB): DynamicItemForm 필드 타입 확장 및 컴포넌트 레지스트리 추가

- DynamicFieldRenderer에 신규 필드 타입 추가 (Currency, File, MultiSelect, Radio, Reference, Toggle, UnitValue, Computed)
- DynamicTableSection 및 TableCellRenderer 추가
- 필드 프리셋 및 설정 구조 분리
- 컴포넌트 레지스트리 개발 도구 페이지 추가
- UniversalListPage 개선
- 근태관리 코드 정리
- 즐겨찾기 기능 및 동적 필드 타입 백엔드 스펙 문서 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-02-12 11:17:57 +09:00
parent 4decb99856
commit 020d74f36c
39 changed files with 12368 additions and 116 deletions

View File

@@ -0,0 +1,522 @@
'use client';
import { useState, useMemo, useCallback } from 'react';
import {
Search,
Copy,
Check,
ChevronDown,
ChevronRight,
Layers,
FileCode,
Code,
Eye,
Loader2,
X,
} from 'lucide-react';
import { getComponentSource } from './actions';
import { UI_PREVIEWS } from './previews';
interface ComponentEntry {
name: string;
fileName: string;
filePath: string;
tier: string;
category: string;
subcategory: string | null;
exportType: 'default' | 'named' | 'both' | 'none';
hasProps: boolean;
propsName: string | null;
isClientComponent: boolean;
lineCount: number;
}
interface CategorySummary {
tier: string;
category: string;
count: number;
}
export interface RegistryData {
generatedAt: string;
totalCount: number;
categories: CategorySummary[];
components: ComponentEntry[];
}
interface ComponentRegistryClientProps {
registry: RegistryData;
}
const TIER_CONFIG: Record<string, { label: string; color: string; bg: string; border: string }> = {
ui: { label: 'UI', color: 'text-blue-700', bg: 'bg-blue-50', border: 'border-blue-200' },
atoms: { label: 'Atoms', color: 'text-green-700', bg: 'bg-green-50', border: 'border-green-200' },
molecules: { label: 'Molecules', color: 'text-purple-700', bg: 'bg-purple-50', border: 'border-purple-200' },
organisms: { label: 'Organisms', color: 'text-orange-700', bg: 'bg-orange-50', border: 'border-orange-200' },
common: { label: 'Common', color: 'text-teal-700', bg: 'bg-teal-50', border: 'border-teal-200' },
layout: { label: 'Layout', color: 'text-indigo-700', bg: 'bg-indigo-50', border: 'border-indigo-200' },
dev: { label: 'Dev', color: 'text-yellow-700', bg: 'bg-yellow-50', border: 'border-yellow-200' },
domain: { label: 'Domain', color: 'text-gray-700', bg: 'bg-gray-50', border: 'border-gray-200' },
};
const ALL_TIERS = ['전체', 'ui', 'atoms', 'molecules', 'organisms', 'common', 'layout', 'dev', 'domain'];
function CopyButton({ text, label }: { text: string; label: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async (e: React.MouseEvent) => {
e.stopPropagation();
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<button
onClick={handleCopy}
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
title={label}
>
{copied ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
</button>
);
}
function TierBadge({ tier }: { tier: string }) {
const config = TIER_CONFIG[tier] || TIER_CONFIG.domain;
return (
<span className={`text-xs px-1.5 py-0.5 rounded ${config.bg} ${config.color} ${config.border} border`}>
{config.label}
</span>
);
}
function SourceCodeViewer({ source }: { source: string }) {
const lines = source.split('\n');
return (
<div className="relative">
<div className="absolute top-2 right-2 z-10">
<CopyButton text={source} label="소스코드 전체 복사" />
</div>
<pre className="bg-gray-950 text-gray-300 rounded-lg p-4 text-xs overflow-x-auto max-h-[500px] overflow-y-auto leading-relaxed">
<code>
{lines.map((line, i) => (
<div key={i} className="flex">
<span className="text-gray-600 select-none w-10 text-right pr-3 shrink-0">
{i + 1}
</span>
<span className="flex-1 whitespace-pre">{line}</span>
</div>
))}
</code>
</pre>
</div>
);
}
function ComponentCard({
comp,
isExpanded,
onToggle,
}: {
comp: ComponentEntry;
isExpanded: boolean;
onToggle: () => void;
}) {
const [activeTab, setActiveTab] = useState<'preview' | 'code'>('preview');
const [source, setSource] = useState<string | null>(null);
const [isLoadingSource, setIsLoadingSource] = useState(false);
const importPath = '@/' + comp.filePath.replace(/^src\//, '').replace(/\.tsx$/, '');
const hasPreview = !!UI_PREVIEWS[comp.fileName];
const handleToggle = useCallback(async () => {
if (!isExpanded) {
// Opening: load source code
if (!source && !isLoadingSource) {
setIsLoadingSource(true);
const result = await getComponentSource(comp.filePath);
if (result.source) setSource(result.source);
setIsLoadingSource(false);
}
if (hasPreview) {
setActiveTab('preview');
} else {
setActiveTab('code');
}
}
onToggle();
}, [isExpanded, source, isLoadingSource, comp.filePath, hasPreview, onToggle]);
const handleTabChange = useCallback(async (tab: 'preview' | 'code') => {
setActiveTab(tab);
if (tab === 'code' && !source && !isLoadingSource) {
setIsLoadingSource(true);
const result = await getComponentSource(comp.filePath);
if (result.source) setSource(result.source);
setIsLoadingSource(false);
}
}, [source, isLoadingSource, comp.filePath]);
return (
<div className={`bg-white dark:bg-gray-800 rounded-lg border transition-all ${
isExpanded
? 'border-blue-400 dark:border-blue-500 shadow-md'
: 'border-gray-200 dark:border-gray-700 hover:border-blue-400 dark:hover:border-blue-500 hover:shadow-sm'
}`}>
{/* Card Header */}
<button
onClick={handleToggle}
className="flex items-center justify-between p-3 w-full text-left"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-gray-900 dark:text-white">
{comp.name}
</span>
{comp.isClientComponent && (
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-50 text-amber-700 border border-amber-200">
client
</span>
)}
{comp.hasProps && (
<span className="text-xs px-1.5 py-0.5 rounded bg-sky-50 text-sky-700 border border-sky-200">
{comp.propsName || 'Props'}
</span>
)}
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-500">
{comp.exportType}
</span>
{hasPreview && (
<span className="text-xs px-1.5 py-0.5 rounded bg-emerald-50 text-emerald-700 border border-emerald-200">
Preview
</span>
)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 font-mono mt-1 truncate">
{comp.filePath}
</p>
</div>
<div className="flex items-center gap-2 ml-2 shrink-0">
<span className="text-xs text-gray-400">{comp.lineCount}L</span>
<CopyButton text={importPath} label={`복사: ${importPath}`} />
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-gray-400" />
) : (
<ChevronRight className="w-4 h-4 text-gray-400" />
)}
</div>
</button>
{/* Expanded Content */}
{isExpanded && (
<div className="border-t border-gray-200 dark:border-gray-700">
{/* Tab Buttons */}
<div className="flex items-center gap-1 px-3 pt-2">
{hasPreview && (
<button
onClick={() => handleTabChange('preview')}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-t transition-colors ${
activeTab === 'preview'
? 'bg-white dark:bg-gray-800 text-blue-600 border border-b-0 border-gray-200 dark:border-gray-700'
: 'text-gray-500 hover:text-gray-700'
}`}
>
<Eye className="w-3.5 h-3.5" />
</button>
)}
<button
onClick={() => handleTabChange('code')}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-t transition-colors ${
activeTab === 'code'
? 'bg-white dark:bg-gray-800 text-blue-600 border border-b-0 border-gray-200 dark:border-gray-700'
: 'text-gray-500 hover:text-gray-700'
}`}
>
<Code className="w-3.5 h-3.5" />
</button>
</div>
{/* Tab Content */}
<div className="p-3">
{activeTab === 'preview' && hasPreview && (
<div className="space-y-4">
{UI_PREVIEWS[comp.fileName]!.map((preview) => (
<div key={preview.label}>
<p className="text-xs font-medium text-gray-500 mb-2">{preview.label}</p>
<div className="p-4 bg-white dark:bg-gray-900 rounded-lg border border-dashed border-gray-300 dark:border-gray-600">
{preview.render()}
</div>
</div>
))}
</div>
)}
{activeTab === 'code' && (
<>
{isLoadingSource && (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-5 h-5 animate-spin text-gray-400 mr-2" />
<span className="text-sm text-gray-500"> ...</span>
</div>
)}
{source && <SourceCodeViewer source={source} />}
{!isLoadingSource && !source && (
<p className="text-sm text-gray-500 text-center py-4"> .</p>
)}
</>
)}
</div>
</div>
)}
</div>
);
}
function CategorySection({
category,
components,
tier,
expandedCard,
onCardToggle,
}: {
category: string;
components: ComponentEntry[];
tier: string;
expandedCard: string | null;
onCardToggle: (filePath: string) => void;
}) {
const [expanded, setExpanded] = useState(true);
// Group by subcategory
const groups = useMemo(() => {
const map = new Map<string, ComponentEntry[]>();
for (const comp of components) {
const key = comp.subcategory || '__root__';
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(comp);
}
return map;
}, [components]);
return (
<div className="mb-4">
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-2 w-full text-left mb-2"
>
{expanded ? (
<ChevronDown className="w-4 h-4 text-gray-500 shrink-0" />
) : (
<ChevronRight className="w-4 h-4 text-gray-500 shrink-0" />
)}
<TierBadge tier={tier} />
<h3 className="text-base font-semibold text-gray-900 dark:text-white">
{category}
</h3>
<span className="text-sm text-gray-500 ml-auto">
{components.length}
</span>
</button>
{expanded && (
<div className="pl-6 space-y-3">
{[...groups.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([sub, comps]) => (
<div key={sub}>
{sub !== '__root__' && (
<p className="text-xs text-gray-500 dark:text-gray-400 font-medium mb-1.5 pl-1">
{sub}
</p>
)}
<div className="grid gap-1.5">
{comps.map((comp) => (
<ComponentCard
key={comp.filePath}
comp={comp}
isExpanded={expandedCard === comp.filePath}
onToggle={() => onCardToggle(comp.filePath)}
/>
))}
</div>
</div>
))}
</div>
)}
</div>
);
}
export default function ComponentRegistryClient({ registry }: ComponentRegistryClientProps) {
const [searchTerm, setSearchTerm] = useState('');
const [activeTier, setActiveTier] = useState('전체');
const [expandedCard, setExpandedCard] = useState<string | null>(null);
const handleCardToggle = useCallback((filePath: string) => {
setExpandedCard((prev) => (prev === filePath ? null : filePath));
}, []);
const filtered = useMemo(() => {
let comps = registry.components;
if (activeTier !== '전체') {
comps = comps.filter((c) => c.tier === activeTier);
}
if (searchTerm) {
const q = searchTerm.toLowerCase();
comps = comps.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
c.fileName.toLowerCase().includes(q) ||
c.filePath.toLowerCase().includes(q) ||
(c.propsName && c.propsName.toLowerCase().includes(q))
);
}
return comps;
}, [registry.components, activeTier, searchTerm]);
// Group filtered components by category
const groupedByCategory = useMemo(() => {
const map = new Map<string, { tier: string; components: ComponentEntry[] }>();
for (const comp of filtered) {
if (!map.has(comp.category)) {
map.set(comp.category, { tier: comp.tier, components: [] });
}
map.get(comp.category)!.components.push(comp);
}
return [...map.entries()].sort(([, a], [, b]) => {
const tierOrder = ALL_TIERS.indexOf(a.tier) - ALL_TIERS.indexOf(b.tier);
if (tierOrder !== 0) return tierOrder;
return a.tier.localeCompare(b.tier);
});
}, [filtered]);
// Tier counts for chips
const tierCounts = useMemo(() => {
const counts: Record<string, number> = { '전체': registry.components.length };
for (const comp of registry.components) {
counts[comp.tier] = (counts[comp.tier] || 0) + 1;
}
return counts;
}, [registry.components]);
// Count of previewable components
const previewCount = useMemo(() => {
return registry.components.filter(
(c) => c.tier === 'ui' && UI_PREVIEWS[c.fileName]
).length;
}, [registry.components]);
const generatedDate = new Date(registry.generatedAt).toLocaleString('ko-KR');
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-6">
<div className="max-w-5xl mx-auto">
{/* Header */}
<div className="mb-6">
<div className="flex items-center gap-2 mb-2">
<Layers className="w-6 h-6 text-blue-500" />
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Component Registry
</h1>
<span className="text-sm text-gray-500 ml-2">
{registry.totalCount}
</span>
</div>
<p className="text-xs text-gray-400">
: {generatedDate} &middot; <code className="bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded text-xs">npm run gen:components</code>
&middot; 클릭: 소스코드 &middot; UI : {previewCount}
</p>
</div>
{/* Search */}
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="컴포넌트명, 파일명, 경로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
{searchTerm && (
<button
onClick={() => setSearchTerm('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{/* Tier Filter Chips */}
<div className="flex flex-wrap gap-2 mb-6">
{ALL_TIERS.map((tier) => {
const isActive = activeTier === tier;
const count = tierCounts[tier] || 0;
if (tier !== '전체' && count === 0) return null;
return (
<button
key={tier}
onClick={() => setActiveTier(tier)}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
isActive
? 'bg-blue-500 text-white'
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 hover:border-blue-400'
}`}
>
{tier === '전체' ? '전체' : TIER_CONFIG[tier]?.label || tier}
<span className={`ml-1.5 text-xs ${isActive ? 'text-blue-100' : 'text-gray-400'}`}>
{count}
</span>
</button>
);
})}
</div>
{/* Results count */}
{(searchTerm || activeTier !== '전체') && (
<p className="text-sm text-gray-500 mb-4">
{filtered.length}
</p>
)}
{/* Component List */}
<div className="space-y-2">
{groupedByCategory.map(([category, { tier, components }]) => (
<CategorySection
key={category}
category={category}
tier={tier}
components={components}
expandedCard={expandedCard}
onCardToggle={handleCardToggle}
/>
))}
</div>
{/* Empty State */}
{filtered.length === 0 && (
<div className="text-center py-16">
<FileCode className="w-12 h-12 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500"> .</p>
</div>
)}
{/* Footer */}
<div className="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-center text-sm text-gray-500">
<p>
: <code className="bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded">src/generated/component-registry.json</code>
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
'use server';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
export async function getComponentSource(filePath: string): Promise<{ source: string | null; error: string | null }> {
// filePath는 "src/components/ui/button.tsx" 형태
if (!filePath.startsWith('src/components/') || !filePath.endsWith('.tsx')) {
return { source: null, error: 'Invalid file path' };
}
try {
const fullPath = join(process.cwd(), filePath);
const source = await readFile(fullPath, 'utf-8');
return { source, error: null };
} catch {
return { source: null, error: 'File not found' };
}
}

View File

@@ -0,0 +1,8 @@
'use client';
import registry from '@/generated/component-registry.json';
import ComponentRegistryClient, { type RegistryData } from './ComponentRegistryClient';
export default function ComponentRegistryPage() {
return <ComponentRegistryClient registry={registry as unknown as RegistryData} />;
}

File diff suppressed because it is too large Load Diff