feat: 수주관리 품목 CRUD 및 공통 필드 자동 복사 구현

- 품목 추가 시 공통 필드(거래처, 담당자, 메모) 자동 복사
- ModalRepeaterTable onChange 시 groupData 반영
- 백엔드 타입 캐스팅으로 PostgreSQL 에러 해결
- 타입 정규화로 불필요한 UPDATE 방지
- 수정 모달에서 거래처/수주번호 읽기 전용 처리
This commit is contained in:
SeongHyun Kim 2025-11-25 14:23:54 +09:00
parent d04330283a
commit 5609e32daf
6 changed files with 117 additions and 47 deletions

View File

@ -811,9 +811,39 @@ export class DynamicFormService {
const primaryKeyColumn = primaryKeys[0];
console.log(`🔑 테이블 ${tableName}의 기본키: ${primaryKeyColumn}`);
// 동적 UPDATE SQL 생성 (변경된 필드만)
// 🆕 컬럼 타입 조회 (타입 캐스팅용)
const columnTypesQuery = `
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name = $1 AND table_schema = 'public'
`;
const columnTypesResult = await query<{ column_name: string; data_type: string }>(
columnTypesQuery,
[tableName]
);
const columnTypes: Record<string, string> = {};
columnTypesResult.forEach((row) => {
columnTypes[row.column_name] = row.data_type;
});
console.log("📊 컬럼 타입 정보:", columnTypes);
// 🆕 동적 UPDATE SQL 생성 (타입 캐스팅 포함)
const setClause = Object.keys(changedFields)
.map((key, index) => `${key} = $${index + 1}`)
.map((key, index) => {
const dataType = columnTypes[key];
// 숫자 타입인 경우 명시적 캐스팅
if (dataType === 'integer' || dataType === 'bigint' || dataType === 'smallint') {
return `${key} = $${index + 1}::integer`;
} else if (dataType === 'numeric' || dataType === 'decimal' || dataType === 'real' || dataType === 'double precision') {
return `${key} = $${index + 1}::numeric`;
} else if (dataType === 'boolean') {
return `${key} = $${index + 1}::boolean`;
} else {
// 문자열 타입은 캐스팅 불필요
return `${key} = $${index + 1}`;
}
})
.join(", ");
const values: any[] = Object.values(changedFields);

View File

@ -320,43 +320,24 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
let updatedCount = 0;
let deletedCount = 0;
// 🆕 sales_order_mng 테이블의 실제 컬럼만 포함 (조인된 컬럼 제외)
const salesOrderColumns = [
"id",
"order_no",
"customer_code",
"customer_name",
"order_date",
"delivery_date",
"item_code",
"quantity",
"unit_price",
"amount",
"status",
"notes",
"created_at",
"updated_at",
"company_code",
];
// 1⃣ 신규 품목 추가 (id가 없는 항목)
for (const currentData of groupData) {
if (!currentData.id) {
console.log(" 신규 품목 추가:", currentData);
console.log("📋 [신규 품목] currentData 키 목록:", Object.keys(currentData));
// 실제 테이블 컬럼만 추출
const insertData: Record<string, any> = {};
Object.keys(currentData).forEach((key) => {
if (salesOrderColumns.includes(key) && key !== "id") {
insertData[key] = currentData[key];
}
});
// 🆕 모든 데이터를 포함 (id 제외)
const insertData: Record<string, any> = { ...currentData };
console.log("📦 [신규 품목] 복사 직후 insertData:", insertData);
console.log("📋 [신규 품목] insertData 키 목록:", Object.keys(insertData));
delete insertData.id; // id는 자동 생성되므로 제거
// 🆕 groupByColumns의 값을 강제로 포함 (order_no 등)
if (modalState.groupByColumns && modalState.groupByColumns.length > 0) {
modalState.groupByColumns.forEach((colName) => {
// 기존 품목(groupData[0])에서 groupByColumns 값 가져오기
const referenceData = originalGroupData[0] || groupData.find(item => item.id);
// 기존 품목(originalGroupData[0])에서 groupByColumns 값 가져오기
const referenceData = originalGroupData[0] || groupData.find((item) => item.id);
if (referenceData && referenceData[colName]) {
insertData[colName] = referenceData[colName];
console.log(`🔑 [신규 품목] ${colName} 값 추가:`, referenceData[colName]);
@ -364,7 +345,31 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
});
}
// 🆕 공통 필드 추가 (거래처, 담당자, 납품처, 메모 등)
// formData에서 품목별 필드가 아닌 공통 필드를 복사
const commonFields = [
'partner_id', // 거래처
'manager_id', // 담당자
'delivery_partner_id', // 납품처
'delivery_address', // 납품장소
'memo', // 메모
'order_date', // 주문일
'due_date', // 납기일
'shipping_method', // 배송방법
'status', // 상태
'sales_type', // 영업유형
];
commonFields.forEach((fieldName) => {
// formData에 값이 있으면 추가
if (formData[fieldName] !== undefined && formData[fieldName] !== null) {
insertData[fieldName] = formData[fieldName];
console.log(`🔗 [공통 필드] ${fieldName} 값 추가:`, formData[fieldName]);
}
});
console.log("📦 [신규 품목] 최종 insertData:", insertData);
console.log("📋 [신규 품목] 최종 insertData 키 목록:", Object.keys(insertData));
try {
const response = await dynamicFormApi.saveFormData({
@ -398,16 +403,32 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
continue;
}
// 변경된 필드만 추출
// 🆕 값 정규화 함수 (타입 통일)
const normalizeValue = (val: any): any => {
if (val === null || val === undefined || val === "") return null;
if (typeof val === "string" && !isNaN(Number(val))) {
// 숫자로 변환 가능한 문자열은 숫자로
return Number(val);
}
return val;
};
// 변경된 필드만 추출 (id 제외)
const changedData: Record<string, any> = {};
Object.keys(currentData).forEach((key) => {
// sales_order_mng 테이블의 컬럼만 처리 (조인 컬럼 제외)
if (!salesOrderColumns.includes(key)) {
// id는 변경 불가
if (key === "id") {
return;
}
if (currentData[key] !== originalItemData[key]) {
changedData[key] = currentData[key];
// 🆕 타입 정규화 후 비교
const currentValue = normalizeValue(currentData[key]);
const originalValue = normalizeValue(originalItemData[key]);
// 값이 변경된 경우만 포함
if (currentValue !== originalValue) {
console.log(`🔍 [품목 수정 감지] ${key}: ${originalValue}${currentValue}`);
changedData[key] = currentData[key]; // 원본 값 사용 (문자열 그대로)
}
});
@ -677,6 +698,8 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
isInModal={true}
// 🆕 그룹 데이터를 ModalRepeaterTable에 전달
groupedData={groupData.length > 0 ? groupData : undefined}
// 🆕 수정 모달에서 읽기 전용 필드 지정 (수주번호, 거래처)
disabledFields={["order_no", "partner_id"]}
/>
);
})}

View File

@ -48,6 +48,8 @@ interface InteractiveScreenViewerProps {
companyCode?: string;
// 🆕 그룹 데이터 (EditModal에서 전달)
groupedData?: Record<string, any>[];
// 🆕 비활성화할 필드 목록 (EditModal에서 전달)
disabledFields?: string[];
// 🆕 EditModal 내부인지 여부 (button-primary가 EditModal의 handleSave 사용하도록)
isInModal?: boolean;
}
@ -66,6 +68,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
userName: externalUserName,
companyCode: externalCompanyCode,
groupedData,
disabledFields = [],
isInModal = false,
}) => {
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
@ -341,6 +344,8 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
}}
// 🆕 그룹 데이터 전달 (EditModal → ModalRepeaterTable)
groupedData={groupedData}
// 🆕 비활성화 필드 전달 (EditModal → 각 컴포넌트)
disabledFields={disabledFields}
flowSelectedData={flowSelectedData}
flowSelectedStepId={flowSelectedStepId}
onFlowSelectedDataChange={(selectedData, stepId) => {

View File

@ -110,6 +110,8 @@ export interface DynamicComponentRendererProps {
selectedRows?: any[];
// 🆕 그룹 데이터 (EditModal → ModalRepeaterTable)
groupedData?: Record<string, any>[];
// 🆕 비활성화할 필드 목록 (EditModal → 각 컴포넌트)
disabledFields?: string[];
selectedRowsData?: any[];
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[], tableDisplayData?: any[]) => void;
// 테이블 정렬 정보 (엑셀 다운로드용)
@ -168,6 +170,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
}
};
// 🆕 disabledFields 체크
const isFieldDisabled = props.disabledFields?.includes(columnName) || (component as any).readonly;
return (
<CategorySelectComponent
tableName={tableName}
@ -176,7 +181,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
onChange={handleChange}
placeholder={component.componentConfig?.placeholder || "선택하세요"}
required={(component as any).required}
disabled={(component as any).readonly}
disabled={isFieldDisabled}
className="w-full"
/>
);
@ -271,6 +276,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
onConfigChange,
isPreview,
autoGeneration,
disabledFields, // 🆕 비활성화 필드 목록
...restProps
} = props;
@ -368,7 +374,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
mode,
isInModal,
readonly: component.readonly,
disabled: component.readonly,
// 🆕 disabledFields 체크 또는 기존 readonly
disabled: disabledFields?.includes(fieldName) || component.readonly,
originalData,
allComponents,
onUpdateLayout,

