서비스 레이어 리팩토링: - services/ 폴더 생성 (fieldService, masterFieldService, sectionService, pageService, templateService, attributeService) - 도메인 로직 중앙화 (validation, parsing, transform) - hooks와 dialogs에서 서비스 호출로 변경 버그 수정: - 섹션탭 실시간 동기화 문제 수정 (sectionsAsTemplates 중복 제거 순서 변경) - 422 Validation Error 수정 (createIndependentField → addFieldToSection) - 페이지 삭제 시 섹션-필드 연결 유지 (refreshIndependentSections 대신 직접 이동) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
378 lines
14 KiB
TypeScript
378 lines
14 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useRef } from 'react';
|
|
import { toast } from 'sonner';
|
|
import { useItemMaster } from '@/contexts/ItemMasterContext';
|
|
import type { MasterOption, OptionColumn } from '../types';
|
|
import { attributeService } from '../services';
|
|
|
|
export interface UseAttributeManagementReturn {
|
|
// 속성 옵션 상태
|
|
unitOptions: MasterOption[];
|
|
setUnitOptions: React.Dispatch<React.SetStateAction<MasterOption[]>>;
|
|
materialOptions: MasterOption[];
|
|
setMaterialOptions: React.Dispatch<React.SetStateAction<MasterOption[]>>;
|
|
surfaceTreatmentOptions: MasterOption[];
|
|
setSurfaceTreatmentOptions: React.Dispatch<React.SetStateAction<MasterOption[]>>;
|
|
customAttributeOptions: Record<string, MasterOption[]>;
|
|
setCustomAttributeOptions: React.Dispatch<React.SetStateAction<Record<string, MasterOption[]>>>;
|
|
|
|
// 옵션 다이얼로그 상태
|
|
isOptionDialogOpen: boolean;
|
|
setIsOptionDialogOpen: (open: boolean) => void;
|
|
editingOptionType: string | null;
|
|
setEditingOptionType: (type: string | null) => void;
|
|
|
|
// 옵션 폼 상태
|
|
newOptionValue: string;
|
|
setNewOptionValue: (value: string) => void;
|
|
newOptionLabel: string;
|
|
setNewOptionLabel: (label: string) => void;
|
|
newOptionColumnValues: Record<string, string>;
|
|
setNewOptionColumnValues: React.Dispatch<React.SetStateAction<Record<string, string>>>;
|
|
newOptionInputType: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea';
|
|
setNewOptionInputType: (type: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea') => void;
|
|
newOptionRequired: boolean;
|
|
setNewOptionRequired: (required: boolean) => void;
|
|
newOptionOptions: string;
|
|
setNewOptionOptions: (options: string) => void;
|
|
newOptionPlaceholder: string;
|
|
setNewOptionPlaceholder: (placeholder: string) => void;
|
|
newOptionDefaultValue: string;
|
|
setNewOptionDefaultValue: (value: string) => void;
|
|
|
|
// 칼럼 관리 상태
|
|
isColumnManageDialogOpen: boolean;
|
|
setIsColumnManageDialogOpen: (open: boolean) => void;
|
|
managingColumnType: string | null;
|
|
setManagingColumnType: (type: string | null) => void;
|
|
attributeColumns: Record<string, OptionColumn[]>;
|
|
setAttributeColumns: React.Dispatch<React.SetStateAction<Record<string, OptionColumn[]>>>;
|
|
|
|
// 칼럼 폼 상태
|
|
newColumnName: string;
|
|
setNewColumnName: (name: string) => void;
|
|
newColumnKey: string;
|
|
setNewColumnKey: (key: string) => void;
|
|
newColumnType: 'text' | 'number';
|
|
setNewColumnType: (type: 'text' | 'number') => void;
|
|
newColumnRequired: boolean;
|
|
setNewColumnRequired: (required: boolean) => void;
|
|
|
|
// 핸들러
|
|
handleAddOption: () => void;
|
|
handleDeleteOption: (type: string, id: string) => void;
|
|
handleAddColumn: () => void;
|
|
handleDeleteColumn: (columnKey: string) => void;
|
|
resetOptionForm: () => void;
|
|
resetColumnForm: () => void;
|
|
}
|
|
|
|
export function useAttributeManagement(): UseAttributeManagementReturn {
|
|
const {
|
|
itemMasterFields,
|
|
updateItemMasterField
|
|
} = useItemMaster();
|
|
|
|
// 속성 옵션 상태 (기본값 하드코딩 - TODO: 나중에 백엔드 API로 대체)
|
|
const [unitOptions, setUnitOptions] = useState<MasterOption[]>([
|
|
{ id: 'unit-1', value: 'EA', label: 'EA (개)', isActive: true },
|
|
{ id: 'unit-2', value: 'KG', label: 'KG (킬로그램)', isActive: true },
|
|
{ id: 'unit-3', value: 'M', label: 'M (미터)', isActive: true },
|
|
{ id: 'unit-4', value: 'MM', label: 'MM (밀리미터)', isActive: true },
|
|
{ id: 'unit-5', value: 'L', label: 'L (리터)', isActive: true },
|
|
{ id: 'unit-6', value: 'SET', label: 'SET (세트)', isActive: true },
|
|
{ id: 'unit-7', value: 'BOX', label: 'BOX (박스)', isActive: true },
|
|
{ id: 'unit-8', value: 'ROLL', label: 'ROLL (롤)', isActive: true },
|
|
]);
|
|
const [materialOptions, setMaterialOptions] = useState<MasterOption[]>([
|
|
{ id: 'mat-1', value: 'SUS304', label: 'SUS304 (스테인리스)', isActive: true },
|
|
{ id: 'mat-2', value: 'SUS316', label: 'SUS316 (스테인리스)', isActive: true },
|
|
{ id: 'mat-3', value: 'AL6061', label: 'AL6061 (알루미늄)', isActive: true },
|
|
{ id: 'mat-4', value: 'AL5052', label: 'AL5052 (알루미늄)', isActive: true },
|
|
{ id: 'mat-5', value: 'SS400', label: 'SS400 (일반강)', isActive: true },
|
|
{ id: 'mat-6', value: 'S45C', label: 'S45C (탄소강)', isActive: true },
|
|
{ id: 'mat-7', value: 'POM', label: 'POM (폴리아세탈)', isActive: true },
|
|
{ id: 'mat-8', value: 'PEEK', label: 'PEEK (폴리에테르에테르케톤)', isActive: true },
|
|
]);
|
|
const [surfaceTreatmentOptions, setSurfaceTreatmentOptions] = useState<MasterOption[]>([
|
|
{ id: 'surf-1', value: 'NONE', label: '없음', isActive: true },
|
|
{ id: 'surf-2', value: 'ANODIZE', label: '아노다이징', isActive: true },
|
|
{ id: 'surf-3', value: 'PLATING', label: '도금', isActive: true },
|
|
{ id: 'surf-4', value: 'PAINTING', label: '도장', isActive: true },
|
|
{ id: 'surf-5', value: 'PASSIVATION', label: '부동태처리', isActive: true },
|
|
{ id: 'surf-6', value: 'SANDBLAST', label: '샌드블라스트', isActive: true },
|
|
{ id: 'surf-7', value: 'POLISHING', label: '폴리싱', isActive: true },
|
|
]);
|
|
const [customAttributeOptions, setCustomAttributeOptions] = useState<Record<string, MasterOption[]>>({});
|
|
|
|
// 옵션 다이얼로그 상태
|
|
const [isOptionDialogOpen, setIsOptionDialogOpen] = useState(false);
|
|
const [editingOptionType, setEditingOptionType] = useState<string | null>(null);
|
|
|
|
// 옵션 폼 상태
|
|
const [newOptionValue, setNewOptionValue] = useState('');
|
|
const [newOptionLabel, setNewOptionLabel] = useState('');
|
|
const [newOptionColumnValues, setNewOptionColumnValues] = useState<Record<string, string>>({});
|
|
const [newOptionInputType, setNewOptionInputType] = useState<'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea'>('textbox');
|
|
const [newOptionRequired, setNewOptionRequired] = useState(false);
|
|
const [newOptionOptions, setNewOptionOptions] = useState('');
|
|
const [newOptionPlaceholder, setNewOptionPlaceholder] = useState('');
|
|
const [newOptionDefaultValue, setNewOptionDefaultValue] = useState('');
|
|
|
|
// 칼럼 관리 상태
|
|
const [isColumnManageDialogOpen, setIsColumnManageDialogOpen] = useState(false);
|
|
const [managingColumnType, setManagingColumnType] = useState<string | null>(null);
|
|
const [attributeColumns, setAttributeColumns] = useState<Record<string, OptionColumn[]>>({});
|
|
|
|
// 칼럼 폼 상태
|
|
const [newColumnName, setNewColumnName] = useState('');
|
|
const [newColumnKey, setNewColumnKey] = useState('');
|
|
const [newColumnType, setNewColumnType] = useState<'text' | 'number'>('text');
|
|
const [newColumnRequired, setNewColumnRequired] = useState(false);
|
|
|
|
// 이전 옵션 값 추적용 ref (무한 루프 방지)
|
|
const prevOptionsRef = useRef<string>('');
|
|
|
|
// 속성 변경 시 연동된 마스터 항목의 옵션 자동 업데이트
|
|
// 주의: itemMasterFields를 의존성에서 제거하여 무한 루프 방지
|
|
useEffect(() => {
|
|
// 현재 옵션 상태를 문자열로 직렬화
|
|
const currentOptionsState = JSON.stringify({
|
|
unit: unitOptions.map(o => o.label).sort(),
|
|
material: materialOptions.map(o => o.label).sort(),
|
|
surface: surfaceTreatmentOptions.map(o => o.label).sort(),
|
|
custom: Object.keys(customAttributeOptions).reduce((acc, key) => {
|
|
acc[key] = (customAttributeOptions[key] || []).map(o => o.label).sort();
|
|
return acc;
|
|
}, {} as Record<string, string[]>)
|
|
});
|
|
|
|
// 이전 상태와 동일하면 업데이트 스킵
|
|
if (prevOptionsRef.current === currentOptionsState) {
|
|
return;
|
|
}
|
|
prevOptionsRef.current = currentOptionsState;
|
|
|
|
// 실제 업데이트가 필요한 경우만 처리
|
|
itemMasterFields.forEach(field => {
|
|
// properties가 null/undefined인 경우 스킵
|
|
if (!field.properties) return;
|
|
const attributeType = (field.properties as any).attributeType;
|
|
if (attributeType && attributeType !== 'custom' && (field.properties as any)?.inputType === 'dropdown') {
|
|
let newOptions: string[] = [];
|
|
|
|
if (attributeType === 'unit') {
|
|
newOptions = unitOptions.map(opt => opt.label);
|
|
} else if (attributeType === 'material') {
|
|
newOptions = materialOptions.map(opt => opt.label);
|
|
} else if (attributeType === 'surface') {
|
|
newOptions = surfaceTreatmentOptions.map(opt => opt.label);
|
|
} else {
|
|
const customOpts = customAttributeOptions[attributeType] || [];
|
|
newOptions = customOpts.map(opt => opt.label);
|
|
}
|
|
|
|
const currentOptions = (field.properties as any)?.options || [];
|
|
const optionsChanged = JSON.stringify([...currentOptions].sort()) !== JSON.stringify([...newOptions].sort());
|
|
|
|
if (optionsChanged && newOptions.length > 0) {
|
|
updateItemMasterField(field.id, {
|
|
properties: {
|
|
...(field.properties || {}),
|
|
options: newOptions
|
|
}
|
|
});
|
|
}
|
|
}
|
|
});
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [unitOptions, materialOptions, surfaceTreatmentOptions, customAttributeOptions]);
|
|
|
|
// 옵션 추가
|
|
const handleAddOption = () => {
|
|
if (!editingOptionType || !newOptionValue.trim() || !newOptionLabel.trim()) {
|
|
toast.error('모든 항목을 입력해주세요');
|
|
return;
|
|
}
|
|
|
|
// dropdown일 경우 옵션 필수 체크
|
|
if (newOptionInputType === 'dropdown' && !newOptionOptions.trim()) {
|
|
toast.error('드롭다운 옵션을 입력해주세요');
|
|
return;
|
|
}
|
|
|
|
// 칼럼 필수 값 체크
|
|
const currentColumns = attributeColumns[editingOptionType] || [];
|
|
for (const column of currentColumns) {
|
|
if (column.required && !newOptionColumnValues[column.key]?.trim()) {
|
|
toast.error(`${column.name}은(는) 필수 입력 항목입니다`);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const newOption: MasterOption = {
|
|
id: `${editingOptionType}-${Date.now()}`,
|
|
value: newOptionValue,
|
|
label: newOptionLabel,
|
|
isActive: true,
|
|
inputType: newOptionInputType,
|
|
required: newOptionRequired,
|
|
options: newOptionInputType === 'dropdown' ? newOptionOptions.split(',').map(o => o.trim()).filter(o => o) : undefined,
|
|
placeholder: newOptionPlaceholder || undefined,
|
|
defaultValue: newOptionDefaultValue || undefined,
|
|
columnValues: Object.keys(newOptionColumnValues).length > 0 ? { ...newOptionColumnValues } : undefined
|
|
};
|
|
|
|
if (editingOptionType === 'unit') {
|
|
setUnitOptions(prev => [...prev, newOption]);
|
|
} else if (editingOptionType === 'material') {
|
|
setMaterialOptions(prev => [...prev, newOption]);
|
|
} else if (editingOptionType === 'surface') {
|
|
setSurfaceTreatmentOptions(prev => [...prev, newOption]);
|
|
} else {
|
|
setCustomAttributeOptions(prev => ({
|
|
...prev,
|
|
[editingOptionType]: [...(prev[editingOptionType] || []), newOption]
|
|
}));
|
|
}
|
|
|
|
resetOptionForm();
|
|
toast.success('속성이 추가되었습니다 (저장 필요)');
|
|
};
|
|
|
|
// 옵션 삭제
|
|
const handleDeleteOption = (type: string, id: string) => {
|
|
if (type === 'unit') {
|
|
setUnitOptions(prev => prev.filter(o => o.id !== id));
|
|
} else if (type === 'material') {
|
|
setMaterialOptions(prev => prev.filter(o => o.id !== id));
|
|
} else if (type === 'surface') {
|
|
setSurfaceTreatmentOptions(prev => prev.filter(o => o.id !== id));
|
|
} else {
|
|
setCustomAttributeOptions(prev => ({
|
|
...prev,
|
|
[type]: (prev[type] || []).filter(o => o.id !== id)
|
|
}));
|
|
}
|
|
toast.success('삭제되었습니다');
|
|
};
|
|
|
|
// 칼럼 추가
|
|
const handleAddColumn = () => {
|
|
if (!managingColumnType || !newColumnName.trim() || !newColumnKey.trim()) {
|
|
toast.error('칼럼명과 키를 입력해주세요');
|
|
return;
|
|
}
|
|
|
|
const newColumn: OptionColumn = {
|
|
id: `col-${Date.now()}`,
|
|
key: newColumnKey,
|
|
name: newColumnName,
|
|
type: newColumnType,
|
|
required: newColumnRequired
|
|
};
|
|
|
|
setAttributeColumns(prev => ({
|
|
...prev,
|
|
[managingColumnType]: [...(prev[managingColumnType] || []), newColumn]
|
|
}));
|
|
|
|
resetColumnForm();
|
|
toast.success('칼럼이 추가되었습니다');
|
|
};
|
|
|
|
// 칼럼 삭제
|
|
const handleDeleteColumn = (columnKey: string) => {
|
|
if (!managingColumnType) return;
|
|
|
|
setAttributeColumns(prev => ({
|
|
...prev,
|
|
[managingColumnType]: (prev[managingColumnType] || []).filter(c => c.key !== columnKey)
|
|
}));
|
|
toast.success('칼럼이 삭제되었습니다');
|
|
};
|
|
|
|
// 옵션 폼 초기화
|
|
const resetOptionForm = () => {
|
|
setNewOptionValue('');
|
|
setNewOptionLabel('');
|
|
setNewOptionColumnValues({});
|
|
setNewOptionInputType('textbox');
|
|
setNewOptionRequired(false);
|
|
setNewOptionOptions('');
|
|
setNewOptionPlaceholder('');
|
|
setNewOptionDefaultValue('');
|
|
setIsOptionDialogOpen(false);
|
|
};
|
|
|
|
// 칼럼 폼 초기화
|
|
const resetColumnForm = () => {
|
|
setNewColumnName('');
|
|
setNewColumnKey('');
|
|
setNewColumnType('text');
|
|
setNewColumnRequired(false);
|
|
};
|
|
|
|
return {
|
|
// 속성 옵션 상태
|
|
unitOptions,
|
|
setUnitOptions,
|
|
materialOptions,
|
|
setMaterialOptions,
|
|
surfaceTreatmentOptions,
|
|
setSurfaceTreatmentOptions,
|
|
customAttributeOptions,
|
|
setCustomAttributeOptions,
|
|
|
|
// 옵션 다이얼로그 상태
|
|
isOptionDialogOpen,
|
|
setIsOptionDialogOpen,
|
|
editingOptionType,
|
|
setEditingOptionType,
|
|
|
|
// 옵션 폼 상태
|
|
newOptionValue,
|
|
setNewOptionValue,
|
|
newOptionLabel,
|
|
setNewOptionLabel,
|
|
newOptionColumnValues,
|
|
setNewOptionColumnValues,
|
|
newOptionInputType,
|
|
setNewOptionInputType,
|
|
newOptionRequired,
|
|
setNewOptionRequired,
|
|
newOptionOptions,
|
|
setNewOptionOptions,
|
|
newOptionPlaceholder,
|
|
setNewOptionPlaceholder,
|
|
newOptionDefaultValue,
|
|
setNewOptionDefaultValue,
|
|
|
|
// 칼럼 관리 상태
|
|
isColumnManageDialogOpen,
|
|
setIsColumnManageDialogOpen,
|
|
managingColumnType,
|
|
setManagingColumnType,
|
|
attributeColumns,
|
|
setAttributeColumns,
|
|
|
|
// 칼럼 폼 상태
|
|
newColumnName,
|
|
setNewColumnName,
|
|
newColumnKey,
|
|
setNewColumnKey,
|
|
newColumnType,
|
|
setNewColumnType,
|
|
newColumnRequired,
|
|
setNewColumnRequired,
|
|
|
|
// 핸들러
|
|
handleAddOption,
|
|
handleDeleteOption,
|
|
handleAddColumn,
|
|
handleDeleteColumn,
|
|
resetOptionForm,
|
|
resetColumnForm,
|
|
};
|
|
} |