512 lines
17 KiB
TypeScript
512 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Plus, Columns, AlignJustify } 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, DynamicDataSourceOption } from "../modal-repeater-table/types";
|
|
|
|
// 타입 정의
|
|
import {
|
|
TableSectionConfig,
|
|
TableColumnConfig,
|
|
ValueMappingConfig,
|
|
TableJoinCondition,
|
|
FormDataState,
|
|
} from "./types";
|
|
|
|
interface TableSectionRendererProps {
|
|
sectionId: string;
|
|
tableConfig: TableSectionConfig;
|
|
formData: FormDataState;
|
|
onFormDataChange: (field: string, value: any) => void;
|
|
onTableDataChange: (data: any[]) => void;
|
|
className?: string;
|
|
}
|
|
|
|
/**
|
|
* TableColumnConfig를 RepeaterColumnConfig로 변환
|
|
* columnModes가 있으면 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,
|
|
defaultValue: col.defaultValue,
|
|
selectOptions: col.selectOptions,
|
|
// valueMapping은 별도로 처리
|
|
};
|
|
|
|
// columnModes를 dynamicDataSource로 변환
|
|
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 || "",
|
|
valueField: mode.valueMapping?.externalRef?.valueColumn || "",
|
|
joinConditions: (mode.valueMapping?.externalRef?.joinConditions || []).map((jc) => ({
|
|
sourceTable: jc.sourceType === "row" ? "target" : "source",
|
|
sourceField: jc.sourceField,
|
|
targetField: jc.targetColumn,
|
|
operator: jc.operator || "=",
|
|
})),
|
|
},
|
|
})),
|
|
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,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 외부 테이블에서 값을 조회하는 함수
|
|
*/
|
|
async function fetchExternalValue(
|
|
tableName: string,
|
|
valueColumn: string,
|
|
joinConditions: TableJoinCondition[],
|
|
rowData: any,
|
|
formData: FormDataState
|
|
): Promise<any> {
|
|
if (joinConditions.length === 0) {
|
|
console.warn("조인 조건이 없습니다.");
|
|
return undefined;
|
|
}
|
|
|
|
try {
|
|
const whereConditions: Record<string, any> = {};
|
|
|
|
for (const condition of joinConditions) {
|
|
let value: any;
|
|
|
|
// 값 출처에 따라 가져오기
|
|
if (condition.sourceType === "row") {
|
|
// 현재 행에서 가져오기
|
|
value = rowData[condition.sourceField];
|
|
} else if (condition.sourceType === "formData") {
|
|
// formData에서 가져오기 (핵심 기능!)
|
|
value = formData[condition.sourceField];
|
|
}
|
|
|
|
if (value === undefined || value === null) {
|
|
console.warn(`조인 조건의 필드 "${condition.sourceField}" 값이 없습니다. (sourceType: ${condition.sourceType})`);
|
|
return undefined;
|
|
}
|
|
|
|
// 숫자형 ID 변환
|
|
let convertedValue = value;
|
|
if (condition.targetColumn.endsWith("_id") || condition.targetColumn === "id") {
|
|
const numValue = Number(value);
|
|
if (!isNaN(numValue)) {
|
|
convertedValue = numValue;
|
|
}
|
|
}
|
|
|
|
whereConditions[condition.targetColumn] = convertedValue;
|
|
}
|
|
|
|
// 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,
|
|
className,
|
|
}: TableSectionRendererProps) {
|
|
// 테이블 데이터 상태
|
|
const [tableData, setTableData] = useState<any[]>([]);
|
|
|
|
// 모달 상태
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
|
|
// 체크박스 선택 상태
|
|
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
|
|
|
// 너비 조정 트리거 (홀수: 자동맞춤, 짝수: 균등분배)
|
|
const [widthTrigger, setWidthTrigger] = useState(0);
|
|
|
|
// 동적 데이터 소스 활성화 상태
|
|
const [activeDataSources, setActiveDataSources] = useState<Record<string, string>>({});
|
|
|
|
// RepeaterColumnConfig로 변환
|
|
const columns: RepeaterColumnConfig[] = (tableConfig.columns || []).map(convertToRepeaterColumn);
|
|
|
|
// 계산 규칙 변환
|
|
const calculationRules: CalculationRule[] = (tableConfig.calculations || []).map(convertToCalculationRule);
|
|
|
|
// 계산 로직
|
|
const calculateRow = useCallback(
|
|
(row: any): any => {
|
|
if (calculationRules.length === 0) return row;
|
|
|
|
const updatedRow = { ...row };
|
|
|
|
for (const rule of calculationRules) {
|
|
try {
|
|
let formula = rule.formula;
|
|
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.result) 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.result] = result;
|
|
} catch (error) {
|
|
console.error(`계산 오류 (${rule.formula}):`, error);
|
|
updatedRow[rule.result] = 0;
|
|
}
|
|
}
|
|
|
|
return updatedRow;
|
|
},
|
|
[calculationRules]
|
|
);
|
|
|
|
const calculateAll = useCallback(
|
|
(data: any[]): any[] => {
|
|
return data.map((row) => calculateRow(row));
|
|
},
|
|
[calculateRow]
|
|
);
|
|
|
|
// 데이터 변경 핸들러
|
|
const handleDataChange = useCallback(
|
|
(newData: any[]) => {
|
|
setTableData(newData);
|
|
onTableDataChange(newData);
|
|
},
|
|
[onTableDataChange]
|
|
);
|
|
|
|
// 행 변경 핸들러
|
|
const handleRowChange = useCallback(
|
|
(index: number, newRow: any) => {
|
|
const calculatedRow = calculateRow(newRow);
|
|
const newData = [...tableData];
|
|
newData[index] = calculatedRow;
|
|
handleDataChange(newData);
|
|
},
|
|
[tableData, calculateRow, handleDataChange]
|
|
);
|
|
|
|
// 행 삭제 핸들러
|
|
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());
|
|
}, [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;
|
|
|
|
// 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 }, // 현재까지 빌드된 아이템
|
|
formData
|
|
);
|
|
if (value !== undefined) {
|
|
newItem[col.field] = value;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
// 기본값 적용
|
|
if (col.defaultValue !== undefined && newItem[col.field] === undefined) {
|
|
newItem[col.field] = col.defaultValue;
|
|
}
|
|
}
|
|
|
|
return newItem;
|
|
})
|
|
);
|
|
|
|
// 계산 필드 업데이트
|
|
const calculatedItems = calculateAll(mappedItems);
|
|
|
|
// 기존 데이터에 추가
|
|
const newData = [...tableData, ...calculatedItems];
|
|
handleDataChange(newData);
|
|
},
|
|
[tableConfig.columns, formData, tableData, calculateAll, handleDataChange]
|
|
);
|
|
|
|
// 컬럼 모드 변경 핸들러
|
|
const handleDataSourceChange = useCallback(
|
|
async (columnField: string, optionId: string) => {
|
|
setActiveDataSources((prev) => ({
|
|
...prev,
|
|
[columnField]: optionId,
|
|
}));
|
|
|
|
// 해당 컬럼의 모든 행 데이터 재조회
|
|
const column = tableConfig.columns.find((col) => col.field === columnField);
|
|
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];
|
|
|
|
if (mapping.type === "external" && mapping.externalRef) {
|
|
const { tableName, valueColumn, joinConditions } = mapping.externalRef;
|
|
const value = await fetchExternalValue(tableName, valueColumn, joinConditions, row, 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 addButtonText = uiConfig?.addButtonText || "항목 검색";
|
|
const multiSelect = uiConfig?.multiSelect ?? true;
|
|
|
|
// 기본 필터 조건 생성 (사전 필터만 - 모달 필터는 ItemSelectionModal에서 처리)
|
|
const baseFilterCondition: Record<string, any> = {};
|
|
if (filters?.preFilters) {
|
|
for (const filter of filters.preFilters) {
|
|
// 간단한 "=" 연산자만 처리 (확장 가능)
|
|
if (filter.operator === "=") {
|
|
baseFilterCondition[filter.column] = filter.value;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 모달 필터 설정을 ItemSelectionModal에 전달할 형식으로 변환
|
|
const modalFiltersForModal = useMemo(() => {
|
|
if (!filters?.modalFilters) return [];
|
|
return filters.modalFilters.map((filter) => ({
|
|
column: filter.column,
|
|
label: filter.label || filter.column,
|
|
type: filter.type,
|
|
options: filter.options,
|
|
categoryRef: filter.categoryRef,
|
|
booleanRef: filter.booleanRef,
|
|
defaultValue: filter.defaultValue,
|
|
}));
|
|
}, [filters?.modalFilters]);
|
|
|
|
return (
|
|
<div className={cn("space-y-4", className)}>
|
|
{/* 추가 버튼 영역 */}
|
|
<div className="flex justify-between items-center">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm text-muted-foreground">
|
|
{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 text-xs px-2"
|
|
title={widthTrigger % 2 === 0 ? "내용에 맞게 자동 조정" : "균등 분배"}
|
|
>
|
|
{widthTrigger % 2 === 0 ? (
|
|
<>
|
|
<AlignJustify className="h-3.5 w-3.5 mr-1" />
|
|
자동 맞춤
|
|
</>
|
|
) : (
|
|
<>
|
|
<Columns className="h-3.5 w-3.5 mr-1" />
|
|
균등 분배
|
|
</>
|
|
)}
|
|
</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>
|
|
)}
|
|
<Button
|
|
onClick={() => setModalOpen(true)}
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
>
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
{addButtonText}
|
|
</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}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|