View File

@ -154,18 +154,18 @@ export function ConditionalSectionViewer({
}}
>
<DynamicComponentRenderer
component={component}
component={component}
isInteractive={true}
screenId={screenInfo?.id}
tableName={screenInfo?.tableName}
userId={userId}
userName={userName}
companyCode={user?.companyCode}
formData={formData}
onFormDataChange={onFormDataChange}
screenId={screenInfo?.id}
tableName={screenInfo?.tableName}
userId={userId}
userName={userName}
companyCode={user?.companyCode}
formData={formData}
onFormDataChange={onFormDataChange}
groupedData={groupedData}
onSave={onSave}
/>
/>
</div>
);
})}

View File

@ -195,13 +195,18 @@ export function ModalRepeaterTableComponent({
const columnName = component?.columnName;
const value = (columnName && formData?.[columnName]) || componentConfig?.value || propValue || [];
// ✅ onChange 래퍼 (기존 onChange 콜백만 호출, formData는 beforeFormSave에서 처리)
// ✅ onChange 래퍼 (기존 onChange 콜백 + onFormDataChange 호출)
const handleChange = (newData: any[]) => {
// 기존 onChange 콜백 호출 (호환성)
const externalOnChange = componentConfig?.onChange || propOnChange;
if (externalOnChange) {
externalOnChange(newData);
}
// 🆕 onFormDataChange 호출하여 EditModal의 groupData 업데이트
if (onFormDataChange && columnName) {
onFormDataChange(columnName, newData);
}
};
// uniqueField 자동 보정: order_no는 item_info 테이블에 없으므로 item_number로 변경