- Integrated express-async-errors to automatically handle errors in async route handlers, enhancing the overall error management in the application. - Updated app.ts to include the express-async-errors import for global error handling. - Removed redundant logging statements in admin and user menu retrieval functions to streamline the code and improve readability. - Adjusted logging levels from info to debug for less critical logs, ensuring that important information is logged appropriately without cluttering the logs.
952 lines
34 KiB
TypeScript
952 lines
34 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* V2Repeater 컴포넌트
|
|
*
|
|
* 렌더링 모드:
|
|
* - inline: 현재 테이블 컬럼 직접 입력
|
|
* - modal: 엔티티 선택 (FK 저장) + 추가 입력 컬럼
|
|
*
|
|
* RepeaterTable 및 ItemSelectionModal 재사용
|
|
*/
|
|
|
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Plus } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
V2RepeaterConfig,
|
|
V2RepeaterProps,
|
|
RepeaterColumnConfig as V2ColumnConfig,
|
|
DEFAULT_REPEATER_CONFIG,
|
|
} from "@/types/v2-repeater";
|
|
import { apiClient } from "@/lib/api/client";
|
|
import { allocateNumberingCode } from "@/lib/api/numberingRule";
|
|
import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core";
|
|
|
|
// modal-repeater-table 컴포넌트 재사용
|
|
import { RepeaterTable } from "@/lib/registry/components/modal-repeater-table/RepeaterTable";
|
|
import { ItemSelectionModal } from "@/lib/registry/components/modal-repeater-table/ItemSelectionModal";
|
|
import { RepeaterColumnConfig } from "@/lib/registry/components/modal-repeater-table/types";
|
|
|
|
// 전역 V2Repeater 등록 (buttonActions에서 사용)
|
|
declare global {
|
|
interface Window {
|
|
__v2RepeaterInstances?: Set<string>;
|
|
}
|
|
}
|
|
|
|
export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|
config: propConfig,
|
|
parentId,
|
|
data: initialData,
|
|
onDataChange,
|
|
onRowClick,
|
|
className,
|
|
}) => {
|
|
// 설정 병합
|
|
const config: V2RepeaterConfig = useMemo(
|
|
() => ({
|
|
...DEFAULT_REPEATER_CONFIG,
|
|
...propConfig,
|
|
dataSource: { ...DEFAULT_REPEATER_CONFIG.dataSource, ...propConfig.dataSource },
|
|
features: { ...DEFAULT_REPEATER_CONFIG.features, ...propConfig.features },
|
|
modal: { ...DEFAULT_REPEATER_CONFIG.modal, ...propConfig.modal },
|
|
}),
|
|
[propConfig],
|
|
);
|
|
|
|
// 상태
|
|
const [data, setData] = useState<any[]>(initialData || []);
|
|
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
|
|
// 🆕 데이터 변경 시 자동으로 컬럼 너비 조정 트리거
|
|
const [autoWidthTrigger, setAutoWidthTrigger] = useState(0);
|
|
|
|
// 소스 테이블 컬럼 라벨 매핑
|
|
const [sourceColumnLabels, setSourceColumnLabels] = useState<Record<string, string>>({});
|
|
|
|
// 🆕 소스 테이블의 카테고리 타입 컬럼 목록
|
|
const [sourceCategoryColumns, setSourceCategoryColumns] = useState<string[]>([]);
|
|
|
|
// 🆕 카테고리 코드 → 라벨 매핑 (RepeaterTable 표시용)
|
|
const [categoryLabelMap, setCategoryLabelMap] = useState<Record<string, string>>({});
|
|
|
|
// 현재 테이블 컬럼 정보 (inputType 매핑용)
|
|
const [currentTableColumnInfo, setCurrentTableColumnInfo] = useState<Record<string, any>>({});
|
|
|
|
// 동적 데이터 소스 상태
|
|
const [activeDataSources, setActiveDataSources] = useState<Record<string, string>>({});
|
|
|
|
// 🆕 최신 엔티티 참조 정보 (column_labels에서 조회)
|
|
const [resolvedSourceTable, setResolvedSourceTable] = useState<string>("");
|
|
const [resolvedReferenceKey, setResolvedReferenceKey] = useState<string>("id");
|
|
|
|
const isModalMode = config.renderMode === "modal";
|
|
|
|
// 전역 리피터 등록
|
|
// 🆕 useCustomTable이 설정된 경우 mainTableName 사용 (실제 저장될 테이블)
|
|
useEffect(() => {
|
|
const targetTableName =
|
|
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
|
|
|
|
if (targetTableName) {
|
|
if (!window.__v2RepeaterInstances) {
|
|
window.__v2RepeaterInstances = new Set();
|
|
}
|
|
window.__v2RepeaterInstances.add(targetTableName);
|
|
}
|
|
|
|
return () => {
|
|
if (targetTableName && window.__v2RepeaterInstances) {
|
|
window.__v2RepeaterInstances.delete(targetTableName);
|
|
}
|
|
};
|
|
}, [config.useCustomTable, config.mainTableName, config.dataSource?.tableName]);
|
|
|
|
// 저장 이벤트 리스너
|
|
useEffect(() => {
|
|
const handleSaveEvent = async (event: CustomEvent) => {
|
|
// 🆕 mainTableName이 설정된 경우 우선 사용, 없으면 dataSource.tableName 사용
|
|
const tableName =
|
|
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
|
|
const eventParentId = event.detail?.parentId;
|
|
const mainFormData = event.detail?.mainFormData;
|
|
|
|
// 🆕 마스터 테이블에서 생성된 ID (FK 연결용)
|
|
const masterRecordId = event.detail?.masterRecordId || mainFormData?.id;
|
|
|
|
if (!tableName || data.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// V2Repeater 저장 시작
|
|
const saveInfo = {
|
|
tableName,
|
|
useCustomTable: config.useCustomTable,
|
|
mainTableName: config.mainTableName,
|
|
foreignKeyColumn: config.foreignKeyColumn,
|
|
masterRecordId,
|
|
dataLength: data.length,
|
|
};
|
|
console.log("V2Repeater 저장 시작", saveInfo);
|
|
|
|
try {
|
|
// 테이블 유효 컬럼 조회
|
|
let validColumns: Set<string> = new Set();
|
|
try {
|
|
const columnsResponse = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
|
const columns =
|
|
columnsResponse.data?.data?.columns || columnsResponse.data?.columns || columnsResponse.data || [];
|
|
validColumns = new Set(columns.map((col: any) => col.columnName || col.column_name || col.name));
|
|
} catch {
|
|
console.warn("테이블 컬럼 정보 조회 실패");
|
|
}
|
|
|
|
for (let i = 0; i < data.length; i++) {
|
|
const row = data[i];
|
|
|
|
// 내부 필드 제거
|
|
const cleanRow = Object.fromEntries(Object.entries(row).filter(([key]) => !key.startsWith("_")));
|
|
|
|
// 메인 폼 데이터 병합 (커스텀 테이블 사용 시에는 메인 폼 데이터 병합 안함)
|
|
let mergedData: Record<string, any>;
|
|
if (config.useCustomTable && config.mainTableName) {
|
|
// 커스텀 테이블: 리피터 데이터만 저장
|
|
mergedData = { ...cleanRow };
|
|
|
|
// 🆕 FK 자동 연결 - foreignKeySourceColumn이 설정된 경우 해당 컬럼 값 사용
|
|
if (config.foreignKeyColumn) {
|
|
// foreignKeySourceColumn이 있으면 mainFormData에서 해당 컬럼 값 사용
|
|
// 없으면 마스터 레코드 ID 사용 (기존 동작)
|
|
const sourceColumn = config.foreignKeySourceColumn;
|
|
let fkValue: any;
|
|
|
|
if (sourceColumn && mainFormData && mainFormData[sourceColumn] !== undefined) {
|
|
// mainFormData에서 참조 컬럼 값 가져오기
|
|
fkValue = mainFormData[sourceColumn];
|
|
} else {
|
|
// 기본: 마스터 레코드 ID 사용
|
|
fkValue = masterRecordId;
|
|
}
|
|
|
|
if (fkValue !== undefined && fkValue !== null) {
|
|
mergedData[config.foreignKeyColumn] = fkValue;
|
|
}
|
|
}
|
|
} else {
|
|
// 기존 방식: 메인 폼 데이터 병합
|
|
const { id: _mainId, ...mainFormDataWithoutId } = mainFormData || {};
|
|
mergedData = {
|
|
...mainFormDataWithoutId,
|
|
...cleanRow,
|
|
};
|
|
}
|
|
|
|
// 유효하지 않은 컬럼 제거
|
|
const filteredData: Record<string, any> = {};
|
|
for (const [key, value] of Object.entries(mergedData)) {
|
|
if (validColumns.size === 0 || validColumns.has(key)) {
|
|
filteredData[key] = value;
|
|
}
|
|
}
|
|
|
|
await apiClient.post(`/table-management/tables/${tableName}/add`, filteredData);
|
|
}
|
|
} catch (error) {
|
|
console.error("❌ V2Repeater 저장 실패:", error);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
// V2 EventBus 구독
|
|
const unsubscribe = v2EventBus.subscribe(
|
|
V2_EVENTS.REPEATER_SAVE,
|
|
async (payload) => {
|
|
const tableName =
|
|
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
|
|
if (payload.tableName === tableName) {
|
|
await handleSaveEvent({ detail: payload } as CustomEvent);
|
|
}
|
|
},
|
|
{ componentId: `v2-repeater-${config.dataSource?.tableName}` },
|
|
);
|
|
|
|
// 레거시 이벤트도 계속 지원 (점진적 마이그레이션)
|
|
window.addEventListener("repeaterSave" as any, handleSaveEvent);
|
|
return () => {
|
|
unsubscribe();
|
|
window.removeEventListener("repeaterSave" as any, handleSaveEvent);
|
|
};
|
|
}, [
|
|
data,
|
|
config.dataSource?.tableName,
|
|
config.useCustomTable,
|
|
config.mainTableName,
|
|
config.foreignKeyColumn,
|
|
parentId,
|
|
]);
|
|
|
|
// 현재 테이블 컬럼 정보 로드
|
|
useEffect(() => {
|
|
const loadCurrentTableColumnInfo = async () => {
|
|
const tableName = config.dataSource?.tableName;
|
|
if (!tableName) return;
|
|
|
|
try {
|
|
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
|
const columns = response.data?.data?.columns || response.data?.columns || response.data || [];
|
|
|
|
const columnMap: Record<string, any> = {};
|
|
columns.forEach((col: any) => {
|
|
const name = col.columnName || col.column_name || col.name;
|
|
columnMap[name] = {
|
|
inputType: col.inputType || col.input_type || col.webType || "text",
|
|
displayName: col.displayName || col.display_name || col.label || name,
|
|
detailSettings: col.detailSettings || col.detail_settings,
|
|
};
|
|
});
|
|
setCurrentTableColumnInfo(columnMap);
|
|
} catch (error) {
|
|
console.error("컬럼 정보 로드 실패:", error);
|
|
}
|
|
};
|
|
loadCurrentTableColumnInfo();
|
|
}, [config.dataSource?.tableName]);
|
|
|
|
// 🆕 FK 컬럼 기반으로 최신 참조 테이블 정보 조회 (column_labels에서)
|
|
useEffect(() => {
|
|
const resolveEntityReference = async () => {
|
|
const tableName = config.dataSource?.tableName;
|
|
const foreignKey = config.dataSource?.foreignKey;
|
|
|
|
if (!isModalMode || !tableName || !foreignKey) {
|
|
// config에 저장된 값을 기본값으로 사용
|
|
setResolvedSourceTable(config.dataSource?.sourceTable || "");
|
|
setResolvedReferenceKey(config.dataSource?.referenceKey || "id");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// 현재 테이블의 컬럼 정보에서 FK 컬럼의 참조 테이블 조회
|
|
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
|
const columns = response.data?.data?.columns || response.data?.columns || response.data || [];
|
|
|
|
const fkColumn = columns.find((col: any) => (col.columnName || col.column_name || col.name) === foreignKey);
|
|
|
|
if (fkColumn) {
|
|
// column_labels의 reference_table 사용 (항상 최신값)
|
|
const refTable =
|
|
fkColumn.detailSettings?.referenceTable ||
|
|
fkColumn.reference_table ||
|
|
fkColumn.referenceTable ||
|
|
config.dataSource?.sourceTable ||
|
|
"";
|
|
const refKey =
|
|
fkColumn.detailSettings?.referenceColumn ||
|
|
fkColumn.reference_column ||
|
|
fkColumn.referenceColumn ||
|
|
config.dataSource?.referenceKey ||
|
|
"id";
|
|
|
|
setResolvedSourceTable(refTable);
|
|
setResolvedReferenceKey(refKey);
|
|
} else {
|
|
// FK 컬럼을 찾지 못한 경우 config 값 사용
|
|
setResolvedSourceTable(config.dataSource?.sourceTable || "");
|
|
setResolvedReferenceKey(config.dataSource?.referenceKey || "id");
|
|
}
|
|
} catch (error) {
|
|
console.error("엔티티 참조 정보 조회 실패:", error);
|
|
// 오류 시 config 값 사용
|
|
setResolvedSourceTable(config.dataSource?.sourceTable || "");
|
|
setResolvedReferenceKey(config.dataSource?.referenceKey || "id");
|
|
}
|
|
};
|
|
|
|
resolveEntityReference();
|
|
}, [
|
|
config.dataSource?.tableName,
|
|
config.dataSource?.foreignKey,
|
|
config.dataSource?.sourceTable,
|
|
config.dataSource?.referenceKey,
|
|
isModalMode,
|
|
]);
|
|
|
|
// 소스 테이블 컬럼 라벨 로드 (modal 모드) - resolvedSourceTable 사용
|
|
// 🆕 카테고리 타입 컬럼도 함께 감지
|
|
useEffect(() => {
|
|
const loadSourceColumnLabels = async () => {
|
|
if (!isModalMode || !resolvedSourceTable) return;
|
|
|
|
try {
|
|
const response = await apiClient.get(`/table-management/tables/${resolvedSourceTable}/columns`);
|
|
const columns = response.data?.data?.columns || response.data?.columns || response.data || [];
|
|
|
|
const labels: Record<string, string> = {};
|
|
const categoryCols: string[] = [];
|
|
|
|
columns.forEach((col: any) => {
|
|
const name = col.columnName || col.column_name || col.name;
|
|
labels[name] = col.displayName || col.display_name || col.label || name;
|
|
|
|
// 🆕 카테고리 타입 컬럼 감지
|
|
const inputType = col.inputType || col.input_type || "";
|
|
if (inputType === "category") {
|
|
categoryCols.push(name);
|
|
}
|
|
});
|
|
|
|
setSourceColumnLabels(labels);
|
|
setSourceCategoryColumns(categoryCols);
|
|
} catch (error) {
|
|
console.error("소스 컬럼 라벨 로드 실패:", error);
|
|
}
|
|
};
|
|
loadSourceColumnLabels();
|
|
}, [resolvedSourceTable, isModalMode]);
|
|
|
|
// V2ColumnConfig → RepeaterColumnConfig 변환
|
|
// 🆕 모든 컬럼을 columns 배열의 순서대로 처리 (isSourceDisplay 플래그로 구분)
|
|
const repeaterColumns: RepeaterColumnConfig[] = useMemo(() => {
|
|
return config.columns
|
|
.filter((col: V2ColumnConfig) => col.visible !== false)
|
|
.map((col: V2ColumnConfig): RepeaterColumnConfig => {
|
|
const colInfo = currentTableColumnInfo[col.key];
|
|
const inputType = col.inputType || colInfo?.inputType || "text";
|
|
|
|
// 소스 표시 컬럼인 경우 (모달 모드에서 읽기 전용)
|
|
if (col.isSourceDisplay) {
|
|
const label = col.title || sourceColumnLabels[col.key] || col.key;
|
|
return {
|
|
field: `_display_${col.key}`,
|
|
label,
|
|
type: "text",
|
|
editable: false,
|
|
calculated: true,
|
|
width: col.width === "auto" ? undefined : col.width,
|
|
};
|
|
}
|
|
|
|
// 일반 입력 컬럼
|
|
let type: "text" | "number" | "date" | "select" | "category" = "text";
|
|
if (inputType === "number" || inputType === "decimal") type = "number";
|
|
else if (inputType === "date" || inputType === "datetime") type = "date";
|
|
else if (inputType === "code") type = "select";
|
|
else if (inputType === "category") type = "category"; // 🆕 카테고리 타입
|
|
|
|
// 🆕 카테고리 참조 ID 가져오기 (tableName.columnName 형식)
|
|
// category 타입인 경우 현재 테이블명과 컬럼명을 조합
|
|
let categoryRef: string | undefined;
|
|
if (inputType === "category") {
|
|
// 🆕 소스 표시 컬럼이면 소스 테이블 사용, 아니면 타겟 테이블 사용
|
|
const tableName = col.isSourceDisplay ? resolvedSourceTable : config.dataSource?.tableName;
|
|
if (tableName) {
|
|
categoryRef = `${tableName}.${col.key}`;
|
|
}
|
|
}
|
|
|
|
return {
|
|
field: col.key,
|
|
label: col.title || colInfo?.displayName || col.key,
|
|
type,
|
|
editable: col.editable !== false,
|
|
width: col.width === "auto" ? undefined : col.width,
|
|
required: false,
|
|
categoryRef, // 🆕 카테고리 참조 ID 전달
|
|
hidden: col.hidden, // 🆕 히든 처리
|
|
autoFill: col.autoFill, // 🆕 자동 입력 설정
|
|
};
|
|
});
|
|
}, [config.columns, sourceColumnLabels, currentTableColumnInfo, resolvedSourceTable, config.dataSource?.tableName]);
|
|
|
|
// 🆕 데이터 변경 시 카테고리 라벨 로드 (RepeaterTable 표시용)
|
|
useEffect(() => {
|
|
const loadCategoryLabels = async () => {
|
|
if (sourceCategoryColumns.length === 0 || data.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// 데이터에서 카테고리 컬럼의 모든 고유 코드 수집
|
|
const allCodes = new Set<string>();
|
|
for (const row of data) {
|
|
for (const col of sourceCategoryColumns) {
|
|
// _display_ 접두사가 있는 컬럼과 원본 컬럼 모두 확인
|
|
const val = row[`_display_${col}`] || row[col];
|
|
if (val && typeof val === "string") {
|
|
const codes = val
|
|
.split(",")
|
|
.map((c: string) => c.trim())
|
|
.filter(Boolean);
|
|
for (const code of codes) {
|
|
if (!categoryLabelMap[code] && code.startsWith("CATEGORY_")) {
|
|
allCodes.add(code);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (allCodes.size === 0) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await apiClient.post("/table-categories/labels-by-codes", {
|
|
valueCodes: Array.from(allCodes),
|
|
});
|
|
|
|
if (response.data?.success && response.data.data) {
|
|
setCategoryLabelMap((prev) => ({
|
|
...prev,
|
|
...response.data.data,
|
|
}));
|
|
}
|
|
} catch (error) {
|
|
console.error("카테고리 라벨 조회 실패:", error);
|
|
}
|
|
};
|
|
|
|
loadCategoryLabels();
|
|
}, [data, sourceCategoryColumns]);
|
|
|
|
// 데이터 변경 핸들러
|
|
const handleDataChange = useCallback(
|
|
(newData: any[]) => {
|
|
setData(newData);
|
|
|
|
// 🆕 _targetTable 메타데이터 포함하여 전달 (백엔드에서 테이블 분리용)
|
|
if (onDataChange) {
|
|
const targetTable =
|
|
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
|
|
|
|
if (targetTable) {
|
|
// 각 행에 _targetTable 추가
|
|
const dataWithTarget = newData.map((row) => ({
|
|
...row,
|
|
_targetTable: targetTable,
|
|
}));
|
|
onDataChange(dataWithTarget);
|
|
} else {
|
|
onDataChange(newData);
|
|
}
|
|
}
|
|
|
|
// 🆕 데이터 변경 시 자동으로 컬럼 너비 조정
|
|
setAutoWidthTrigger((prev) => prev + 1);
|
|
},
|
|
[onDataChange, config.useCustomTable, config.mainTableName, config.dataSource?.tableName],
|
|
);
|
|
|
|
// 행 변경 핸들러
|
|
const handleRowChange = useCallback(
|
|
(index: number, newRow: any) => {
|
|
const newData = [...data];
|
|
newData[index] = newRow;
|
|
setData(newData);
|
|
|
|
// 🆕 _targetTable 메타데이터 포함
|
|
if (onDataChange) {
|
|
const targetTable =
|
|
config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName;
|
|
|
|
if (targetTable) {
|
|
const dataWithTarget = newData.map((row) => ({
|
|
...row,
|
|
_targetTable: targetTable,
|
|
}));
|
|
onDataChange(dataWithTarget);
|
|
} else {
|
|
onDataChange(newData);
|
|
}
|
|
}
|
|
},
|
|
[data, onDataChange, config.useCustomTable, config.mainTableName, config.dataSource?.tableName],
|
|
);
|
|
|
|
// 행 삭제 핸들러
|
|
const handleRowDelete = useCallback(
|
|
(index: number) => {
|
|
const newData = data.filter((_, i) => i !== index);
|
|
handleDataChange(newData); // 🆕 handleDataChange 사용
|
|
|
|
// 선택 상태 업데이트
|
|
const newSelected = new Set<number>();
|
|
selectedRows.forEach((i) => {
|
|
if (i < index) newSelected.add(i);
|
|
else if (i > index) newSelected.add(i - 1);
|
|
});
|
|
setSelectedRows(newSelected);
|
|
},
|
|
[data, selectedRows, handleDataChange],
|
|
);
|
|
|
|
// 일괄 삭제 핸들러
|
|
const handleBulkDelete = useCallback(() => {
|
|
const newData = data.filter((_, index) => !selectedRows.has(index));
|
|
handleDataChange(newData); // 🆕 handleDataChange 사용
|
|
setSelectedRows(new Set());
|
|
}, [data, selectedRows, handleDataChange]);
|
|
|
|
// 행 추가 (inline 모드)
|
|
// 🆕 자동 입력 값 생성 함수 (동기 - 채번 제외)
|
|
const generateAutoFillValueSync = useCallback(
|
|
(col: any, rowIndex: number, mainFormData?: Record<string, unknown>) => {
|
|
if (!col.autoFill || col.autoFill.type === "none") return undefined;
|
|
|
|
const now = new Date();
|
|
|
|
switch (col.autoFill.type) {
|
|
case "currentDate":
|
|
return now.toISOString().split("T")[0]; // YYYY-MM-DD
|
|
|
|
case "currentDateTime":
|
|
return now.toISOString().slice(0, 19).replace("T", " "); // YYYY-MM-DD HH:mm:ss
|
|
|
|
case "sequence":
|
|
return rowIndex + 1; // 1부터 시작하는 순번
|
|
|
|
case "numbering":
|
|
// 채번은 별도 비동기 처리 필요
|
|
return null; // null 반환하여 비동기 처리 필요함을 표시
|
|
|
|
case "fromMainForm":
|
|
if (col.autoFill.sourceField && mainFormData) {
|
|
return mainFormData[col.autoFill.sourceField];
|
|
}
|
|
return "";
|
|
|
|
case "fixed":
|
|
return col.autoFill.fixedValue ?? "";
|
|
|
|
default:
|
|
return undefined;
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
// 🆕 채번 API 호출 (비동기)
|
|
// 🆕 수동 입력 부분 지원을 위해 userInputCode 파라미터 추가
|
|
const generateNumberingCode = useCallback(
|
|
async (ruleId: string, userInputCode?: string, formData?: Record<string, any>): Promise<string> => {
|
|
try {
|
|
const result = await allocateNumberingCode(ruleId, userInputCode, formData);
|
|
if (result.success && result.data?.generatedCode) {
|
|
return result.data.generatedCode;
|
|
}
|
|
console.error("채번 실패:", result.error);
|
|
return "";
|
|
} catch (error) {
|
|
console.error("채번 API 호출 실패:", error);
|
|
return "";
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
// 🆕 행 추가 (inline 모드 또는 모달 열기) - 비동기로 변경
|
|
const handleAddRow = useCallback(async () => {
|
|
if (isModalMode) {
|
|
setModalOpen(true);
|
|
} else {
|
|
const newRow: any = { _id: `new_${Date.now()}` };
|
|
const currentRowCount = data.length;
|
|
|
|
// 먼저 동기적 자동 입력 값 적용
|
|
for (const col of config.columns) {
|
|
const autoValue = generateAutoFillValueSync(col, currentRowCount);
|
|
if (autoValue === null && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId) {
|
|
// 채번 규칙: 즉시 API 호출
|
|
newRow[col.key] = await generateNumberingCode(col.autoFill.numberingRuleId);
|
|
} else if (autoValue !== undefined) {
|
|
newRow[col.key] = autoValue;
|
|
} else {
|
|
newRow[col.key] = "";
|
|
}
|
|
}
|
|
|
|
const newData = [...data, newRow];
|
|
handleDataChange(newData);
|
|
}
|
|
}, [isModalMode, config.columns, data, handleDataChange, generateAutoFillValueSync, generateNumberingCode]);
|
|
|
|
// 모달에서 항목 선택 - 비동기로 변경
|
|
const handleSelectItems = useCallback(
|
|
async (items: Record<string, unknown>[]) => {
|
|
const fkColumn = config.dataSource?.foreignKey;
|
|
const currentRowCount = data.length;
|
|
|
|
// 채번이 필요한 컬럼 찾기
|
|
const numberingColumns = config.columns.filter(
|
|
(col) => !col.isSourceDisplay && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId,
|
|
);
|
|
|
|
const newRows = await Promise.all(
|
|
items.map(async (item, index) => {
|
|
const row: any = { _id: `new_${Date.now()}_${Math.random()}` };
|
|
|
|
// FK 값 저장 (resolvedReferenceKey 사용)
|
|
if (fkColumn && item[resolvedReferenceKey]) {
|
|
row[fkColumn] = item[resolvedReferenceKey];
|
|
}
|
|
|
|
// 모든 컬럼 처리 (순서대로)
|
|
for (const col of config.columns) {
|
|
if (col.isSourceDisplay) {
|
|
// 소스 표시 컬럼: 소스 테이블에서 값 복사 (읽기 전용)
|
|
row[`_display_${col.key}`] = item[col.key] || "";
|
|
} else {
|
|
// 자동 입력 값 적용
|
|
const autoValue = generateAutoFillValueSync(col, currentRowCount + index);
|
|
if (autoValue === null && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId) {
|
|
// 채번 규칙: 즉시 API 호출
|
|
row[col.key] = await generateNumberingCode(col.autoFill.numberingRuleId);
|
|
} else if (autoValue !== undefined) {
|
|
row[col.key] = autoValue;
|
|
} else if (row[col.key] === undefined) {
|
|
// 입력 컬럼: 빈 값으로 초기화
|
|
row[col.key] = "";
|
|
}
|
|
}
|
|
}
|
|
|
|
return row;
|
|
}),
|
|
);
|
|
|
|
const newData = [...data, ...newRows];
|
|
handleDataChange(newData);
|
|
setModalOpen(false);
|
|
},
|
|
[
|
|
config.dataSource?.foreignKey,
|
|
resolvedReferenceKey,
|
|
config.columns,
|
|
data,
|
|
handleDataChange,
|
|
generateAutoFillValueSync,
|
|
generateNumberingCode,
|
|
],
|
|
);
|
|
|
|
// 소스 컬럼 목록 (모달용) - 🆕 columns 배열에서 isSourceDisplay인 것만 필터링
|
|
const sourceColumns = useMemo(() => {
|
|
return config.columns
|
|
.filter((col) => col.isSourceDisplay && col.visible !== false)
|
|
.map((col) => col.key)
|
|
.filter((key) => key && key !== "none");
|
|
}, [config.columns]);
|
|
|
|
// 🆕 beforeFormSave 이벤트에서 채번 placeholder를 실제 값으로 변환
|
|
const dataRef = useRef(data);
|
|
dataRef.current = data;
|
|
|
|
useEffect(() => {
|
|
const handleBeforeFormSave = async (event: Event) => {
|
|
const customEvent = event as CustomEvent;
|
|
const formData = customEvent.detail?.formData;
|
|
|
|
if (!formData || !dataRef.current.length) return;
|
|
|
|
// 채번 placeholder가 있는 행들을 찾아서 실제 값으로 변환
|
|
const processedData = await Promise.all(
|
|
dataRef.current.map(async (row) => {
|
|
const newRow = { ...row };
|
|
|
|
for (const key of Object.keys(newRow)) {
|
|
const value = newRow[key];
|
|
if (typeof value === "string" && value.startsWith("__NUMBERING_RULE__")) {
|
|
// __NUMBERING_RULE__ruleId__ 형식에서 ruleId 추출
|
|
const match = value.match(/__NUMBERING_RULE__(.+)__/);
|
|
if (match) {
|
|
const ruleId = match[1];
|
|
try {
|
|
// 🆕 사용자가 편집한 값을 전달 (수동 입력 부분 추출용)
|
|
const result = await allocateNumberingCode(ruleId, undefined, newRow);
|
|
if (result.success && result.data?.generatedCode) {
|
|
newRow[key] = result.data.generatedCode;
|
|
} else {
|
|
console.error("채번 실패:", result.error);
|
|
newRow[key] = ""; // 채번 실패 시 빈 값
|
|
}
|
|
} catch (error) {
|
|
console.error("채번 API 호출 실패:", error);
|
|
newRow[key] = "";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return newRow;
|
|
}),
|
|
);
|
|
|
|
// 처리된 데이터를 formData에 추가
|
|
const fieldName = config.fieldName || "repeaterData";
|
|
formData[fieldName] = processedData;
|
|
};
|
|
|
|
// V2 EventBus 구독
|
|
const unsubscribe = v2EventBus.subscribe(
|
|
V2_EVENTS.FORM_SAVE_COLLECT,
|
|
async (payload) => {
|
|
// formData 객체가 있으면 데이터 수집
|
|
const fakeEvent = {
|
|
detail: { formData: payload.formData },
|
|
} as CustomEvent;
|
|
await handleBeforeFormSave(fakeEvent);
|
|
},
|
|
{ componentId: `v2-repeater-${config.dataSource?.tableName}` },
|
|
);
|
|
|
|
// 레거시 이벤트도 계속 지원 (점진적 마이그레이션)
|
|
window.addEventListener("beforeFormSave", handleBeforeFormSave);
|
|
|
|
return () => {
|
|
unsubscribe();
|
|
window.removeEventListener("beforeFormSave", handleBeforeFormSave);
|
|
};
|
|
}, [config.fieldName]);
|
|
|
|
// 🆕 데이터 전달 이벤트 리스너 (transferData 버튼 액션용)
|
|
useEffect(() => {
|
|
// componentDataTransfer: 특정 컴포넌트 ID로 데이터 전달
|
|
const handleComponentDataTransfer = async (event: Event) => {
|
|
const customEvent = event as CustomEvent;
|
|
const { targetComponentId, data: transferData, mappingRules, mode } = customEvent.detail || {};
|
|
|
|
// 이 컴포넌트가 대상인지 확인
|
|
if (targetComponentId !== parentId && targetComponentId !== config.fieldName) {
|
|
return;
|
|
}
|
|
|
|
if (!transferData || transferData.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// 데이터 매핑 처리
|
|
const mappedData = transferData.map((item: any, index: number) => {
|
|
const newRow: any = { _id: `transfer_${Date.now()}_${index}` };
|
|
|
|
if (mappingRules && mappingRules.length > 0) {
|
|
// 매핑 규칙이 있으면 적용
|
|
mappingRules.forEach((rule: any) => {
|
|
newRow[rule.targetField] = item[rule.sourceField];
|
|
});
|
|
} else {
|
|
// 매핑 규칙 없으면 그대로 복사
|
|
Object.assign(newRow, item);
|
|
}
|
|
|
|
return newRow;
|
|
});
|
|
|
|
// mode에 따라 데이터 처리
|
|
if (mode === "replace") {
|
|
handleDataChange(mappedData);
|
|
} else if (mode === "merge") {
|
|
// 중복 제거 후 병합 (id 기준)
|
|
const existingIds = new Set(data.map((row) => row.id || row._id));
|
|
const newItems = mappedData.filter((row: any) => !existingIds.has(row.id || row._id));
|
|
handleDataChange([...data, ...newItems]);
|
|
} else {
|
|
// 기본: append
|
|
handleDataChange([...data, ...mappedData]);
|
|
}
|
|
};
|
|
|
|
// splitPanelDataTransfer: 분할 패널에서 전역 이벤트로 전달
|
|
const handleSplitPanelDataTransfer = async (event: Event) => {
|
|
const customEvent = event as CustomEvent;
|
|
const { data: transferData, mappingRules, mode, sourcePosition } = customEvent.detail || {};
|
|
|
|
if (!transferData || transferData.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// 데이터 매핑 처리
|
|
const mappedData = transferData.map((item: any, index: number) => {
|
|
const newRow: any = { _id: `transfer_${Date.now()}_${index}` };
|
|
|
|
if (mappingRules && mappingRules.length > 0) {
|
|
mappingRules.forEach((rule: any) => {
|
|
newRow[rule.targetField] = item[rule.sourceField];
|
|
});
|
|
} else {
|
|
Object.assign(newRow, item);
|
|
}
|
|
|
|
return newRow;
|
|
});
|
|
|
|
// mode에 따라 데이터 처리
|
|
if (mode === "replace") {
|
|
handleDataChange(mappedData);
|
|
} else {
|
|
handleDataChange([...data, ...mappedData]);
|
|
}
|
|
};
|
|
|
|
// V2 EventBus 구독
|
|
const unsubscribeComponent = v2EventBus.subscribe(
|
|
V2_EVENTS.COMPONENT_DATA_TRANSFER,
|
|
(payload) => {
|
|
const fakeEvent = {
|
|
detail: {
|
|
targetComponentId: payload.targetComponentId,
|
|
transferData: [payload.data],
|
|
mappingRules: [],
|
|
mode: "append",
|
|
},
|
|
} as CustomEvent;
|
|
handleComponentDataTransfer(fakeEvent);
|
|
},
|
|
{ componentId: `v2-repeater-${config.dataSource?.tableName}` },
|
|
);
|
|
|
|
const unsubscribeSplitPanel = v2EventBus.subscribe(
|
|
V2_EVENTS.SPLIT_PANEL_DATA_TRANSFER,
|
|
(payload) => {
|
|
const fakeEvent = {
|
|
detail: {
|
|
transferData: [payload.data],
|
|
mappingRules: [],
|
|
mode: "append",
|
|
},
|
|
} as CustomEvent;
|
|
handleSplitPanelDataTransfer(fakeEvent);
|
|
},
|
|
{ componentId: `v2-repeater-${config.dataSource?.tableName}` },
|
|
);
|
|
|
|
// 레거시 이벤트도 계속 지원 (점진적 마이그레이션)
|
|
window.addEventListener("componentDataTransfer", handleComponentDataTransfer as EventListener);
|
|
window.addEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener);
|
|
|
|
return () => {
|
|
unsubscribeComponent();
|
|
unsubscribeSplitPanel();
|
|
window.removeEventListener("componentDataTransfer", handleComponentDataTransfer as EventListener);
|
|
window.removeEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener);
|
|
};
|
|
}, [parentId, config.fieldName, data, handleDataChange]);
|
|
|
|
return (
|
|
<div className={cn("flex h-full flex-col overflow-hidden", className)}>
|
|
{/* 헤더 영역 */}
|
|
<div className="flex shrink-0 items-center justify-between pb-2">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-muted-foreground text-sm">
|
|
{data.length > 0 && `${data.length}개 항목`}
|
|
{selectedRows.size > 0 && ` (${selectedRows.size}개 선택됨)`}
|
|
</span>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
{selectedRows.size > 0 && (
|
|
<Button variant="destructive" onClick={handleBulkDelete} className="h-8 text-xs sm:h-10 sm:text-sm">
|
|
선택 삭제 ({selectedRows.size})
|
|
</Button>
|
|
)}
|
|
<Button onClick={handleAddRow} className="h-8 text-xs sm:h-10 sm:text-sm">
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
{isModalMode ? config.modal?.buttonText || "검색" : "추가"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Repeater 테이블 - 남은 공간에서 스크롤 */}
|
|
<div className="min-h-0 flex-1">
|
|
<RepeaterTable
|
|
columns={repeaterColumns}
|
|
data={data}
|
|
onDataChange={handleDataChange}
|
|
onRowChange={handleRowChange}
|
|
onRowDelete={handleRowDelete}
|
|
activeDataSources={activeDataSources}
|
|
onDataSourceChange={(field, optionId) => {
|
|
setActiveDataSources((prev) => ({ ...prev, [field]: optionId }));
|
|
}}
|
|
selectedRows={selectedRows}
|
|
onSelectionChange={setSelectedRows}
|
|
equalizeWidthsTrigger={autoWidthTrigger}
|
|
categoryColumns={sourceCategoryColumns}
|
|
categoryLabelMap={categoryLabelMap}
|
|
/>
|
|
</div>
|
|
|
|
{/* 항목 선택 모달 (modal 모드) - 검색 필드는 표시 컬럼과 동일하게 자동 설정 */}
|
|
{isModalMode && (
|
|
<ItemSelectionModal
|
|
open={modalOpen}
|
|
onOpenChange={setModalOpen}
|
|
sourceTable={resolvedSourceTable}
|
|
sourceColumns={sourceColumns}
|
|
sourceSearchFields={sourceColumns}
|
|
multiSelect={config.features?.multiSelect ?? true}
|
|
modalTitle={config.modal?.title || "항목 검색"}
|
|
alreadySelected={data}
|
|
uniqueField={resolvedReferenceKey}
|
|
onSelect={handleSelectItems}
|
|
columnLabels={sourceColumnLabels}
|
|
categoryColumns={sourceCategoryColumns}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
V2Repeater.displayName = "V2Repeater";
|
|
|
|
// V2ErrorBoundary로 래핑된 안전한 버전 export
|
|
export const SafeV2Repeater: React.FC<V2RepeaterProps> = (props) => {
|
|
return (
|
|
<V2ErrorBoundary componentId={props.parentId || "v2-repeater"} componentType="V2Repeater" fallbackStyle="compact">
|
|
<V2Repeater {...props} />
|
|
</V2ErrorBoundary>
|
|
);
|
|
};
|
|
|
|
export default V2Repeater;
|