ERP-node/frontend/components/unified/UnifiedRepeater.tsx

561 lines
20 KiB
TypeScript
Raw Normal View History

2025-12-23 14:45:19 +09:00
"use client";
/**
* UnifiedRepeater
*
* :
* - inline: 현재
* - modal: 엔티티 (FK ) +
*
2025-12-24 10:31:36 +09:00
* RepeaterTable ItemSelectionModal
2025-12-23 14:45:19 +09:00
*/
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
2025-12-23 14:45:19 +09:00
import { cn } from "@/lib/utils";
import {
UnifiedRepeaterConfig,
UnifiedRepeaterProps,
2025-12-24 10:31:36 +09:00
RepeaterColumnConfig as UnifiedColumnConfig,
2025-12-23 14:45:19 +09:00
DEFAULT_REPEATER_CONFIG,
} from "@/types/unified-repeater";
import { apiClient } from "@/lib/api/client";
2025-12-24 10:31:36 +09:00
// 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";
2025-12-23 14:45:19 +09:00
2025-12-24 10:31:36 +09:00
// 전역 UnifiedRepeater 등록 (buttonActions에서 사용)
2025-12-24 09:58:22 +09:00
declare global {
interface Window {
__unifiedRepeaterInstances?: Set<string>;
}
}
2025-12-23 14:45:19 +09:00
export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
config: propConfig,
parentId,
data: initialData,
onDataChange,
onRowClick,
className,
}) => {
// 설정 병합
const config: UnifiedRepeaterConfig = 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],
);
2025-12-24 10:31:36 +09:00
// 상태
2025-12-23 14:45:19 +09:00
const [data, setData] = useState<any[]>(initialData || []);
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
2025-12-24 10:31:36 +09:00
const [modalOpen, setModalOpen] = useState(false);
// 🆕 데이터 변경 시 자동으로 컬럼 너비 조정 트리거
const [autoWidthTrigger, setAutoWidthTrigger] = useState(0);
2025-12-24 10:31:36 +09:00
// 소스 테이블 컬럼 라벨 매핑
const [sourceColumnLabels, setSourceColumnLabels] = useState<Record<string, string>>({});
// 🆕 소스 테이블의 카테고리 타입 컬럼 목록
const [sourceCategoryColumns, setSourceCategoryColumns] = useState<string[]>([]);
// 🆕 카테고리 코드 → 라벨 매핑 (RepeaterTable 표시용)
const [categoryLabelMap, setCategoryLabelMap] = useState<Record<string, string>>({});
2025-12-24 10:31:36 +09:00
// 현재 테이블 컬럼 정보 (inputType 매핑용)
const [currentTableColumnInfo, setCurrentTableColumnInfo] = useState<Record<string, any>>({});
2025-12-24 10:31:36 +09:00
// 동적 데이터 소스 상태
const [activeDataSources, setActiveDataSources] = useState<Record<string, string>>({});
// 🆕 최신 엔티티 참조 정보 (column_labels에서 조회)
const [resolvedSourceTable, setResolvedSourceTable] = useState<string>("");
const [resolvedReferenceKey, setResolvedReferenceKey] = useState<string>("id");
2025-12-24 10:31:36 +09:00
const isModalMode = config.renderMode === "modal";
2025-12-24 09:58:22 +09:00
2025-12-24 10:31:36 +09:00
// 전역 리피터 등록
2025-12-24 09:58:22 +09:00
useEffect(() => {
const tableName = config.dataSource?.tableName;
2025-12-24 10:31:36 +09:00
if (tableName) {
if (!window.__unifiedRepeaterInstances) {
window.__unifiedRepeaterInstances = new Set();
}
window.__unifiedRepeaterInstances.add(tableName);
2025-12-24 09:58:22 +09:00
}
return () => {
2025-12-24 10:31:36 +09:00
if (tableName && window.__unifiedRepeaterInstances) {
window.__unifiedRepeaterInstances.delete(tableName);
}
2025-12-24 09:58:22 +09:00
};
}, [config.dataSource?.tableName]);
2025-12-23 14:45:19 +09:00
2025-12-24 10:31:36 +09:00
// 저장 이벤트 리스너
2025-12-24 09:58:22 +09:00
useEffect(() => {
const handleSaveEvent = async (event: CustomEvent) => {
const tableName = config.dataSource?.tableName;
const eventParentId = event.detail?.parentId;
2025-12-24 10:31:36 +09:00
const mainFormData = event.detail?.mainFormData;
2025-12-24 09:58:22 +09:00
if (!tableName || data.length === 0) {
return;
}
try {
2025-12-24 10:31:36 +09:00
// 테이블 유효 컬럼 조회
2025-12-24 09:58:22 +09:00
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 || [];
2025-12-24 09:58:22 +09:00
validColumns = new Set(columns.map((col: any) => col.columnName || col.column_name || col.name));
} catch {
2025-12-24 10:31:36 +09:00
console.warn("테이블 컬럼 정보 조회 실패");
2025-12-24 09:58:22 +09:00
}
2025-12-24 09:58:22 +09:00
for (let i = 0; i < data.length; i++) {
const row = data[i];
2025-12-24 10:31:36 +09:00
// 내부 필드 제거
const cleanRow = Object.fromEntries(Object.entries(row).filter(([key]) => !key.startsWith("_")));
2025-12-24 09:58:22 +09:00
2025-12-24 10:31:36 +09:00
// 메인 폼 데이터 병합
const { id: _mainId, ...mainFormDataWithoutId } = mainFormData || {};
2025-12-24 09:58:22 +09:00
const mergedData = {
2025-12-24 10:31:36 +09:00
...mainFormDataWithoutId,
...cleanRow,
2025-12-24 09:58:22 +09:00
};
2025-12-24 10:31:36 +09:00
// 유효하지 않은 컬럼 제거
2025-12-24 09:58:22 +09:00
const filteredData: Record<string, any> = {};
for (const [key, value] of Object.entries(mergedData)) {
if (validColumns.size === 0 || validColumns.has(key)) {
filteredData[key] = value;
}
}
2025-12-24 10:31:36 +09:00
await apiClient.post(`/table-management/tables/${tableName}/add`, filteredData);
2025-12-24 09:58:22 +09:00
}
2025-12-24 10:31:36 +09:00
console.log("UnifiedRepeater 저장 완료:", data.length, "건");
2025-12-24 09:58:22 +09:00
} catch (error) {
2025-12-24 10:31:36 +09:00
console.error("UnifiedRepeater 저장 실패:", error);
throw error;
2025-12-24 09:58:22 +09:00
}
};
window.addEventListener("repeaterSave" as any, handleSaveEvent);
return () => {
window.removeEventListener("repeaterSave" as any, handleSaveEvent);
};
2025-12-24 10:31:36 +09:00
}, [data, config.dataSource?.tableName, parentId]);
2025-12-24 09:58:22 +09:00
2025-12-24 10:31:36 +09:00
// 현재 테이블 컬럼 정보 로드
2025-12-24 09:58:22 +09:00
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 || [];
2025-12-24 10:31:36 +09:00
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);
2025-12-24 09:58:22 +09:00
} catch (error) {
2025-12-24 10:31:36 +09:00
console.error("컬럼 정보 로드 실패:", error);
2025-12-24 09:58:22 +09:00
}
};
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";
console.log("🔄 [UnifiedRepeater] 엔티티 참조 정보 조회:", {
foreignKey,
resolvedSourceTable: refTable,
resolvedReferenceKey: refKey,
configSourceTable: config.dataSource?.sourceTable,
});
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(() => {
2025-12-24 10:31:36 +09:00
const loadSourceColumnLabels = async () => {
if (!isModalMode || !resolvedSourceTable) return;
2025-12-24 10:31:36 +09:00
try {
const response = await apiClient.get(`/table-management/tables/${resolvedSourceTable}/columns`);
2025-12-24 10:31:36 +09:00
const columns = response.data?.data?.columns || response.data?.columns || response.data || [];
2025-12-24 10:31:36 +09:00
const labels: Record<string, string> = {};
const categoryCols: string[] = [];
columns.forEach((col: any) => {
2025-12-24 10:31:36 +09:00
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);
}
});
2025-12-24 10:31:36 +09:00
setSourceColumnLabels(labels);
setSourceCategoryColumns(categoryCols);
} catch (error) {
2025-12-24 10:31:36 +09:00
console.error("소스 컬럼 라벨 로드 실패:", error);
}
};
2025-12-24 10:31:36 +09:00
loadSourceColumnLabels();
}, [resolvedSourceTable, isModalMode]);
2025-12-24 10:31:36 +09:00
// UnifiedColumnConfig → RepeaterColumnConfig 변환
// 🆕 모든 컬럼을 columns 배열의 순서대로 처리 (isSourceDisplay 플래그로 구분)
2025-12-24 10:31:36 +09:00
const repeaterColumns: RepeaterColumnConfig[] = useMemo(() => {
return config.columns
.filter((col: UnifiedColumnConfig) => col.visible !== false)
.map((col: UnifiedColumnConfig): 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}`,
2025-12-24 10:31:36 +09:00
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}`;
}
2025-12-24 09:58:22 +09:00
}
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 전달
};
2025-12-24 09:58:22 +09:00
});
}, [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]);
2025-12-24 10:31:36 +09:00
// 데이터 변경 핸들러
const handleDataChange = useCallback(
(newData: any[]) => {
setData(newData);
onDataChange?.(newData);
// 🆕 데이터 변경 시 자동으로 컬럼 너비 조정
setAutoWidthTrigger((prev) => prev + 1);
},
[onDataChange],
);
2025-12-24 10:31:36 +09:00
// 행 변경 핸들러
const handleRowChange = useCallback(
(index: number, newRow: any) => {
const newData = [...data];
newData[index] = newRow;
// 🆕 handleDataChange 대신 직접 호출 (행 변경마다 너비 조정은 불필요)
setData(newData);
onDataChange?.(newData);
},
[data, onDataChange],
);
2025-12-24 10:31:36 +09:00
// 행 삭제 핸들러
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],
);
2025-12-24 10:31:36 +09:00
// 일괄 삭제 핸들러
const handleBulkDelete = useCallback(() => {
const newData = data.filter((_, index) => !selectedRows.has(index));
handleDataChange(newData); // 🆕 handleDataChange 사용
2025-12-24 10:31:36 +09:00
setSelectedRows(new Set());
}, [data, selectedRows, handleDataChange]);
2025-12-24 10:31:36 +09:00
// 행 추가 (inline 모드)
const handleAddRow = useCallback(() => {
if (isModalMode) {
setModalOpen(true);
2025-12-23 14:45:19 +09:00
} else {
2025-12-24 10:31:36 +09:00
const newRow: any = { _id: `new_${Date.now()}` };
2025-12-23 14:45:19 +09:00
config.columns.forEach((col) => {
newRow[col.key] = "";
});
const newData = [...data, newRow];
handleDataChange(newData); // 🆕 handleDataChange 사용
2025-12-23 14:45:19 +09:00
}
}, [isModalMode, config.columns, data, handleDataChange]);
// 모달에서 항목 선택 - 🆕 columns 배열에서 isSourceDisplay 플래그로 구분
const handleSelectItems = useCallback(
(items: Record<string, unknown>[]) => {
const fkColumn = config.dataSource?.foreignKey;
const newRows = items.map((item) => {
const row: any = { _id: `new_${Date.now()}_${Math.random()}` };
// FK 값 저장 (resolvedReferenceKey 사용)
if (fkColumn && item[resolvedReferenceKey]) {
row[fkColumn] = item[resolvedReferenceKey];
}
// 모든 컬럼 처리 (순서대로)
config.columns.forEach((col) => {
if (col.isSourceDisplay) {
// 소스 표시 컬럼: 소스 테이블에서 값 복사 (읽기 전용)
row[`_display_${col.key}`] = item[col.key] || "";
} else {
// 입력 컬럼: 빈 값으로 초기화
if (row[col.key] === undefined) {
row[col.key] = "";
}
2025-12-24 10:31:36 +09:00
}
});
return row;
2025-12-24 10:31:36 +09:00
});
const newData = [...data, ...newRows];
handleDataChange(newData); // 🆕 handleDataChange 사용하여 autoWidthTrigger도 증가
setModalOpen(false);
},
[config.dataSource?.foreignKey, resolvedReferenceKey, config.columns, data, handleDataChange],
);
// 소스 컬럼 목록 (모달용) - 🆕 columns 배열에서 isSourceDisplay인 것만 필터링
2025-12-24 10:31:36 +09:00
const sourceColumns = useMemo(() => {
return config.columns
.filter((col) => col.isSourceDisplay && col.visible !== false)
.map((col) => col.key)
2025-12-24 10:31:36 +09:00
.filter((key) => key && key !== "none");
}, [config.columns]);
2025-12-23 14:45:19 +09:00
2025-12-24 10:31:36 +09:00
return (
<div className={cn("space-y-4", className)}>
{/* 헤더 영역 */}
<div className="flex items-center justify-between">
2025-12-24 10:31:36 +09:00
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-sm">
2025-12-24 10:31:36 +09:00
{data.length > 0 && `${data.length}개 항목`}
{selectedRows.size > 0 && ` (${selectedRows.size}개 선택됨)`}
</span>
2025-12-23 14:45:19 +09:00
</div>
2025-12-24 10:31:36 +09:00
<div className="flex gap-2">
{selectedRows.size > 0 && (
<Button variant="destructive" onClick={handleBulkDelete} className="h-8 text-xs sm:h-10 sm:text-sm">
2025-12-24 10:31:36 +09:00
({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 || "검색" : "추가"}
2025-12-23 14:45:19 +09:00
</Button>
2025-12-24 10:31:36 +09:00
</div>
2025-12-23 14:45:19 +09:00
</div>
2025-12-24 10:31:36 +09:00
{/* Repeater 테이블 */}
<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}
2025-12-24 10:31:36 +09:00
/>
{/* 항목 선택 모달 (modal 모드) - 검색 필드는 표시 컬럼과 동일하게 자동 설정 */}
2025-12-24 10:31:36 +09:00
{isModalMode && (
<ItemSelectionModal
open={modalOpen}
onOpenChange={setModalOpen}
sourceTable={resolvedSourceTable}
2025-12-24 10:31:36 +09:00
sourceColumns={sourceColumns}
sourceSearchFields={sourceColumns}
2025-12-24 10:31:36 +09:00
multiSelect={config.features?.multiSelect ?? true}
modalTitle={config.modal?.title || "항목 검색"}
alreadySelected={data}
uniqueField={resolvedReferenceKey}
2025-12-24 10:31:36 +09:00
onSelect={handleSelectItems}
columnLabels={sourceColumnLabels}
categoryColumns={sourceCategoryColumns}
2025-12-24 10:31:36 +09:00
/>
2025-12-23 14:45:19 +09:00
)}
</div>
);
};
UnifiedRepeater.displayName = "UnifiedRepeater";
export default UnifiedRepeater;