refactor: 품목관리 시스템 리팩토링 및 Sales 페이지 추가

DynamicItemForm 개선:
- 품목코드 자동생성 기능 추가
- 조건부 표시 로직 개선
- 불필요한 컴포넌트 정리 (DynamicField, DynamicSection 등)
- 타입 시스템 단순화

새로운 기능:
- Sales 페이지 마이그레이션 (견적관리, 거래처관리)
- 공통 컴포넌트 추가 (atoms, molecules, organisms, templates)

문서화:
- 구현 문서 및 참조 문서 추가

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2025-12-04 12:48:41 +09:00
parent 0552b02ba9
commit 3be5714805
73 changed files with 9318 additions and 4353 deletions

View File

@@ -1,12 +1,11 @@
/**
* DropdownField Component
*
* 드롭다운/선택 필드 (dropdown, searchable-dropdown)
* 드롭다운(Select) 필드 컴포넌트
* 기존 ItemForm과 100% 동일한 디자인
*/
'use client';
import { useState, useEffect } from 'react';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
@@ -14,105 +13,110 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import type { DynamicFieldProps, DropdownOption } from '../types';
import { cn } from '@/lib/utils';
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 => {
// 이미 {label, value} 형태
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 DropdownField({
field,
value,
error,
onChange,
onBlur,
error,
disabled,
}: DynamicFieldProps) {
const [options, setOptions] = useState<DropdownOption[]>(
field.dropdown_config?.options || []
);
const [isLoading, setIsLoading] = useState(false);
unitOptions,
}: DynamicFieldRendererProps) {
const fieldKey = field.field_key || `field_${field.id}`;
const stringValue = value !== null && value !== undefined ? String(value) : '';
// API에서 옵션 로드 (options_endpoint가 있는 경우)
useEffect(() => {
if (field.dropdown_config?.options_endpoint) {
setIsLoading(true);
fetch(field.dropdown_config.options_endpoint)
.then((res) => res.json())
.then((data) => {
if (data.success && Array.isArray(data.data)) {
setOptions(data.data);
}
})
.catch((err) => {
console.error('[DropdownField] Failed to load options:', err);
})
.finally(() => {
setIsLoading(false);
});
}
}, [field.dropdown_config?.options_endpoint]);
// field_key 또는 field_name이 '단위'/'unit' 관련이면 unitOptions 사용
const isUnitField =
fieldKey.toLowerCase().includes('unit') ||
fieldKey.includes('단위') ||
field.field_name.includes('단위') ||
field.field_name.toLowerCase().includes('unit');
const displayValue = value === null || value === undefined ? '' : String(value);
// 옵션 목록 결정
let options: Array<{ label: string; value: string }> = [];
const handleValueChange = (newValue: string) => {
onChange(newValue);
onBlur();
};
if (isUnitField && unitOptions && unitOptions.length > 0) {
options = unitOptions.map((u) => ({
label: `${u.label} (${u.value})`,
value: u.value,
}));
} else {
// field.options를 정규화
options = normalizeOptions(field.options);
}
// 옵션이 없으면 드롭다운을 disabled로 표시
const hasOptions = options.length > 0;
return (
<div className="space-y-2">
<Label
htmlFor={field.field_key}
className={cn(
'text-sm font-medium',
field.is_required && "after:content-['*'] after:ml-0.5 after:text-red-500"
)}
>
<div>
<Label htmlFor={fieldKey}>
{field.field_name}
{field.is_required && <span className="text-red-500"> *</span>}
</Label>
<Select
value={displayValue}
onValueChange={handleValueChange}
disabled={disabled || field.is_readonly || isLoading}
value={stringValue}
onValueChange={onChange}
disabled={disabled || !hasOptions}
>
<SelectTrigger
id={field.field_key}
className={cn(
error && 'border-red-500 focus:ring-red-500',
field.is_readonly && 'bg-gray-50 text-gray-500'
)}
id={fieldKey}
className={error ? 'border-red-500' : ''}
>
<SelectValue
placeholder={
isLoading
? '로딩 중...'
: field.dropdown_config?.placeholder || '선택하세요'
}
/>
<SelectValue placeholder={
hasOptions
? (field.placeholder || `${field.field_name}을(를) 선택하세요`)
: '옵션이 없습니다'
} />
</SelectTrigger>
<SelectContent>
{field.dropdown_config?.allow_empty && (
<SelectItem value=""> </SelectItem>
)}
{options.map((option) => (
<SelectItem
key={option.value}
value={option.value}
disabled={option.disabled}
>
{options.map((option, index) => (
<SelectItem key={`${option.value}-${index}`} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{field.help_text && !error && (
<p className="text-xs text-muted-foreground">{field.help_text}</p>
{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>
)}
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
);
}
export default DropdownField;