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:
@@ -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} · <code className="bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded text-xs">npm run gen:components</code>로 갱신
|
||||
· 카드 클릭: 소스코드 보기 · 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>
|
||||
);
|
||||
}
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
1191
src/app/[locale]/(protected)/dev/component-registry/previews.tsx
Normal file
1191
src/app/[locale]/(protected)/dev/component-registry/previews.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -37,6 +37,8 @@ import {
|
||||
Building2, // 거래처
|
||||
// 자재 아이콘
|
||||
PackageCheck, // 입고
|
||||
// Dev 도구 아이콘
|
||||
Layers, // 컴포넌트 레지스트리
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -193,6 +195,15 @@ export function DevToolbar() {
|
||||
<Badge variant="outline" className="bg-yellow-200 border-yellow-500 text-yellow-800">
|
||||
DEV MODE
|
||||
</Badge>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-1.5 text-yellow-700 hover:bg-yellow-200"
|
||||
onClick={() => handleNavigate('/dev/component-registry')}
|
||||
title="컴포넌트 레지스트리"
|
||||
>
|
||||
<Layers className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
{detectedPage && (
|
||||
<span className="text-sm text-yellow-700">
|
||||
현재: <strong>{detectedPage.label}</strong>
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
type StatCard,
|
||||
type TabOption,
|
||||
type FilterFieldConfig,
|
||||
type FilterValues,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
@@ -250,19 +249,6 @@ export function AttendanceManagement() {
|
||||
},
|
||||
], [stats]);
|
||||
|
||||
// 탭 옵션 (mergedRecords 기반)
|
||||
const tabs: TabOption[] = useMemo(() => [
|
||||
{ value: 'all', label: '전체', count: mergedRecords.length, color: 'gray' },
|
||||
{ value: 'notYetIn', label: '미출근', count: stats.notYetInCount, color: 'gray' },
|
||||
{ value: 'onTime', label: '정시 출근', count: stats.onTimeCount, color: 'green' },
|
||||
{ value: 'late', label: '지각', count: stats.lateCount, color: 'yellow' },
|
||||
{ value: 'absent', label: '결근', count: stats.absentCount, color: 'red' },
|
||||
{ value: 'vacation', label: '휴가', count: stats.vacationCount, color: 'blue' },
|
||||
{ value: 'businessTrip', label: '출장', count: mergedRecords.filter(r => r.status === 'businessTrip').length, color: 'purple' },
|
||||
{ value: 'fieldWork', label: '외근', count: mergedRecords.filter(r => r.status === 'fieldWork').length, color: 'orange' },
|
||||
{ value: 'overtime', label: '연장근무', count: mergedRecords.filter(r => r.status === 'overtime').length, color: 'indigo' },
|
||||
], [mergedRecords.length, stats]);
|
||||
|
||||
// 테이블 컬럼 정의
|
||||
const tableColumns = useMemo(() => [
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
@@ -441,8 +427,6 @@ export function AttendanceManagement() {
|
||||
|
||||
columns: tableColumns,
|
||||
|
||||
tabs: tabs,
|
||||
defaultTab: activeTab,
|
||||
|
||||
filterConfig: filterConfig,
|
||||
initialFilters: filterValues,
|
||||
@@ -480,63 +464,11 @@ export function AttendanceManagement() {
|
||||
|
||||
searchPlaceholder: '이름, 부서 검색...',
|
||||
|
||||
// 엑셀 다운로드 설정 (fetchAllUrl로 전체 데이터 조회)
|
||||
// 엑셀 다운로드 설정 (프론트 mergedRecords 사용 - 미출근 직원 포함)
|
||||
excelDownload: {
|
||||
columns: excelColumns,
|
||||
filename: '근태현황',
|
||||
sheetName: '근태',
|
||||
fetchAllUrl: '/api/proxy/attendances',
|
||||
fetchAllParams: () => {
|
||||
const params: Record<string, string> = {};
|
||||
if (startDate) params.date_from = startDate;
|
||||
if (endDate) params.date_to = endDate;
|
||||
return params;
|
||||
},
|
||||
mapResponse: (result: unknown) => {
|
||||
const res = result as { data?: { data?: Record<string, unknown>[] } };
|
||||
const items = res.data?.data ?? [];
|
||||
return items.map((item) => {
|
||||
const user = item.user as Record<string, unknown> | undefined;
|
||||
const profiles = (user?.tenant_profiles ?? []) as Record<string, unknown>[];
|
||||
const profile = profiles[0] as Record<string, unknown> | undefined;
|
||||
const dept = profile?.department as Record<string, unknown> | undefined;
|
||||
const legacyProfile = user?.tenant_profile as Record<string, unknown> | undefined;
|
||||
const legacyDept = legacyProfile?.department as Record<string, unknown> | undefined;
|
||||
const jsonDetails = (item.json_details ?? {}) as Record<string, unknown>;
|
||||
const breakMins = item.break_minutes as number | null;
|
||||
const overtimeMins = jsonDetails.overtime_minutes as number | undefined;
|
||||
|
||||
return {
|
||||
id: String(item.id),
|
||||
employeeId: String(item.user_id),
|
||||
employeeName: (user?.name ?? '') as string,
|
||||
department: (dept?.name ?? legacyDept?.name ?? '') as string,
|
||||
position: (profile?.position_key ?? '') as string,
|
||||
rank: ((legacyProfile?.rank ?? '') as string),
|
||||
baseDate: item.base_date as string,
|
||||
checkIn: (item.check_in ?? jsonDetails.check_in ?? null) as string | null,
|
||||
checkOut: (item.check_out ?? jsonDetails.check_out ?? null) as string | null,
|
||||
breakTime: breakMins != null
|
||||
? `${Math.floor(breakMins / 60)}:${(breakMins % 60).toString().padStart(2, '0')}`
|
||||
: (jsonDetails.break_time as string || null),
|
||||
overtimeHours: overtimeMins
|
||||
? (() => {
|
||||
const h = Math.floor(overtimeMins / 60);
|
||||
const m = overtimeMins % 60;
|
||||
if (h > 0 && m > 0) return `${h}시간 ${m}분`;
|
||||
if (h > 0) return `${h}시간`;
|
||||
return `${m}분`;
|
||||
})()
|
||||
: null,
|
||||
workMinutes: (jsonDetails.work_minutes || null) as number | null,
|
||||
reason: (jsonDetails.reason || null) as AttendanceRecord['reason'],
|
||||
status: item.status as string,
|
||||
remarks: (item.remarks ?? null) as string | null,
|
||||
createdAt: item.created_at as string,
|
||||
updatedAt: item.updated_at as string,
|
||||
} as AttendanceRecord;
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
itemsPerPage: itemsPerPage,
|
||||
@@ -551,11 +483,6 @@ export function AttendanceManagement() {
|
||||
);
|
||||
},
|
||||
|
||||
tabFilter: (item, activeTab) => {
|
||||
if (activeTab === 'all') return true;
|
||||
return item.status === activeTab;
|
||||
},
|
||||
|
||||
customFilterFn: (items, filterValues) => {
|
||||
if (!items || items.length === 0) return items;
|
||||
let filtered = items;
|
||||
@@ -700,8 +627,6 @@ export function AttendanceManagement() {
|
||||
}), [
|
||||
mergedRecords,
|
||||
tableColumns,
|
||||
tabs,
|
||||
activeTab,
|
||||
filterConfig,
|
||||
filterValues,
|
||||
statCards,
|
||||
|
||||
112
src/components/items/DynamicItemForm/config/reference-sources.ts
Normal file
112
src/components/items/DynamicItemForm/config/reference-sources.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Reference 필드 소스 프리셋
|
||||
*
|
||||
* ReferenceField가 properties.source 값을 키로 조회하여
|
||||
* API URL, 표시 필드, 검색 컬럼 등을 자동 결정
|
||||
*
|
||||
* 확장: 새 소스 추가 = 이 파일에 객체 1개 추가
|
||||
*/
|
||||
|
||||
import type { ReferenceSourcePreset } from '../types';
|
||||
|
||||
export const REFERENCE_SOURCES: Record<string, ReferenceSourcePreset> = {
|
||||
// ===== 공통 =====
|
||||
vendors: {
|
||||
apiUrl: '/api/proxy/vendors',
|
||||
displayField: 'name',
|
||||
valueField: 'id',
|
||||
searchFields: ['name', 'code'],
|
||||
columns: [
|
||||
{ key: 'code', label: '거래처 코드', width: '120px' },
|
||||
{ key: 'name', label: '거래처명' },
|
||||
{ key: 'contact_person', label: '담당자', width: '100px' },
|
||||
],
|
||||
},
|
||||
items: {
|
||||
apiUrl: '/api/proxy/items',
|
||||
displayField: 'name',
|
||||
valueField: 'id',
|
||||
searchFields: ['name', 'code'],
|
||||
columns: [
|
||||
{ key: 'code', label: '품목코드', width: '120px' },
|
||||
{ key: 'name', label: '품목명' },
|
||||
{ key: 'spec', label: '규격', width: '120px' },
|
||||
],
|
||||
},
|
||||
customers: {
|
||||
apiUrl: '/api/proxy/customers',
|
||||
displayField: 'name',
|
||||
valueField: 'id',
|
||||
searchFields: ['name', 'code'],
|
||||
columns: [
|
||||
{ key: 'code', label: '고객 코드', width: '120px' },
|
||||
{ key: 'name', label: '고객명' },
|
||||
],
|
||||
},
|
||||
employees: {
|
||||
apiUrl: '/api/proxy/employees',
|
||||
displayField: 'name',
|
||||
valueField: 'id',
|
||||
searchFields: ['name'],
|
||||
columns: [
|
||||
{ key: 'name', label: '직원명' },
|
||||
{ key: 'department', label: '부서', width: '120px' },
|
||||
],
|
||||
},
|
||||
warehouses: {
|
||||
apiUrl: '/api/proxy/warehouses',
|
||||
displayField: 'name',
|
||||
valueField: 'id',
|
||||
searchFields: ['name', 'code'],
|
||||
columns: [
|
||||
{ key: 'code', label: '창고코드', width: '120px' },
|
||||
{ key: 'name', label: '창고명' },
|
||||
],
|
||||
},
|
||||
|
||||
// ===== 제조 =====
|
||||
processes: {
|
||||
apiUrl: '/api/proxy/processes',
|
||||
displayField: 'name',
|
||||
valueField: 'id',
|
||||
searchFields: ['name', 'code'],
|
||||
columns: [
|
||||
{ key: 'code', label: '공정코드', width: '120px' },
|
||||
{ key: 'name', label: '공정명' },
|
||||
],
|
||||
},
|
||||
equipment: {
|
||||
apiUrl: '/api/proxy/equipment',
|
||||
displayField: 'name',
|
||||
valueField: 'id',
|
||||
searchFields: ['name', 'code'],
|
||||
columns: [
|
||||
{ key: 'code', label: '설비코드', width: '120px' },
|
||||
{ key: 'name', label: '설비명' },
|
||||
],
|
||||
},
|
||||
|
||||
// ===== 공사 =====
|
||||
sites: {
|
||||
apiUrl: '/api/proxy/construction-sites',
|
||||
displayField: 'name',
|
||||
valueField: 'id',
|
||||
searchFields: ['name', 'address'],
|
||||
columns: [
|
||||
{ key: 'name', label: '현장명' },
|
||||
{ key: 'address', label: '주소' },
|
||||
],
|
||||
},
|
||||
|
||||
// ===== 물류 =====
|
||||
vehicles: {
|
||||
apiUrl: '/api/proxy/vehicles',
|
||||
displayField: 'plate_number',
|
||||
valueField: 'id',
|
||||
searchFields: ['plate_number', 'driver_name'],
|
||||
columns: [
|
||||
{ key: 'plate_number', label: '차량번호', width: '120px' },
|
||||
{ key: 'driver_name', label: '기사명' },
|
||||
],
|
||||
},
|
||||
};
|
||||
135
src/components/items/DynamicItemForm/fields/ComputedField.tsx
Normal file
135
src/components/items/DynamicItemForm/fields/ComputedField.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* 계산 필드 (읽기전용)
|
||||
* 다른 필드 값 기반 자동 계산, formula 평가
|
||||
*
|
||||
* properties: { formula, dependsOn, format, precision }
|
||||
* 저장값: 계산 결과 number (자동 업데이트)
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import type { DynamicFieldRendererProps, ComputedConfig, DynamicFormData } from '../types';
|
||||
|
||||
/**
|
||||
* 안전한 수식 평가기
|
||||
* 지원 연산: +, -, *, /, (, )
|
||||
* 변수: field_key 이름으로 참조
|
||||
*/
|
||||
function evaluateFormula(
|
||||
formula: string,
|
||||
variables: Record<string, number>
|
||||
): number | null {
|
||||
try {
|
||||
// 변수를 숫자로 치환 (긴 키부터 치환하여 부분 매칭 방지)
|
||||
let expression = formula;
|
||||
const sortedKeys = Object.keys(variables).sort((a, b) => b.length - a.length);
|
||||
for (const key of sortedKeys) {
|
||||
expression = expression.replace(
|
||||
new RegExp(key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'),
|
||||
String(variables[key])
|
||||
);
|
||||
}
|
||||
|
||||
// 숫자, 연산자, 괄호, 공백만 허용 (안전성 검사)
|
||||
if (!/^[\d\s+\-*/().]+$/.test(expression)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Function 생성자로 안전하게 평가
|
||||
const result = new Function(`"use strict"; return (${expression})`)();
|
||||
if (typeof result !== 'number' || !isFinite(result)) {
|
||||
return null;
|
||||
}
|
||||
return result;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatResult(
|
||||
value: number,
|
||||
format?: string,
|
||||
precision?: number
|
||||
): string {
|
||||
const p = precision ?? 2;
|
||||
|
||||
switch (format) {
|
||||
case 'currency': {
|
||||
const formatted = value.toFixed(p);
|
||||
const [intPart, decPart] = formatted.split('.');
|
||||
const withCommas = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
return decPart ? `\u20A9${withCommas}.${decPart}` : `\u20A9${withCommas}`;
|
||||
}
|
||||
case 'percent':
|
||||
return `${value.toFixed(p)}%`;
|
||||
case 'number':
|
||||
default:
|
||||
return value.toFixed(p);
|
||||
}
|
||||
}
|
||||
|
||||
export function ComputedField({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
disabled: _disabled,
|
||||
formData,
|
||||
}: DynamicFieldRendererProps) {
|
||||
const fieldKey = field.field_key || `field_${field.id}`;
|
||||
const config = (field.properties || {}) as ComputedConfig;
|
||||
const { formula, dependsOn, format, precision } = config;
|
||||
|
||||
const prevResultRef = useRef<number | null>(null);
|
||||
|
||||
// dependsOn 필드 값이 변경될 때마다 재계산
|
||||
useEffect(() => {
|
||||
if (!formula || !dependsOn || !formData) return;
|
||||
|
||||
// 의존 필드 값 수집
|
||||
const variables: Record<string, number> = {};
|
||||
for (const dep of dependsOn) {
|
||||
const depValue = formData[dep];
|
||||
variables[dep] = depValue !== null && depValue !== undefined ? Number(depValue) || 0 : 0;
|
||||
}
|
||||
|
||||
const result = evaluateFormula(formula, variables);
|
||||
|
||||
// 이전 결과와 다를 때만 업데이트 (무한 루프 방지)
|
||||
if (result !== null && result !== prevResultRef.current) {
|
||||
prevResultRef.current = result;
|
||||
onChange(result);
|
||||
}
|
||||
}, [formula, dependsOn, formData, onChange]);
|
||||
|
||||
const displayValue = value !== null && value !== undefined
|
||||
? formatResult(Number(value), format, precision)
|
||||
: '-';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor={fieldKey}>
|
||||
{field.field_name}
|
||||
{field.is_required && <span className="text-red-500"> *</span>}
|
||||
</Label>
|
||||
<Input
|
||||
id={fieldKey}
|
||||
value={displayValue}
|
||||
readOnly
|
||||
disabled
|
||||
className="bg-muted text-muted-foreground cursor-default"
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-xs text-red-500 mt-1">{error}</p>
|
||||
)}
|
||||
{!error && field.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* {field.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
126
src/components/items/DynamicItemForm/fields/CurrencyField.tsx
Normal file
126
src/components/items/DynamicItemForm/fields/CurrencyField.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* 통화 금액 입력 필드
|
||||
* 천단위 콤마 포맷, 통화 기호 prefix 지원
|
||||
*
|
||||
* properties: { currency, precision, showSymbol, allowNegative }
|
||||
* 저장값: number (포맷 없이)
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import type { DynamicFieldRendererProps, CurrencyConfig } from '../types';
|
||||
|
||||
const CURRENCY_SYMBOLS: Record<string, string> = {
|
||||
KRW: '\u20A9',
|
||||
USD: '$',
|
||||
EUR: '\u20AC',
|
||||
JPY: '\u00A5',
|
||||
CNY: '\u00A5',
|
||||
GBP: '\u00A3',
|
||||
};
|
||||
|
||||
function formatCurrency(num: number, precision: number): string {
|
||||
const fixed = num.toFixed(precision);
|
||||
const [intPart, decPart] = fixed.split('.');
|
||||
const formatted = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
return decPart !== undefined ? `${formatted}.${decPart}` : formatted;
|
||||
}
|
||||
|
||||
function parseCurrency(str: string): number {
|
||||
const cleaned = str.replace(/[^0-9.\-]/g, '');
|
||||
const num = parseFloat(cleaned);
|
||||
return isNaN(num) ? 0 : num;
|
||||
}
|
||||
|
||||
export function CurrencyField({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
disabled,
|
||||
}: DynamicFieldRendererProps) {
|
||||
const fieldKey = field.field_key || `field_${field.id}`;
|
||||
const config = (field.properties || {}) as CurrencyConfig;
|
||||
const currency = config.currency || 'KRW';
|
||||
const precision = config.precision ?? 0;
|
||||
const showSymbol = config.showSymbol !== false;
|
||||
const allowNegative = config.allowNegative === true;
|
||||
|
||||
const symbol = CURRENCY_SYMBOLS[currency] || currency;
|
||||
|
||||
const numericValue = value !== null && value !== undefined ? Number(value) : null;
|
||||
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [inputValue, setInputValue] = useState(
|
||||
numericValue !== null ? String(numericValue) : ''
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
setIsFocused(true);
|
||||
setInputValue(numericValue !== null ? String(numericValue) : '');
|
||||
}, [numericValue]);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setIsFocused(false);
|
||||
if (inputValue === '' || inputValue === '-') {
|
||||
onChange(null);
|
||||
return;
|
||||
}
|
||||
const parsed = parseCurrency(inputValue);
|
||||
const final = allowNegative ? parsed : Math.abs(parsed);
|
||||
onChange(final);
|
||||
}, [inputValue, onChange, allowNegative]);
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const raw = e.target.value;
|
||||
// 숫자, 점, 마이너스만 허용
|
||||
const pattern = allowNegative ? /[^0-9.\-]/g : /[^0-9.]/g;
|
||||
const cleaned = raw.replace(pattern, '');
|
||||
setInputValue(cleaned);
|
||||
}, [allowNegative]);
|
||||
|
||||
const displayValue = isFocused
|
||||
? inputValue
|
||||
: numericValue !== null
|
||||
? formatCurrency(numericValue, precision)
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor={fieldKey}>
|
||||
{field.field_name}
|
||||
{field.is_required && <span className="text-red-500"> *</span>}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
{showSymbol && (
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground pointer-events-none">
|
||||
{symbol}
|
||||
</span>
|
||||
)}
|
||||
<Input
|
||||
id={fieldKey}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
placeholder={field.placeholder || '0'}
|
||||
value={displayValue}
|
||||
onChange={handleChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
disabled={disabled}
|
||||
className={`${showSymbol ? 'pl-8' : ''} text-right ${error ? 'border-red-500' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-xs text-red-500 mt-1">{error}</p>
|
||||
)}
|
||||
{!error && field.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* {field.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
/**
|
||||
* 동적 필드 렌더러
|
||||
* field_type에 따라 적절한 필드 컴포넌트를 선택하여 렌더링
|
||||
*
|
||||
* 기본 6종 + 확장 8종 = 14종 지원
|
||||
* 알 수 없는 타입은 TextField로 폴백
|
||||
*/
|
||||
|
||||
'use client';
|
||||
@@ -11,12 +14,24 @@ import { DropdownField } from './DropdownField';
|
||||
import { CheckboxField } from './CheckboxField';
|
||||
import { DateField } from './DateField';
|
||||
import { TextareaField } from './TextareaField';
|
||||
// Phase 1: 핵심 컴포넌트
|
||||
import { ReferenceField } from './ReferenceField';
|
||||
import { MultiSelectField } from './MultiSelectField';
|
||||
import { FileField } from './FileField';
|
||||
// Phase 2: 편의 컴포넌트
|
||||
import { CurrencyField } from './CurrencyField';
|
||||
import { UnitValueField } from './UnitValueField';
|
||||
import { RadioField } from './RadioField';
|
||||
// Phase 3: 고급 컴포넌트
|
||||
import { ToggleField } from './ToggleField';
|
||||
import { ComputedField } from './ComputedField';
|
||||
import type { DynamicFieldRendererProps } from '../types';
|
||||
|
||||
export function DynamicFieldRenderer(props: DynamicFieldRendererProps) {
|
||||
const { field } = props;
|
||||
|
||||
switch (field.field_type) {
|
||||
// 기본 6종
|
||||
case 'textbox':
|
||||
return <TextField {...props} />;
|
||||
|
||||
@@ -35,6 +50,33 @@ export function DynamicFieldRenderer(props: DynamicFieldRendererProps) {
|
||||
case 'textarea':
|
||||
return <TextareaField {...props} />;
|
||||
|
||||
// Phase 1: 핵심 타입
|
||||
case 'reference':
|
||||
return <ReferenceField {...props} />;
|
||||
|
||||
case 'multi-select':
|
||||
return <MultiSelectField {...props} />;
|
||||
|
||||
case 'file':
|
||||
return <FileField {...props} />;
|
||||
|
||||
// Phase 2: 편의 타입
|
||||
case 'currency':
|
||||
return <CurrencyField {...props} />;
|
||||
|
||||
case 'unit-value':
|
||||
return <UnitValueField {...props} />;
|
||||
|
||||
case 'radio':
|
||||
return <RadioField {...props} />;
|
||||
|
||||
// Phase 3: 고급 타입
|
||||
case 'toggle':
|
||||
return <ToggleField {...props} />;
|
||||
|
||||
case 'computed':
|
||||
return <ComputedField {...props} />;
|
||||
|
||||
default:
|
||||
// 알 수 없는 타입은 텍스트 필드로 폴백
|
||||
console.warn(`Unknown field type: ${field.field_type}, falling back to TextField`);
|
||||
|
||||
199
src/components/items/DynamicItemForm/fields/FileField.tsx
Normal file
199
src/components/items/DynamicItemForm/fields/FileField.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* File 필드 컴포넌트
|
||||
* 파일/이미지 첨부 (드래그 앤 드롭 + 파일 선택)
|
||||
*
|
||||
* API 연동 전: File 객체를 로컬 상태로 관리, URL.createObjectURL로 미리보기
|
||||
* API 연동 시: POST /v1/files/upload (multipart)
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { Upload, X, FileIcon, ImageIcon } from 'lucide-react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { DynamicFieldRendererProps, FileConfig } from '../types';
|
||||
|
||||
interface LocalFile {
|
||||
file: File;
|
||||
previewUrl?: string;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes}B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
||||
}
|
||||
|
||||
function isImageFile(file: File): boolean {
|
||||
return file.type.startsWith('image/');
|
||||
}
|
||||
|
||||
export function FileField({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
disabled,
|
||||
}: DynamicFieldRendererProps) {
|
||||
const fieldKey = field.field_key || `field_${field.id}`;
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [localFiles, setLocalFiles] = useState<LocalFile[]>([]);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
const config = (field.properties || {}) as FileConfig;
|
||||
const accept = config.accept || '*';
|
||||
const maxSize = config.maxSize || 10485760; // 10MB
|
||||
const maxFiles = config.maxFiles || 1;
|
||||
const showPreview = config.preview !== false;
|
||||
|
||||
// 파일 추가
|
||||
const addFiles = useCallback((newFiles: FileList | File[]) => {
|
||||
const files = Array.from(newFiles);
|
||||
const validFiles: LocalFile[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (localFiles.length + validFiles.length >= maxFiles) break;
|
||||
if (file.size > maxSize) continue;
|
||||
|
||||
const previewUrl = showPreview && isImageFile(file)
|
||||
? URL.createObjectURL(file)
|
||||
: undefined;
|
||||
validFiles.push({ file, previewUrl });
|
||||
}
|
||||
|
||||
if (validFiles.length === 0) return;
|
||||
|
||||
const updated = [...localFiles, ...validFiles];
|
||||
setLocalFiles(updated);
|
||||
// 단일 파일이면 File 객체, 복수면 배열 이름을 저장
|
||||
onChange(maxFiles === 1
|
||||
? updated[0]?.file.name || null
|
||||
: updated.map(f => f.file.name)
|
||||
);
|
||||
}, [localFiles, maxFiles, maxSize, showPreview, onChange]);
|
||||
|
||||
// 파일 제거
|
||||
const removeFile = (index: number) => {
|
||||
const file = localFiles[index];
|
||||
if (file.previewUrl) URL.revokeObjectURL(file.previewUrl);
|
||||
const updated = localFiles.filter((_, i) => i !== index);
|
||||
setLocalFiles(updated);
|
||||
onChange(updated.length === 0 ? null : (
|
||||
maxFiles === 1 ? updated[0]?.file.name : updated.map(f => f.file.name)
|
||||
));
|
||||
};
|
||||
|
||||
// 드래그 핸들러
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (!disabled) setIsDragOver(true);
|
||||
};
|
||||
const handleDragLeave = () => setIsDragOver(false);
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
if (!disabled && e.dataTransfer.files.length > 0) {
|
||||
addFiles(e.dataTransfer.files);
|
||||
}
|
||||
};
|
||||
|
||||
const canAddMore = localFiles.length < maxFiles;
|
||||
// value가 이미 있으면 (서버에서 받은 파일명) 기존 파일 표시
|
||||
const existingFileName = typeof value === 'string' && value && localFiles.length === 0 ? value : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor={fieldKey}>
|
||||
{field.field_name}
|
||||
{field.is_required && <span className="text-red-500"> *</span>}
|
||||
</Label>
|
||||
|
||||
{/* 드래그 앤 드롭 영역 */}
|
||||
{canAddMore && !disabled && (
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={`mt-1 flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-6 transition-colors cursor-pointer ${
|
||||
isDragOver ? 'border-primary bg-primary/5' : 'border-muted-foreground/25 hover:border-muted-foreground/50'
|
||||
} ${error ? 'border-red-500' : ''}`}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="h-8 w-8 text-muted-foreground mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
파일을 드래그하거나 클릭하여 선택
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
최대 {formatFileSize(maxSize)}
|
||||
{accept !== '*' && ` (${accept})`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
id={fieldKey}
|
||||
accept={accept}
|
||||
multiple={maxFiles > 1}
|
||||
className="hidden"
|
||||
onChange={(e) => e.target.files && addFiles(e.target.files)}
|
||||
/>
|
||||
|
||||
{/* 기존 파일 (서버에서 받은) */}
|
||||
{existingFileName && (
|
||||
<div className="mt-2 flex items-center gap-2 rounded-md border p-2">
|
||||
<FileIcon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm truncate flex-1">{existingFileName}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 선택된 파일 목록 */}
|
||||
{localFiles.length > 0 && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{localFiles.map((lf, index) => (
|
||||
<div key={index} className="flex items-center gap-2 rounded-md border p-2">
|
||||
{/* 이미지 미리보기 */}
|
||||
{lf.previewUrl ? (
|
||||
<img
|
||||
src={lf.previewUrl}
|
||||
alt={lf.file.name}
|
||||
className="h-10 w-10 rounded object-cover shrink-0"
|
||||
/>
|
||||
) : (
|
||||
isImageFile(lf.file)
|
||||
? <ImageIcon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
: <FileIcon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm truncate">{lf.file.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{formatFileSize(lf.file.size)}</p>
|
||||
</div>
|
||||
{!disabled && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={() => removeFile(index)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-500 mt-1">{error}</p>
|
||||
)}
|
||||
{!error && field.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* {field.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
191
src/components/items/DynamicItemForm/fields/MultiSelectField.tsx
Normal file
191
src/components/items/DynamicItemForm/fields/MultiSelectField.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* MultiSelect 필드 컴포넌트
|
||||
* 여러 항목을 동시에 선택 (태그 칩 형태로 표시)
|
||||
*
|
||||
* 저장값: string[] (예: ["CUT", "BEND", "WELD"])
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { X, ChevronDown } from 'lucide-react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { DynamicFieldRendererProps, MultiSelectConfig } from '../types';
|
||||
|
||||
// 옵션 정규화 (DropdownField와 동일 로직)
|
||||
function normalizeOptions(rawOptions: unknown): Array<{ label: string; value: string }> {
|
||||
if (!rawOptions) return [];
|
||||
if (typeof rawOptions === 'string') {
|
||||
return rawOptions.split(',').map(o => {
|
||||
const trimmed = o.trim();
|
||||
return { label: trimmed, value: trimmed };
|
||||
});
|
||||
}
|
||||
if (Array.isArray(rawOptions)) {
|
||||
return rawOptions.map(item => {
|
||||
if (typeof item === 'object' && item !== null && 'value' in item) {
|
||||
return {
|
||||
label: String((item as Record<string, unknown>).label || (item as Record<string, unknown>).value),
|
||||
value: String((item as Record<string, unknown>).value),
|
||||
};
|
||||
}
|
||||
const str = String(item);
|
||||
return { label: str, value: str };
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function MultiSelectField({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
disabled,
|
||||
}: DynamicFieldRendererProps) {
|
||||
const fieldKey = field.field_key || `field_${field.id}`;
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const config = (field.properties || {}) as MultiSelectConfig;
|
||||
const maxSelections = config.maxSelections;
|
||||
const allowCustom = config.allowCustom ?? false;
|
||||
|
||||
// 현재 선택된 값 배열
|
||||
const selectedValues: string[] = Array.isArray(value) ? (value as string[]) : [];
|
||||
|
||||
// 옵션 목록
|
||||
const options = normalizeOptions(field.options);
|
||||
const filteredOptions = options.filter(
|
||||
opt => opt.label.toLowerCase().includes(searchTerm.toLowerCase()) &&
|
||||
!selectedValues.includes(opt.value)
|
||||
);
|
||||
|
||||
// 외부 클릭 감지
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// 항목 추가
|
||||
const addValue = (val: string) => {
|
||||
if (maxSelections && selectedValues.length >= maxSelections) return;
|
||||
const newValues = [...selectedValues, val];
|
||||
onChange(newValues);
|
||||
setSearchTerm('');
|
||||
};
|
||||
|
||||
// 항목 제거
|
||||
const removeValue = (val: string) => {
|
||||
onChange(selectedValues.filter(v => v !== val));
|
||||
};
|
||||
|
||||
// 커스텀 입력 추가 (Enter)
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && searchTerm.trim()) {
|
||||
e.preventDefault();
|
||||
if (allowCustom && !selectedValues.includes(searchTerm.trim())) {
|
||||
addValue(searchTerm.trim());
|
||||
} else {
|
||||
// 기존 옵션에서 일치하는 것 선택
|
||||
const match = filteredOptions[0];
|
||||
if (match) addValue(match.value);
|
||||
}
|
||||
}
|
||||
if (e.key === 'Backspace' && !searchTerm && selectedValues.length > 0) {
|
||||
removeValue(selectedValues[selectedValues.length - 1]);
|
||||
}
|
||||
};
|
||||
|
||||
// 옵션 라벨 조회
|
||||
const getLabel = (val: string) => {
|
||||
const opt = options.find(o => o.value === val);
|
||||
return opt ? opt.label : val;
|
||||
};
|
||||
|
||||
const isAtLimit = maxSelections ? selectedValues.length >= maxSelections : false;
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<Label htmlFor={fieldKey}>
|
||||
{field.field_name}
|
||||
{field.is_required && <span className="text-red-500"> *</span>}
|
||||
{maxSelections && (
|
||||
<span className="text-muted-foreground font-normal ml-1">
|
||||
({selectedValues.length}/{maxSelections})
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<div
|
||||
className={`min-h-10 flex flex-wrap items-center gap-1 rounded-md border bg-background px-3 py-2 text-sm cursor-text ${
|
||||
error ? 'border-red-500' : 'border-input'
|
||||
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
onClick={() => !disabled && setIsOpen(true)}
|
||||
>
|
||||
{/* 선택된 칩들 */}
|
||||
{selectedValues.map(val => (
|
||||
<Badge key={val} variant="secondary" className="gap-1 pr-1">
|
||||
{getLabel(val)}
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); removeValue(val); }}
|
||||
className="hover:text-destructive"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</Badge>
|
||||
))}
|
||||
{/* 검색 입력 */}
|
||||
{!disabled && !isAtLimit && (
|
||||
<Input
|
||||
id={fieldKey}
|
||||
value={searchTerm}
|
||||
onChange={(e) => { setSearchTerm(e.target.value); setIsOpen(true); }}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={selectedValues.length === 0 ? (field.placeholder || '선택하세요') : ''}
|
||||
className="border-0 shadow-none p-0 h-6 min-w-[60px] flex-1 focus-visible:ring-0"
|
||||
/>
|
||||
)}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground ml-auto" />
|
||||
</div>
|
||||
|
||||
{/* 드롭다운 */}
|
||||
{isOpen && filteredOptions.length > 0 && (
|
||||
<div className="relative">
|
||||
<div className="absolute z-50 w-full mt-1 max-h-48 overflow-auto rounded-md border bg-popover shadow-md">
|
||||
{filteredOptions.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground"
|
||||
onClick={() => { addValue(opt.value); setIsOpen(false); }}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-500 mt-1">{error}</p>
|
||||
)}
|
||||
{!error && field.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* {field.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
src/components/items/DynamicItemForm/fields/RadioField.tsx
Normal file
91
src/components/items/DynamicItemForm/fields/RadioField.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 라디오 버튼 그룹 필드
|
||||
* 수평/수직 레이아웃 지원
|
||||
*
|
||||
* properties: { layout: "horizontal" | "vertical" }
|
||||
* options: [{label, value}]
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import type { DynamicFieldRendererProps } from '../types';
|
||||
|
||||
// 옵션을 {label, value} 형태로 정규화
|
||||
function normalizeOptions(rawOptions: unknown): Array<{ label: string; value: string }> {
|
||||
if (!rawOptions) return [];
|
||||
|
||||
if (typeof rawOptions === 'string') {
|
||||
return rawOptions.split(',').map(o => {
|
||||
const trimmed = o.trim();
|
||||
return { label: trimmed, value: trimmed };
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(rawOptions)) {
|
||||
return rawOptions.map(item => {
|
||||
if (typeof item === 'object' && item !== null && 'value' in item) {
|
||||
return {
|
||||
label: String(item.label || item.value),
|
||||
value: String(item.value),
|
||||
};
|
||||
}
|
||||
const str = String(item);
|
||||
return { label: str, value: str };
|
||||
});
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function RadioField({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
disabled,
|
||||
}: DynamicFieldRendererProps) {
|
||||
const fieldKey = field.field_key || `field_${field.id}`;
|
||||
const layout = (field.properties?.layout as string) || 'vertical';
|
||||
const options = normalizeOptions(field.options);
|
||||
const stringValue = value !== null && value !== undefined ? String(value) : '';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label>
|
||||
{field.field_name}
|
||||
{field.is_required && <span className="text-red-500"> *</span>}
|
||||
</Label>
|
||||
<RadioGroup
|
||||
value={stringValue}
|
||||
onValueChange={onChange}
|
||||
disabled={disabled}
|
||||
className={`mt-2 ${layout === 'horizontal' ? 'flex flex-wrap gap-4' : 'grid gap-2'}`}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<div key={option.value} className="flex items-center gap-2">
|
||||
<RadioGroupItem
|
||||
value={option.value}
|
||||
id={`${fieldKey}-${option.value}`}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`${fieldKey}-${option.value}`}
|
||||
className="font-normal cursor-pointer"
|
||||
>
|
||||
{option.label}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
{error && (
|
||||
<p className="text-xs text-red-500 mt-1">{error}</p>
|
||||
)}
|
||||
{!error && field.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* {field.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
167
src/components/items/DynamicItemForm/fields/ReferenceField.tsx
Normal file
167
src/components/items/DynamicItemForm/fields/ReferenceField.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Reference 필드 컴포넌트
|
||||
* 다른 테이블의 데이터를 검색하여 선택 (거래처, 품목, 고객 등)
|
||||
*
|
||||
* API 연동 시: REFERENCE_SOURCES[source]에서 apiUrl 조회 → 검색 API 호출
|
||||
* API 연동 전: props로 전달된 options 사용 또는 빈 상태에서 UI 확인
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Search, X } from 'lucide-react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { SearchableSelectionModal } from '@/components/organisms/SearchableSelectionModal';
|
||||
import { REFERENCE_SOURCES } from '../config/reference-sources';
|
||||
import type { DynamicFieldRendererProps, ReferenceConfig } from '../types';
|
||||
|
||||
interface ReferenceItem {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function ReferenceField({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
disabled,
|
||||
}: DynamicFieldRendererProps) {
|
||||
const fieldKey = field.field_key || `field_${field.id}`;
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [displayValue, setDisplayValue] = useState<string>('');
|
||||
|
||||
// properties에서 config 추출
|
||||
const config = (field.properties || {}) as ReferenceConfig;
|
||||
const source = config.source || '';
|
||||
const displayField = config.displayField || 'name';
|
||||
const valueField = config.valueField || 'id';
|
||||
const displayFormat = config.displayFormat;
|
||||
|
||||
// 소스 프리셋 조회
|
||||
const preset = source ? REFERENCE_SOURCES[source] : null;
|
||||
const apiUrl = config.searchApiUrl || preset?.apiUrl || '';
|
||||
const columns = config.columns || preset?.columns || [
|
||||
{ key: 'name', label: '이름' },
|
||||
];
|
||||
|
||||
// 표시값 포맷팅
|
||||
const formatDisplay = useCallback((item: ReferenceItem): string => {
|
||||
if (displayFormat) {
|
||||
return displayFormat.replace(/\{(\w+)\}/g, (_, key) =>
|
||||
String(item[key] || '')
|
||||
);
|
||||
}
|
||||
return String(item[displayField] || '');
|
||||
}, [displayFormat, displayField]);
|
||||
|
||||
// 검색 API 호출
|
||||
const fetchData = useCallback(async (query: string): Promise<ReferenceItem[]> => {
|
||||
if (!apiUrl) return [];
|
||||
try {
|
||||
const separator = apiUrl.includes('?') ? '&' : '?';
|
||||
const url = `${apiUrl}${separator}search=${encodeURIComponent(query)}&size=20`;
|
||||
const response = await fetch(url);
|
||||
const result = await response.json();
|
||||
// 다양한 응답 구조 대응
|
||||
if (Array.isArray(result)) return result;
|
||||
if (result.data?.data) return result.data.data;
|
||||
if (Array.isArray(result.data)) return result.data;
|
||||
return [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, [apiUrl]);
|
||||
|
||||
// 선택 핸들러
|
||||
const handleSelect = (item: ReferenceItem) => {
|
||||
const selectedValue = item[valueField];
|
||||
onChange(selectedValue as string | number);
|
||||
setDisplayValue(formatDisplay(item));
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
// 선택 해제
|
||||
const handleClear = () => {
|
||||
onChange(null);
|
||||
setDisplayValue('');
|
||||
};
|
||||
|
||||
// 현재 표시값
|
||||
const currentDisplay = displayValue || (value ? String(value) : '');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor={fieldKey}>
|
||||
{field.field_name}
|
||||
{field.is_required && <span className="text-red-500"> *</span>}
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
id={fieldKey}
|
||||
value={currentDisplay}
|
||||
placeholder={field.placeholder || `${field.field_name}을(를) 선택하세요`}
|
||||
readOnly
|
||||
disabled={disabled}
|
||||
className={`pr-8 ${error ? 'border-red-500' : ''}`}
|
||||
/>
|
||||
{currentDisplay && !disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={disabled || !apiUrl}
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-xs text-red-500 mt-1">{error}</p>
|
||||
)}
|
||||
{!error && field.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* {field.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 검색 모달 */}
|
||||
<SearchableSelectionModal<ReferenceItem>
|
||||
mode="single"
|
||||
open={isModalOpen}
|
||||
onOpenChange={setIsModalOpen}
|
||||
title={`${field.field_name} 검색`}
|
||||
searchPlaceholder="검색어를 입력하세요"
|
||||
fetchData={fetchData}
|
||||
keyExtractor={(item) => String(item[valueField])}
|
||||
renderItem={(item) => (
|
||||
<div className="flex items-center gap-4 px-3 py-2">
|
||||
{columns.map((col) => (
|
||||
<span
|
||||
key={col.key}
|
||||
className="text-sm"
|
||||
style={col.width ? { width: col.width, flexShrink: 0 } : { flex: 1 }}
|
||||
>
|
||||
{String(item[col.key] || '')}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
onSelect={handleSelect}
|
||||
searchMode="debounce"
|
||||
loadOnOpen
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
src/components/items/DynamicItemForm/fields/ToggleField.tsx
Normal file
61
src/components/items/DynamicItemForm/fields/ToggleField.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* On/Off 토글 스위치 필드
|
||||
*
|
||||
* properties: { onLabel, offLabel, onValue, offValue }
|
||||
* 저장값: onValue/offValue (기본: "true"/"false")
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import type { DynamicFieldRendererProps, ToggleConfig } from '../types';
|
||||
|
||||
export function ToggleField({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
disabled,
|
||||
}: DynamicFieldRendererProps) {
|
||||
const fieldKey = field.field_key || `field_${field.id}`;
|
||||
const config = (field.properties || {}) as ToggleConfig;
|
||||
const onLabel = config.onLabel || 'ON';
|
||||
const offLabel = config.offLabel || 'OFF';
|
||||
const onValue = config.onValue || 'true';
|
||||
const offValue = config.offValue || 'false';
|
||||
|
||||
const isChecked = value === onValue || value === true || value === 'true';
|
||||
|
||||
const handleChange = (checked: boolean) => {
|
||||
onChange(checked ? onValue : offValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor={fieldKey}>
|
||||
{field.field_name}
|
||||
{field.is_required && <span className="text-red-500"> *</span>}
|
||||
</Label>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<Switch
|
||||
id={fieldKey}
|
||||
checked={isChecked}
|
||||
onCheckedChange={handleChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{isChecked ? onLabel : offLabel}
|
||||
</span>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-xs text-red-500 mt-1">{error}</p>
|
||||
)}
|
||||
{!error && field.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* {field.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
128
src/components/items/DynamicItemForm/fields/UnitValueField.tsx
Normal file
128
src/components/items/DynamicItemForm/fields/UnitValueField.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 값+단위 조합 입력 필드
|
||||
* Input(숫자) + Select(단위) 가로 배치
|
||||
*
|
||||
* properties: { units, defaultUnit, precision }
|
||||
* 저장값: { value: number, unit: string }
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { DynamicFieldRendererProps, UnitValueConfig } from '../types';
|
||||
|
||||
interface UnitValueData {
|
||||
value: number | null;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
function parseUnitValue(raw: unknown, defaultUnit: string): UnitValueData {
|
||||
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
||||
const obj = raw as Record<string, unknown>;
|
||||
return {
|
||||
value: obj.value !== null && obj.value !== undefined ? Number(obj.value) : null,
|
||||
unit: typeof obj.unit === 'string' ? obj.unit : defaultUnit,
|
||||
};
|
||||
}
|
||||
// 숫자만 들어온 경우
|
||||
if (typeof raw === 'number') {
|
||||
return { value: raw, unit: defaultUnit };
|
||||
}
|
||||
return { value: null, unit: defaultUnit };
|
||||
}
|
||||
|
||||
export function UnitValueField({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
disabled,
|
||||
}: DynamicFieldRendererProps) {
|
||||
const fieldKey = field.field_key || `field_${field.id}`;
|
||||
const config = (field.properties || {}) as UnitValueConfig;
|
||||
const units = config.units || [];
|
||||
const defaultUnit = config.defaultUnit || (units.length > 0 ? units[0].value : '');
|
||||
const precision = config.precision;
|
||||
|
||||
const data = parseUnitValue(value, defaultUnit);
|
||||
|
||||
const handleValueChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const raw = e.target.value;
|
||||
if (raw === '' || raw === '-') {
|
||||
onChange({ value: null, unit: data.unit });
|
||||
return;
|
||||
}
|
||||
const num = parseFloat(raw);
|
||||
if (!isNaN(num)) {
|
||||
const final = precision !== undefined
|
||||
? Math.round(num * Math.pow(10, precision)) / Math.pow(10, precision)
|
||||
: num;
|
||||
onChange({ value: final, unit: data.unit });
|
||||
}
|
||||
}, [data.unit, precision, onChange]);
|
||||
|
||||
const handleUnitChange = useCallback((newUnit: string) => {
|
||||
onChange({ value: data.value, unit: newUnit });
|
||||
}, [data.value, onChange]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor={fieldKey}>
|
||||
{field.field_name}
|
||||
{field.is_required && <span className="text-red-500"> *</span>}
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id={fieldKey}
|
||||
type="number"
|
||||
placeholder={field.placeholder || '값 입력'}
|
||||
value={data.value !== null ? String(data.value) : ''}
|
||||
onChange={handleValueChange}
|
||||
disabled={disabled}
|
||||
step={precision !== undefined ? Math.pow(10, -precision) : 'any'}
|
||||
className={`flex-1 ${error ? 'border-red-500' : ''}`}
|
||||
/>
|
||||
{units.length > 0 ? (
|
||||
<Select
|
||||
key={`${fieldKey}-unit-${data.unit}`}
|
||||
value={data.unit}
|
||||
onValueChange={handleUnitChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="w-24 shrink-0">
|
||||
<SelectValue placeholder="단위" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{units.map((u) => (
|
||||
<SelectItem key={u.value} value={u.value}>
|
||||
{u.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<span className="flex items-center text-sm text-muted-foreground px-2">
|
||||
{data.unit || '-'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-xs text-red-500 mt-1">{error}</p>
|
||||
)}
|
||||
{!error && field.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
* {field.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,4 +4,15 @@ export { DropdownField } from './DropdownField';
|
||||
export { CheckboxField } from './CheckboxField';
|
||||
export { DateField } from './DateField';
|
||||
export { TextareaField } from './TextareaField';
|
||||
// Phase 1: 핵심 컴포넌트
|
||||
export { ReferenceField } from './ReferenceField';
|
||||
export { MultiSelectField } from './MultiSelectField';
|
||||
export { FileField } from './FileField';
|
||||
// Phase 2: 편의 컴포넌트
|
||||
export { CurrencyField } from './CurrencyField';
|
||||
export { UnitValueField } from './UnitValueField';
|
||||
export { RadioField } from './RadioField';
|
||||
// Phase 3: 고급 컴포넌트
|
||||
export { ToggleField } from './ToggleField';
|
||||
export { ComputedField } from './ComputedField';
|
||||
export { DynamicFieldRenderer } from './DynamicFieldRenderer';
|
||||
|
||||
@@ -12,7 +12,50 @@ import type {
|
||||
DynamicFormData,
|
||||
DisplayCondition,
|
||||
FieldConditionConfig,
|
||||
ConditionOperator,
|
||||
} from '../types';
|
||||
import type { DynamicFieldValue } from '../types';
|
||||
|
||||
/**
|
||||
* 조건 연산자 평가
|
||||
* operator가 없으면 'equals' (하위호환)
|
||||
*/
|
||||
function evaluateCondition(
|
||||
currentValue: DynamicFieldValue,
|
||||
operator: ConditionOperator,
|
||||
expectedValue: string
|
||||
): boolean {
|
||||
const strValue = currentValue !== null && currentValue !== undefined ? String(currentValue) : '';
|
||||
const numValue = Number(currentValue);
|
||||
const numExpected = Number(expectedValue);
|
||||
|
||||
switch (operator) {
|
||||
case 'equals':
|
||||
return strValue === expectedValue;
|
||||
case 'not_equals':
|
||||
return strValue !== expectedValue;
|
||||
case 'in': {
|
||||
const list = expectedValue.split(',').map(s => s.trim());
|
||||
return list.includes(strValue);
|
||||
}
|
||||
case 'not_in': {
|
||||
const list = expectedValue.split(',').map(s => s.trim());
|
||||
return !list.includes(strValue);
|
||||
}
|
||||
case 'greater_than':
|
||||
return !isNaN(numValue) && !isNaN(numExpected) && numValue > numExpected;
|
||||
case 'less_than':
|
||||
return !isNaN(numValue) && !isNaN(numExpected) && numValue < numExpected;
|
||||
case 'gte':
|
||||
return !isNaN(numValue) && !isNaN(numExpected) && numValue >= numExpected;
|
||||
case 'lte':
|
||||
return !isNaN(numValue) && !isNaN(numExpected) && numValue <= numExpected;
|
||||
case 'contains':
|
||||
return strValue.includes(expectedValue);
|
||||
default:
|
||||
return strValue === expectedValue;
|
||||
}
|
||||
}
|
||||
|
||||
interface ConditionalDisplayResult {
|
||||
/** 섹션이 표시되어야 하는지 확인 */
|
||||
@@ -88,8 +131,7 @@ export function useConditionalDisplay(
|
||||
// fieldConditions 배열 순회
|
||||
if (condition.fieldConditions) {
|
||||
condition.fieldConditions.forEach((fc: FieldConditionConfig) => {
|
||||
// 현재 값과 기대값이 일치하는지 확인
|
||||
const isMatch = String(currentValue) === fc.expectedValue;
|
||||
const isMatch = evaluateCondition(currentValue, fc.operator || 'equals', fc.expectedValue);
|
||||
|
||||
if (isMatch) {
|
||||
// 일치하면 타겟 섹션/필드 활성화
|
||||
|
||||
@@ -683,6 +683,7 @@ export default function DynamicItemForm({
|
||||
error={errors[fieldKey]}
|
||||
disabled={isSubmitting}
|
||||
unitOptions={unitOptions}
|
||||
formData={formData}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -737,6 +738,7 @@ export default function DynamicItemForm({
|
||||
error={errors[fieldKey]}
|
||||
disabled={isSubmitting}
|
||||
unitOptions={unitOptions}
|
||||
formData={formData}
|
||||
/>
|
||||
{/* 규격 필드 바로 다음에 품목코드 자동생성 필드 표시 (절곡부품 제외) */}
|
||||
{isSpecField && hasAutoItemCode && !isBendingPart && (
|
||||
@@ -887,6 +889,7 @@ export default function DynamicItemForm({
|
||||
error={errors[fieldKey]}
|
||||
disabled={isSubmitting}
|
||||
unitOptions={unitOptions}
|
||||
formData={formData}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
253
src/components/items/DynamicItemForm/presets/section-presets.ts
Normal file
253
src/components/items/DynamicItemForm/presets/section-presets.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* 산업별 섹션 프리셋
|
||||
* 백엔드 DB에 저장할 section config의 프론트엔드 프리셋 정의
|
||||
*
|
||||
* 사용: 관리자가 페이지 구조 설계 시 프리셋 선택 → properties JSON으로 저장
|
||||
* 확장: 새 산업/섹션 = 이 파일에 객체 추가
|
||||
*/
|
||||
|
||||
import type { TableConfig } from '../types';
|
||||
|
||||
export interface SectionPreset {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
industry: string;
|
||||
sectionType: 'fields' | 'table';
|
||||
tableConfig?: TableConfig;
|
||||
fields?: Array<{
|
||||
field_key: string;
|
||||
field_name: string;
|
||||
field_type: string;
|
||||
options?: Array<{ label: string; value: string }>;
|
||||
properties?: Record<string, unknown>;
|
||||
is_required?: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 제조업 프리셋
|
||||
// ============================================
|
||||
|
||||
export const MANUFACTURING_PROCESS_TABLE: SectionPreset = {
|
||||
id: 'mfg-process',
|
||||
name: '공정 정보',
|
||||
description: '제조 공정 순서 및 상세 정보',
|
||||
industry: '제조',
|
||||
sectionType: 'table',
|
||||
tableConfig: {
|
||||
columns: [
|
||||
{ key: 'process_name', label: '공정명', fieldType: 'reference', width: '150px', isRequired: true, properties: { source: 'processes' } },
|
||||
{ key: 'equipment', label: '설비', fieldType: 'reference', width: '130px', properties: { source: 'equipment' } },
|
||||
{ key: 'cycle_time', label: '사이클타임(초)', fieldType: 'number', width: '100px' },
|
||||
{ key: 'setup_time', label: '셋업시간(분)', fieldType: 'number', width: '100px' },
|
||||
{ key: 'note', label: '비고', fieldType: 'textbox' },
|
||||
],
|
||||
minRows: 1,
|
||||
maxRows: 30,
|
||||
summaryRow: [
|
||||
{ columnKey: 'cycle_time', type: 'sum' },
|
||||
{ columnKey: 'setup_time', type: 'sum' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const MANUFACTURING_QC_TABLE: SectionPreset = {
|
||||
id: 'mfg-qc',
|
||||
name: '품질검사 항목',
|
||||
description: '검사 항목 및 기준값',
|
||||
industry: '제조',
|
||||
sectionType: 'table',
|
||||
tableConfig: {
|
||||
columns: [
|
||||
{ key: 'inspection_item', label: '검사항목', fieldType: 'textbox', width: '150px', isRequired: true },
|
||||
{ key: 'method', label: '검사방법', fieldType: 'dropdown', width: '120px', options: [
|
||||
{ label: '육안검사', value: 'visual' },
|
||||
{ label: '치수검사', value: 'dimension' },
|
||||
{ label: '기능검사', value: 'functional' },
|
||||
{ label: '파괴검사', value: 'destructive' },
|
||||
]},
|
||||
{ key: 'standard_value', label: '기준값', fieldType: 'textbox', width: '100px' },
|
||||
{ key: 'tolerance_min', label: '하한', fieldType: 'number', width: '80px' },
|
||||
{ key: 'tolerance_max', label: '상한', fieldType: 'number', width: '80px' },
|
||||
{ key: 'unit', label: '단위', fieldType: 'textbox', width: '60px' },
|
||||
],
|
||||
minRows: 1,
|
||||
maxRows: 50,
|
||||
},
|
||||
};
|
||||
|
||||
export const MANUFACTURING_VENDOR_TABLE: SectionPreset = {
|
||||
id: 'mfg-vendor',
|
||||
name: '구매처 정보',
|
||||
description: '원자재/부품 구매처 목록',
|
||||
industry: '제조',
|
||||
sectionType: 'table',
|
||||
tableConfig: {
|
||||
columns: [
|
||||
{ key: 'vendor', label: '거래처', fieldType: 'reference', width: '150px', isRequired: true, properties: { source: 'vendors' } },
|
||||
{ key: 'unit_price', label: '단가', fieldType: 'number', width: '100px' },
|
||||
{ key: 'lead_time', label: '리드타임(일)', fieldType: 'number', width: '90px' },
|
||||
{ key: 'is_primary', label: '주거래처', fieldType: 'checkbox', width: '80px' },
|
||||
{ key: 'note', label: '비고', fieldType: 'textbox' },
|
||||
],
|
||||
maxRows: 10,
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 건설/공사업 프리셋
|
||||
// ============================================
|
||||
|
||||
export const CONSTRUCTION_SPEC_FIELDS: SectionPreset = {
|
||||
id: 'const-spec',
|
||||
name: '시방 정보',
|
||||
description: '자재 시방 및 규격 상세',
|
||||
industry: '건설',
|
||||
sectionType: 'fields',
|
||||
fields: [
|
||||
{ field_key: 'material_grade', field_name: '자재 등급', field_type: 'dropdown', options: [
|
||||
{ label: 'SS400', value: 'SS400' },
|
||||
{ label: 'STS304', value: 'STS304' },
|
||||
{ label: 'STS316', value: 'STS316' },
|
||||
{ label: 'AL6061', value: 'AL6061' },
|
||||
]},
|
||||
{ field_key: 'surface_treatment', field_name: '표면처리', field_type: 'dropdown', options: [
|
||||
{ label: '무처리', value: 'none' },
|
||||
{ label: '도금', value: 'plating' },
|
||||
{ label: '도장', value: 'painting' },
|
||||
{ label: '아노다이징', value: 'anodizing' },
|
||||
]},
|
||||
{ field_key: 'fire_rating', field_name: '내화등급', field_type: 'textbox' },
|
||||
{ field_key: 'installation_site', field_name: '설치현장', field_type: 'reference', properties: { source: 'sites' } },
|
||||
],
|
||||
};
|
||||
|
||||
export const CONSTRUCTION_SCHEDULE_TABLE: SectionPreset = {
|
||||
id: 'const-schedule',
|
||||
name: '공정표',
|
||||
description: '시공 일정 및 공정 정보',
|
||||
industry: '건설',
|
||||
sectionType: 'table',
|
||||
tableConfig: {
|
||||
columns: [
|
||||
{ key: 'work_name', label: '작업명', fieldType: 'textbox', width: '150px', isRequired: true },
|
||||
{ key: 'start_date', label: '시작일', fieldType: 'date', width: '120px' },
|
||||
{ key: 'end_date', label: '종료일', fieldType: 'date', width: '120px' },
|
||||
{ key: 'worker_count', label: '작업인원', fieldType: 'number', width: '80px' },
|
||||
{ key: 'status', label: '상태', fieldType: 'dropdown', width: '100px', options: [
|
||||
{ label: '대기', value: 'pending' },
|
||||
{ label: '진행중', value: 'in_progress' },
|
||||
{ label: '완료', value: 'completed' },
|
||||
]},
|
||||
],
|
||||
minRows: 1,
|
||||
maxRows: 100,
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 유통업 프리셋
|
||||
// ============================================
|
||||
|
||||
export const DISTRIBUTION_LOGISTICS_TABLE: SectionPreset = {
|
||||
id: 'dist-logistics',
|
||||
name: '물류 정보',
|
||||
description: '보관 및 배송 조건',
|
||||
industry: '유통',
|
||||
sectionType: 'table',
|
||||
tableConfig: {
|
||||
columns: [
|
||||
{ key: 'warehouse', label: '창고', fieldType: 'reference', width: '130px', isRequired: true, properties: { source: 'warehouses' } },
|
||||
{ key: 'location', label: '로케이션', fieldType: 'textbox', width: '100px' },
|
||||
{ key: 'stock_qty', label: '재고수량', fieldType: 'number', width: '90px' },
|
||||
{ key: 'min_stock', label: '안전재고', fieldType: 'number', width: '90px' },
|
||||
{ key: 'max_stock', label: '최대재고', fieldType: 'number', width: '90px' },
|
||||
],
|
||||
maxRows: 20,
|
||||
summaryRow: [
|
||||
{ columnKey: 'stock_qty', type: 'sum' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const DISTRIBUTION_PRODUCT_FIELDS: SectionPreset = {
|
||||
id: 'dist-product',
|
||||
name: '상품 정보',
|
||||
description: '유통 상품 속성',
|
||||
industry: '유통',
|
||||
sectionType: 'fields',
|
||||
fields: [
|
||||
{ field_key: 'barcode', field_name: '바코드', field_type: 'textbox', is_required: true },
|
||||
{ field_key: 'shelf_life', field_name: '유통기한(일)', field_type: 'number' },
|
||||
{ field_key: 'storage_temp', field_name: '보관온도', field_type: 'dropdown', options: [
|
||||
{ label: '상온', value: 'room' },
|
||||
{ label: '냉장 (0~10\u00B0C)', value: 'cold' },
|
||||
{ label: '냉동 (-18\u00B0C 이하)', value: 'frozen' },
|
||||
]},
|
||||
{ field_key: 'origin_country', field_name: '원산지', field_type: 'textbox' },
|
||||
],
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 물류업 프리셋
|
||||
// ============================================
|
||||
|
||||
export const LOGISTICS_VEHICLE_TABLE: SectionPreset = {
|
||||
id: 'log-vehicle',
|
||||
name: '배차 정보',
|
||||
description: '운송 차량 배정 목록',
|
||||
industry: '물류',
|
||||
sectionType: 'table',
|
||||
tableConfig: {
|
||||
columns: [
|
||||
{ key: 'vehicle', label: '차량', fieldType: 'reference', width: '130px', isRequired: true, properties: { source: 'vehicles' } },
|
||||
{ key: 'driver', label: '기사', fieldType: 'reference', width: '100px', properties: { source: 'employees' } },
|
||||
{ key: 'load_weight', label: '적재중량(kg)', fieldType: 'number', width: '100px' },
|
||||
{ key: 'delivery_date', label: '배송일', fieldType: 'date', width: '120px' },
|
||||
{ key: 'status', label: '상태', fieldType: 'dropdown', width: '90px', options: [
|
||||
{ label: '배정', value: 'assigned' },
|
||||
{ label: '운행중', value: 'in_transit' },
|
||||
{ label: '완료', value: 'delivered' },
|
||||
]},
|
||||
],
|
||||
maxRows: 50,
|
||||
summaryRow: [
|
||||
{ columnKey: 'load_weight', type: 'sum' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 전체 프리셋 레지스트리
|
||||
// ============================================
|
||||
|
||||
export const SECTION_PRESETS: Record<string, SectionPreset> = {
|
||||
// 제조
|
||||
'mfg-process': MANUFACTURING_PROCESS_TABLE,
|
||||
'mfg-qc': MANUFACTURING_QC_TABLE,
|
||||
'mfg-vendor': MANUFACTURING_VENDOR_TABLE,
|
||||
// 건설
|
||||
'const-spec': CONSTRUCTION_SPEC_FIELDS,
|
||||
'const-schedule': CONSTRUCTION_SCHEDULE_TABLE,
|
||||
// 유통
|
||||
'dist-logistics': DISTRIBUTION_LOGISTICS_TABLE,
|
||||
'dist-product': DISTRIBUTION_PRODUCT_FIELDS,
|
||||
// 물류
|
||||
'log-vehicle': LOGISTICS_VEHICLE_TABLE,
|
||||
};
|
||||
|
||||
/**
|
||||
* 산업별 프리셋 필터
|
||||
*/
|
||||
export function getPresetsByIndustry(industry: string): SectionPreset[] {
|
||||
return Object.values(SECTION_PRESETS).filter(p => p.industry === industry);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용 가능한 산업 목록
|
||||
*/
|
||||
export function getAvailableIndustries(): string[] {
|
||||
const industries = new Set(Object.values(SECTION_PRESETS).map(p => p.industry));
|
||||
return Array.from(industries);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* DynamicTableSection
|
||||
* config 기반 범용 테이블 섹션 (공정, 품질검사, 구매처, 공정표, 배차 등)
|
||||
*
|
||||
* API 연동 시: GET/PUT /v1/items/{itemId}/section-data/{sectionId}
|
||||
* API 연동 전: rows를 폼 상태(formData)에 로컬 관리
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { TableCellRenderer } from './TableCellRenderer';
|
||||
import type { ItemSectionResponse } from '@/types/item-master-api';
|
||||
import type { TableConfig } from '../types';
|
||||
|
||||
export interface DynamicTableSectionProps {
|
||||
section: {
|
||||
section: ItemSectionResponse;
|
||||
orderNo: number;
|
||||
};
|
||||
tableConfig: TableConfig;
|
||||
rows: Record<string, unknown>[];
|
||||
onRowsChange: (rows: Record<string, unknown>[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function DynamicTableSection({
|
||||
section,
|
||||
tableConfig,
|
||||
rows,
|
||||
onRowsChange,
|
||||
disabled,
|
||||
}: DynamicTableSectionProps) {
|
||||
const { columns, minRows, maxRows, summaryRow } = tableConfig;
|
||||
|
||||
// 행 추가
|
||||
const handleAddRow = useCallback(() => {
|
||||
if (maxRows && rows.length >= maxRows) return;
|
||||
const newRow: Record<string, unknown> = {};
|
||||
columns.forEach(col => {
|
||||
newRow[col.key] = null;
|
||||
});
|
||||
onRowsChange([...rows, newRow]);
|
||||
}, [rows, columns, maxRows, onRowsChange]);
|
||||
|
||||
// 행 삭제
|
||||
const handleRemoveRow = useCallback((index: number) => {
|
||||
if (minRows && rows.length <= minRows) return;
|
||||
onRowsChange(rows.filter((_, i) => i !== index));
|
||||
}, [rows, minRows, onRowsChange]);
|
||||
|
||||
// 셀 값 변경
|
||||
const handleCellChange = useCallback((rowIndex: number, columnKey: string, value: unknown) => {
|
||||
const updated = rows.map((row, i) =>
|
||||
i === rowIndex ? { ...row, [columnKey]: value } : row
|
||||
);
|
||||
onRowsChange(updated);
|
||||
}, [rows, onRowsChange]);
|
||||
|
||||
// 요약행 계산
|
||||
const computeSummary = (columnKey: string, type: string): string | number => {
|
||||
const values = rows.map(r => Number(r[columnKey]) || 0);
|
||||
switch (type) {
|
||||
case 'sum': return values.reduce((a, b) => a + b, 0);
|
||||
case 'avg': return values.length > 0
|
||||
? Math.round(values.reduce((a, b) => a + b, 0) / values.length * 100) / 100
|
||||
: 0;
|
||||
case 'count': return rows.length;
|
||||
default: return '';
|
||||
}
|
||||
};
|
||||
|
||||
const canAdd = !maxRows || rows.length < maxRows;
|
||||
const canRemove = !minRows || rows.length > minRows;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-4 flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base font-medium">
|
||||
{section.section.title}
|
||||
</CardTitle>
|
||||
{section.section.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{section.section.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{!disabled && canAdd && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddRow}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
행 추가
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{rows.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||
데이터가 없습니다. "행 추가" 버튼으로 항목을 추가하세요.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12 text-center">#</TableHead>
|
||||
{columns.map(col => (
|
||||
<TableHead
|
||||
key={col.key}
|
||||
style={col.width ? { width: col.width } : undefined}
|
||||
>
|
||||
{col.label}
|
||||
{col.isRequired && <span className="text-red-500"> *</span>}
|
||||
</TableHead>
|
||||
))}
|
||||
{!disabled && <TableHead className="w-12" />}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row, rowIndex) => (
|
||||
<TableRow key={rowIndex}>
|
||||
<TableCell className="text-center text-muted-foreground text-sm">
|
||||
{rowIndex + 1}
|
||||
</TableCell>
|
||||
{columns.map(col => (
|
||||
<TableCell key={col.key} className="p-1">
|
||||
<TableCellRenderer
|
||||
column={col}
|
||||
value={row[col.key] as any}
|
||||
onChange={(val) => handleCellChange(rowIndex, col.key, val)}
|
||||
disabled={disabled}
|
||||
rowIndex={rowIndex}
|
||||
/>
|
||||
</TableCell>
|
||||
))}
|
||||
{!disabled && (
|
||||
<TableCell className="p-1 text-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
disabled={!canRemove}
|
||||
onClick={() => handleRemoveRow(rowIndex)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
{/* 요약행 */}
|
||||
{summaryRow && summaryRow.length > 0 && (
|
||||
<TableRow className="bg-muted/50 font-medium">
|
||||
<TableCell className="text-center text-sm">합계</TableCell>
|
||||
{columns.map(col => {
|
||||
const summary = summaryRow.find(s => s.columnKey === col.key);
|
||||
return (
|
||||
<TableCell key={col.key} className="p-2 text-sm">
|
||||
{summary
|
||||
? summary.type === 'label'
|
||||
? summary.label || ''
|
||||
: computeSummary(col.key, summary.type)
|
||||
: ''}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
{!disabled && <TableCell />}
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* TableCellRenderer
|
||||
* 테이블 셀 = DynamicFieldRenderer를 테이블 셀용 축소 모드로 래핑
|
||||
*
|
||||
* column config → ItemFieldResponse 호환 객체로 변환 → DynamicFieldRenderer 호출
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { DynamicFieldRenderer } from '../fields/DynamicFieldRenderer';
|
||||
import type { DynamicFieldValue } from '../types';
|
||||
import type { TableColumnConfig } from '../types';
|
||||
import type { ItemFieldResponse } from '@/types/item-master-api';
|
||||
|
||||
interface TableCellRendererProps {
|
||||
column: TableColumnConfig;
|
||||
value: DynamicFieldValue;
|
||||
onChange: (value: DynamicFieldValue) => void;
|
||||
disabled?: boolean;
|
||||
rowIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* column config를 ItemFieldResponse 호환 객체로 변환
|
||||
*/
|
||||
function columnToFieldResponse(column: TableColumnConfig, rowIndex: number): ItemFieldResponse {
|
||||
return {
|
||||
id: 0,
|
||||
tenant_id: 0,
|
||||
group_id: null,
|
||||
section_id: null,
|
||||
field_name: column.label,
|
||||
field_key: `table_${column.key}_${rowIndex}`,
|
||||
field_type: (column.fieldType || 'textbox') as ItemFieldResponse['field_type'],
|
||||
order_no: 0,
|
||||
is_required: column.isRequired ?? false,
|
||||
placeholder: null,
|
||||
default_value: null,
|
||||
display_condition: null,
|
||||
validation_rules: null,
|
||||
options: column.options || null,
|
||||
properties: (column.properties as Record<string, any>) || null,
|
||||
category: null,
|
||||
description: null,
|
||||
is_common: false,
|
||||
is_locked: false,
|
||||
locked_by: null,
|
||||
locked_at: null,
|
||||
created_by: null,
|
||||
updated_by: null,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
};
|
||||
}
|
||||
|
||||
export function TableCellRenderer({
|
||||
column,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
rowIndex,
|
||||
}: TableCellRendererProps) {
|
||||
const fieldLike = columnToFieldResponse(column, rowIndex);
|
||||
|
||||
// 읽기 전용 컬럼
|
||||
if (column.isReadonly) {
|
||||
return (
|
||||
<span className="text-sm px-2 py-1 block truncate">
|
||||
{value !== null && value !== undefined ? String(value) : '-'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="[&_label]:hidden [&_p]:hidden">
|
||||
<DynamicFieldRenderer
|
||||
field={fieldLike}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +1,5 @@
|
||||
export { default as DynamicBOMSection } from './DynamicBOMSection';
|
||||
export type { DynamicBOMSectionProps } from './DynamicBOMSection';
|
||||
export type { DynamicBOMSectionProps } from './DynamicBOMSection';
|
||||
export { default as DynamicTableSection } from './DynamicTableSection';
|
||||
export type { DynamicTableSectionProps } from './DynamicTableSection';
|
||||
export { TableCellRenderer } from './TableCellRenderer';
|
||||
@@ -20,12 +20,33 @@ export type { ItemType } from '@/types/item';
|
||||
// 조건부 표시 타입
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 조건부 표시 연산자
|
||||
* - equals: 기본값 (하위호환), 정확히 일치
|
||||
* - not_equals: 불일치
|
||||
* - in: expectedValue를 콤마 구분 목록으로 해석, 그 중 하나에 일치
|
||||
* - not_in: 콤마 구분 목록 중 어느 것에도 불일치
|
||||
* - greater_than / less_than / gte / lte: 숫자 비교
|
||||
* - contains: 부분 문자열 포함
|
||||
*/
|
||||
export type ConditionOperator =
|
||||
| 'equals'
|
||||
| 'not_equals'
|
||||
| 'in'
|
||||
| 'not_in'
|
||||
| 'greater_than'
|
||||
| 'less_than'
|
||||
| 'gte'
|
||||
| 'lte'
|
||||
| 'contains';
|
||||
|
||||
/**
|
||||
* 조건부 표시 설정 - 개별 조건
|
||||
*/
|
||||
export interface FieldConditionConfig {
|
||||
fieldKey: string; // 조건을 가진 필드의 키
|
||||
expectedValue: string; // 이 값일 때
|
||||
operator?: ConditionOperator; // 기본: "equals" (하위호환)
|
||||
expectedValue: string; // 이 값일 때 (in/not_in은 콤마 구분)
|
||||
targetFieldIds?: string[]; // 이 필드들 표시
|
||||
targetSectionIds?: string[]; // 이 섹션들 표시
|
||||
}
|
||||
@@ -172,6 +193,8 @@ export interface DynamicFieldRendererProps {
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
unitOptions?: SimpleUnitOption[];
|
||||
/** ComputedField에서 다른 필드 값 참조용 */
|
||||
formData?: DynamicFormData;
|
||||
}
|
||||
|
||||
|
||||
@@ -207,6 +230,122 @@ export interface UseDynamicFormStateResult {
|
||||
resetForm: (initialData?: DynamicFormData) => void;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 확장 필드 config 타입 (Phase 1~3)
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Reference 필드 properties config
|
||||
*/
|
||||
export interface ReferenceConfig {
|
||||
source: string; // "vendors" | "items" | "customers" 등
|
||||
displayField?: string; // 기본 "name"
|
||||
valueField?: string; // 기본 "id"
|
||||
searchFields?: string[]; // 기본 ["name"]
|
||||
searchApiUrl?: string; // source="custom"일 때 필수
|
||||
columns?: Array<{ key: string; label: string; width?: string }>;
|
||||
displayFormat?: string; // "{code} - {name}"
|
||||
returnFields?: string[]; // ["id", "code", "name"]
|
||||
}
|
||||
|
||||
/**
|
||||
* MultiSelect 필드 properties config
|
||||
*/
|
||||
export interface MultiSelectConfig {
|
||||
maxSelections?: number; // 최대 선택 수 (기본: 무제한)
|
||||
allowCustom?: boolean; // 직접 입력 허용 (기본: false)
|
||||
layout?: 'chips' | 'list'; // 기본: "chips"
|
||||
}
|
||||
|
||||
/**
|
||||
* File 필드 properties config
|
||||
*/
|
||||
export interface FileConfig {
|
||||
accept?: string; // ".pdf,.doc" (기본: "*")
|
||||
maxSize?: number; // bytes (기본: 10MB = 10485760)
|
||||
maxFiles?: number; // 기본: 1
|
||||
preview?: boolean; // 이미지 미리보기 (기본: true)
|
||||
category?: string; // 파일 카테고리 태그
|
||||
}
|
||||
|
||||
/**
|
||||
* Currency 필드 properties config
|
||||
*/
|
||||
export interface CurrencyConfig {
|
||||
currency?: string; // 기본: "KRW"
|
||||
precision?: number; // 기본: 0
|
||||
showSymbol?: boolean; // 기본: true
|
||||
allowNegative?: boolean; // 기본: false
|
||||
}
|
||||
|
||||
/**
|
||||
* UnitValue 필드 properties config
|
||||
*/
|
||||
export interface UnitValueConfig {
|
||||
units: Array<{ label: string; value: string }>;
|
||||
defaultUnit?: string;
|
||||
precision?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle 필드 properties config
|
||||
*/
|
||||
export interface ToggleConfig {
|
||||
onLabel?: string; // 기본: "ON"
|
||||
offLabel?: string; // 기본: "OFF"
|
||||
onValue?: string; // 기본: "true"
|
||||
offValue?: string; // 기본: "false"
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed 필드 properties config
|
||||
*/
|
||||
export interface ComputedConfig {
|
||||
formula: string; // "price * quantity"
|
||||
dependsOn: string[]; // ["price", "quantity"]
|
||||
format?: string; // "currency" | "number" | "percent"
|
||||
precision?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reference 소스 프리셋 엔트리
|
||||
*/
|
||||
export interface ReferenceSourcePreset {
|
||||
apiUrl: string;
|
||||
displayField: string;
|
||||
valueField: string;
|
||||
searchFields: string[];
|
||||
columns: Array<{ key: string; label: string; width?: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 컬럼 config
|
||||
*/
|
||||
export interface TableColumnConfig {
|
||||
key: string;
|
||||
label: string;
|
||||
width?: string;
|
||||
fieldType: string; // ItemFieldType
|
||||
options?: Array<{ label: string; value: string }>;
|
||||
properties?: Record<string, unknown>;
|
||||
isRequired?: boolean;
|
||||
isReadonly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 섹션 config (properties에서 읽음)
|
||||
*/
|
||||
export interface TableConfig {
|
||||
columns: TableColumnConfig[];
|
||||
minRows?: number;
|
||||
maxRows?: number;
|
||||
summaryRow?: Array<{
|
||||
columnKey: string;
|
||||
type: 'sum' | 'avg' | 'count' | 'label';
|
||||
label?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 유틸리티 타입
|
||||
// ============================================
|
||||
|
||||
@@ -8,10 +8,11 @@
|
||||
*/
|
||||
|
||||
import type { ItemField, FieldDisplayCondition } from '@/contexts/ItemMasterContext';
|
||||
import type { ItemFieldType } from '@/types/item-master-api';
|
||||
|
||||
// ===== Types =====
|
||||
|
||||
export type FieldType = 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea';
|
||||
export type FieldType = ItemFieldType;
|
||||
|
||||
export interface FieldFormData {
|
||||
name: string;
|
||||
|
||||
@@ -8,11 +8,12 @@
|
||||
*/
|
||||
|
||||
import type { ItemMasterField } from '@/contexts/ItemMasterContext';
|
||||
import type { ItemFieldType } from '@/types/item-master-api';
|
||||
import { fieldService, type SingleFieldValidation } from './fieldService';
|
||||
|
||||
// ===== Types =====
|
||||
|
||||
export type MasterFieldType = 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea';
|
||||
export type MasterFieldType = ItemFieldType;
|
||||
export type AttributeType = 'custom' | 'unit' | 'material' | 'surface';
|
||||
|
||||
export interface MasterFieldFormData {
|
||||
|
||||
@@ -671,30 +671,12 @@ export function UniversalListPage<T>({
|
||||
toast.success(`${selectedData.length}건 다운로드 완료`);
|
||||
}, [config.excelDownload, effectiveSelectedItems, rawData, getItemId]);
|
||||
|
||||
// 엑셀 다운로드 버튼 렌더링
|
||||
// 엑셀 전체 다운로드 버튼 (헤더 영역)
|
||||
const renderExcelDownloadButton = useMemo(() => {
|
||||
if (!config.excelDownload || config.excelDownload.enabled === false || !canExport) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { enableSelectedDownload = true } = config.excelDownload;
|
||||
|
||||
// 선택 항목이 있고 선택 다운로드가 활성화된 경우
|
||||
if (enableSelectedDownload && effectiveSelectedItems.size > 0) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSelectedExcelDownload}
|
||||
className="gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
선택 다운로드 ({effectiveSelectedItems.size})
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// 전체 다운로드
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -711,7 +693,30 @@ export function UniversalListPage<T>({
|
||||
{isExcelDownloading ? '다운로드 중...' : '엑셀 다운로드'}
|
||||
</Button>
|
||||
);
|
||||
}, [config.excelDownload, effectiveSelectedItems.size, isExcelDownloading, handleExcelDownload, handleSelectedExcelDownload]);
|
||||
}, [config.excelDownload, canExport, isExcelDownloading, handleExcelDownload]);
|
||||
|
||||
// 엑셀 선택 다운로드 버튼 (selectionActions 영역 - "전체 N건 / N개 항목 선택됨" 뒤)
|
||||
const renderExcelSelectedDownloadButton = useMemo(() => {
|
||||
if (!config.excelDownload || config.excelDownload.enabled === false || !canExport) {
|
||||
return null;
|
||||
}
|
||||
const { enableSelectedDownload = true } = config.excelDownload;
|
||||
if (!enableSelectedDownload || effectiveSelectedItems.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSelectedExcelDownload}
|
||||
className="gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
선택 다운로드 ({effectiveSelectedItems.size})
|
||||
</Button>
|
||||
);
|
||||
}, [config.excelDownload, canExport, effectiveSelectedItems.size, handleSelectedExcelDownload]);
|
||||
|
||||
// ===== 정렬 핸들러 =====
|
||||
const handleSort = useCallback((key: string) => {
|
||||
@@ -952,11 +957,16 @@ export function UniversalListPage<T>({
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
getItemId={effectiveGetItemId}
|
||||
onBulkDelete={config.actions?.deleteItem ? handleBulkDeleteClick : undefined}
|
||||
selectionActions={config.selectionActions?.({
|
||||
selectedItems: effectiveSelectedItems,
|
||||
onClearSelection: () => externalSelection ? undefined : setSelectedItems(new Set()),
|
||||
onRefresh: fetchData,
|
||||
})}
|
||||
selectionActions={
|
||||
<>
|
||||
{renderExcelSelectedDownloadButton}
|
||||
{config.selectionActions?.({
|
||||
selectedItems: effectiveSelectedItems,
|
||||
onClearSelection: () => externalSelection ? undefined : setSelectedItems(new Set()),
|
||||
onRefresh: fetchData,
|
||||
})}
|
||||
</>
|
||||
}
|
||||
// 표시 옵션
|
||||
showCheckbox={config.showCheckbox}
|
||||
showRowNumber={config.showRowNumber}
|
||||
|
||||
6691
src/generated/component-registry.json
Normal file
6691
src/generated/component-registry.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,8 @@ export type ItemType = 'FG' | 'PT' | 'SM' | 'RM' | 'CS';
|
||||
export type SectionType = 'BASIC' | 'BOM' | 'CUSTOM';
|
||||
|
||||
/** 필드 타입 */
|
||||
export type FieldType = 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea';
|
||||
import type { ItemFieldType } from '@/types/item-master-api';
|
||||
export type FieldType = ItemFieldType;
|
||||
|
||||
/** 부품 유형 */
|
||||
export type PartType = 'ASSEMBLY' | 'BENDING' | 'PURCHASED';
|
||||
|
||||
@@ -200,6 +200,27 @@ export interface LinkSectionRequest {
|
||||
// 필드 관리
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 필드 타입 정의
|
||||
* 기본 6종 + 확장 8종 = 14종
|
||||
* 2026-02-11: 동적 필드 타입 확장 (Phase 1~3)
|
||||
*/
|
||||
export type ItemFieldType =
|
||||
// 기본 타입 (기존)
|
||||
| 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea'
|
||||
// Phase 1: 핵심 타입
|
||||
| 'reference' | 'multi-select' | 'file'
|
||||
// Phase 2: 편의 타입
|
||||
| 'currency' | 'unit-value' | 'radio'
|
||||
// Phase 3: 고급 타입
|
||||
| 'toggle' | 'computed';
|
||||
|
||||
/**
|
||||
* 섹션 타입 정의
|
||||
* 2026-02-11: 'table' 타입 추가 (범용 테이블 섹션)
|
||||
*/
|
||||
export type ItemSectionType = 'fields' | 'bom' | 'table';
|
||||
|
||||
/**
|
||||
* 필드 생성/수정 요청
|
||||
* POST /v1/item-master/sections/{sectionId}/fields
|
||||
@@ -208,7 +229,7 @@ export interface LinkSectionRequest {
|
||||
export interface ItemFieldRequest {
|
||||
field_name: string;
|
||||
field_key?: string | null; // 2025-11-28: 필드 키 (영문, 숫자, 언더스코어만 허용, 영문으로 시작)
|
||||
field_type: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea';
|
||||
field_type: ItemFieldType;
|
||||
section_id?: number | null;
|
||||
master_field_id?: number | null;
|
||||
order_no?: number;
|
||||
@@ -235,7 +256,7 @@ export interface ItemFieldResponse {
|
||||
master_field_id?: number | null; // 마스터 항목 ID (마스터에서 가져온 경우)
|
||||
field_name: string;
|
||||
field_key: string | null; // 2025-11-28: 필드 키 (형식: {ID}_{사용자입력})
|
||||
field_type: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea';
|
||||
field_type: ItemFieldType;
|
||||
order_no: number;
|
||||
is_required: boolean;
|
||||
placeholder: string | null;
|
||||
@@ -280,7 +301,7 @@ export interface IndependentFieldRequest {
|
||||
group_id?: number;
|
||||
field_name: string;
|
||||
field_key?: string; // 2025-11-28: 필드 키 (영문, 숫자, 언더스코어만 허용, 영문으로 시작)
|
||||
field_type: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea';
|
||||
field_type: ItemFieldType;
|
||||
is_required?: boolean;
|
||||
default_value?: string;
|
||||
placeholder?: string;
|
||||
@@ -524,7 +545,7 @@ export interface SectionTemplateResponse {
|
||||
*/
|
||||
export interface MasterFieldRequest {
|
||||
field_name: string;
|
||||
field_type: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea';
|
||||
field_type: ItemFieldType;
|
||||
category?: string;
|
||||
description?: string;
|
||||
is_common?: boolean;
|
||||
@@ -544,7 +565,7 @@ export interface MasterFieldResponse {
|
||||
tenant_id: number;
|
||||
field_name: string;
|
||||
field_key: string | null; // 2025-11-28: 필드 키 추가 (형식: {ID}_{사용자입력})
|
||||
field_type: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea';
|
||||
field_type: ItemFieldType;
|
||||
category: string | null;
|
||||
description: string | null;
|
||||
is_common: boolean;
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* ItemMasterContext에서 분리됨 (2026-01-06)
|
||||
*/
|
||||
|
||||
import type { ItemFieldType } from './item-master-api';
|
||||
|
||||
// ===== 기본 타입 =====
|
||||
|
||||
// 전개도 상세 정보
|
||||
@@ -244,7 +246,7 @@ export interface ItemMasterField {
|
||||
tenant_id: number;
|
||||
field_name: string;
|
||||
field_key?: string | null; // 2025-11-28: field_key 추가 (형식: {ID}_{사용자입력})
|
||||
field_type: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea'; // API와 동일
|
||||
field_type: ItemFieldType; // API와 동일
|
||||
category: string | null;
|
||||
description: string | null;
|
||||
is_common: boolean; // 공통 필드 여부
|
||||
@@ -280,7 +282,7 @@ export interface ItemField {
|
||||
master_field_id?: number | null; // 마스터 항목 ID (마스터에서 가져온 경우)
|
||||
field_name: string; // 항목명 (name → field_name)
|
||||
field_key?: string | null; // 2025-11-28: 필드 키 (형식: {ID}_{사용자입력}, 백엔드에서 생성)
|
||||
field_type: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea'; // 필드 타입
|
||||
field_type: ItemFieldType; // 필드 타입
|
||||
order_no: number; // 항목 순서 (order → order_no, required)
|
||||
is_required: boolean; // 필수 여부
|
||||
placeholder?: string | null; // 플레이스홀더
|
||||
|
||||
Reference in New Issue
Block a user