2046 lines
77 KiB
TypeScript
2046 lines
77 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Plus, Columns, AlignJustify, Trash2, Search } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { apiClient } from "@/lib/api/client";
|
|
|
|
// 기존 ModalRepeaterTable 컴포넌트 재사용
|
|
import { RepeaterTable } from "../modal-repeater-table/RepeaterTable";
|
|
import { ItemSelectionModal } from "../modal-repeater-table/ItemSelectionModal";
|
|
import { RepeaterColumnConfig, CalculationRule } from "../modal-repeater-table/types";
|
|
|
|
// 타입 정의
|
|
import {
|
|
TableSectionConfig,
|
|
TableColumnConfig,
|
|
TableJoinCondition,
|
|
FormDataState,
|
|
TableCalculationRule,
|
|
} from "./types";
|
|
|
|
interface TableSectionRendererProps {
|
|
sectionId: string;
|
|
tableConfig: TableSectionConfig;
|
|
formData: FormDataState;
|
|
onFormDataChange: (field: string, value: any) => void;
|
|
onTableDataChange: (data: any[]) => void;
|
|
// 조건부 테이블용 콜백 (조건별 데이터 변경)
|
|
onConditionalTableDataChange?: (conditionValue: string, data: any[]) => void;
|
|
className?: string;
|
|
}
|
|
|
|
// 조건부 테이블 데이터 타입
|
|
interface ConditionalTableData {
|
|
[conditionValue: string]: any[];
|
|
}
|
|
|
|
/**
|
|
* TableColumnConfig를 RepeaterColumnConfig로 변환
|
|
* columnModes 또는 lookup이 있으면 dynamicDataSource로 변환
|
|
*/
|
|
function convertToRepeaterColumn(col: TableColumnConfig): RepeaterColumnConfig {
|
|
const baseColumn: RepeaterColumnConfig = {
|
|
field: col.field,
|
|
label: col.label,
|
|
type: col.type,
|
|
editable: col.editable ?? true,
|
|
calculated: col.calculated ?? false,
|
|
width: col.width || "150px",
|
|
required: col.required,
|
|
hidden: col.hidden ?? false,
|
|
defaultValue: col.defaultValue,
|
|
selectOptions: col.selectOptions,
|
|
// valueMapping은 별도로 처리
|
|
};
|
|
|
|
// lookup 설정을 dynamicDataSource로 변환 (새로운 조회 기능)
|
|
if (col.lookup?.enabled && col.lookup.options && col.lookup.options.length > 0) {
|
|
baseColumn.dynamicDataSource = {
|
|
enabled: true,
|
|
options: col.lookup.options.map((option) => ({
|
|
id: option.id,
|
|
// "컬럼명 - 옵션라벨" 형식으로 헤더에 표시
|
|
label: option.displayLabel || option.label,
|
|
// 헤더에 표시될 전체 라벨 (컬럼명 - 옵션라벨)
|
|
headerLabel: `${col.label} - ${option.displayLabel || option.label}`,
|
|
sourceType: "table" as const,
|
|
tableConfig: {
|
|
tableName: option.tableName,
|
|
valueColumn: option.valueColumn,
|
|
joinConditions: option.conditions.map((cond) => ({
|
|
sourceField: cond.sourceField,
|
|
targetField: cond.targetColumn,
|
|
// sourceType에 따른 데이터 출처 설정
|
|
sourceType: cond.sourceType, // "currentRow" | "sectionField" | "externalTable"
|
|
fromFormData: cond.sourceType === "sectionField",
|
|
sectionId: cond.sectionId,
|
|
// 외부 테이블 조회 설정 (sourceType이 "externalTable"인 경우)
|
|
externalLookup: cond.externalLookup,
|
|
// 값 변환 설정 전달 (레거시 호환)
|
|
transform: cond.transform?.enabled
|
|
? {
|
|
tableName: cond.transform.tableName,
|
|
matchColumn: cond.transform.matchColumn,
|
|
resultColumn: cond.transform.resultColumn,
|
|
}
|
|
: undefined,
|
|
})),
|
|
},
|
|
// 조회 유형 정보 추가
|
|
lookupType: option.type,
|
|
})),
|
|
defaultOptionId: col.lookup.options.find((o) => o.isDefault)?.id || col.lookup.options[0]?.id,
|
|
};
|
|
}
|
|
// columnModes를 dynamicDataSource로 변환 (기존 로직 유지)
|
|
else if (col.columnModes && col.columnModes.length > 0) {
|
|
baseColumn.dynamicDataSource = {
|
|
enabled: true,
|
|
options: col.columnModes.map((mode) => ({
|
|
id: mode.id,
|
|
label: mode.label,
|
|
sourceType: "table" as const,
|
|
// 실제 조회 로직은 TableSectionRenderer에서 처리
|
|
tableConfig: {
|
|
tableName: mode.valueMapping?.externalRef?.tableName || "",
|
|
valueColumn: mode.valueMapping?.externalRef?.valueColumn || "",
|
|
joinConditions: (mode.valueMapping?.externalRef?.joinConditions || []).map((jc) => ({
|
|
sourceField: jc.sourceField,
|
|
targetField: jc.targetColumn,
|
|
})),
|
|
},
|
|
})),
|
|
defaultOptionId: col.columnModes.find((m) => m.isDefault)?.id || col.columnModes[0]?.id,
|
|
};
|
|
}
|
|
|
|
return baseColumn;
|
|
}
|
|
|
|
/**
|
|
* TableCalculationRule을 CalculationRule로 변환
|
|
*/
|
|
function convertToCalculationRule(calc: {
|
|
resultField: string;
|
|
formula: string;
|
|
dependencies: string[];
|
|
}): CalculationRule {
|
|
return {
|
|
result: calc.resultField,
|
|
formula: calc.formula,
|
|
dependencies: calc.dependencies,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 값 변환 함수: 중간 테이블을 통해 값을 변환
|
|
* 예: 거래처 이름 "(무)테스트업체" → 거래처 코드 "CUST-0002"
|
|
*/
|
|
async function transformValue(
|
|
value: any,
|
|
transform: { tableName: string; matchColumn: string; resultColumn: string },
|
|
): Promise<any> {
|
|
if (!value || !transform.tableName || !transform.matchColumn || !transform.resultColumn) {
|
|
return value;
|
|
}
|
|
|
|
try {
|
|
// 정확히 일치하는 검색
|
|
const response = await apiClient.post(`/table-management/tables/${transform.tableName}/data`, {
|
|
search: {
|
|
[transform.matchColumn]: {
|
|
value: value,
|
|
operator: "equals",
|
|
},
|
|
},
|
|
size: 1,
|
|
page: 1,
|
|
});
|
|
|
|
if (response.data.success && response.data.data?.data?.length > 0) {
|
|
const transformedValue = response.data.data.data[0][transform.resultColumn];
|
|
return transformedValue;
|
|
}
|
|
|
|
console.warn(`변환 실패: ${transform.tableName}.${transform.matchColumn} = "${value}" 인 행을 찾을 수 없습니다.`);
|
|
return undefined;
|
|
} catch (error) {
|
|
console.error("값 변환 오류:", error);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 외부 테이블에서 조건 값을 조회하는 함수
|
|
* LookupCondition.sourceType이 "externalTable"인 경우 사용
|
|
*/
|
|
async function fetchExternalLookupValue(
|
|
externalLookup: {
|
|
tableName: string;
|
|
matchColumn: string;
|
|
matchSourceType: "currentRow" | "sourceTable" | "sectionField";
|
|
matchSourceField: string;
|
|
matchSectionId?: string;
|
|
resultColumn: string;
|
|
},
|
|
rowData: any,
|
|
sourceData: any,
|
|
formData: FormDataState,
|
|
): Promise<any> {
|
|
// 1. 비교 값 가져오기
|
|
let matchValue: any;
|
|
if (externalLookup.matchSourceType === "currentRow") {
|
|
matchValue = rowData[externalLookup.matchSourceField];
|
|
} else if (externalLookup.matchSourceType === "sourceTable") {
|
|
matchValue = sourceData?.[externalLookup.matchSourceField];
|
|
} else {
|
|
matchValue = formData[externalLookup.matchSourceField];
|
|
}
|
|
|
|
if (matchValue === undefined || matchValue === null || matchValue === "") {
|
|
console.warn(
|
|
`외부 테이블 조회: 비교 값이 없습니다. (${externalLookup.matchSourceType}.${externalLookup.matchSourceField})`,
|
|
);
|
|
return undefined;
|
|
}
|
|
|
|
// 2. 외부 테이블에서 값 조회 (정확히 일치하는 검색)
|
|
try {
|
|
const response = await apiClient.post(`/table-management/tables/${externalLookup.tableName}/data`, {
|
|
search: {
|
|
[externalLookup.matchColumn]: {
|
|
value: matchValue,
|
|
operator: "equals",
|
|
},
|
|
},
|
|
size: 1,
|
|
page: 1,
|
|
});
|
|
|
|
if (response.data.success && response.data.data?.data?.length > 0) {
|
|
return response.data.data.data[0][externalLookup.resultColumn];
|
|
}
|
|
|
|
console.warn(
|
|
`외부 테이블 조회: ${externalLookup.tableName}.${externalLookup.matchColumn} = "${matchValue}" 인 행을 찾을 수 없습니다.`,
|
|
);
|
|
return undefined;
|
|
} catch (error) {
|
|
console.error("외부 테이블 조회 오류:", error);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 외부 테이블에서 값을 조회하는 함수
|
|
*
|
|
* @param tableName - 조회할 테이블명
|
|
* @param valueColumn - 가져올 컬럼명
|
|
* @param joinConditions - 조인 조건 목록
|
|
* @param rowData - 현재 행 데이터 (설정된 컬럼 필드)
|
|
* @param sourceData - 원본 소스 데이터 (_sourceData)
|
|
* @param formData - 폼 데이터 (다른 섹션 필드)
|
|
*/
|
|
async function fetchExternalValue(
|
|
tableName: string,
|
|
valueColumn: string,
|
|
joinConditions: TableJoinCondition[],
|
|
rowData: any,
|
|
sourceData: any,
|
|
formData: FormDataState,
|
|
): Promise<any> {
|
|
if (joinConditions.length === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
try {
|
|
const whereConditions: Record<string, any> = {};
|
|
|
|
for (const condition of joinConditions) {
|
|
let value: any;
|
|
|
|
// 값 출처에 따라 가져오기 (4가지 소스 타입 지원)
|
|
if (condition.sourceType === "row") {
|
|
// 현재 행 데이터 (설정된 컬럼 필드)
|
|
value = rowData[condition.sourceField];
|
|
} else if (condition.sourceType === "sourceData") {
|
|
// 원본 소스 테이블 데이터 (_sourceData)
|
|
value = sourceData?.[condition.sourceField];
|
|
} else if (condition.sourceType === "formData") {
|
|
// formData에서 가져오기 (다른 섹션)
|
|
value = formData[condition.sourceField];
|
|
} else if (condition.sourceType === "externalTable" && condition.externalLookup) {
|
|
// 외부 테이블에서 조회하여 가져오기
|
|
value = await fetchExternalLookupValue(condition.externalLookup, rowData, sourceData, formData);
|
|
}
|
|
|
|
if (value === undefined || value === null || value === "") {
|
|
return undefined;
|
|
}
|
|
|
|
// 값 변환이 필요한 경우 (예: 이름 → 코드) - 레거시 호환
|
|
if (condition.transform) {
|
|
value = await transformValue(value, condition.transform);
|
|
if (value === undefined) {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
// 숫자형 ID 변환
|
|
let convertedValue = value;
|
|
if (condition.targetColumn.endsWith("_id") || condition.targetColumn === "id") {
|
|
const numValue = Number(value);
|
|
if (!isNaN(numValue)) {
|
|
convertedValue = numValue;
|
|
}
|
|
}
|
|
|
|
// 정확히 일치하는 검색을 위해 operator: "equals" 사용
|
|
whereConditions[condition.targetColumn] = {
|
|
value: convertedValue,
|
|
operator: "equals",
|
|
};
|
|
}
|
|
|
|
// API 호출
|
|
const response = await apiClient.post(`/table-management/tables/${tableName}/data`, {
|
|
search: whereConditions,
|
|
size: 1,
|
|
page: 1,
|
|
});
|
|
|
|
if (response.data.success && response.data.data?.data?.length > 0) {
|
|
return response.data.data.data[0][valueColumn];
|
|
}
|
|
|
|
return undefined;
|
|
} catch (error) {
|
|
console.error("외부 테이블 조회 오류:", error);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 테이블 섹션 렌더러
|
|
* UniversalFormModal 내에서 테이블 형식의 데이터를 표시하고 편집
|
|
*/
|
|
export function TableSectionRenderer({
|
|
sectionId,
|
|
tableConfig,
|
|
formData,
|
|
onFormDataChange,
|
|
onTableDataChange,
|
|
onConditionalTableDataChange,
|
|
className,
|
|
}: TableSectionRendererProps) {
|
|
// 테이블 데이터 상태 (일반 모드)
|
|
const [tableData, setTableData] = useState<any[]>([]);
|
|
|
|
// 조건부 테이블 데이터 상태 (조건별로 분리)
|
|
const [conditionalTableData, setConditionalTableData] = useState<ConditionalTableData>({});
|
|
|
|
// 조건부 테이블: 선택된 조건들 (체크박스 모드)
|
|
const [selectedConditions, setSelectedConditions] = useState<string[]>([]);
|
|
|
|
// 조건부 테이블: 현재 활성 탭
|
|
const [activeConditionTab, setActiveConditionTab] = useState<string>("");
|
|
|
|
// 조건부 테이블: 현재 모달이 열린 조건 (어떤 조건의 테이블에 추가할지)
|
|
const [modalCondition, setModalCondition] = useState<string>("");
|
|
|
|
// 모달 상태
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
|
|
// 체크박스 선택 상태 (조건별로 분리)
|
|
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
|
const [conditionalSelectedRows, setConditionalSelectedRows] = useState<Record<string, Set<number>>>({});
|
|
|
|
// 너비 조정 트리거 (홀수: 자동맞춤, 짝수: 균등분배)
|
|
const [widthTrigger, setWidthTrigger] = useState(0);
|
|
|
|
// 동적 데이터 소스 활성화 상태
|
|
const [activeDataSources, setActiveDataSources] = useState<Record<string, string>>({});
|
|
|
|
// 날짜 일괄 적용 완료 플래그 (컬럼별로 한 번만 적용)
|
|
const [batchAppliedFields, setBatchAppliedFields] = useState<Set<string>>(new Set());
|
|
|
|
// 초기 데이터 로드 완료 플래그 (무한 루프 방지)
|
|
const initialDataLoadedRef = React.useRef(false);
|
|
|
|
// 조건부 테이블 설정
|
|
const conditionalConfig = tableConfig.conditionalTable;
|
|
const isConditionalMode = conditionalConfig?.enabled ?? false;
|
|
|
|
// 조건부 테이블: 동적 옵션 로드 상태
|
|
const [dynamicOptions, setDynamicOptions] = useState<{ id: string; value: string; label: string }[]>([]);
|
|
const [dynamicOptionsLoading, setDynamicOptionsLoading] = useState(false);
|
|
const dynamicOptionsLoadedRef = React.useRef(false);
|
|
|
|
// 소스 테이블의 카테고리 타입 컬럼 목록
|
|
const [sourceCategoryColumns, setSourceCategoryColumns] = useState<string[]>([]);
|
|
|
|
// 소스 테이블의 카테고리 타입 컬럼 목록 로드
|
|
useEffect(() => {
|
|
const loadCategoryColumns = async () => {
|
|
if (!tableConfig.source.tableName) return;
|
|
|
|
try {
|
|
const response = await apiClient.get(
|
|
`/table-categories/${tableConfig.source.tableName}/columns`
|
|
);
|
|
|
|
if (response.data?.success && Array.isArray(response.data.data)) {
|
|
const categoryColNames = response.data.data.map(
|
|
(col: { columnName?: string; column_name?: string }) =>
|
|
col.columnName || col.column_name || ""
|
|
).filter(Boolean);
|
|
setSourceCategoryColumns(categoryColNames);
|
|
}
|
|
} catch (error) {
|
|
console.error("카테고리 컬럼 목록 조회 실패:", error);
|
|
}
|
|
};
|
|
|
|
loadCategoryColumns();
|
|
}, [tableConfig.source.tableName]);
|
|
|
|
// 조건부 테이블: 동적 옵션 로드 (optionSource 설정이 있는 경우)
|
|
useEffect(() => {
|
|
if (!isConditionalMode) return;
|
|
if (!conditionalConfig?.optionSource?.enabled) return;
|
|
if (dynamicOptionsLoadedRef.current) return;
|
|
|
|
const { tableName, valueColumn, labelColumn, filterCondition } = conditionalConfig.optionSource;
|
|
|
|
if (!tableName || !valueColumn) return;
|
|
|
|
const loadDynamicOptions = async () => {
|
|
setDynamicOptionsLoading(true);
|
|
try {
|
|
// DISTINCT 값을 가져오기 위한 API 호출
|
|
const response = await apiClient.post(`/table-management/tables/${tableName}/data`, {
|
|
search: filterCondition ? { _raw: filterCondition } : {},
|
|
size: 1000,
|
|
page: 1,
|
|
});
|
|
|
|
if (response.data.success && response.data.data?.data) {
|
|
const rows = response.data.data.data;
|
|
|
|
// 중복 제거하여 고유 값 추출
|
|
const uniqueValues = new Map<string, string>();
|
|
for (const row of rows) {
|
|
const value = row[valueColumn];
|
|
if (value && !uniqueValues.has(value)) {
|
|
const label = labelColumn ? row[labelColumn] || value : value;
|
|
uniqueValues.set(value, label);
|
|
}
|
|
}
|
|
|
|
// 옵션 배열로 변환
|
|
const options = Array.from(uniqueValues.entries()).map(([value, label], index) => ({
|
|
id: `dynamic_${index}`,
|
|
value,
|
|
label,
|
|
}));
|
|
|
|
console.log("[TableSectionRenderer] 동적 옵션 로드 완료:", {
|
|
tableName,
|
|
valueColumn,
|
|
optionCount: options.length,
|
|
options,
|
|
});
|
|
|
|
setDynamicOptions(options);
|
|
dynamicOptionsLoadedRef.current = true;
|
|
}
|
|
} catch (error) {
|
|
console.error("[TableSectionRenderer] 동적 옵션 로드 실패:", error);
|
|
} finally {
|
|
setDynamicOptionsLoading(false);
|
|
}
|
|
};
|
|
|
|
loadDynamicOptions();
|
|
}, [isConditionalMode, conditionalConfig?.optionSource]);
|
|
|
|
// ============================================
|
|
// 동적 Select 옵션 (소스 테이블에서 드롭다운 옵션 로드)
|
|
// ============================================
|
|
|
|
// 소스 테이블 데이터 캐시 (동적 Select 옵션용)
|
|
const [sourceDataCache, setSourceDataCache] = useState<any[]>([]);
|
|
const sourceDataLoadedRef = React.useRef(false);
|
|
|
|
// 동적 Select 옵션이 있는 컬럼 확인
|
|
const hasDynamicSelectColumns = useMemo(() => {
|
|
return tableConfig.columns?.some((col) => col.dynamicSelectOptions?.enabled);
|
|
}, [tableConfig.columns]);
|
|
|
|
// 소스 테이블 데이터 로드 (동적 Select 옵션용)
|
|
useEffect(() => {
|
|
if (!hasDynamicSelectColumns) return;
|
|
if (sourceDataLoadedRef.current) return;
|
|
if (!tableConfig.source?.tableName) return;
|
|
|
|
const loadSourceData = async () => {
|
|
try {
|
|
// 조건부 테이블 필터 조건 적용
|
|
const filterCondition: Record<string, any> = {};
|
|
|
|
// 소스 필터가 활성화되어 있고 조건이 선택되어 있으면 필터 적용
|
|
if (conditionalConfig?.sourceFilter?.enabled && activeConditionTab) {
|
|
filterCondition[conditionalConfig.sourceFilter.filterColumn] = activeConditionTab;
|
|
}
|
|
|
|
const response = await apiClient.post(`/table-management/tables/${tableConfig.source.tableName}/data`, {
|
|
search: filterCondition,
|
|
size: 1000,
|
|
page: 1,
|
|
});
|
|
|
|
if (response.data.success && response.data.data?.data) {
|
|
setSourceDataCache(response.data.data.data);
|
|
sourceDataLoadedRef.current = true;
|
|
console.log("[TableSectionRenderer] 소스 데이터 로드 완료:", {
|
|
tableName: tableConfig.source.tableName,
|
|
rowCount: response.data.data.data.length,
|
|
filter: filterCondition,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error("[TableSectionRenderer] 소스 데이터 로드 실패:", error);
|
|
}
|
|
};
|
|
|
|
loadSourceData();
|
|
}, [hasDynamicSelectColumns, tableConfig.source?.tableName, conditionalConfig?.sourceFilter, activeConditionTab]);
|
|
|
|
// 조건 탭 변경 시 소스 데이터 다시 로드
|
|
useEffect(() => {
|
|
if (!hasDynamicSelectColumns) return;
|
|
if (!conditionalConfig?.sourceFilter?.enabled) return;
|
|
if (!activeConditionTab) return;
|
|
if (!tableConfig.source?.tableName) return;
|
|
|
|
// 조건 변경 시 캐시 리셋하고 즉시 다시 로드
|
|
sourceDataLoadedRef.current = false;
|
|
setSourceDataCache([]);
|
|
|
|
// 즉시 데이터 다시 로드 (기존 useEffect에 의존하지 않고 직접 호출)
|
|
const loadSourceData = async () => {
|
|
try {
|
|
const filterCondition: Record<string, any> = {};
|
|
filterCondition[conditionalConfig.sourceFilter!.filterColumn] = activeConditionTab;
|
|
|
|
const response = await apiClient.post(`/table-management/tables/${tableConfig.source!.tableName}/data`, {
|
|
search: filterCondition,
|
|
size: 1000,
|
|
page: 1,
|
|
});
|
|
|
|
if (response.data.success && response.data.data?.data) {
|
|
setSourceDataCache(response.data.data.data);
|
|
sourceDataLoadedRef.current = true;
|
|
console.log("[TableSectionRenderer] 조건 탭 변경 - 소스 데이터 로드 완료:", {
|
|
tableName: tableConfig.source!.tableName,
|
|
rowCount: response.data.data.data.length,
|
|
filter: filterCondition,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error("[TableSectionRenderer] 소스 데이터 로드 실패:", error);
|
|
}
|
|
};
|
|
|
|
loadSourceData();
|
|
}, [
|
|
activeConditionTab,
|
|
hasDynamicSelectColumns,
|
|
conditionalConfig?.sourceFilter?.enabled,
|
|
conditionalConfig?.sourceFilter?.filterColumn,
|
|
tableConfig.source?.tableName,
|
|
]);
|
|
|
|
// 컬럼별 동적 Select 옵션 생성
|
|
const dynamicSelectOptionsMap = useMemo(() => {
|
|
const optionsMap: Record<string, { value: string; label: string }[]> = {};
|
|
|
|
if (!sourceDataCache.length) return optionsMap;
|
|
|
|
for (const col of tableConfig.columns || []) {
|
|
if (!col.dynamicSelectOptions?.enabled) continue;
|
|
|
|
const { sourceField, labelField, distinct = true } = col.dynamicSelectOptions;
|
|
|
|
if (!sourceField) continue;
|
|
|
|
// 소스 데이터에서 옵션 추출
|
|
const seenValues = new Set<string>();
|
|
const options: { value: string; label: string }[] = [];
|
|
|
|
for (const row of sourceDataCache) {
|
|
const value = row[sourceField];
|
|
if (value === undefined || value === null || value === "") continue;
|
|
|
|
const stringValue = String(value);
|
|
|
|
if (distinct && seenValues.has(stringValue)) continue;
|
|
seenValues.add(stringValue);
|
|
|
|
const label = labelField ? row[labelField] || stringValue : stringValue;
|
|
options.push({ value: stringValue, label: String(label) });
|
|
}
|
|
|
|
optionsMap[col.field] = options;
|
|
}
|
|
|
|
return optionsMap;
|
|
}, [sourceDataCache, tableConfig.columns]);
|
|
|
|
// 데이터 변경 핸들러 (날짜 일괄 적용 로직 포함) - 다른 함수에서 참조하므로 먼저 정의
|
|
const handleDataChange = useCallback(
|
|
(newData: any[]) => {
|
|
let processedData = newData;
|
|
|
|
// 날짜 일괄 적용 로직: batchApply가 활성화된 날짜 컬럼 처리
|
|
const batchApplyColumns = tableConfig.columns.filter((col) => col.type === "date" && col.batchApply === true);
|
|
|
|
for (const dateCol of batchApplyColumns) {
|
|
// 이미 일괄 적용된 컬럼은 건너뜀
|
|
if (batchAppliedFields.has(dateCol.field)) continue;
|
|
|
|
// 해당 컬럼에 값이 있는 행과 없는 행 분류
|
|
const itemsWithDate = processedData.filter((item) => item[dateCol.field]);
|
|
const itemsWithoutDate = processedData.filter((item) => !item[dateCol.field]);
|
|
|
|
// 조건: 정확히 1개만 날짜가 있고, 나머지는 모두 비어있을 때
|
|
if (itemsWithDate.length === 1 && itemsWithoutDate.length > 0) {
|
|
const selectedDate = itemsWithDate[0][dateCol.field];
|
|
|
|
// 모든 행에 동일한 날짜 적용
|
|
processedData = processedData.map((item) => ({
|
|
...item,
|
|
[dateCol.field]: selectedDate,
|
|
}));
|
|
|
|
// 플래그 활성화 (이후 개별 수정 가능)
|
|
setBatchAppliedFields((prev) => new Set([...prev, dateCol.field]));
|
|
}
|
|
}
|
|
|
|
setTableData(processedData);
|
|
onTableDataChange(processedData);
|
|
},
|
|
[onTableDataChange, tableConfig.columns, batchAppliedFields],
|
|
);
|
|
|
|
// 행 선택 모드: 드롭다운 값 변경 시 같은 소스 행의 다른 컬럼들 자동 채움
|
|
const handleDynamicSelectChange = useCallback(
|
|
(rowIndex: number, columnField: string, selectedValue: string, conditionValue?: string) => {
|
|
const column = tableConfig.columns?.find((col) => col.field === columnField);
|
|
if (!column?.dynamicSelectOptions?.rowSelectionMode?.enabled) {
|
|
// 행 선택 모드가 아니면 일반 값 변경만
|
|
if (conditionValue && isConditionalMode) {
|
|
const currentData = conditionalTableData[conditionValue] || [];
|
|
const newData = [...currentData];
|
|
newData[rowIndex] = { ...newData[rowIndex], [columnField]: selectedValue };
|
|
setConditionalTableData((prev) => ({ ...prev, [conditionValue]: newData }));
|
|
onConditionalTableDataChange?.(conditionValue, newData);
|
|
} else {
|
|
const newData = [...tableData];
|
|
newData[rowIndex] = { ...newData[rowIndex], [columnField]: selectedValue };
|
|
handleDataChange(newData);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 행 선택 모드: 소스 데이터에서 해당 값을 가진 행 찾기
|
|
const { sourceField } = column.dynamicSelectOptions;
|
|
const { autoFillColumns, sourceIdColumn, targetIdField } = column.dynamicSelectOptions.rowSelectionMode;
|
|
|
|
const sourceRow = sourceDataCache.find((row) => String(row[sourceField]) === selectedValue);
|
|
|
|
if (!sourceRow) {
|
|
console.warn(`[TableSectionRenderer] 소스 행을 찾을 수 없음: ${sourceField} = ${selectedValue}`);
|
|
return;
|
|
}
|
|
|
|
// 현재 행 데이터 가져오기
|
|
let currentData: any[];
|
|
if (conditionValue && isConditionalMode) {
|
|
currentData = conditionalTableData[conditionValue] || [];
|
|
} else {
|
|
currentData = tableData;
|
|
}
|
|
|
|
const newData = [...currentData];
|
|
const updatedRow = { ...newData[rowIndex], [columnField]: selectedValue };
|
|
|
|
// 자동 채움 매핑 적용
|
|
if (autoFillColumns) {
|
|
for (const mapping of autoFillColumns) {
|
|
const sourceValue = sourceRow[mapping.sourceColumn];
|
|
if (sourceValue !== undefined) {
|
|
updatedRow[mapping.targetField] = sourceValue;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 소스 ID 저장
|
|
if (sourceIdColumn && targetIdField) {
|
|
updatedRow[targetIdField] = sourceRow[sourceIdColumn];
|
|
}
|
|
|
|
newData[rowIndex] = updatedRow;
|
|
|
|
// 데이터 업데이트
|
|
if (conditionValue && isConditionalMode) {
|
|
setConditionalTableData((prev) => ({ ...prev, [conditionValue]: newData }));
|
|
onConditionalTableDataChange?.(conditionValue, newData);
|
|
} else {
|
|
handleDataChange(newData);
|
|
}
|
|
|
|
console.log("[TableSectionRenderer] 행 선택 모드 자동 채움:", {
|
|
columnField,
|
|
selectedValue,
|
|
sourceRow,
|
|
updatedRow,
|
|
});
|
|
},
|
|
[
|
|
tableConfig.columns,
|
|
sourceDataCache,
|
|
tableData,
|
|
conditionalTableData,
|
|
isConditionalMode,
|
|
handleDataChange,
|
|
onConditionalTableDataChange,
|
|
],
|
|
);
|
|
|
|
// 참조 컬럼 값 조회 함수 (saveToTarget: false인 컬럼에 대해 소스 테이블 조회)
|
|
const loadReferenceColumnValues = useCallback(
|
|
async (data: any[]) => {
|
|
// saveToTarget: false이고 referenceDisplay가 설정된 컬럼 찾기
|
|
const referenceColumns = (tableConfig.columns || []).filter(
|
|
(col) => col.saveConfig?.saveToTarget === false && col.saveConfig?.referenceDisplay,
|
|
);
|
|
|
|
if (referenceColumns.length === 0) return;
|
|
|
|
const sourceTableName = tableConfig.source?.tableName;
|
|
if (!sourceTableName) {
|
|
console.warn("[TableSectionRenderer] 참조 조회를 위한 소스 테이블이 설정되지 않았습니다.");
|
|
return;
|
|
}
|
|
|
|
// 참조 ID들 수집 (중복 제거)
|
|
const referenceIdSet = new Set<string>();
|
|
|
|
for (const col of referenceColumns) {
|
|
const refDisplay = col.saveConfig!.referenceDisplay!;
|
|
|
|
for (const row of data) {
|
|
const refId = row[refDisplay.referenceIdField];
|
|
if (refId !== undefined && refId !== null && refId !== "") {
|
|
referenceIdSet.add(String(refId));
|
|
}
|
|
}
|
|
}
|
|
|
|
if (referenceIdSet.size === 0) return;
|
|
|
|
try {
|
|
// 소스 테이블에서 참조 ID에 해당하는 데이터 조회
|
|
const response = await apiClient.post(`/table-management/tables/${sourceTableName}/data`, {
|
|
search: { id: Array.from(referenceIdSet) }, // ID 배열로 조회
|
|
size: 1000,
|
|
page: 1,
|
|
});
|
|
|
|
if (!response.data?.success || !response.data?.data?.data) {
|
|
console.warn("[TableSectionRenderer] 참조 데이터 조회 실패");
|
|
return;
|
|
}
|
|
|
|
const sourceData: any[] = response.data.data.data;
|
|
|
|
// ID를 키로 하는 맵 생성
|
|
const sourceDataMap: Record<string, any> = {};
|
|
for (const sourceRow of sourceData) {
|
|
sourceDataMap[String(sourceRow.id)] = sourceRow;
|
|
}
|
|
|
|
// 각 행에 참조 컬럼 값 채우기
|
|
const updatedData = data.map((row) => {
|
|
const newRow = { ...row };
|
|
|
|
for (const col of referenceColumns) {
|
|
const refDisplay = col.saveConfig!.referenceDisplay!;
|
|
const refId = row[refDisplay.referenceIdField];
|
|
|
|
if (refId !== undefined && refId !== null && refId !== "") {
|
|
const sourceRow = sourceDataMap[String(refId)];
|
|
if (sourceRow) {
|
|
newRow[col.field] = sourceRow[refDisplay.sourceColumn];
|
|
}
|
|
}
|
|
}
|
|
|
|
return newRow;
|
|
});
|
|
|
|
console.log("[TableSectionRenderer] 참조 컬럼 값 조회 완료:", {
|
|
referenceColumns: referenceColumns.map((c) => c.field),
|
|
updatedRowCount: updatedData.length,
|
|
});
|
|
|
|
setTableData(updatedData);
|
|
} catch (error) {
|
|
console.error("[TableSectionRenderer] 참조 데이터 조회 실패:", error);
|
|
}
|
|
},
|
|
[tableConfig.columns, tableConfig.source?.tableName],
|
|
);
|
|
|
|
// formData에서 초기 테이블 데이터 로드 (수정 모드에서 _groupedData 표시)
|
|
useEffect(() => {
|
|
// 이미 초기화되었으면 스킵
|
|
if (initialDataLoadedRef.current) return;
|
|
|
|
const tableSectionKey = `_tableSection_${sectionId}`;
|
|
const initialData = formData[tableSectionKey];
|
|
|
|
if (Array.isArray(initialData) && initialData.length > 0) {
|
|
// console.log("[TableSectionRenderer] 초기 데이터 로드:", {
|
|
// sectionId,
|
|
// itemCount: initialData.length,
|
|
// });
|
|
setTableData(initialData);
|
|
initialDataLoadedRef.current = true;
|
|
|
|
// 참조 컬럼 값 조회 (saveToTarget: false인 컬럼)
|
|
loadReferenceColumnValues(initialData);
|
|
}
|
|
}, [sectionId, formData, loadReferenceColumnValues]);
|
|
|
|
// RepeaterColumnConfig로 변환 (동적 Select 옵션 반영)
|
|
const columns: RepeaterColumnConfig[] = useMemo(() => {
|
|
return (tableConfig.columns || []).map((col) => {
|
|
const baseColumn = convertToRepeaterColumn(col);
|
|
|
|
// 동적 Select 옵션이 있으면 적용
|
|
if (col.dynamicSelectOptions?.enabled && dynamicSelectOptionsMap[col.field]) {
|
|
baseColumn.selectOptions = dynamicSelectOptionsMap[col.field];
|
|
}
|
|
|
|
return baseColumn;
|
|
});
|
|
}, [tableConfig.columns, dynamicSelectOptionsMap]);
|
|
|
|
// 원본 계산 규칙 (조건부 계산 포함)
|
|
const originalCalculationRules: TableCalculationRule[] = useMemo(
|
|
() => tableConfig.calculations || [],
|
|
[tableConfig.calculations],
|
|
);
|
|
|
|
// 기본 계산 규칙 변환 (RepeaterTable용 - 조건부 계산이 없는 경우에 사용)
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const calculationRules: CalculationRule[] = originalCalculationRules.map(convertToCalculationRule);
|
|
|
|
// 조건부 계산 로직: 행의 조건 필드 값에 따라 적절한 계산식 선택
|
|
const getFormulaForRow = useCallback((rule: TableCalculationRule, row: Record<string, unknown>): string => {
|
|
// 조건부 계산이 활성화된 경우
|
|
if (rule.conditionalCalculation?.enabled && rule.conditionalCalculation.conditionField) {
|
|
const conditionValue = row[rule.conditionalCalculation.conditionField];
|
|
// 조건값과 일치하는 규칙 찾기
|
|
const matchedRule = rule.conditionalCalculation.rules?.find((r) => r.conditionValue === conditionValue);
|
|
if (matchedRule) {
|
|
return matchedRule.formula;
|
|
}
|
|
// 일치하는 규칙이 없으면 기본 계산식 사용
|
|
if (rule.conditionalCalculation.defaultFormula) {
|
|
return rule.conditionalCalculation.defaultFormula;
|
|
}
|
|
}
|
|
// 조건부 계산이 비활성화되었거나 기본값이 없으면 원래 계산식 사용
|
|
return rule.formula;
|
|
}, []);
|
|
|
|
// 계산 로직 (조건부 계산 지원)
|
|
const calculateRow = useCallback(
|
|
(row: any): any => {
|
|
if (originalCalculationRules.length === 0) return row;
|
|
|
|
const updatedRow = { ...row };
|
|
|
|
for (const rule of originalCalculationRules) {
|
|
try {
|
|
// 조건부 계산에 따라 적절한 계산식 선택
|
|
let formula = getFormulaForRow(rule, row);
|
|
|
|
if (!formula) continue;
|
|
|
|
const fieldMatches = formula.match(/[a-zA-Z_][a-zA-Z0-9_]*/g) || [];
|
|
const dependencies = rule.dependencies.length > 0 ? rule.dependencies : fieldMatches;
|
|
|
|
for (const dep of dependencies) {
|
|
if (dep === rule.resultField) continue;
|
|
const value = parseFloat(row[dep]) || 0;
|
|
formula = formula.replace(new RegExp(`\\b${dep}\\b`, "g"), value.toString());
|
|
}
|
|
|
|
const result = new Function(`return ${formula}`)();
|
|
updatedRow[rule.resultField] = result;
|
|
} catch (error) {
|
|
console.error(`계산 오류 (${rule.formula}):`, error);
|
|
updatedRow[rule.resultField] = 0;
|
|
}
|
|
}
|
|
|
|
return updatedRow;
|
|
},
|
|
[originalCalculationRules, getFormulaForRow],
|
|
);
|
|
|
|
const calculateAll = useCallback(
|
|
(data: any[]): any[] => {
|
|
return data.map((row) => calculateRow(row));
|
|
},
|
|
[calculateRow],
|
|
);
|
|
|
|
// 행 변경 핸들러 (동적 Select 행 선택 모드 지원)
|
|
const handleRowChange = useCallback(
|
|
(index: number, newRow: any, conditionValue?: string) => {
|
|
const oldRow =
|
|
conditionValue && isConditionalMode
|
|
? conditionalTableData[conditionValue]?.[index] || {}
|
|
: tableData[index] || {};
|
|
|
|
// 변경된 필드 찾기
|
|
const changedFields: string[] = [];
|
|
for (const key of Object.keys(newRow)) {
|
|
if (oldRow[key] !== newRow[key]) {
|
|
changedFields.push(key);
|
|
}
|
|
}
|
|
|
|
// 동적 Select 컬럼의 행 선택 모드 확인
|
|
for (const changedField of changedFields) {
|
|
const column = tableConfig.columns?.find((col) => col.field === changedField);
|
|
if (column?.dynamicSelectOptions?.rowSelectionMode?.enabled) {
|
|
// 행 선택 모드 처리 (자동 채움)
|
|
handleDynamicSelectChange(index, changedField, newRow[changedField], conditionValue);
|
|
return; // 행 선택 모드에서 처리 완료
|
|
}
|
|
}
|
|
|
|
// 일반 행 변경 처리
|
|
const calculatedRow = calculateRow(newRow);
|
|
|
|
if (conditionValue && isConditionalMode) {
|
|
const currentData = conditionalTableData[conditionValue] || [];
|
|
const newData = [...currentData];
|
|
newData[index] = calculatedRow;
|
|
setConditionalTableData((prev) => ({ ...prev, [conditionValue]: newData }));
|
|
onConditionalTableDataChange?.(conditionValue, newData);
|
|
} else {
|
|
const newData = [...tableData];
|
|
newData[index] = calculatedRow;
|
|
handleDataChange(newData);
|
|
}
|
|
},
|
|
[
|
|
tableData,
|
|
conditionalTableData,
|
|
isConditionalMode,
|
|
tableConfig.columns,
|
|
calculateRow,
|
|
handleDataChange,
|
|
handleDynamicSelectChange,
|
|
onConditionalTableDataChange,
|
|
],
|
|
);
|
|
|
|
// 행 삭제 핸들러
|
|
const handleRowDelete = useCallback(
|
|
(index: number) => {
|
|
const newData = tableData.filter((_, i) => i !== index);
|
|
handleDataChange(newData);
|
|
},
|
|
[tableData, handleDataChange],
|
|
);
|
|
|
|
// 선택된 항목 일괄 삭제
|
|
const handleBulkDelete = useCallback(() => {
|
|
if (selectedRows.size === 0) return;
|
|
const newData = tableData.filter((_, index) => !selectedRows.has(index));
|
|
handleDataChange(newData);
|
|
setSelectedRows(new Set());
|
|
|
|
// 데이터가 모두 삭제되면 일괄 적용 플래그도 리셋
|
|
if (newData.length === 0) {
|
|
setBatchAppliedFields(new Set());
|
|
}
|
|
}, [tableData, selectedRows, handleDataChange]);
|
|
|
|
// 아이템 추가 핸들러 (모달에서 선택)
|
|
const handleAddItems = useCallback(
|
|
async (items: any[]) => {
|
|
// 각 아이템에 대해 valueMapping 적용
|
|
const mappedItems = await Promise.all(
|
|
items.map(async (sourceItem) => {
|
|
const newItem: any = {};
|
|
|
|
for (const col of tableConfig.columns) {
|
|
const mapping = col.valueMapping;
|
|
|
|
// 0. lookup 설정이 있는 경우 (동적 조회)
|
|
if (col.lookup?.enabled && col.lookup.options && col.lookup.options.length > 0) {
|
|
// 현재 활성화된 옵션 또는 기본 옵션 사용
|
|
const activeOptionId = activeDataSources[col.field];
|
|
const defaultOption = col.lookup.options.find((o) => o.isDefault) || col.lookup.options[0];
|
|
const selectedOption = activeOptionId
|
|
? col.lookup.options.find((o) => o.id === activeOptionId) || defaultOption
|
|
: defaultOption;
|
|
|
|
if (selectedOption) {
|
|
// sameTable 타입: 소스 데이터에서 직접 값 복사
|
|
if (selectedOption.type === "sameTable") {
|
|
const value = sourceItem[selectedOption.valueColumn];
|
|
if (value !== undefined) {
|
|
newItem[col.field] = value;
|
|
}
|
|
// _sourceData에 원본 저장 (나중에 다른 옵션으로 전환 시 사용)
|
|
newItem._sourceData = sourceItem;
|
|
continue;
|
|
}
|
|
|
|
// relatedTable, combinedLookup: 외부 테이블 조회
|
|
// 조인 조건 구성 (4가지 소스 타입 지원)
|
|
const joinConditions: TableJoinCondition[] = selectedOption.conditions.map((cond) => {
|
|
// sourceType 매핑
|
|
let sourceType: "row" | "sourceData" | "formData" | "externalTable";
|
|
if (cond.sourceType === "currentRow") {
|
|
sourceType = "row";
|
|
} else if (cond.sourceType === "sourceTable") {
|
|
sourceType = "sourceData";
|
|
} else if (cond.sourceType === "externalTable") {
|
|
sourceType = "externalTable";
|
|
} else {
|
|
sourceType = "formData";
|
|
}
|
|
|
|
return {
|
|
sourceType,
|
|
sourceField: cond.sourceField,
|
|
targetColumn: cond.targetColumn,
|
|
// 외부 테이블 조회 설정
|
|
externalLookup: cond.externalLookup,
|
|
// 값 변환 설정 전달 (레거시 호환)
|
|
transform: cond.transform?.enabled
|
|
? {
|
|
tableName: cond.transform.tableName,
|
|
matchColumn: cond.transform.matchColumn,
|
|
resultColumn: cond.transform.resultColumn,
|
|
}
|
|
: undefined,
|
|
};
|
|
});
|
|
|
|
// 외부 테이블에서 값 조회 (sourceItem이 _sourceData 역할)
|
|
const value = await fetchExternalValue(
|
|
selectedOption.tableName,
|
|
selectedOption.valueColumn,
|
|
joinConditions,
|
|
{ ...sourceItem, ...newItem }, // rowData (현재 행)
|
|
sourceItem, // sourceData (소스 테이블 원본)
|
|
formData,
|
|
);
|
|
|
|
if (value !== undefined) {
|
|
newItem[col.field] = value;
|
|
}
|
|
|
|
// _sourceData에 원본 저장
|
|
newItem._sourceData = sourceItem;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// 1. 먼저 col.sourceField 확인 (간단 매핑)
|
|
if (!mapping && col.sourceField) {
|
|
// sourceField가 명시적으로 설정된 경우
|
|
if (sourceItem[col.sourceField] !== undefined) {
|
|
newItem[col.field] = sourceItem[col.sourceField];
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (!mapping) {
|
|
// 매핑 없으면 소스에서 동일 필드명으로 복사
|
|
if (sourceItem[col.field] !== undefined) {
|
|
newItem[col.field] = sourceItem[col.field];
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// 2. valueMapping이 있는 경우 (고급 매핑)
|
|
switch (mapping.type) {
|
|
case "source":
|
|
// 소스 테이블에서 복사
|
|
const srcField = mapping.sourceField || col.sourceField || col.field;
|
|
if (sourceItem[srcField] !== undefined) {
|
|
newItem[col.field] = sourceItem[srcField];
|
|
}
|
|
break;
|
|
|
|
case "manual":
|
|
// 사용자 입력 (빈 값 또는 기본값)
|
|
newItem[col.field] = col.defaultValue ?? undefined;
|
|
break;
|
|
|
|
case "internal":
|
|
// formData에서 값 가져오기
|
|
if (mapping.internalField) {
|
|
newItem[col.field] = formData[mapping.internalField];
|
|
}
|
|
break;
|
|
|
|
case "external":
|
|
// 외부 테이블에서 조회
|
|
if (mapping.externalRef) {
|
|
const { tableName, valueColumn, joinConditions } = mapping.externalRef;
|
|
const value = await fetchExternalValue(
|
|
tableName,
|
|
valueColumn,
|
|
joinConditions,
|
|
{ ...sourceItem, ...newItem }, // rowData
|
|
sourceItem, // sourceData
|
|
formData,
|
|
);
|
|
if (value !== undefined) {
|
|
newItem[col.field] = value;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
// 기본값 적용
|
|
if (col.defaultValue !== undefined && newItem[col.field] === undefined) {
|
|
newItem[col.field] = col.defaultValue;
|
|
}
|
|
|
|
// 부모에서 값 받기 (receiveFromParent)
|
|
if (col.receiveFromParent) {
|
|
const parentField = col.parentFieldName || col.field;
|
|
if (formData[parentField] !== undefined) {
|
|
newItem[col.field] = formData[parentField];
|
|
}
|
|
}
|
|
}
|
|
|
|
return newItem;
|
|
}),
|
|
);
|
|
|
|
// 계산 필드 업데이트
|
|
const calculatedItems = calculateAll(mappedItems);
|
|
|
|
// 기존 데이터에 추가
|
|
const newData = [...tableData, ...calculatedItems];
|
|
handleDataChange(newData);
|
|
},
|
|
[tableConfig.columns, formData, tableData, calculateAll, handleDataChange, activeDataSources],
|
|
);
|
|
|
|
// 컬럼 모드/조회 옵션 변경 핸들러
|
|
const handleDataSourceChange = useCallback(
|
|
async (columnField: string, optionId: string) => {
|
|
setActiveDataSources((prev) => ({
|
|
...prev,
|
|
[columnField]: optionId,
|
|
}));
|
|
|
|
// 해당 컬럼의 모든 행 데이터 재조회
|
|
const column = tableConfig.columns.find((col) => col.field === columnField);
|
|
|
|
// lookup 설정이 있는 경우 (새로운 조회 기능)
|
|
if (column?.lookup?.enabled && column.lookup.options) {
|
|
const selectedOption = column.lookup.options.find((opt) => opt.id === optionId);
|
|
if (!selectedOption) return;
|
|
|
|
// sameTable 타입: 현재 행의 소스 데이터에서 값 복사 (외부 조회 필요 없음)
|
|
if (selectedOption.type === "sameTable") {
|
|
const updatedData = tableData.map((row) => {
|
|
// sourceField에서 값을 가져와 해당 컬럼에 복사
|
|
// row에 _sourceData가 있으면 거기서, 없으면 row 자체에서 가져옴
|
|
const sourceData = row._sourceData || row;
|
|
const newValue = sourceData[selectedOption.valueColumn] ?? row[columnField];
|
|
return { ...row, [columnField]: newValue };
|
|
});
|
|
|
|
const calculatedData = calculateAll(updatedData);
|
|
handleDataChange(calculatedData);
|
|
return;
|
|
}
|
|
|
|
// 모든 행에 대해 새 값 조회
|
|
const updatedData = await Promise.all(
|
|
tableData.map(async (row) => {
|
|
let newValue: any = row[columnField];
|
|
|
|
// 조인 조건 구성 (4가지 소스 타입 지원)
|
|
const joinConditions: TableJoinCondition[] = selectedOption.conditions.map((cond) => {
|
|
// sourceType 매핑
|
|
let sourceType: "row" | "sourceData" | "formData" | "externalTable";
|
|
if (cond.sourceType === "currentRow") {
|
|
sourceType = "row";
|
|
} else if (cond.sourceType === "sourceTable") {
|
|
sourceType = "sourceData";
|
|
} else if (cond.sourceType === "externalTable") {
|
|
sourceType = "externalTable";
|
|
} else {
|
|
sourceType = "formData";
|
|
}
|
|
|
|
return {
|
|
sourceType,
|
|
sourceField: cond.sourceField,
|
|
targetColumn: cond.targetColumn,
|
|
// 외부 테이블 조회 설정
|
|
externalLookup: cond.externalLookup,
|
|
// 값 변환 설정 전달 (레거시 호환)
|
|
transform: cond.transform?.enabled
|
|
? {
|
|
tableName: cond.transform.tableName,
|
|
matchColumn: cond.transform.matchColumn,
|
|
resultColumn: cond.transform.resultColumn,
|
|
}
|
|
: undefined,
|
|
};
|
|
});
|
|
|
|
// 외부 테이블에서 값 조회 (_sourceData 전달)
|
|
const sourceData = row._sourceData || row;
|
|
const value = await fetchExternalValue(
|
|
selectedOption.tableName,
|
|
selectedOption.valueColumn,
|
|
joinConditions,
|
|
row,
|
|
sourceData,
|
|
formData,
|
|
);
|
|
|
|
if (value !== undefined) {
|
|
newValue = value;
|
|
}
|
|
|
|
return { ...row, [columnField]: newValue };
|
|
}),
|
|
);
|
|
|
|
// 계산 필드 업데이트
|
|
const calculatedData = calculateAll(updatedData);
|
|
handleDataChange(calculatedData);
|
|
return;
|
|
}
|
|
|
|
// 기존 columnModes 처리 (레거시 호환)
|
|
if (!column?.columnModes) return;
|
|
|
|
const selectedMode = column.columnModes.find((mode) => mode.id === optionId);
|
|
if (!selectedMode) return;
|
|
|
|
// 모든 행에 대해 새 값 조회
|
|
const updatedData = await Promise.all(
|
|
tableData.map(async (row) => {
|
|
const mapping = selectedMode.valueMapping;
|
|
let newValue: any = row[columnField];
|
|
const sourceData = row._sourceData || row;
|
|
|
|
if (mapping.type === "external" && mapping.externalRef) {
|
|
const { tableName, valueColumn, joinConditions } = mapping.externalRef;
|
|
const value = await fetchExternalValue(tableName, valueColumn, joinConditions, row, sourceData, formData);
|
|
if (value !== undefined) {
|
|
newValue = value;
|
|
}
|
|
} else if (mapping.type === "source" && mapping.sourceField) {
|
|
newValue = row[mapping.sourceField];
|
|
} else if (mapping.type === "internal" && mapping.internalField) {
|
|
newValue = formData[mapping.internalField];
|
|
}
|
|
|
|
return { ...row, [columnField]: newValue };
|
|
}),
|
|
);
|
|
|
|
// 계산 필드 업데이트
|
|
const calculatedData = calculateAll(updatedData);
|
|
handleDataChange(calculatedData);
|
|
},
|
|
[tableConfig.columns, tableData, formData, calculateAll, handleDataChange],
|
|
);
|
|
|
|
// 소스 테이블 정보
|
|
const { source, filters, uiConfig } = tableConfig;
|
|
const sourceTable = source.tableName;
|
|
const sourceColumns = source.displayColumns;
|
|
const sourceSearchFields = source.searchColumns;
|
|
const columnLabels = source.columnLabels || {};
|
|
const modalTitle = uiConfig?.modalTitle || "항목 검색 및 선택";
|
|
const multiSelect = uiConfig?.multiSelect ?? true;
|
|
|
|
// 버튼 표시 설정 (두 버튼 동시 표시 가능)
|
|
// 레거시 호환: 기존 addButtonType 설정이 있으면 그에 맞게 변환
|
|
const legacyAddButtonType = uiConfig?.addButtonType;
|
|
const showSearchButton = legacyAddButtonType === "addRow" ? false : (uiConfig?.showSearchButton ?? true);
|
|
const showAddRowButton = legacyAddButtonType === "addRow" ? true : (uiConfig?.showAddRowButton ?? false);
|
|
const searchButtonText = uiConfig?.searchButtonText || uiConfig?.addButtonText || "품목 검색";
|
|
const addRowButtonText = uiConfig?.addRowButtonText || "직접 입력";
|
|
|
|
// 기본 필터 조건 생성 (사전 필터만 - 모달 필터는 ItemSelectionModal에서 처리)
|
|
// 연산자별로 특수 키 형식 사용: column__operator (예: division__in)
|
|
const baseFilterCondition: Record<string, any> = useMemo(() => {
|
|
const condition: Record<string, any> = {};
|
|
if (filters?.preFilters) {
|
|
for (const filter of filters.preFilters) {
|
|
if (!filter.column || filter.value === undefined || filter.value === "") continue;
|
|
|
|
const operator = filter.operator || "=";
|
|
|
|
if (operator === "=") {
|
|
// 기본 등호 연산자는 그대로 전달
|
|
condition[filter.column] = filter.value;
|
|
} else {
|
|
// 다른 연산자는 특수 키 형식 사용: column__operator
|
|
condition[`${filter.column}__${operator}`] = filter.value;
|
|
}
|
|
}
|
|
}
|
|
// console.log("[TableSectionRenderer] baseFilterCondition:", condition, "preFilters:", filters?.preFilters);
|
|
return condition;
|
|
}, [filters?.preFilters]);
|
|
|
|
// 조건부 테이블용 필터 조건 생성 (선택된 조건값으로 소스 테이블 필터링)
|
|
const conditionalFilterCondition = useMemo(() => {
|
|
const filter = { ...baseFilterCondition };
|
|
|
|
// 조건부 테이블의 소스 필터 설정이 있고, 모달에서 선택된 조건이 있으면 적용
|
|
if (conditionalConfig?.sourceFilter?.enabled && modalCondition) {
|
|
filter[conditionalConfig.sourceFilter.filterColumn] = modalCondition;
|
|
}
|
|
|
|
return filter;
|
|
}, [baseFilterCondition, conditionalConfig?.sourceFilter, modalCondition]);
|
|
|
|
// 모달 필터 설정을 ItemSelectionModal에 전달할 형식으로 변환
|
|
const modalFiltersForModal = useMemo(() => {
|
|
if (!filters?.modalFilters) return [];
|
|
return filters.modalFilters.map((filter) => ({
|
|
column: filter.column,
|
|
label: filter.label || filter.column,
|
|
// category 타입을 select로 변환 (ModalFilterConfig 호환)
|
|
type: filter.type === "category" ? ("select" as const) : (filter.type as "text" | "select"),
|
|
options: filter.options,
|
|
categoryRef: filter.categoryRef,
|
|
defaultValue: filter.defaultValue,
|
|
}));
|
|
}, [filters?.modalFilters]);
|
|
|
|
// ============================================
|
|
// 조건부 테이블 관련 핸들러
|
|
// ============================================
|
|
|
|
// 조건부 테이블: 조건 체크박스 토글
|
|
const handleConditionToggle = useCallback(
|
|
(conditionValue: string, checked: boolean) => {
|
|
setSelectedConditions((prev) => {
|
|
if (checked) {
|
|
const newConditions = [...prev, conditionValue];
|
|
// 첫 번째 조건 선택 시 해당 탭 활성화
|
|
if (prev.length === 0) {
|
|
setActiveConditionTab(conditionValue);
|
|
}
|
|
return newConditions;
|
|
} else {
|
|
const newConditions = prev.filter((c) => c !== conditionValue);
|
|
// 현재 활성 탭이 제거된 경우 다른 탭으로 전환
|
|
if (activeConditionTab === conditionValue && newConditions.length > 0) {
|
|
setActiveConditionTab(newConditions[0]);
|
|
}
|
|
return newConditions;
|
|
}
|
|
});
|
|
},
|
|
[activeConditionTab],
|
|
);
|
|
|
|
// 조건부 테이블: 조건별 데이터 변경
|
|
const handleConditionalDataChange = useCallback(
|
|
(conditionValue: string, newData: any[]) => {
|
|
setConditionalTableData((prev) => ({
|
|
...prev,
|
|
[conditionValue]: newData,
|
|
}));
|
|
|
|
// 부모에게 조건별 데이터 변경 알림
|
|
if (onConditionalTableDataChange) {
|
|
onConditionalTableDataChange(conditionValue, newData);
|
|
}
|
|
|
|
// 전체 데이터를 flat array로 변환하여 onTableDataChange 호출
|
|
// (저장 시 조건 컬럼 값이 자동으로 추가됨)
|
|
const conditionColumn = conditionalConfig?.conditionColumn;
|
|
const allData: any[] = [];
|
|
|
|
// 현재 변경된 조건의 데이터 업데이트
|
|
const updatedConditionalData = { ...conditionalTableData, [conditionValue]: newData };
|
|
|
|
for (const [condition, data] of Object.entries(updatedConditionalData)) {
|
|
for (const row of data) {
|
|
allData.push({
|
|
...row,
|
|
...(conditionColumn ? { [conditionColumn]: condition } : {}),
|
|
});
|
|
}
|
|
}
|
|
|
|
onTableDataChange(allData);
|
|
},
|
|
[conditionalTableData, conditionalConfig?.conditionColumn, onConditionalTableDataChange, onTableDataChange],
|
|
);
|
|
|
|
// 조건부 테이블: 조건별 행 변경
|
|
const handleConditionalRowChange = useCallback(
|
|
(conditionValue: string, index: number, newRow: any) => {
|
|
const calculatedRow = calculateRow(newRow);
|
|
const currentData = conditionalTableData[conditionValue] || [];
|
|
const newData = [...currentData];
|
|
newData[index] = calculatedRow;
|
|
handleConditionalDataChange(conditionValue, newData);
|
|
},
|
|
[conditionalTableData, calculateRow, handleConditionalDataChange],
|
|
);
|
|
|
|
// 조건부 테이블: 조건별 행 삭제
|
|
const handleConditionalRowDelete = useCallback(
|
|
(conditionValue: string, index: number) => {
|
|
const currentData = conditionalTableData[conditionValue] || [];
|
|
const newData = currentData.filter((_, i) => i !== index);
|
|
handleConditionalDataChange(conditionValue, newData);
|
|
},
|
|
[conditionalTableData, handleConditionalDataChange],
|
|
);
|
|
|
|
// 조건부 테이블: 조건별 선택 행 일괄 삭제
|
|
const handleConditionalBulkDelete = useCallback(
|
|
(conditionValue: string) => {
|
|
const selected = conditionalSelectedRows[conditionValue] || new Set();
|
|
if (selected.size === 0) return;
|
|
|
|
const currentData = conditionalTableData[conditionValue] || [];
|
|
const newData = currentData.filter((_, index) => !selected.has(index));
|
|
handleConditionalDataChange(conditionValue, newData);
|
|
|
|
// 선택 상태 초기화
|
|
setConditionalSelectedRows((prev) => ({
|
|
...prev,
|
|
[conditionValue]: new Set(),
|
|
}));
|
|
},
|
|
[conditionalTableData, conditionalSelectedRows, handleConditionalDataChange],
|
|
);
|
|
|
|
// 조건부 테이블: 아이템 추가 (특정 조건에)
|
|
const handleConditionalAddItems = useCallback(
|
|
async (items: any[]) => {
|
|
if (!modalCondition) return;
|
|
|
|
// 기존 handleAddItems 로직을 재사용하여 매핑된 아이템 생성
|
|
const mappedItems = await Promise.all(
|
|
items.map(async (sourceItem) => {
|
|
const newItem: any = {};
|
|
|
|
for (const col of tableConfig.columns) {
|
|
const mapping = col.valueMapping;
|
|
|
|
// 소스 필드에서 값 복사 (기본)
|
|
if (!mapping) {
|
|
const sourceField = col.sourceField || col.field;
|
|
if (sourceItem[sourceField] !== undefined) {
|
|
newItem[col.field] = sourceItem[sourceField];
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// valueMapping 처리
|
|
if (mapping.type === "source" && mapping.sourceField) {
|
|
const value = sourceItem[mapping.sourceField];
|
|
if (value !== undefined) {
|
|
newItem[col.field] = value;
|
|
}
|
|
} else if (mapping.type === "manual") {
|
|
newItem[col.field] = col.defaultValue || "";
|
|
} else if (mapping.type === "internal" && mapping.internalField) {
|
|
newItem[col.field] = formData[mapping.internalField];
|
|
}
|
|
}
|
|
|
|
// 원본 소스 데이터 보존
|
|
newItem._sourceData = sourceItem;
|
|
|
|
return newItem;
|
|
}),
|
|
);
|
|
|
|
// 현재 조건의 데이터에 추가
|
|
const currentData = conditionalTableData[modalCondition] || [];
|
|
const newData = [...currentData, ...mappedItems];
|
|
handleConditionalDataChange(modalCondition, newData);
|
|
|
|
setModalOpen(false);
|
|
},
|
|
[modalCondition, tableConfig.columns, formData, conditionalTableData, handleConditionalDataChange],
|
|
);
|
|
|
|
// 조건부 테이블: 모달 열기 (특정 조건에 대해)
|
|
const openConditionalModal = useCallback((conditionValue: string) => {
|
|
setModalCondition(conditionValue);
|
|
setModalOpen(true);
|
|
}, []);
|
|
|
|
// 조건부 테이블: 빈 행 추가 (addRow 모드에서 사용)
|
|
const addEmptyRowToCondition = useCallback(
|
|
(conditionValue: string) => {
|
|
const newRow: Record<string, any> = {};
|
|
|
|
// 각 컬럼의 기본값으로 빈 행 생성
|
|
for (const col of tableConfig.columns) {
|
|
if (col.defaultValue !== undefined) {
|
|
newRow[col.field] = col.defaultValue;
|
|
} else if (col.type === "number") {
|
|
newRow[col.field] = 0;
|
|
} else if (col.type === "checkbox") {
|
|
newRow[col.field] = false;
|
|
} else {
|
|
newRow[col.field] = "";
|
|
}
|
|
}
|
|
|
|
// 조건 컬럼에 현재 조건 값 설정
|
|
if (conditionalConfig?.conditionColumn) {
|
|
newRow[conditionalConfig.conditionColumn] = conditionValue;
|
|
}
|
|
|
|
// 현재 조건의 데이터에 추가
|
|
const currentData = conditionalTableData[conditionValue] || [];
|
|
const newData = [...currentData, newRow];
|
|
handleConditionalDataChange(conditionValue, newData);
|
|
},
|
|
[tableConfig.columns, conditionalConfig?.conditionColumn, conditionalTableData, handleConditionalDataChange],
|
|
);
|
|
|
|
// 검색 버튼 클릭 핸들러
|
|
const handleSearchButtonClick = useCallback(
|
|
(conditionValue: string) => {
|
|
openConditionalModal(conditionValue);
|
|
},
|
|
[openConditionalModal],
|
|
);
|
|
|
|
// 행 추가 버튼 클릭 핸들러
|
|
const handleAddRowButtonClick = useCallback(
|
|
(conditionValue: string) => {
|
|
addEmptyRowToCondition(conditionValue);
|
|
},
|
|
[addEmptyRowToCondition],
|
|
);
|
|
|
|
// 조건부 테이블: 초기 데이터 로드 (수정 모드)
|
|
useEffect(() => {
|
|
if (!isConditionalMode) return;
|
|
if (initialDataLoadedRef.current) return;
|
|
|
|
const tableSectionKey = `_tableSection_${sectionId}`;
|
|
const initialData = formData[tableSectionKey];
|
|
|
|
if (Array.isArray(initialData) && initialData.length > 0) {
|
|
const conditionColumn = conditionalConfig?.conditionColumn;
|
|
|
|
if (conditionColumn) {
|
|
// 조건별로 데이터 그룹핑
|
|
const grouped: ConditionalTableData = {};
|
|
const conditions = new Set<string>();
|
|
|
|
for (const row of initialData) {
|
|
const conditionValue = row[conditionColumn] || "";
|
|
if (conditionValue) {
|
|
if (!grouped[conditionValue]) {
|
|
grouped[conditionValue] = [];
|
|
}
|
|
grouped[conditionValue].push(row);
|
|
conditions.add(conditionValue);
|
|
}
|
|
}
|
|
|
|
setConditionalTableData(grouped);
|
|
setSelectedConditions(Array.from(conditions));
|
|
|
|
// 첫 번째 조건을 활성 탭으로 설정
|
|
if (conditions.size > 0) {
|
|
setActiveConditionTab(Array.from(conditions)[0]);
|
|
}
|
|
|
|
initialDataLoadedRef.current = true;
|
|
}
|
|
}
|
|
}, [isConditionalMode, sectionId, formData, conditionalConfig?.conditionColumn]);
|
|
|
|
// 조건부 테이블: 전체 항목 수 계산
|
|
const totalConditionalItems = useMemo(() => {
|
|
return Object.values(conditionalTableData).reduce((sum, data) => sum + data.length, 0);
|
|
}, [conditionalTableData]);
|
|
|
|
// ============================================
|
|
// 조건부 테이블 렌더링
|
|
// ============================================
|
|
if (isConditionalMode && conditionalConfig) {
|
|
const { triggerType } = conditionalConfig;
|
|
|
|
// 정적 옵션과 동적 옵션 병합 (동적 옵션이 있으면 우선 사용)
|
|
// 빈 value를 가진 옵션은 제외 (Select.Item은 빈 문자열 value를 허용하지 않음)
|
|
const effectiveOptions = (
|
|
conditionalConfig.optionSource?.enabled && dynamicOptions.length > 0
|
|
? dynamicOptions
|
|
: conditionalConfig.options || []
|
|
).filter((opt) => opt.value && opt.value.trim() !== "");
|
|
|
|
// 로딩 중이면 로딩 표시
|
|
if (dynamicOptionsLoading) {
|
|
return (
|
|
<div className={cn("space-y-4", className)}>
|
|
<div className="text-muted-foreground flex items-center justify-center py-8 text-sm">
|
|
<div className="flex items-center gap-2">
|
|
<div className="border-primary h-4 w-4 animate-spin rounded-full border-2 border-t-transparent" />
|
|
조건 옵션을 불러오는 중...
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={cn("space-y-4", className)}>
|
|
{/* 조건 선택 UI */}
|
|
{triggerType === "checkbox" && (
|
|
<div className="space-y-3">
|
|
<div className="flex flex-wrap gap-3">
|
|
{effectiveOptions.map((option) => (
|
|
<label
|
|
key={option.id}
|
|
className="hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 transition-colors"
|
|
>
|
|
<Checkbox
|
|
checked={selectedConditions.includes(option.value)}
|
|
onCheckedChange={(checked) => handleConditionToggle(option.value, !!checked)}
|
|
/>
|
|
<span className="text-sm">{option.label}</span>
|
|
{conditionalTableData[option.value]?.length > 0 && (
|
|
<Badge variant="secondary" className="ml-1 text-xs">
|
|
{conditionalTableData[option.value].length}
|
|
</Badge>
|
|
)}
|
|
</label>
|
|
))}
|
|
</div>
|
|
|
|
{selectedConditions.length > 0 && (
|
|
<div className="text-muted-foreground text-xs">
|
|
{selectedConditions.length}개 유형 선택됨, 총 {totalConditionalItems}개 항목
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{triggerType === "dropdown" && (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm">유형 선택:</span>
|
|
<Select
|
|
value={selectedConditions[0] || ""}
|
|
onValueChange={(value) => {
|
|
setSelectedConditions([value]);
|
|
setActiveConditionTab(value);
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-9 w-[200px]">
|
|
<SelectValue placeholder="유형을 선택하세요" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{effectiveOptions.map((option) => (
|
|
<SelectItem key={option.id} value={option.value}>
|
|
{option.label}
|
|
{conditionalTableData[option.value]?.length > 0 &&
|
|
` (${conditionalTableData[option.value].length})`}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
|
|
{/* 선택된 조건들의 테이블 (탭 형태) */}
|
|
{selectedConditions.length > 0 && (
|
|
<Tabs value={activeConditionTab} onValueChange={setActiveConditionTab} className="w-full">
|
|
<TabsList className="w-full justify-start">
|
|
{selectedConditions.map((conditionValue) => {
|
|
const option = effectiveOptions.find((o) => o.value === conditionValue);
|
|
const itemCount = conditionalTableData[conditionValue]?.length || 0;
|
|
return (
|
|
<TabsTrigger key={conditionValue} value={conditionValue} className="gap-1.5">
|
|
{option?.label || conditionValue}
|
|
{itemCount > 0 && (
|
|
<Badge variant="outline" className="ml-1 h-5 px-1.5 text-xs">
|
|
{itemCount}
|
|
</Badge>
|
|
)}
|
|
</TabsTrigger>
|
|
);
|
|
})}
|
|
</TabsList>
|
|
|
|
{selectedConditions.map((conditionValue) => {
|
|
const data = conditionalTableData[conditionValue] || [];
|
|
const selected = conditionalSelectedRows[conditionValue] || new Set();
|
|
|
|
return (
|
|
<TabsContent key={conditionValue} value={conditionValue} className="mt-4 space-y-4">
|
|
{/* 테이블 상단 컨트롤 */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-muted-foreground text-sm">
|
|
{data.length > 0 && `${data.length}개 항목`}
|
|
{selected.size > 0 && ` (${selected.size}개 선택됨)`}
|
|
</span>
|
|
{columns.length > 0 && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setWidthTrigger((prev) => prev + 1)}
|
|
className="h-7 px-2 text-xs"
|
|
title={widthTrigger % 2 === 0 ? "내용에 맞게 자동 조정" : "균등 분배"}
|
|
>
|
|
{widthTrigger % 2 === 0 ? (
|
|
<>
|
|
<AlignJustify className="mr-1 h-3.5 w-3.5" />
|
|
자동 맞춤
|
|
</>
|
|
) : (
|
|
<>
|
|
<Columns className="mr-1 h-3.5 w-3.5" />
|
|
균등 분배
|
|
</>
|
|
)}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
{selected.size > 0 && (
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={() => handleConditionalBulkDelete(conditionValue)}
|
|
className="h-8 text-xs"
|
|
>
|
|
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
|
선택 삭제 ({selected.size})
|
|
</Button>
|
|
)}
|
|
{showSearchButton && (
|
|
<Button onClick={() => handleSearchButtonClick(conditionValue)} className="h-8 text-xs">
|
|
<Search className="mr-2 h-4 w-4" />
|
|
{searchButtonText}
|
|
</Button>
|
|
)}
|
|
{showAddRowButton && (
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => handleAddRowButtonClick(conditionValue)}
|
|
className="h-8 text-xs"
|
|
>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
{addRowButtonText}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 테이블 */}
|
|
<RepeaterTable
|
|
columns={columns}
|
|
data={data}
|
|
onDataChange={(newData) => handleConditionalDataChange(conditionValue, newData)}
|
|
onRowChange={(index, newRow) => handleConditionalRowChange(conditionValue, index, newRow)}
|
|
onRowDelete={(index) => handleConditionalRowDelete(conditionValue, index)}
|
|
activeDataSources={activeDataSources}
|
|
onDataSourceChange={handleDataSourceChange}
|
|
selectedRows={selected}
|
|
onSelectionChange={(newSelected) => {
|
|
setConditionalSelectedRows((prev) => ({
|
|
...prev,
|
|
[conditionValue]: newSelected,
|
|
}));
|
|
}}
|
|
equalizeWidthsTrigger={widthTrigger}
|
|
/>
|
|
</TabsContent>
|
|
);
|
|
})}
|
|
</Tabs>
|
|
)}
|
|
|
|
{/* tabs 모드: 모든 옵션을 탭으로 표시 (선택 UI 없음) */}
|
|
{triggerType === "tabs" && effectiveOptions.length > 0 && (
|
|
<Tabs
|
|
value={activeConditionTab || effectiveOptions[0]?.value}
|
|
onValueChange={setActiveConditionTab}
|
|
className="w-full"
|
|
>
|
|
<TabsList className="w-full justify-start">
|
|
{effectiveOptions.map((option) => {
|
|
const itemCount = conditionalTableData[option.value]?.length || 0;
|
|
return (
|
|
<TabsTrigger key={option.id} value={option.value} className="gap-1.5">
|
|
{option.label}
|
|
{itemCount > 0 && (
|
|
<Badge variant="outline" className="ml-1 h-5 px-1.5 text-xs">
|
|
{itemCount}
|
|
</Badge>
|
|
)}
|
|
</TabsTrigger>
|
|
);
|
|
})}
|
|
</TabsList>
|
|
|
|
{effectiveOptions.map((option) => {
|
|
const data = conditionalTableData[option.value] || [];
|
|
const selected = conditionalSelectedRows[option.value] || new Set();
|
|
|
|
return (
|
|
<TabsContent key={option.id} value={option.value} className="mt-4 space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-muted-foreground text-sm">
|
|
{data.length > 0 && `${data.length}개 항목`}
|
|
{selected.size > 0 && ` (${selected.size}개 선택됨)`}
|
|
</span>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
{selected.size > 0 && (
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={() => handleConditionalBulkDelete(option.value)}
|
|
className="h-8 text-xs"
|
|
>
|
|
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
|
선택 삭제 ({selected.size})
|
|
</Button>
|
|
)}
|
|
{showSearchButton && (
|
|
<Button onClick={() => handleSearchButtonClick(option.value)} className="h-8 text-xs">
|
|
<Search className="mr-2 h-4 w-4" />
|
|
{searchButtonText}
|
|
</Button>
|
|
)}
|
|
{showAddRowButton && (
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => handleAddRowButtonClick(option.value)}
|
|
className="h-8 text-xs"
|
|
>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
{addRowButtonText}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<RepeaterTable
|
|
columns={columns}
|
|
data={data}
|
|
onDataChange={(newData) => handleConditionalDataChange(option.value, newData)}
|
|
onRowChange={(index, newRow) => handleConditionalRowChange(option.value, index, newRow)}
|
|
onRowDelete={(index) => handleConditionalRowDelete(option.value, index)}
|
|
activeDataSources={activeDataSources}
|
|
onDataSourceChange={handleDataSourceChange}
|
|
selectedRows={selected}
|
|
onSelectionChange={(newSelected) => {
|
|
setConditionalSelectedRows((prev) => ({
|
|
...prev,
|
|
[option.value]: newSelected,
|
|
}));
|
|
}}
|
|
equalizeWidthsTrigger={widthTrigger}
|
|
/>
|
|
</TabsContent>
|
|
);
|
|
})}
|
|
</Tabs>
|
|
)}
|
|
|
|
{/* 조건이 선택되지 않은 경우 안내 메시지 (checkbox/dropdown 모드에서만) */}
|
|
{selectedConditions.length === 0 && triggerType !== "tabs" && (
|
|
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-12 text-center">
|
|
<p className="text-muted-foreground text-sm">
|
|
{triggerType === "checkbox" ? "위에서 유형을 선택하여 검사항목을 추가하세요." : "유형을 선택하세요."}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* 옵션이 없는 경우 안내 메시지 */}
|
|
{effectiveOptions.length === 0 && (
|
|
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-12 text-center">
|
|
<p className="text-muted-foreground text-sm">조건 옵션이 설정되지 않았습니다.</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* 항목 선택 모달 (조건부 테이블용) */}
|
|
<ItemSelectionModal
|
|
open={modalOpen}
|
|
onOpenChange={setModalOpen}
|
|
sourceTable={sourceTable}
|
|
sourceColumns={sourceColumns}
|
|
sourceSearchFields={sourceSearchFields}
|
|
multiSelect={multiSelect}
|
|
filterCondition={conditionalFilterCondition}
|
|
modalTitle={`${effectiveOptions.find((o) => o.value === modalCondition)?.label || modalCondition} - ${modalTitle}`}
|
|
alreadySelected={conditionalTableData[modalCondition] || []}
|
|
uniqueField={tableConfig.saveConfig?.uniqueField}
|
|
onSelect={handleConditionalAddItems}
|
|
columnLabels={columnLabels}
|
|
modalFilters={modalFiltersForModal}
|
|
categoryColumns={sourceCategoryColumns}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ============================================
|
|
// 일반 테이블 렌더링 (기존 로직)
|
|
// ============================================
|
|
return (
|
|
<div className={cn("space-y-4", className)}>
|
|
{/* 추가 버튼 영역 */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-muted-foreground text-sm">
|
|
{tableData.length > 0 && `${tableData.length}개 항목`}
|
|
{selectedRows.size > 0 && ` (${selectedRows.size}개 선택됨)`}
|
|
</span>
|
|
{columns.length > 0 && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setWidthTrigger((prev) => prev + 1)}
|
|
className="h-7 px-2 text-xs"
|
|
title={widthTrigger % 2 === 0 ? "내용에 맞게 자동 조정" : "균등 분배"}
|
|
>
|
|
{widthTrigger % 2 === 0 ? (
|
|
<>
|
|
<AlignJustify className="mr-1 h-3.5 w-3.5" />
|
|
자동 맞춤
|
|
</>
|
|
) : (
|
|
<>
|
|
<Columns className="mr-1 h-3.5 w-3.5" />
|
|
균등 분배
|
|
</>
|
|
)}
|
|
</Button>
|
|
)}
|
|
</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>
|
|
)}
|
|
{showSearchButton && (
|
|
<Button onClick={() => setModalOpen(true)} className="h-8 text-xs sm:h-10 sm:text-sm">
|
|
<Search className="mr-2 h-4 w-4" />
|
|
{searchButtonText}
|
|
</Button>
|
|
)}
|
|
{showAddRowButton && (
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
// 빈 행 추가
|
|
const newRow: Record<string, any> = {};
|
|
for (const col of columns) {
|
|
if (col.defaultValue !== undefined) {
|
|
newRow[col.field] = col.defaultValue;
|
|
} else if (col.type === "number") {
|
|
newRow[col.field] = 0;
|
|
} else if (col.type === "checkbox") {
|
|
newRow[col.field] = false;
|
|
} else {
|
|
newRow[col.field] = "";
|
|
}
|
|
}
|
|
handleDataChange([...tableData, newRow]);
|
|
}}
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
{addRowButtonText}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Repeater 테이블 */}
|
|
<RepeaterTable
|
|
columns={columns}
|
|
data={tableData}
|
|
onDataChange={handleDataChange}
|
|
onRowChange={handleRowChange}
|
|
onRowDelete={handleRowDelete}
|
|
activeDataSources={activeDataSources}
|
|
onDataSourceChange={handleDataSourceChange}
|
|
selectedRows={selectedRows}
|
|
onSelectionChange={setSelectedRows}
|
|
equalizeWidthsTrigger={widthTrigger}
|
|
/>
|
|
|
|
{/* 항목 선택 모달 */}
|
|
<ItemSelectionModal
|
|
open={modalOpen}
|
|
onOpenChange={setModalOpen}
|
|
sourceTable={sourceTable}
|
|
sourceColumns={sourceColumns}
|
|
sourceSearchFields={sourceSearchFields}
|
|
multiSelect={multiSelect}
|
|
filterCondition={baseFilterCondition}
|
|
modalTitle={modalTitle}
|
|
alreadySelected={tableData}
|
|
uniqueField={tableConfig.saveConfig?.uniqueField}
|
|
onSelect={handleAddItems}
|
|
columnLabels={columnLabels}
|
|
modalFilters={modalFiltersForModal}
|
|
categoryColumns={sourceCategoryColumns}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|