ERP-node/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent...

570 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
import { ItemSelectionModal } from "./ItemSelectionModal";
import { RepeaterTable } from "./RepeaterTable";
import { ModalRepeaterTableProps, RepeaterColumnConfig, JoinCondition } from "./types";
import { useCalculation } from "./useCalculation";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { ComponentRendererProps } from "@/types/component";
// ✅ ComponentRendererProps 상속으로 필수 props 자동 확보
export interface ModalRepeaterTableComponentProps extends ComponentRendererProps {
config?: ModalRepeaterTableProps;
// ModalRepeaterTableProps의 개별 prop들도 지원 (호환성)
sourceTable?: string;
sourceColumns?: string[];
sourceSearchFields?: string[];
targetTable?: string;
modalTitle?: string;
modalButtonText?: string;
multiSelect?: boolean;
columns?: RepeaterColumnConfig[];
calculationRules?: any[];
value?: any[];
onChange?: (newData: any[]) => void;
uniqueField?: string;
filterCondition?: Record<string, any>;
companyCode?: string;
}
/**
* 외부 테이블에서 참조 값을 조회하는 함수
* @param referenceTable 참조 테이블명 (예: "customer_item_mapping")
* @param referenceField 참조할 컬럼명 (예: "basic_price")
* @param joinConditions 조인 조건 배열
* @param sourceItem 소스 데이터 (모달에서 선택한 항목)
* @param currentItem 현재 빌드 중인 항목 (이미 설정된 필드들)
* @returns 참조된 값 또는 undefined
*/
async function fetchReferenceValue(
referenceTable: string,
referenceField: string,
joinConditions: JoinCondition[],
sourceItem: any,
currentItem: any
): Promise<any> {
if (joinConditions.length === 0) {
console.warn("⚠️ 조인 조건이 없습니다. 참조 조회를 건너뜁니다.");
return undefined;
}
try {
// 조인 조건을 WHERE 절로 변환
const whereConditions: Record<string, any> = {};
for (const condition of joinConditions) {
const { sourceTable = "target", sourceField, targetField, operator = "=" } = condition;
// 소스 테이블에 따라 값을 가져오기
let value: any;
if (sourceTable === "source") {
// 소스 테이블 (item_info 등): 모달에서 선택한 원본 데이터
value = sourceItem[sourceField];
console.log(` 📘 소스 테이블에서 값 가져오기: ${sourceField} =`, value);
} else {
// 저장 테이블 (sales_order_mng 등): 반복 테이블에 이미 복사된 값
value = currentItem[sourceField];
console.log(` 📗 저장 테이블(반복테이블)에서 값 가져오기: ${sourceField} =`, value);
}
if (value === undefined || value === null) {
console.warn(`⚠️ 조인 조건의 소스 필드 "${sourceField}" 값이 없습니다. (sourceTable: ${sourceTable})`);
return undefined;
}
// 연산자가 "=" 인 경우만 지원 (확장 가능)
if (operator === "=") {
whereConditions[targetField] = value;
} else {
console.warn(`⚠️ 연산자 "${operator}"는 아직 지원되지 않습니다.`);
}
}
console.log(`🔍 참조 조회 API 호출:`, {
table: referenceTable,
field: referenceField,
where: whereConditions,
});
// API 호출: 테이블 데이터 조회 (POST 방식)
const requestBody = {
search: whereConditions, // ✅ filters → search 변경 (백엔드 파라미터명)
size: 1, // 첫 번째 결과만 가져오기
page: 1,
};
console.log("📤 API 요청 Body:", JSON.stringify(requestBody, null, 2));
const response = await apiClient.post(
`/table-management/tables/${referenceTable}/data`,
requestBody
);
console.log("📥 API 전체 응답:", {
success: response.data.success,
dataLength: response.data.data?.data?.length, // ✅ data.data.data 구조
total: response.data.data?.total, // ✅ data.data.total
firstRow: response.data.data?.data?.[0], // ✅ data.data.data[0]
});
if (response.data.success && response.data.data?.data?.length > 0) {
const firstRow = response.data.data.data[0]; // ✅ data.data.data[0]
const value = firstRow[referenceField];
console.log(`✅ 참조 조회 성공:`, {
table: referenceTable,
field: referenceField,
value,
fullRow: firstRow,
});
return value;
} else {
console.warn(`⚠️ 참조 조회 결과 없음:`, {
table: referenceTable,
where: whereConditions,
responseData: response.data.data,
total: response.data.total,
});
return undefined;
}
} catch (error) {
console.error(`❌ 참조 조회 API 오류:`, error);
return undefined;
}
}
export function ModalRepeaterTableComponent({
// ComponentRendererProps (자동 전달)
component,
isDesignMode = false,
isSelected = false,
isInteractive = false,
onClick,
onDragStart,
onDragEnd,
className,
style,
formData,
onFormDataChange,
// ModalRepeaterTable 전용 props
config,
sourceTable: propSourceTable,
sourceColumns: propSourceColumns,
sourceSearchFields: propSourceSearchFields,
targetTable: propTargetTable,
modalTitle: propModalTitle,
modalButtonText: propModalButtonText,
multiSelect: propMultiSelect,
columns: propColumns,
calculationRules: propCalculationRules,
value: propValue,
onChange: propOnChange,
uniqueField: propUniqueField,
filterCondition: propFilterCondition,
companyCode: propCompanyCode,
...props
}: ModalRepeaterTableComponentProps) {
// ✅ config 또는 component.config 또는 개별 prop 우선순위로 병합
const componentConfig = {
...config,
...component?.config,
};
// config prop 우선, 없으면 개별 prop 사용
const sourceTable = componentConfig?.sourceTable || propSourceTable || "";
const targetTable = componentConfig?.targetTable || propTargetTable;
// sourceColumns에서 빈 문자열 필터링
const rawSourceColumns = componentConfig?.sourceColumns || propSourceColumns || [];
const sourceColumns = rawSourceColumns.filter((col: string) => col && col.trim() !== "");
const sourceSearchFields = componentConfig?.sourceSearchFields || propSourceSearchFields || [];
const modalTitle = componentConfig?.modalTitle || propModalTitle || "항목 검색";
const modalButtonText = componentConfig?.modalButtonText || propModalButtonText || "품목 검색";
const multiSelect = componentConfig?.multiSelect ?? propMultiSelect ?? true;
const calculationRules = componentConfig?.calculationRules || propCalculationRules || [];
// ✅ value는 formData[columnName] 우선, 없으면 prop 사용
const columnName = component?.columnName;
const value = (columnName && formData?.[columnName]) || componentConfig?.value || propValue || [];
// ✅ onChange 래퍼 (기존 onChange 콜백 + onFormDataChange 호출 + 납기일 일괄 적용)
const handleChange = (newData: any[]) => {
console.log("🔄 ModalRepeaterTableComponent.handleChange 호출:", {
dataLength: newData.length,
columnName,
hasExternalOnChange: !!(componentConfig?.onChange || propOnChange),
hasOnFormDataChange: !!(onFormDataChange && columnName),
});
// 🆕 납기일 일괄 적용 로직 (납기일 필드가 있는 경우만)
let processedData = newData;
// 납기일 필드 찾기 (item_due_date, delivery_date, due_date 등)
const dateField = columns.find(
(col) =>
col.field === "item_due_date" ||
col.field === "delivery_date" ||
col.field === "due_date"
);
if (dateField && !isDeliveryDateApplied && newData.length > 0) {
// 현재 상태: 납기일이 있는 행과 없는 행 개수 체크
const itemsWithDate = newData.filter((item) => item[dateField.field]);
const itemsWithoutDate = newData.filter((item) => !item[dateField.field]);
// 정확히 1개만 날짜가 있고, 나머지는 모두 비어있을 때 일괄 적용
if (itemsWithDate.length === 1 && itemsWithoutDate.length > 0) {
const selectedDate = itemsWithDate[0][dateField.field];
processedData = newData.map((item) => ({
...item,
[dateField.field]: selectedDate, // 모든 행에 동일한 납기일 적용
}));
setIsDeliveryDateApplied(true); // 플래그 활성화
console.log("✅ 납기일 일괄 적용 완료:", selectedDate);
console.log(` - 대상: ${itemsWithoutDate.length}개 행에 ${selectedDate} 적용`);
}
}
// 기존 onChange 콜백 호출 (호환성)
const externalOnChange = componentConfig?.onChange || propOnChange;
if (externalOnChange) {
console.log("📤 외부 onChange 호출");
externalOnChange(processedData);
}
// 🆕 onFormDataChange 호출하여 EditModal의 groupData 업데이트
if (onFormDataChange && columnName) {
console.log("📤 onFormDataChange 호출:", columnName);
onFormDataChange(columnName, processedData);
}
};
// uniqueField 자동 보정: order_no는 item_info 테이블에 없으므로 item_number로 변경
const rawUniqueField = componentConfig?.uniqueField || propUniqueField;
const uniqueField = rawUniqueField === "order_no" && sourceTable === "item_info"
? "item_number"
: rawUniqueField;
const filterCondition = componentConfig?.filterCondition || propFilterCondition || {};
const companyCode = componentConfig?.companyCode || propCompanyCode;
const [modalOpen, setModalOpen] = useState(false);
// 🆕 납기일 일괄 적용 플래그 (딱 한 번만 실행)
const [isDeliveryDateApplied, setIsDeliveryDateApplied] = useState(false);
// columns가 비어있으면 sourceColumns로부터 자동 생성
const columns = React.useMemo((): RepeaterColumnConfig[] => {
const configuredColumns = componentConfig?.columns || propColumns || [];
if (configuredColumns.length > 0) {
return configuredColumns;
}
// columns가 비어있으면 sourceColumns로부터 자동 생성
if (sourceColumns.length > 0) {
const autoColumns: RepeaterColumnConfig[] = sourceColumns.map((field) => ({
field: field,
label: field, // 필드명을 라벨로 사용 (나중에 설정에서 변경 가능)
editable: false, // 기본적으로 읽기 전용
type: "text" as const,
width: "150px",
}));
return autoColumns;
}
console.warn("⚠️ [ModalRepeaterTable] columns와 sourceColumns 모두 비어있음!");
return [];
}, [componentConfig?.columns, propColumns, sourceColumns]);
// 초기 props 검증
useEffect(() => {
if (rawSourceColumns.length !== sourceColumns.length) {
console.warn(`⚠️ [ModalRepeaterTable] sourceColumns 필터링: ${rawSourceColumns.length}개 → ${sourceColumns.length}`);
}
if (rawUniqueField !== uniqueField) {
console.warn(`⚠️ [ModalRepeaterTable] uniqueField 자동 보정: "${rawUniqueField}" → "${uniqueField}"`);
}
if (columns.length === 0) {
console.error("❌ [ModalRepeaterTable] columns가 비어있습니다!", { sourceColumns });
}
}, []);
// 🆕 저장 요청 시에만 데이터 전달 (beforeFormSave 이벤트 리스너)
useEffect(() => {
const handleSaveRequest = async (event: Event) => {
const componentKey = columnName || component?.id || "modal_repeater_data";
if (value.length === 0) {
console.warn("⚠️ [ModalRepeaterTable] 저장할 데이터 없음");
return;
}
// sourceColumns에 포함된 컬럼 제외 (조인된 컬럼 제거)
// 단, columnMappings에 정의된 컬럼은 저장해야 하므로 제외하지 않음
const mappedFields = columns
.filter(col => col.mapping?.type === "source" && col.mapping?.sourceField)
.map(col => col.field);
const filteredData = value.map((item: any) => {
const filtered: Record<string, any> = {};
Object.keys(item).forEach((key) => {
// 메타데이터 필드 제외
if (key.startsWith("_")) {
return;
}
// sourceColumns에 포함되어 있지만 columnMappings에도 정의된 경우 → 저장함
if (mappedFields.includes(key)) {
filtered[key] = item[key];
return;
}
// sourceColumns에만 있고 매핑 안 된 경우 → 제외 (조인 전용)
if (sourceColumns.includes(key)) {
return;
}
// 나머지는 모두 저장
filtered[key] = item[key];
});
return filtered;
});
// targetTable 메타데이터를 배열 항목에 추가
const dataWithTargetTable = targetTable
? filteredData.map((item: any) => ({
...item,
_targetTable: targetTable, // 백엔드가 인식할 메타데이터
}))
: filteredData;
// CustomEvent의 detail에 데이터 추가
if (event instanceof CustomEvent && event.detail) {
event.detail.formData[componentKey] = dataWithTargetTable;
console.log("✅ [ModalRepeaterTable] 저장 데이터 준비:", {
key: componentKey,
itemCount: dataWithTargetTable.length,
targetTable: targetTable || "미설정",
});
}
// 기존 onFormDataChange도 호출 (호환성)
if (onFormDataChange) {
onFormDataChange(componentKey, dataWithTargetTable);
}
};
// 저장 버튼 클릭 시 데이터 수집
window.addEventListener("beforeFormSave", handleSaveRequest as EventListener);
return () => {
window.removeEventListener("beforeFormSave", handleSaveRequest as EventListener);
};
}, [value, columnName, component?.id, onFormDataChange, targetTable]);
const { calculateRow, calculateAll } = useCalculation(calculationRules);
// 초기 데이터에 계산 필드 적용
useEffect(() => {
if (value.length > 0 && calculationRules.length > 0) {
const calculated = calculateAll(value);
// 값이 실제로 변경된 경우만 업데이트
if (JSON.stringify(calculated) !== JSON.stringify(value)) {
handleChange(calculated);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleAddItems = async (items: any[]) => {
console.log(" handleAddItems 호출:", items.length, "개 항목");
console.log("📋 소스 데이터:", items);
// 매핑 규칙에 따라 데이터 변환 (비동기 처리)
const mappedItems = await Promise.all(items.map(async (sourceItem) => {
const newItem: any = {};
// ⚠️ 중요: reference 매핑은 다른 컬럼에 의존할 수 있으므로
// 1단계: source/manual 매핑을 먼저 처리
// 2단계: reference 매핑을 나중에 처리
const referenceColumns: typeof columns = [];
const otherColumns: typeof columns = [];
for (const col of columns) {
if (col.mapping?.type === "reference") {
referenceColumns.push(col);
} else {
otherColumns.push(col);
}
}
// 1단계: source/manual 컬럼 먼저 처리
for (const col of otherColumns) {
console.log(`🔄 컬럼 "${col.field}" 매핑 처리:`, col.mapping);
// 1. 매핑 규칙이 있는 경우
if (col.mapping) {
if (col.mapping.type === "source") {
// 소스 테이블 컬럼에서 복사
const sourceField = col.mapping.sourceField;
if (sourceField && sourceItem[sourceField] !== undefined) {
newItem[col.field] = sourceItem[sourceField];
console.log(` ✅ 소스 복사: ${sourceField}${col.field}:`, newItem[col.field]);
} else {
console.warn(` ⚠️ 소스 필드 "${sourceField}" 값이 없음`);
}
} else if (col.mapping.type === "manual") {
// 사용자 입력 (빈 값)
newItem[col.field] = undefined;
console.log(` ✏️ 수동 입력 필드`);
}
}
// 2. 매핑 규칙이 없는 경우 - 소스 데이터에서 같은 필드명으로 복사
else if (sourceItem[col.field] !== undefined) {
newItem[col.field] = sourceItem[col.field];
console.log(` 📝 직접 복사: ${col.field}:`, newItem[col.field]);
}
// 3. 기본값 적용
if (col.defaultValue !== undefined && newItem[col.field] === undefined) {
newItem[col.field] = col.defaultValue;
console.log(` 🎯 기본값 적용: ${col.field}:`, col.defaultValue);
}
}
// 2단계: reference 컬럼 처리 (다른 컬럼들이 모두 설정된 후)
console.log("🔗 2단계: reference 컬럼 처리 시작");
for (const col of referenceColumns) {
console.log(`🔄 컬럼 "${col.field}" 참조 매핑 처리:`, col.mapping);
// 외부 테이블 참조 (API 호출)
console.log(` ⏳ 참조 조회 시작: ${col.mapping?.referenceTable}.${col.mapping?.referenceField}`);
try {
const referenceValue = await fetchReferenceValue(
col.mapping!.referenceTable!,
col.mapping!.referenceField!,
col.mapping!.joinCondition || [],
sourceItem,
newItem
);
if (referenceValue !== null && referenceValue !== undefined) {
newItem[col.field] = referenceValue;
console.log(` ✅ 참조 조회 성공: ${col.field}:`, referenceValue);
} else {
newItem[col.field] = undefined;
console.warn(` ⚠️ 참조 조회 결과 없음`);
}
} catch (error) {
console.error(` ❌ 참조 조회 오류:`, error);
newItem[col.field] = undefined;
}
// 기본값 적용
if (col.defaultValue !== undefined && newItem[col.field] === undefined) {
newItem[col.field] = col.defaultValue;
console.log(` 🎯 기본값 적용: ${col.field}:`, col.defaultValue);
}
}
console.log("📦 변환된 항목:", newItem);
return newItem;
}));
// 계산 필드 업데이트
const calculatedItems = calculateAll(mappedItems);
// 기존 데이터에 추가
const newData = [...value, ...calculatedItems];
console.log("✅ 최종 데이터:", newData.length, "개 항목");
// ✅ 통합 onChange 호출 (formData 반영 포함)
handleChange(newData);
};
const handleRowChange = (index: number, newRow: any) => {
// 계산 필드 업데이트
const calculatedRow = calculateRow(newRow);
// 데이터 업데이트
const newData = [...value];
newData[index] = calculatedRow;
// ✅ 통합 onChange 호출 (formData 반영 포함)
handleChange(newData);
};
const handleRowDelete = (index: number) => {
const newData = value.filter((_, i) => i !== index);
// ✅ 통합 onChange 호출 (formData 반영 포함)
handleChange(newData);
};
// 컬럼명 -> 라벨명 매핑 생성
const columnLabels = columns.reduce((acc, col) => {
acc[col.field] = col.label;
return acc;
}, {} as Record<string, string>);
return (
<div className={cn("space-y-4", className)}>
{/* 추가 버튼 */}
<div className="flex justify-between items-center">
<div className="text-sm text-muted-foreground">
{value.length > 0 && `${value.length}개 항목`}
</div>
<Button
onClick={() => setModalOpen(true)}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
<Plus className="h-4 w-4 mr-2" />
{modalButtonText}
</Button>
</div>
{/* Repeater 테이블 */}
<RepeaterTable
columns={columns}
data={value}
onDataChange={handleChange}
onRowChange={handleRowChange}
onRowDelete={handleRowDelete}
/>
{/* 항목 선택 모달 */}
<ItemSelectionModal
open={modalOpen}
onOpenChange={setModalOpen}
sourceTable={sourceTable}
sourceColumns={sourceColumns}
sourceSearchFields={sourceSearchFields}
multiSelect={multiSelect}
filterCondition={filterCondition}
modalTitle={modalTitle}
alreadySelected={value}
uniqueField={uniqueField}
onSelect={handleAddItems}
columnLabels={columnLabels}
/>
</div>
);
}