fix: 품목관리 수정 기능 버그 수정 및 Sales 페이지 추가

## 품목관리 수정 버그 수정
- FG(제품) 수정 시 품목명 반영 안되는 문제 해결
  - productName → name 필드 매핑 추가
  - FG 품목코드 = 품목명 동기화 로직 추가
- Materials(SM, RM, CS) 수정페이지 진입 오류 해결
- UNIQUE 제약조건 위반 오류 해결

## Sales 페이지
- 거래처관리 (client-management-sales-admin) 페이지 구현
- 견적관리 (quote-management) 페이지 구현
- 관련 컴포넌트 및 훅 추가

## 기타
- 회원가입 페이지 차단 처리
- 디버깅용 콘솔 로그 추가 (PUT 요청/응답 확인용)

🤖 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 20:52:42 +09:00
parent 42f80e2b16
commit 751e65f59b
52 changed files with 8869 additions and 1088 deletions

View File

@@ -9,7 +9,7 @@
'use client';
import { useState, useCallback } from 'react';
import { useState, useCallback, useEffect, useRef } from 'react';
import type {
DynamicFormData,
DynamicFormErrors,
@@ -25,6 +25,21 @@ export function useDynamicFormState(
const [errors, setErrors] = useState<DynamicFormErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
// 2025-12-04: Edit 모드에서 initialData가 비동기로 로드될 때 formData 동기화
// useState의 초기값은 첫 렌더 시에만 사용되므로,
// initialData가 나중에 변경되면 formData를 업데이트해야 함
const isInitialDataLoaded = useRef(false);
useEffect(() => {
// initialData가 있고, 아직 로드되지 않았을 때만 동기화
// (사용자가 수정 중인 데이터를 덮어쓰지 않도록)
if (initialData && Object.keys(initialData).length > 0 && !isInitialDataLoaded.current) {
console.log('[useDynamicFormState] initialData 동기화:', initialData);
setFormData(initialData);
isInitialDataLoaded.current = true;
}
}, [initialData]);
// 필드 값 설정
const setFieldValue = useCallback((fieldKey: string, value: DynamicFieldValue) => {
setFormData((prev) => ({
@@ -149,17 +164,21 @@ export function useDynamicFormState(
);
// 폼 제출
// 2025-12-04: 실패 시에만 버튼 다시 활성화 (로그인 방식)
// 성공 시에는 페이지 이동하므로 버튼 비활성화 상태 유지
const handleSubmit = useCallback(
async (onSubmit: (data: DynamicFormData) => Promise<void>) => {
setIsSubmitting(true);
try {
await onSubmit(formData);
// 성공 시: setIsSubmitting(false)를 호출하지 않음
// 페이지 이동하므로 버튼 비활성화 상태 유지 → 중복 클릭 방지
} catch (err) {
console.error('폼 제출 실패:', err);
throw err;
} finally {
// 실패 시에만 버튼 다시 활성화 → 재시도 가능
setIsSubmitting(false);
throw err;
}
},
[formData]

View File

@@ -32,6 +32,7 @@ import {
generateAssemblyItemNameSimple,
generateAssemblySpecification,
generateBendingItemCodeSimple,
generatePurchasedItemCode,
} from './utils/itemCodeGenerator';
import type { DynamicItemFormProps, DynamicFormData, DynamicSection, DynamicFieldValue, BOMLine, BOMSearchState } from './types';
import type { ItemType, BendingDetail } from '@/types/item';
@@ -255,6 +256,10 @@ export default function DynamicItemForm({
const [bendingDetails, setBendingDetails] = useState<BendingDetail[]>([]);
const [widthSum, setWidthSum] = useState<string>('');
// FG(제품) 전용 파일 업로드 상태 관리
const [specificationFile, setSpecificationFile] = useState<File | null>(null);
const [certificationFile, setCertificationFile] = useState<File | null>(null);
// 조건부 표시 관리
const { shouldShowSection, shouldShowField } = useConditionalDisplay(structure, formData);
@@ -274,7 +279,7 @@ export default function DynamicItemForm({
.map((item: { code?: string; item_code?: string }) => item.code || item.item_code || '')
.filter((code: string) => code);
setExistingItemCodes(codes);
console.log('[DynamicItemForm] PT 기존 품목코드 로드:', codes.length, '개');
// console.log('[DynamicItemForm] PT 기존 품목코드 로드:', codes.length, '개');
}
} catch (err) {
console.error('[DynamicItemForm] PT 품목코드 조회 실패:', err);
@@ -287,7 +292,7 @@ export default function DynamicItemForm({
}
}, [selectedItemType]);
// 품목 유형 변경 시 폼 초기화
// 품목 유형 변경 시 폼 초기화 (create 모드)
useEffect(() => {
if (selectedItemType && mode === 'create' && structure) {
// 기본값 설정
@@ -322,6 +327,82 @@ export default function DynamicItemForm({
}
}, [selectedItemType, structure, mode, resetForm]);
// Edit 모드: structure 로드 후 initialData를 field_key 형식으로 변환
// 2025-12-04: initialData 키(item_name)와 structure의 field_key(98_item_name)가 다른 문제 해결
const [isEditDataMapped, setIsEditDataMapped] = useState(false);
useEffect(() => {
if (mode !== 'edit' || !structure || !initialData || isEditDataMapped) return;
// console.log('[DynamicItemForm] Edit mode: mapping initialData to field_key format');
// initialData의 간단한 키를 structure의 field_key로 매핑
// 예: { item_name: '테스트' } → { '98_item_name': '테스트' }
const mappedData: DynamicFormData = {};
// field_key에서 실제 필드명 추출하는 함수
// 예: '98_item_name' → 'item_name', '110_품목명' → '품목명'
const extractFieldName = (fieldKey: string): string => {
const underscoreIndex = fieldKey.indexOf('_');
if (underscoreIndex > 0) {
return fieldKey.substring(underscoreIndex + 1);
}
return fieldKey;
};
// structure에서 모든 필드의 field_key 수집
const fieldKeyMap: Record<string, string> = {}; // 간단한 키 → field_key 매핑
structure.sections.forEach((section) => {
section.fields.forEach((f) => {
const field = f.field;
const fieldKey = field.field_key || `field_${field.id}`;
const simpleName = extractFieldName(fieldKey);
fieldKeyMap[simpleName] = fieldKey;
// field_name도 매핑에 추가 (한글 필드명 지원)
if (field.field_name) {
fieldKeyMap[field.field_name] = fieldKey;
}
});
});
structure.directFields.forEach((f) => {
const field = f.field;
const fieldKey = field.field_key || `field_${field.id}`;
const simpleName = extractFieldName(fieldKey);
fieldKeyMap[simpleName] = fieldKey;
if (field.field_name) {
fieldKeyMap[field.field_name] = fieldKey;
}
});
// console.log('[DynamicItemForm] fieldKeyMap:', fieldKeyMap);
// initialData를 field_key 형식으로 변환
Object.entries(initialData).forEach(([key, value]) => {
// 이미 field_key 형식인 경우 그대로 사용
if (key.includes('_') && /^\d+_/.test(key)) {
mappedData[key] = value;
}
// 간단한 키인 경우 field_key로 변환
else if (fieldKeyMap[key]) {
mappedData[fieldKeyMap[key]] = value;
}
// 매핑 없는 경우 그대로 유지
else {
mappedData[key] = value;
}
});
// console.log('[DynamicItemForm] Mapped initialData:', mappedData);
// 변환된 데이터로 폼 리셋
resetForm(mappedData);
setIsEditDataMapped(true);
}, [mode, structure, initialData, isEditDataMapped, resetForm]);
// 모든 필드 목록 (밸리데이션용) - 숨겨진 섹션/필드 제외
const allFields = useMemo<ItemFieldResponse[]>(() => {
if (!structure) return [];
@@ -440,10 +521,10 @@ export default function DynamicItemForm({
return allSpecificationKeys[0] || '';
}, [structure, allSpecificationKeys, shouldShowSection, shouldShowField]);
// 부품 유형 필드 탐지 (PT 품목에서 절곡/조립 부품 판별용)
const { partTypeFieldKey, selectedPartType, isBendingPart, isAssemblyPart } = useMemo(() => {
// 부품 유형 필드 탐지 (PT 품목에서 절곡/조립/구매 부품 판별용)
const { partTypeFieldKey, selectedPartType, isBendingPart, isAssemblyPart, isPurchasedPart } = useMemo(() => {
if (!structure || selectedItemType !== 'PT') {
return { partTypeFieldKey: '', selectedPartType: '', isBendingPart: false, isAssemblyPart: false };
return { partTypeFieldKey: '', selectedPartType: '', isBendingPart: false, isAssemblyPart: false, isPurchasedPart: false };
}
let foundPartTypeKey = '';
@@ -477,19 +558,17 @@ export default function DynamicItemForm({
const isBending = currentPartType.includes('절곡') || currentPartType.toUpperCase() === 'BENDING';
// "조립 부품", "ASSEMBLY", "조립부품" 등 다양한 형태 지원
const isAssembly = currentPartType.includes('조립') || currentPartType.toUpperCase() === 'ASSEMBLY';
// "구매 부품", "PURCHASED", "구매부품" 등 다양한 형태 지원
const isPurchased = currentPartType.includes('구매') || currentPartType.toUpperCase() === 'PURCHASED';
console.log('[DynamicItemForm] 부품 유형 감지:', {
partTypeFieldKey: foundPartTypeKey,
currentPartType,
isBending,
isAssembly,
});
// console.log('[DynamicItemForm] 부품 유형 감지:', { partTypeFieldKey: foundPartTypeKey, currentPartType, isBending, isAssembly, isPurchased });
return {
partTypeFieldKey: foundPartTypeKey,
selectedPartType: currentPartType,
isBendingPart: isBending,
isAssemblyPart: isAssembly,
isPurchasedPart: isPurchased,
};
}, [structure, selectedItemType, formData]);
@@ -508,7 +587,7 @@ export default function DynamicItemForm({
// 이전 값이 있고, 현재 값과 다른 경우에만 초기화
if (prevPartType && prevPartType !== currentPartType) {
console.log('[DynamicItemForm] 부품 유형 변경 감지:', prevPartType, '→', currentPartType);
// console.log('[DynamicItemForm] 부품 유형 변경 감지:', prevPartType, '→', currentPartType);
// setTimeout으로 다음 틱에서 초기화 실행
// → 부품 유형 Select 값 변경이 먼저 완료된 후 초기화
@@ -555,7 +634,7 @@ export default function DynamicItemForm({
// 중복 제거 후 초기화
const uniqueFields = [...new Set(fieldsToReset)];
console.log('[DynamicItemForm] 초기화할 필드:', uniqueFields);
// console.log('[DynamicItemForm] 초기화할 필드:', uniqueFields);
uniqueFields.forEach((fieldKey) => {
setFieldValue(fieldKey, '');
@@ -612,12 +691,12 @@ export default function DynamicItemForm({
// bending_parts는 무조건 우선 (덮어쓰기)
if (isBendingItemNameField) {
console.log('[checkField] 절곡부품 품목명 필드 발견!', { fieldKey, fieldName });
// console.log('[checkField] 절곡부품 품목명 필드 발견!', { fieldKey, fieldName });
bendingItemNameKey = fieldKey;
}
// 일반 품목명은 아직 없을 때만
else if (isGeneralItemNameField && !bendingItemNameKey) {
console.log('[checkField] 일반 품목명 필드 발견!', { fieldKey, fieldName });
// console.log('[checkField] 일반 품목명 필드 발견!', { fieldKey, fieldName });
bendingItemNameKey = fieldKey;
}
@@ -686,19 +765,7 @@ export default function DynamicItemForm({
const autoCode = generateBendingItemCodeSimple(itemNameValue, categoryValue, shapeLengthValue);
console.log('[DynamicItemForm] 절곡부품 필드 탐지:', {
bendingItemNameKey,
itemNameKey,
effectiveItemNameKey,
materialKey,
categoryKeysWithIds,
activeCategoryKey,
widthSumKey,
shapeLengthKey,
formDataKeys: Object.keys(formData),
values: { itemNameValue, categoryValue, shapeLengthValue },
autoCode,
});
// console.log('[DynamicItemForm] 절곡부품 필드 탐지:', { bendingItemNameKey, materialKey, activeCategoryKey, autoCode });
return {
bendingFieldKeys: {
@@ -726,13 +793,13 @@ export default function DynamicItemForm({
// 품목명이 변경되었고, 이전 값이 있었을 때만 종류 필드 초기화
if (prevItemNameValue && prevItemNameValue !== currentItemNameValue) {
console.log('[DynamicItemForm] 품목명 변경 감지:', prevItemNameValue, '→', currentItemNameValue);
// console.log('[DynamicItemForm] 품목명 변경 감지:', prevItemNameValue, '→', currentItemNameValue);
// 모든 종류 필드 값 초기화
allCategoryKeysWithIds.forEach(({ key }) => {
const currentVal = (formData[key] as string) || '';
if (currentVal) {
console.log('[DynamicItemForm] 종류 필드 초기화:', key);
// console.log('[DynamicItemForm] 종류 필드 초기화:', key);
setFieldValue(key, '');
}
});
@@ -763,12 +830,7 @@ export default function DynamicItemForm({
fieldKey.includes('부품구성');
if (isCheckbox && isBomRelated) {
console.log('[DynamicItemForm] BOM 체크박스 필드 발견:', {
fieldKey,
fieldName,
fieldType,
resultKey: field.field_key || `field_${field.id}`,
});
// console.log('[DynamicItemForm] BOM 체크박스 필드 발견:', { fieldKey, fieldName });
return field.field_key || `field_${field.id}`;
}
}
@@ -789,17 +851,12 @@ export default function DynamicItemForm({
fieldKey.includes('부품구성');
if (isCheckbox && isBomRelated) {
console.log('[DynamicItemForm] BOM 체크박스 필드 발견 (직접필드):', {
fieldKey,
fieldName,
fieldType,
resultKey: field.field_key || `field_${field.id}`,
});
// console.log('[DynamicItemForm] BOM 체크박스 필드 발견 (직접필드):', { fieldKey, fieldName });
return field.field_key || `field_${field.id}`;
}
}
console.log('[DynamicItemForm] BOM 체크박스 필드를 찾지 못함');
// console.log('[DynamicItemForm] BOM 체크박스 필드를 찾지 못함');
return '';
}, [structure]);
@@ -878,15 +935,7 @@ export default function DynamicItemForm({
// 규격: 가로x세로x길이(네자리)
const autoSpec = generateAssemblySpecification(sideSpecWidth, sideSpecHeight, assemblyLength);
console.log('[DynamicItemForm] 조립 부품 필드 탐지:', {
isAssembly,
sideSpecWidthKey,
sideSpecHeightKey,
assemblyLengthKey,
values: { sideSpecWidth, sideSpecHeight, assemblyLength },
autoItemName,
autoSpec,
});
// console.log('[DynamicItemForm] 조립 부품 필드 탐지:', { isAssembly, autoItemName, autoSpec });
return {
hasAssemblyFields: isAssembly,
@@ -900,6 +949,97 @@ export default function DynamicItemForm({
};
}, [structure, selectedItemType, formData, itemNameKey]);
// 구매 부품(전동개폐기) 필드 탐지 - 품목명, 용량, 전원
// 2025-12-04: 구매 부품 품목코드 자동생성 추가
const { purchasedFieldKeys, autoPurchasedItemCode } = useMemo(() => {
if (!structure || selectedItemType !== 'PT' || !isPurchasedPart) {
return {
purchasedFieldKeys: {
itemName: '', // 품목명 (전동개폐기 등)
capacity: '', // 용량 (150, 300, etc.)
power: '', // 전원 (220V, 380V)
},
autoPurchasedItemCode: '',
};
}
let purchasedItemNameKey = '';
let capacityKey = '';
let powerKey = '';
const checkField = (fieldKey: string, field: ItemFieldResponse) => {
const fieldName = field.field_name || '';
const lowerKey = fieldKey.toLowerCase();
// 구매 부품 품목명 필드 탐지 - PurchasedItemName 우선 탐지
const isPurchasedItemNameField = lowerKey.includes('purchaseditemname');
const isItemNameField =
isPurchasedItemNameField ||
lowerKey.includes('item_name') ||
lowerKey.includes('품목명') ||
fieldName.includes('품목명') ||
fieldName === '품목명';
// PurchasedItemName을 우선적으로 사용 (더 정확한 매칭)
if (isPurchasedItemNameField) {
purchasedItemNameKey = fieldKey; // 덮어쓰기 (우선순위 높음)
} else if (isItemNameField && !purchasedItemNameKey) {
purchasedItemNameKey = fieldKey;
}
// 용량 필드 탐지
const isCapacityField =
lowerKey.includes('capacity') ||
lowerKey.includes('용량') ||
fieldName.includes('용량') ||
fieldName === '용량';
if (isCapacityField && !capacityKey) {
capacityKey = fieldKey;
}
// 전원 필드 탐지
const isPowerField =
lowerKey.includes('power') ||
lowerKey.includes('전원') ||
fieldName.includes('전원') ||
fieldName === '전원';
if (isPowerField && !powerKey) {
powerKey = fieldKey;
}
};
// 모든 필드 검사
structure.sections.forEach((section) => {
section.fields.forEach((f) => {
const key = f.field.field_key || `field_${f.field.id}`;
checkField(key, f.field);
});
});
structure.directFields.forEach((f) => {
const key = f.field.field_key || `field_${f.field.id}`;
checkField(key, f.field);
});
// 품목코드 자동생성: 품목명 + 용량 + 전원
const itemNameValue = purchasedItemNameKey ? (formData[purchasedItemNameKey] as string) || '' : '';
const capacityValue = capacityKey ? (formData[capacityKey] as string) || '' : '';
const powerValue = powerKey ? (formData[powerKey] as string) || '' : '';
const autoCode = generatePurchasedItemCode(itemNameValue, capacityValue, powerValue);
// console.log('[DynamicItemForm] 구매 부품 필드 탐지:', { purchasedItemNameKey, autoCode });
return {
purchasedFieldKeys: {
itemName: purchasedItemNameKey,
capacity: capacityKey,
power: powerKey,
},
autoPurchasedItemCode: autoCode,
};
}, [structure, selectedItemType, isPurchasedPart, formData]);
// 품목코드 자동생성 값
// PT(부품): 영문약어-순번 (예: GR-001, MOTOR-002)
// 기타 품목: 품목명-규격 (기존 방식)
@@ -949,6 +1089,7 @@ export default function DynamicItemForm({
// 2025-12-03: 한글 field_key 지원 추가
const fieldKeyToBackendKey: Record<string, string> = {
'item_name': 'name',
'productName': 'name', // FG(제품) 품목명 필드
'품목명': 'name', // 한글 field_key 지원
'specification': 'spec',
'standard': 'spec', // 규격 대체 필드명
@@ -972,11 +1113,16 @@ export default function DynamicItemForm({
};
// formData를 백엔드 필드명으로 변환
// console.log('[DynamicItemForm] formData before conversion:', formData);
const convertedData: Record<string, any> = {};
Object.entries(formData).forEach(([key, value]) => {
// "{id}_{fieldKey}" 형식에서 fieldKey 추출
const underscoreIndex = key.indexOf('_');
if (underscoreIndex > 0) {
// "{id}_{fieldKey}" 형식 체크: 숫자로 시작하고 _가 있는 경우
// 예: "98_item_name" → true, "item_name" → false
const isFieldKeyFormat = /^\d+_/.test(key);
if (isFieldKeyFormat) {
// "{id}_{fieldKey}" 형식에서 fieldKey 추출
const underscoreIndex = key.indexOf('_');
const fieldKey = key.substring(underscoreIndex + 1);
const backendKey = fieldKeyToBackendKey[fieldKey] || fieldKey;
@@ -990,10 +1136,19 @@ export default function DynamicItemForm({
convertedData[backendKey] = value;
}
} else {
// 변환 불필요한 필드는 그대로
convertedData[key] = value;
// field_key 형식이 아닌 경우, 매핑 테이블에서 변환 시도
const backendKey = fieldKeyToBackendKey[key] || key;
if (backendKey === 'is_active') {
const isActive = value === true || value === 'true' || value === '1' ||
value === 1 || value === '활성' || value === 'active';
convertedData[backendKey] = isActive;
} else {
convertedData[backendKey] = value;
}
}
});
// console.log('[DynamicItemForm] convertedData after conversion:', convertedData);
// 품목명 값 추출 (품목코드와 품목명 모두 필요)
// 2025-12-04: 절곡 부품은 별도 품목명 필드(bendingFieldKeys.itemName) 사용
@@ -1004,9 +1159,10 @@ export default function DynamicItemForm({
? (formData[effectiveItemNameKeyForSubmit] as string) || ''
: '';
// 조립/절곡 부품 자동생성 값 결정
// 조립/절곡/구매 부품 자동생성 값 결정
// 조립 부품: 품목명 = "품목명 가로x세로", 규격 = "가로x세로x길이"
// 절곡 부품: 품목명 = bendingFieldKeys.itemName에서 선택한 값, 규격 = 없음 (품목코드로 대체)
// 구매 부품: 품목명 = purchasedFieldKeys.itemName에서 선택한 값
let finalName: string;
let finalSpec: string | undefined;
@@ -1018,27 +1174,29 @@ export default function DynamicItemForm({
// 절곡 부품: bendingFieldKeys.itemName의 값 사용
finalName = itemNameValue || convertedData.name || '';
finalSpec = convertedData.spec;
} else if (isPurchasedPart) {
// 구매 부품: purchasedFieldKeys.itemName의 값 사용
const purchasedItemNameValue = purchasedFieldKeys.itemName
? (formData[purchasedFieldKeys.itemName] as string) || ''
: '';
finalName = purchasedItemNameValue || convertedData.name || '';
finalSpec = convertedData.spec;
} else {
// 기타: 기존 로직
finalName = convertedData.name || itemNameValue;
finalSpec = convertedData.spec;
}
console.log('[DynamicItemForm] 품목명/규격 결정:', {
isAssemblyPart,
autoAssemblyItemName,
autoAssemblySpec,
convertedDataName: convertedData.name,
convertedDataSpec: convertedData.spec,
finalName,
finalSpec,
});
// console.log('[DynamicItemForm] 품목명/규격 결정:', { finalName, finalSpec });
// 품목코드 결정
// 2025-12-04: 절곡 부품은 autoBendingItemCode 사용
// 2025-12-04: 구매 부품은 autoPurchasedItemCode 사용
let finalCode: string;
if (isBendingPart && autoBendingItemCode) {
finalCode = autoBendingItemCode;
} else if (isPurchasedPart && autoPurchasedItemCode) {
finalCode = autoPurchasedItemCode;
} else if (hasAutoItemCode && autoGeneratedItemCode) {
finalCode = autoGeneratedItemCode;
} else {
@@ -1078,16 +1236,17 @@ export default function DynamicItemForm({
part_type: 'ASSEMBLY',
bending_diagram: bendingDiagram || null, // 조립품도 동일한 전개도 필드 사용
} : {}),
// 구매품 데이터 (PT - 구매 부품 전용)
...(selectedItemType === 'PT' && isPurchasedPart ? {
part_type: 'PURCHASED',
} : {}),
// FG(제품)은 단위 필드가 없으므로 기본값 'EA' 설정
...(selectedItemType === 'FG' && !convertedData.unit ? {
unit: 'EA',
} : {}),
};
// is_active 디버깅 로그
console.log('[DynamicItemForm] is_active 디버깅:', {
formDataKeys: Object.keys(formData).filter(k => k.includes('active') || k.includes('상태') || k.includes('status')),
convertedIsActive: convertedData.is_active,
submitDataIsActive: submitData.is_active,
formDataValues: Object.entries(formData).filter(([k]) => k.includes('active') || k.includes('상태') || k.includes('status')),
});
console.log('[DynamicItemForm] 제출 데이터:', submitData);
// console.log('[DynamicItemForm] 제출 데이터:', submitData);
await handleSubmit(async () => {
await onSubmit(submitData);
@@ -1211,10 +1370,17 @@ export default function DynamicItemForm({
const isSpecField = fieldKey === activeSpecificationKey;
const isStatusField = fieldKey === statusFieldKey;
// 품목명 필드인지 체크 (FG 품목코드 자동생성 위치)
const isItemNameField = fieldKey === itemNameKey;
// 비고 필드인지 체크 (절곡부품 품목코드 자동생성 위치)
const fieldName = field.field_name || '';
const isNoteField = fieldKey.includes('note') || fieldKey.includes('비고') ||
fieldName.includes('비고') || fieldName === '비고';
// 인정 유효기간 종료일 필드인지 체크 (FG 시방서/인정서 파일 업로드 위치)
const isCertEndDateField = fieldKey.includes('certification_end') ||
fieldKey.includes('인정_유효기간_종료') ||
fieldName.includes('인정 유효기간 종료') ||
fieldName.includes('유효기간 종료');
// 절곡부품 박스 스타일링 (재질, 폭합계, 모양&길이)
const isBendingBoxField = isBendingPart && (
@@ -1283,6 +1449,87 @@ export default function DynamicItemForm({
</p>
</div>
)}
{/* 비고 필드 다음에 구매부품(전동개폐기) 품목코드 자동생성 */}
{isNoteField && isPurchasedPart && (
<div className="mt-4">
<Label htmlFor="purchased_item_code_auto"> ()</Label>
<Input
id="purchased_item_code_auto"
value={autoPurchasedItemCode || ''}
placeholder="품목명, 용량, 전원을 선택하면 자동으로 생성됩니다"
disabled
className="bg-muted text-muted-foreground"
/>
<p className="text-xs text-muted-foreground mt-1">
* &apos;++&apos; (: 전동개폐기150KG380V)
</p>
</div>
)}
{/* FG(제품) 전용: 품목명 필드 다음에 품목코드 자동생성 */}
{isItemNameField && selectedItemType === 'FG' && (
<div className="mt-4">
<Label htmlFor="fg_item_code_auto"> ()</Label>
<Input
id="fg_item_code_auto"
value={(formData[itemNameKey] as string) || ''}
placeholder="품목명이 입력되면 자동으로 동일하게 생성됩니다"
disabled
className="bg-muted text-muted-foreground"
/>
<p className="text-xs text-muted-foreground mt-1">
* (FG)
</p>
</div>
)}
{/* FG(제품) 전용: 인정 유효기간 종료일 다음에 시방서/인정서 파일 업로드 */}
{isCertEndDateField && selectedItemType === 'FG' && (
<div className="mt-4 space-y-4">
{/* 시방서 파일 업로드 */}
<div>
<Label htmlFor="specification_file"> (PDF)</Label>
<div className="mt-1.5">
<Input
id="specification_file"
type="file"
accept=".pdf"
onChange={(e) => {
const file = e.target.files?.[0] || null;
setSpecificationFile(file);
}}
disabled={isSubmitting}
className="cursor-pointer"
/>
{specificationFile && (
<p className="text-xs text-muted-foreground mt-1">
: {specificationFile.name}
</p>
)}
</div>
</div>
{/* 인정서 파일 업로드 */}
<div>
<Label htmlFor="certification_file"> (PDF)</Label>
<div className="mt-1.5">
<Input
id="certification_file"
type="file"
accept=".pdf"
onChange={(e) => {
const file = e.target.files?.[0] || null;
setCertificationFile(file);
}}
disabled={isSubmitting}
className="cursor-pointer"
/>
{certificationFile && (
<p className="text-xs text-muted-foreground mt-1">
: {certificationFile.name}
</p>
)}
</div>
</div>
</div>
)}
</div>
);
})}
@@ -1402,12 +1649,7 @@ export default function DynamicItemForm({
const isBomRequired = bomValue === true || bomValue === 'true' || bomValue === '1' || bomValue === 1;
// 디버깅 로그
console.log('[DynamicItemForm] BOM 체크 디버깅:', {
bomRequiredFieldKey,
bomValue,
isBomRequired,
formDataKeys: Object.keys(formData),
});
// console.log('[DynamicItemForm] BOM 체크 디버깅:', { bomRequiredFieldKey, bomValue, isBomRequired });
if (!isBomRequired) return null;

View File

@@ -355,6 +355,41 @@ export function generateAssemblySpecification(
return `${sideSpecWidth}x${sideSpecHeight}x${assemblyLength}`;
}
// ============================================
// 구매 부품 (전동개폐기) 품목코드 자동생성
// 2025-12-04 추가
// ============================================
/**
* 전동개폐기 품목코드 생성 (품목명 + 용량 + 전원)
* @param itemName 품목명 (예: "전동개폐기")
* @param capacity 용량 (예: "150", "300")
* @param power 전원 (예: "220V", "380V")
* @returns 품목코드 (예: "전동개폐기150KG380V")
*/
export function generatePurchasedItemCode(
itemName: string,
capacity?: string,
power?: string
): string {
if (!itemName) return '';
// 품목명에서 괄호 앞부분만 추출 (예: "전동개폐기 (E)" → "전동개폐기")
const cleanItemName = itemName.replace(/\s*\([^)]*\)\s*$/, '').trim();
if (!capacity || !power) {
return cleanItemName;
}
// 용량에서 'KG' 제외하고 숫자만 추출 (이미 "100KG" 형태로 들어올 수 있음)
const cleanCapacity = capacity.replace(/KG$/i, '');
// 전원에서 'V' 제외하고 숫자만 추출 후 다시 V 붙이기 (일관성 유지)
const cleanPower = power.replace(/V$/i, '') + 'V';
return `${cleanItemName}${cleanCapacity}KG${cleanPower}`;
}
// ============================================
// 하드코딩 내역 목록 (문서화용)
// ============================================

View File

@@ -90,7 +90,7 @@ export default function ItemDetailClient({ item }: ItemDetailClientProps) {
</Button>
<Button
type="button"
onClick={() => router.push(`/items/${encodeURIComponent(item.itemCode)}/edit`)}
onClick={() => router.push(`/items/${encodeURIComponent(item.itemCode)}/edit?type=${item.itemType}&id=${item.id}`)}
>
<Edit className="w-4 h-4 mr-2" />

View File

@@ -109,20 +109,16 @@ export default function ProductForm({
)}
</div>
<div className="md:col-span-2">
<div>
<Label> ()</Label>
<Input
value={(() => {
const pName = productName || '';
const iName = getValues('itemName') || '';
return pName && iName ? `${pName}-${iName}` : '';
})()}
value={getValues('itemName') || ''}
disabled
className="bg-muted text-muted-foreground"
placeholder="상품명과 품목명 입력면 자동으로 생성됩니다"
placeholder="품목명 입력면 자동으로 동일하게 생성됩니다"
/>
<p className="text-xs text-muted-foreground mt-1">
* '상품명-품목명'
*
</p>
</div>
@@ -159,6 +155,15 @@ export default function ProductForm({
*
</p>
</div>
<div>
<Label></Label>
<Input
placeholder="비고 사항을 입력하세요"
value={remarks}
onChange={(e) => setRemarks(e.target.value)}
/>
</div>
</>
);
}
@@ -199,7 +204,7 @@ export function ProductCertificationSection({
</div>
{/* 인정번호, 유효기간, 파일 업로드, 비고 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="certificationNumber"></Label>
<Input
@@ -232,20 +237,28 @@ export function ProductCertificationSection({
{/* 시방서 파일 */}
<div className="space-y-2">
<Label> (PDF, DOCX, HWP, JPG, PNG / 20MB)</Label>
<div className="flex gap-2">
<Input
type="file"
accept=".pdf,.docx,.hwp,.jpg,.jpeg,.png"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
setSpecificationFile(file);
}
}}
className="flex-1"
disabled={isSubmitting}
/>
<Label> (PDF)</Label>
<div className="flex items-center gap-2">
<label className="cursor-pointer">
<span className="inline-flex items-center justify-center px-4 py-2 text-sm font-medium border rounded-md bg-background hover:bg-accent hover:text-accent-foreground">
</span>
<input
type="file"
accept=".pdf"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
setSpecificationFile(file);
}
}}
className="hidden"
disabled={isSubmitting}
/>
</label>
<span className="text-sm text-muted-foreground">
{specificationFile ? specificationFile.name : '선택된 파일 없음'}
</span>
{specificationFile && (
<Button
type="button"
@@ -253,34 +266,38 @@ export function ProductCertificationSection({
size="sm"
onClick={() => setSpecificationFile(null)}
disabled={isSubmitting}
className="h-6 w-6 p-0"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
{specificationFile && (
<p className="text-xs text-muted-foreground mt-1">
: {specificationFile.name}
</p>
)}
</div>
{/* 인정서 파일 */}
<div className="space-y-2">
<Label> (PDF, DOCX, HWP, JPG, PNG / 20MB)</Label>
<div className="flex gap-2">
<Input
type="file"
accept=".pdf,.docx,.hwp,.jpg,.jpeg,.png"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
setCertificationFile(file);
}
}}
className="flex-1"
disabled={isSubmitting}
/>
<Label> (PDF)</Label>
<div className="flex items-center gap-2">
<label className="cursor-pointer">
<span className="inline-flex items-center justify-center px-4 py-2 text-sm font-medium border rounded-md bg-background hover:bg-accent hover:text-accent-foreground">
</span>
<input
type="file"
accept=".pdf"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
setCertificationFile(file);
}
}}
className="hidden"
disabled={isSubmitting}
/>
</label>
<span className="text-sm text-muted-foreground">
{certificationFile ? certificationFile.name : '선택된 파일 없음'}
</span>
{certificationFile && (
<Button
type="button"
@@ -288,20 +305,16 @@ export function ProductCertificationSection({
size="sm"
onClick={() => setCertificationFile(null)}
disabled={isSubmitting}
className="h-6 w-6 p-0"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
{certificationFile && (
<p className="text-xs text-muted-foreground mt-1">
: {certificationFile.name}
</p>
)}
</div>
{/* 비고 */}
<div className="md:col-span-2">
<div>
<Label></Label>
<Textarea
value={remarks}

View File

@@ -3,6 +3,7 @@
* - 전동개폐기, 모터, 체인 등
*/
import { useMemo } from 'react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
@@ -16,6 +17,7 @@ import {
import type { UseFormRegister, UseFormSetValue, FieldErrors } from 'react-hook-form';
import type { CreateItemFormData } from '@/lib/utils/validation';
import { PART_TYPE_CATEGORIES } from '../../constants';
import { generatePurchasedItemCode } from '@/components/items/DynamicItemForm/utils/itemCodeGenerator';
export interface PurchasedPartFormProps {
selectedCategory1: string;
@@ -60,6 +62,18 @@ export default function PurchasedPartForm({
setValue,
errors,
}: PurchasedPartFormProps) {
// 전동개폐기 품목코드 자동생성 (품목명 + 용량 + 전원)
const generatedItemCode = useMemo(() => {
if (selectedCategory1 === 'electric_opener') {
const category = PART_TYPE_CATEGORIES.PURCHASED?.categories.find(
c => c.value === selectedCategory1
);
const itemName = category?.label || '';
return generatePurchasedItemCode(itemName, electricOpenerCapacity, electricOpenerPower);
}
return '';
}, [selectedCategory1, electricOpenerCapacity, electricOpenerPower]);
return (
<>
{/* 품목명 선택 */}
@@ -258,19 +272,21 @@ export default function PurchasedPartForm({
/>
</div>
{/* 품목코드 자동생성 */}
<div className="md:col-span-2">
<Label> ()</Label>
<Input
value=""
disabled
className="bg-muted text-muted-foreground"
placeholder="품목명과 규격이 입력되면 자동으로 생성됩니다"
/>
<p className="text-xs text-muted-foreground mt-1">
* '품목명-규격'
</p>
</div>
{/* 품목코드 자동생성 - 전동개폐기만 표시 */}
{selectedCategory1 === 'electric_opener' && (
<div className="md:col-span-2">
<Label> ()</Label>
<Input
value={generatedItemCode}
disabled
className="bg-muted text-muted-foreground"
placeholder="용량과 전원을 선택하면 자동으로 생성됩니다"
/>
<p className="text-xs text-muted-foreground mt-1">
* '품목명+용량+전원' (: 전동개폐기150KG380V)
</p>
</div>
)}
{/* 품목 상태 */}
<div className="md:col-span-2">

View File

@@ -90,7 +90,10 @@ export default function ItemListClient() {
// 삭제 다이얼로그 상태
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [itemToDelete, setItemToDelete] = useState<{ id: string; code: string } | null>(null);
const [itemToDelete, setItemToDelete] = useState<{ id: string; code: string; itemType: string } | null>(null);
// Materials 타입 (SM, RM, CS는 Material 테이블 사용)
const MATERIAL_TYPES = ['SM', 'RM', 'CS'];
// API에서 품목 목록 및 테이블 컬럼 조회 (서버 사이드 검색/필터링)
const {
@@ -148,17 +151,19 @@ export default function ItemListClient() {
});
};
const handleView = (itemCode: string) => {
router.push(`/items/${encodeURIComponent(itemCode)}`);
const handleView = (itemCode: string, itemType: string, itemId: string) => {
// itemType을 query param으로 전달 (Materials 조회를 위해)
router.push(`/items/${encodeURIComponent(itemCode)}?type=${itemType}&id=${itemId}`);
};
const handleEdit = (itemCode: string) => {
router.push(`/items/${encodeURIComponent(itemCode)}/edit`);
const handleEdit = (itemCode: string, itemType: string, itemId: string) => {
// itemType을 query param으로 전달 (Materials 조회를 위해)
router.push(`/items/${encodeURIComponent(itemCode)}/edit?type=${itemType}&id=${itemId}`);
};
// 삭제 확인 다이얼로그 열기
const openDeleteDialog = (itemId: string, itemCode: string) => {
setItemToDelete({ id: itemId, code: itemCode });
const openDeleteDialog = (itemId: string, itemCode: string, itemType: string) => {
setItemToDelete({ id: itemId, code: itemCode, itemType });
setDeleteDialogOpen(true);
};
@@ -168,7 +173,17 @@ export default function ItemListClient() {
try {
console.log('[Delete] 삭제 요청:', itemToDelete);
const response = await fetch(`/api/proxy/items/${itemToDelete.id}`, {
// Materials (SM, RM, CS)는 /products/materials 엔드포인트 사용
// Products (FG, PT)는 /items 엔드포인트 사용
const isMaterial = MATERIAL_TYPES.includes(itemToDelete.itemType);
const deleteUrl = isMaterial
? `/api/proxy/products/materials/${itemToDelete.id}`
: `/api/proxy/items/${itemToDelete.id}`;
console.log('[Delete] URL:', deleteUrl, '(isMaterial:', isMaterial, ')');
const response = await fetch(deleteUrl, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
@@ -222,7 +237,15 @@ export default function ItemListClient() {
for (const id of itemIds) {
try {
const response = await fetch(`/api/proxy/items/${id}`, {
// 해당 품목의 itemType 찾기
const item = items.find((i) => i.id === id);
const isMaterial = item ? MATERIAL_TYPES.includes(item.itemType) : false;
// Materials는 /products/materials 엔드포인트, Products는 /items 엔드포인트
const deleteUrl = isMaterial
? `/api/proxy/products/materials/${id}`
: `/api/proxy/items/${id}`;
const response = await fetch(deleteUrl, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
});
@@ -329,7 +352,7 @@ export default function ItemListClient() {
<Button
variant="ghost"
size="sm"
onClick={() => handleView(item.itemCode)}
onClick={(e) => { e.stopPropagation(); handleView(item.itemCode, item.itemType, item.id); }}
title="상세 보기"
>
<Search className="w-4 h-4" />
@@ -337,7 +360,7 @@ export default function ItemListClient() {
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(item.itemCode)}
onClick={(e) => { e.stopPropagation(); handleEdit(item.itemCode, item.itemType, item.id); }}
title="수정"
>
<Edit className="w-4 h-4" />
@@ -345,7 +368,7 @@ export default function ItemListClient() {
<Button
variant="ghost"
size="sm"
onClick={() => openDeleteDialog(item.id, item.itemCode)}
onClick={(e) => { e.stopPropagation(); openDeleteDialog(item.id, item.itemCode, item.itemType); }}
title="삭제"
>
<Trash2 className="w-4 h-4 text-red-500" />
@@ -388,7 +411,7 @@ export default function ItemListClient() {
}
isSelected={isSelected}
onToggleSelection={onToggle}
onCardClick={() => handleView(item.itemCode)}
onCardClick={() => handleView(item.itemCode, item.itemType, item.id)}
infoGrid={
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
{item.specification && (
@@ -400,34 +423,37 @@ export default function ItemListClient() {
</div>
}
actions={
<div className="flex items-center justify-end gap-1 pt-2">
<Button
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); handleView(item.itemCode); }}
className="h-8 px-3"
>
<Search className="h-4 w-4 mr-1" />
<span className="text-xs"></span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); handleEdit(item.itemCode); }}
className="h-8 px-3"
>
<Edit className="h-4 w-4 mr-1" />
<span className="text-xs"></span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); openDeleteDialog(item.id, item.itemCode); }}
className="h-8 px-2"
>
<Trash2 className="h-4 w-4 text-red-600" />
</Button>
</div>
isSelected ? (
<div className="flex gap-2 flex-wrap">
<Button
variant="default"
size="default"
className="flex-1 min-w-[100px] h-11"
onClick={(e) => { e.stopPropagation(); handleView(item.itemCode, item.itemType, item.id); }}
>
<Search className="h-4 w-4 mr-2" />
</Button>
<Button
variant="default"
size="default"
className="flex-1 min-w-[100px] h-11"
onClick={(e) => { e.stopPropagation(); handleEdit(item.itemCode, item.itemType, item.id); }}
>
<Edit className="h-4 w-4 mr-2" />
</Button>
<Button
variant="outline"
size="default"
className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300 bg-[rgba(255,255,255,0)]"
onClick={(e) => { e.stopPropagation(); openDeleteDialog(item.id, item.itemCode, item.itemType); }}
>
<Trash2 className="h-4 w-4 mr-2" />
</Button>
</div>
) : undefined
}
/>
);

View File

@@ -426,9 +426,25 @@ export function FieldDialog({
<DialogFooter className="shrink-0 bg-white z-10 px-6 py-4 border-t">
<Button variant="outline" onClick={handleClose}></Button>
<Button onClick={async () => {
console.log('[FieldDialog] 🔵 저장 버튼 클릭!', {
fieldInputMode,
editingFieldId,
selectedMasterFieldId,
newFieldName,
newFieldKey,
isNameEmpty,
isKeyEmpty,
isKeyInvalid,
});
setIsSubmitted(true);
// 2025-11-28: field_key validation 추가
if ((fieldInputMode === 'custom' || editingFieldId) && (isNameEmpty || isKeyEmpty || isKeyInvalid)) return;
const shouldValidate = fieldInputMode === 'custom' || editingFieldId;
console.log('[FieldDialog] 🔵 shouldValidate:', shouldValidate);
if (shouldValidate && (isNameEmpty || isKeyEmpty || isKeyInvalid)) {
console.log('[FieldDialog] ❌ 유효성 검사 실패로 return');
return;
}
console.log('[FieldDialog] ✅ handleAddField 호출');
await handleAddField();
setIsSubmitted(false);
}}></Button>

View File

@@ -117,8 +117,8 @@ export function useFieldManagement(): UseFieldManagementReturn {
const masterField = itemMasterFields.find(f => f.id === Number(selectedMasterFieldId));
if (masterField) {
setNewFieldName(masterField.field_name);
// 2025-11-28: field_key 사용 (없으면 빈 문자열로 사용자가 입력하도록)
setNewFieldKey('');
// 2025-12-04: master 모드에서 field_key를 field_{id} 형태로 설정 (백엔드 검증 통과용)
setNewFieldKey(`field_${selectedMasterFieldId}`);
setNewFieldInputType(masterField.field_type || 'textbox');
// properties에서 required 확인, 또는 validation_rules에서 확인
const isRequired = (masterField.properties as any)?.required || false;
@@ -139,7 +139,22 @@ export function useFieldManagement(): UseFieldManagementReturn {
// 필드 추가 (2025-11-27: async/await 추가 - 다른 탭 실시간 동기화)
const handleAddField = async (selectedPage: ItemPage | undefined) => {
console.log('[useFieldManagement] 🟢 handleAddField 시작!', {
selectedPage: selectedPage?.id,
selectedSectionForField,
newFieldName,
newFieldKey,
fieldInputMode,
selectedMasterFieldId,
});
if (!selectedPage || !selectedSectionForField || !newFieldName.trim() || !newFieldKey.trim()) {
console.log('[useFieldManagement] ❌ 필수값 누락으로 return', {
selectedPage: !!selectedPage,
selectedSectionForField,
newFieldName: newFieldName.trim(),
newFieldKey: newFieldKey.trim(),
});
toast.error('모든 필수 항목을 입력해주세요');
return;
}