feature/v2-unified-renewal #379
|
|
@ -92,6 +92,14 @@ export function ComponentsPanel({
|
||||||
tags: ["tree", "org", "bom", "cascading", "unified"],
|
tags: ["tree", "org", "bom", "cascading", "unified"],
|
||||||
defaultSize: { width: 400, height: 300 },
|
defaultSize: { width: 400, height: 300 },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "unified-repeater",
|
||||||
|
name: "통합 반복 데이터",
|
||||||
|
description: "반복 데이터 관리 (인라인/모달/버튼 모드)",
|
||||||
|
category: "data" as ComponentCategory,
|
||||||
|
tags: ["repeater", "table", "modal", "button", "unified"],
|
||||||
|
defaultSize: { width: 600, height: 300 },
|
||||||
|
},
|
||||||
], []);
|
], []);
|
||||||
|
|
||||||
// 카테고리별 컴포넌트 그룹화
|
// 카테고리별 컴포넌트 그룹화
|
||||||
|
|
|
||||||
|
|
@ -3,22 +3,20 @@
|
||||||
/**
|
/**
|
||||||
* UnifiedRepeater 컴포넌트
|
* UnifiedRepeater 컴포넌트
|
||||||
*
|
*
|
||||||
* 기존 컴포넌트 통합:
|
* 렌더링 모드:
|
||||||
* - simple-repeater-table: 인라인 모드
|
* - inline: 현재 테이블 컬럼 직접 입력
|
||||||
* - modal-repeater-table: 모달 모드
|
* - modal: 엔티티 선택 (FK 저장) + 추가 입력 컬럼
|
||||||
* - repeat-screen-modal: 화면 기반 모달 모드
|
* - button: 버튼으로 관련 화면/모달 열기
|
||||||
* - related-data-buttons: 버튼 모드
|
* - mixed: inline + modal 혼합
|
||||||
*
|
|
||||||
* 모든 하드코딩을 제거하고 설정 기반으로 동작합니다.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Plus, Trash2, Edit, Eye, GripVertical } from "lucide-react";
|
import { Plus, Trash2, GripVertical, Search, X, Check } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
UnifiedRepeaterConfig,
|
UnifiedRepeaterConfig,
|
||||||
|
|
@ -61,42 +59,83 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
||||||
[propConfig],
|
[propConfig],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 상태
|
// 상태 - 메인 데이터
|
||||||
const [data, setData] = useState<any[]>(initialData || []);
|
const [data, setData] = useState<any[]>(initialData || []);
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
||||||
const [editingRow, setEditingRow] = useState<number | null>(null);
|
const [editingRow, setEditingRow] = useState<number | null>(null);
|
||||||
const [editedData, setEditedData] = useState<Record<string, any>>({});
|
const [editedData, setEditedData] = useState<Record<string, any>>({});
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
|
||||||
const [modalRow, setModalRow] = useState<any>(null);
|
// 상태 - 검색 모달 (modal 모드)
|
||||||
|
const [searchModalOpen, setSearchModalOpen] = useState(false);
|
||||||
|
const [searchKeyword, setSearchKeyword] = useState("");
|
||||||
|
const [searchResults, setSearchResults] = useState<any[]>([]);
|
||||||
|
const [searchLoading, setSearchLoading] = useState(false);
|
||||||
|
const [selectedSearchItems, setSelectedSearchItems] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
|
// 상태 - 버튼 모드
|
||||||
const [codeButtons, setCodeButtons] = useState<{ label: string; value: string; variant?: string }[]>([]);
|
const [codeButtons, setCodeButtons] = useState<{ label: string; value: string; variant?: string }[]>([]);
|
||||||
|
|
||||||
// 데이터 로드
|
// 상태 - 엔티티 표시 정보 캐시 (FK값 → 표시 데이터)
|
||||||
const loadData = useCallback(async () => {
|
const [entityDisplayCache, setEntityDisplayCache] = useState<Record<string | number, Record<string, any>>>({});
|
||||||
if (!config.dataSource?.tableName || !parentId) return;
|
|
||||||
|
// 상태 - 소스 테이블 컬럼 정보 (라벨 매핑용)
|
||||||
|
const [sourceTableColumnMap, setSourceTableColumnMap] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// 외부 데이터 변경 시 동기화
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialData) {
|
||||||
|
setData(initialData);
|
||||||
|
}
|
||||||
|
}, [initialData]);
|
||||||
|
|
||||||
|
// 소스 테이블 컬럼 정보 로드 (라벨 매핑용)
|
||||||
|
useEffect(() => {
|
||||||
|
const loadSourceTableColumns = async () => {
|
||||||
|
const sourceTable = config.dataSource?.sourceTable;
|
||||||
|
if (!sourceTable) return;
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get(`/dynamic-form/${config.dataSource.tableName}`, {
|
const response = await apiClient.get(`/table-management/tables/${sourceTable}/columns`);
|
||||||
params: {
|
console.log("소스 테이블 컬럼 API 응답:", response.data);
|
||||||
[config.dataSource.foreignKey]: parentId,
|
|
||||||
},
|
const colMap: Record<string, string> = {};
|
||||||
|
|
||||||
|
// 응답 구조에 따라 데이터 추출 - data.data.columns 형태 지원
|
||||||
|
let columns: any[] = [];
|
||||||
|
if (response.data?.success && response.data?.data) {
|
||||||
|
// { success: true, data: { columns: [...] } } 구조
|
||||||
|
if (Array.isArray(response.data.data.columns)) {
|
||||||
|
columns = response.data.data.columns;
|
||||||
|
} else if (Array.isArray(response.data.data)) {
|
||||||
|
columns = response.data.data;
|
||||||
|
}
|
||||||
|
} else if (Array.isArray(response.data)) {
|
||||||
|
columns = response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
columns.forEach((col: any) => {
|
||||||
|
const colName = col.columnName || col.column_name;
|
||||||
|
const colLabel = col.displayName || col.columnLabel || col.column_label || colName;
|
||||||
|
if (colName) {
|
||||||
|
colMap[colName] = colLabel;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.data?.success && response.data?.data) {
|
console.log("sourceTableColumnMap:", colMap);
|
||||||
const items = Array.isArray(response.data.data) ? response.data.data : [response.data.data];
|
setSourceTableColumnMap(colMap);
|
||||||
setData(items);
|
|
||||||
onDataChange?.(items);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("UnifiedRepeater 데이터 로드 실패:", error);
|
console.error("소스 테이블 컬럼 로드 실패:", error);
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
}, [config.dataSource?.tableName, config.dataSource?.foreignKey, parentId, onDataChange]);
|
};
|
||||||
|
|
||||||
|
if (config.renderMode === "modal" || config.renderMode === "mixed") {
|
||||||
|
loadSourceTableColumns();
|
||||||
|
}
|
||||||
|
}, [config.dataSource?.sourceTable, config.renderMode]);
|
||||||
|
|
||||||
// 공통코드 버튼 로드
|
// 공통코드 버튼 로드
|
||||||
const loadCodeButtons = useCallback(async () => {
|
useEffect(() => {
|
||||||
|
const loadCodeButtons = async () => {
|
||||||
if (config.button?.sourceType !== "commonCode" || !config.button?.commonCode?.categoryCode) return;
|
if (config.button?.sourceType !== "commonCode" || !config.button?.commonCode?.categoryCode) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -114,18 +153,51 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("공통코드 버튼 로드 실패:", error);
|
console.error("공통코드 버튼 로드 실패:", error);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
loadCodeButtons();
|
||||||
}, [config.button?.sourceType, config.button?.commonCode]);
|
}, [config.button?.sourceType, config.button?.commonCode]);
|
||||||
|
|
||||||
// 초기 로드
|
// 소스 테이블 검색 (modal 모드)
|
||||||
useEffect(() => {
|
const searchSourceTable = useCallback(async () => {
|
||||||
if (!initialData) {
|
const sourceTable = config.dataSource?.sourceTable;
|
||||||
loadData();
|
if (!sourceTable) return;
|
||||||
}
|
|
||||||
}, [loadData, initialData]);
|
|
||||||
|
|
||||||
|
setSearchLoading(true);
|
||||||
|
try {
|
||||||
|
const searchParams: any = {};
|
||||||
|
|
||||||
|
// 검색어가 있고, 검색 필드가 설정되어 있으면 검색
|
||||||
|
if (searchKeyword && config.modal?.searchFields?.length) {
|
||||||
|
// 검색 필드들에 검색어 적용
|
||||||
|
config.modal.searchFields.forEach((field) => {
|
||||||
|
searchParams[field] = searchKeyword;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiClient.post(`/table-management/tables/${sourceTable}/data`, {
|
||||||
|
search: Object.keys(searchParams).length > 0 ? searchParams : undefined,
|
||||||
|
page: 1,
|
||||||
|
size: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data?.success && response.data?.data) {
|
||||||
|
const items = Array.isArray(response.data.data) ? response.data.data : response.data.data.data || [];
|
||||||
|
setSearchResults(items);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("검색 실패:", error);
|
||||||
|
setSearchResults([]);
|
||||||
|
} finally {
|
||||||
|
setSearchLoading(false);
|
||||||
|
}
|
||||||
|
}, [config.dataSource?.sourceTable, config.modal?.searchFields, searchKeyword]);
|
||||||
|
|
||||||
|
// 검색 모달 열릴 때 자동 검색
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadCodeButtons();
|
if (searchModalOpen) {
|
||||||
}, [loadCodeButtons]);
|
searchSourceTable();
|
||||||
|
}
|
||||||
|
}, [searchModalOpen, searchSourceTable]);
|
||||||
|
|
||||||
// 행 선택 토글
|
// 행 선택 토글
|
||||||
const toggleRowSelection = (index: number) => {
|
const toggleRowSelection = (index: number) => {
|
||||||
|
|
@ -145,42 +217,128 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 검색 결과 항목 선택 토글 (검색 모달에서는 항상 다중 선택 허용)
|
||||||
|
const toggleSearchItemSelection = (index: number) => {
|
||||||
|
setSelectedSearchItems((prev) => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(index)) {
|
||||||
|
newSet.delete(index);
|
||||||
|
} else {
|
||||||
|
newSet.add(index);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 검색 항목 선택 확인 - FK만 저장하고 추가 입력 컬럼은 빈 값으로
|
||||||
|
const confirmSearchSelection = () => {
|
||||||
|
const selectedItems = Array.from(selectedSearchItems).map((index) => searchResults[index]).filter(Boolean);
|
||||||
|
|
||||||
|
console.log("confirmSearchSelection 호출:", {
|
||||||
|
selectedSearchItems: Array.from(selectedSearchItems),
|
||||||
|
selectedItems,
|
||||||
|
searchResults: searchResults.length,
|
||||||
|
foreignKey: config.dataSource?.foreignKey,
|
||||||
|
referenceKey: config.dataSource?.referenceKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selectedItems.length === 0) {
|
||||||
|
console.warn("선택된 항목이 없습니다");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newRows = selectedItems.map((sourceItem) => {
|
||||||
|
const newRow: any = {};
|
||||||
|
|
||||||
|
// 1. FK 저장 (엔티티 참조 ID만!)
|
||||||
|
if (config.dataSource?.foreignKey && config.dataSource?.referenceKey) {
|
||||||
|
newRow[config.dataSource.foreignKey] = sourceItem[config.dataSource.referenceKey];
|
||||||
|
console.log("FK 저장:", config.dataSource.foreignKey, "=", sourceItem[config.dataSource.referenceKey]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 추가 입력 컬럼은 빈 값으로 초기화
|
||||||
|
config.columns.forEach((col) => {
|
||||||
|
if (newRow[col.key] === undefined) {
|
||||||
|
newRow[col.key] = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return newRow;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 선택한 항목의 표시 정보를 캐시에 저장 (렌더링용)
|
||||||
|
const newDisplayCache = { ...entityDisplayCache };
|
||||||
|
selectedItems.forEach((sourceItem) => {
|
||||||
|
const fkValue = sourceItem[config.dataSource?.referenceKey || "id"];
|
||||||
|
if (fkValue) {
|
||||||
|
const displayData: Record<string, any> = {};
|
||||||
|
// 모달 표시 컬럼들의 값을 캐시 - sourceDisplayColumns (useMemo로 변환된 것) 사용
|
||||||
|
sourceDisplayColumns.forEach((col) => {
|
||||||
|
displayData[col.key] = sourceItem[col.key];
|
||||||
|
});
|
||||||
|
// displayColumn도 캐시
|
||||||
|
if (config.dataSource?.displayColumn) {
|
||||||
|
displayData[config.dataSource.displayColumn] = sourceItem[config.dataSource.displayColumn];
|
||||||
|
}
|
||||||
|
newDisplayCache[fkValue] = displayData;
|
||||||
|
console.log("캐시 저장:", fkValue, displayData);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setEntityDisplayCache(newDisplayCache);
|
||||||
|
|
||||||
|
const newData = [...data, ...newRows];
|
||||||
|
const firstNewRowIndex = data.length; // 기존 data 길이가 첫 새 행의 인덱스
|
||||||
|
|
||||||
|
setData(newData);
|
||||||
|
onDataChange?.(newData);
|
||||||
|
|
||||||
|
// 모달 닫기 및 초기화
|
||||||
|
setSearchModalOpen(false);
|
||||||
|
setSelectedSearchItems(new Set());
|
||||||
|
setSearchKeyword("");
|
||||||
|
|
||||||
|
// 마지막 추가된 행을 편집 모드로 (setTimeout으로 state 업데이트 후 실행)
|
||||||
|
const hasEditableColumns = config.columns.length > 0;
|
||||||
|
console.log("편집 모드 전환 체크:", {
|
||||||
|
newRowsLength: newRows.length,
|
||||||
|
inlineEdit: config.features?.inlineEdit,
|
||||||
|
hasEditableColumns,
|
||||||
|
columnsLength: config.columns.length,
|
||||||
|
columns: config.columns,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 추가 입력 컬럼이 있으면 편집 모드 시작
|
||||||
|
if (newRows.length > 0 && hasEditableColumns) {
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log("편집 모드 시작:", firstNewRowIndex, newRows[0]);
|
||||||
|
setEditingRow(firstNewRowIndex);
|
||||||
|
setEditedData({ ...newRows[0] });
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 행 추가
|
// 행 추가
|
||||||
const handleAddRow = async () => {
|
const handleAddRow = () => {
|
||||||
if (config.renderMode === "modal" || config.renderMode === "mixed") {
|
if (config.renderMode === "modal" || config.renderMode === "mixed") {
|
||||||
setModalRow(null);
|
// 검색 모달 열기
|
||||||
setModalOpen(true);
|
setSearchModalOpen(true);
|
||||||
} else {
|
} else {
|
||||||
// 인라인 추가
|
// 인라인 추가
|
||||||
const newRow: any = {};
|
const newRow: any = {};
|
||||||
config.columns.forEach((col) => {
|
config.columns.forEach((col) => {
|
||||||
newRow[col.key] = "";
|
newRow[col.key] = "";
|
||||||
});
|
});
|
||||||
if (config.dataSource?.foreignKey && parentId) {
|
|
||||||
newRow[config.dataSource.foreignKey] = parentId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newData = [...data, newRow];
|
const newData = [...data, newRow];
|
||||||
setData(newData);
|
setData(newData);
|
||||||
onDataChange?.(newData);
|
onDataChange?.(newData);
|
||||||
setEditingRow(newData.length - 1);
|
setEditingRow(newData.length - 1);
|
||||||
|
setEditedData(newRow);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 행 삭제
|
// 행 삭제 (로컬 상태만)
|
||||||
const handleDeleteRow = async (index: number) => {
|
const handleDeleteRow = (index: number) => {
|
||||||
const row = data[index];
|
|
||||||
const rowId = row?.id || row?.objid;
|
|
||||||
|
|
||||||
if (rowId && config.dataSource?.tableName) {
|
|
||||||
try {
|
|
||||||
await apiClient.delete(`/dynamic-form/${config.dataSource.tableName}/${rowId}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("행 삭제 실패:", error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const newData = data.filter((_, i) => i !== index);
|
const newData = data.filter((_, i) => i !== index);
|
||||||
setData(newData);
|
setData(newData);
|
||||||
onDataChange?.(newData);
|
onDataChange?.(newData);
|
||||||
|
|
@ -189,49 +347,38 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
||||||
newSet.delete(index);
|
newSet.delete(index);
|
||||||
return newSet;
|
return newSet;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 편집 중이던 행이 삭제되면 편집 취소
|
||||||
|
if (editingRow === index) {
|
||||||
|
setEditingRow(null);
|
||||||
|
setEditedData({});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 선택된 행 일괄 삭제
|
// 선택된 행 일괄 삭제
|
||||||
const handleDeleteSelected = async () => {
|
const handleDeleteSelected = () => {
|
||||||
if (selectedRows.size === 0) return;
|
if (selectedRows.size === 0) return;
|
||||||
|
|
||||||
const indices = Array.from(selectedRows).sort((a, b) => b - a); // 역순 정렬
|
const indices = Array.from(selectedRows).sort((a, b) => b - a);
|
||||||
|
let newData = [...data];
|
||||||
for (const index of indices) {
|
for (const index of indices) {
|
||||||
await handleDeleteRow(index);
|
newData = newData.filter((_, i) => i !== index);
|
||||||
}
|
}
|
||||||
|
setData(newData);
|
||||||
|
onDataChange?.(newData);
|
||||||
setSelectedRows(new Set());
|
setSelectedRows(new Set());
|
||||||
|
setEditingRow(null);
|
||||||
|
setEditedData({});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 인라인 편집 시작
|
// 인라인 편집 확인
|
||||||
const handleEditRow = (index: number) => {
|
const handleConfirmEdit = () => {
|
||||||
if (!config.features?.inlineEdit) return;
|
|
||||||
setEditingRow(index);
|
|
||||||
setEditedData({ ...data[index] });
|
|
||||||
};
|
|
||||||
|
|
||||||
// 인라인 편집 저장
|
|
||||||
const handleSaveEdit = async () => {
|
|
||||||
if (editingRow === null) return;
|
if (editingRow === null) return;
|
||||||
|
|
||||||
const rowId = editedData?.id || editedData?.objid;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (rowId && config.dataSource?.tableName) {
|
|
||||||
await apiClient.put(`/dynamic-form/${config.dataSource.tableName}/${rowId}`, editedData);
|
|
||||||
} else if (config.dataSource?.tableName) {
|
|
||||||
const response = await apiClient.post(`/dynamic-form/${config.dataSource.tableName}`, editedData);
|
|
||||||
if (response.data?.data?.id) {
|
|
||||||
editedData.id = response.data.data.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const newData = [...data];
|
const newData = [...data];
|
||||||
newData[editingRow] = editedData;
|
newData[editingRow] = editedData;
|
||||||
setData(newData);
|
setData(newData);
|
||||||
onDataChange?.(newData);
|
onDataChange?.(newData);
|
||||||
} catch (error) {
|
|
||||||
console.error("저장 실패:", error);
|
|
||||||
}
|
|
||||||
|
|
||||||
setEditingRow(null);
|
setEditingRow(null);
|
||||||
setEditedData({});
|
setEditedData({});
|
||||||
|
|
@ -245,61 +392,51 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
||||||
|
|
||||||
// 행 클릭
|
// 행 클릭
|
||||||
const handleRowClick = (row: any, index: number) => {
|
const handleRowClick = (row: any, index: number) => {
|
||||||
|
console.log("handleRowClick 호출:", {
|
||||||
|
index,
|
||||||
|
row,
|
||||||
|
currentEditingRow: editingRow,
|
||||||
|
inlineEdit: config.features?.inlineEdit,
|
||||||
|
columns: config.columns
|
||||||
|
});
|
||||||
|
|
||||||
|
if (editingRow === index) return;
|
||||||
|
|
||||||
|
if (editingRow !== null) {
|
||||||
|
handleConfirmEdit();
|
||||||
|
}
|
||||||
|
|
||||||
if (config.features?.selectable) {
|
if (config.features?.selectable) {
|
||||||
toggleRowSelection(index);
|
toggleRowSelection(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
onRowClick?.(row);
|
onRowClick?.(row);
|
||||||
|
|
||||||
if (config.renderMode === "modal" || config.renderMode === "mixed") {
|
// 인라인 편집 모드면 편집 시작 (모달 모드에서도 추가 입력 컬럼이 있으면 편집 가능)
|
||||||
setModalRow(row);
|
const hasEditableColumns = config.columns.length > 0;
|
||||||
setModalOpen(true);
|
const isModalRenderMode = config.renderMode === "modal" || config.renderMode === "mixed";
|
||||||
|
if (config.features?.inlineEdit || (isModalRenderMode && hasEditableColumns)) {
|
||||||
|
console.log("편집 모드 활성화:", { index, row });
|
||||||
|
setEditingRow(index);
|
||||||
|
setEditedData({ ...row });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 버튼 클릭 핸들러
|
// 버튼 클릭 핸들러
|
||||||
const handleButtonAction = (action: ButtonActionType, row?: any, buttonConfig?: RepeaterButtonConfig) => {
|
const handleButtonAction = (action: ButtonActionType, row?: any, buttonConfig?: RepeaterButtonConfig) => {
|
||||||
onButtonClick?.(action, row, buttonConfig);
|
onButtonClick?.(action, row, buttonConfig);
|
||||||
|
|
||||||
if (action === "view" && row) {
|
|
||||||
setModalRow(row);
|
|
||||||
setModalOpen(true);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 공통코드 버튼 클릭
|
// 공통코드 버튼 클릭
|
||||||
const handleCodeButtonClick = async (codeValue: string, row: any, index: number) => {
|
const handleCodeButtonClick = (codeValue: string, row: any, index: number) => {
|
||||||
const valueField = config.button?.commonCode?.valueField;
|
const valueField = config.button?.commonCode?.valueField;
|
||||||
if (!valueField) return;
|
if (!valueField) return;
|
||||||
|
|
||||||
const updatedRow = { ...row, [valueField]: codeValue };
|
const updatedRow = { ...row, [valueField]: codeValue };
|
||||||
const rowId = row?.id || row?.objid;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (rowId && config.dataSource?.tableName) {
|
|
||||||
await apiClient.put(`/dynamic-form/${config.dataSource.tableName}/${rowId}`, { [valueField]: codeValue });
|
|
||||||
}
|
|
||||||
|
|
||||||
const newData = [...data];
|
const newData = [...data];
|
||||||
newData[index] = updatedRow;
|
newData[index] = updatedRow;
|
||||||
setData(newData);
|
setData(newData);
|
||||||
onDataChange?.(newData);
|
onDataChange?.(newData);
|
||||||
} catch (error) {
|
|
||||||
console.error("상태 변경 실패:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 모달 제목 생성
|
|
||||||
const getModalTitle = (row?: any) => {
|
|
||||||
const template = config.modal?.titleTemplate;
|
|
||||||
if (!template) return row ? "상세 보기" : "새 항목";
|
|
||||||
|
|
||||||
let title = template.prefix || "";
|
|
||||||
if (template.columnKey && row?.[template.columnKey]) {
|
|
||||||
title += row[template.columnKey];
|
|
||||||
}
|
|
||||||
title += template.suffix || "";
|
|
||||||
|
|
||||||
return title || (row ? "상세 보기" : "새 항목");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 버튼 렌더링
|
// 버튼 렌더링
|
||||||
|
|
@ -328,7 +465,6 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 수동 버튼
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex gap-1", isVertical && "flex-col")}>
|
<div className={cn("flex gap-1", isVertical && "flex-col")}>
|
||||||
{(config.button?.manualButtons || []).map((btn) => (
|
{(config.button?.manualButtons || []).map((btn) => (
|
||||||
|
|
@ -349,27 +485,60 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 테이블 렌더링 (inline, mixed 모드)
|
// FK 값으로 캐시에서 표시 정보 가져오기
|
||||||
|
const getEntityDisplayInfo = (fkValue: any): Record<string, any> | null => {
|
||||||
|
if (!fkValue) return null;
|
||||||
|
return entityDisplayCache[fkValue] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 테이블 렌더링
|
||||||
const renderTable = () => {
|
const renderTable = () => {
|
||||||
if (config.renderMode === "button") return null;
|
if (config.renderMode === "button") return null;
|
||||||
|
|
||||||
|
const fkColumn = config.dataSource?.foreignKey;
|
||||||
|
const isModalMode = config.renderMode === "modal" || config.renderMode === "mixed";
|
||||||
|
|
||||||
|
// 모달 표시 컬럼들 (호환 처리된 sourceDisplayColumns 사용)
|
||||||
|
const displayColumns = sourceDisplayColumns;
|
||||||
|
|
||||||
|
// 전체 컬럼 수 계산
|
||||||
|
const totalColumns =
|
||||||
|
(config.features?.selectable ? 1 : 0) +
|
||||||
|
(config.features?.showRowNumber ? 1 : 0) +
|
||||||
|
(isModalMode ? displayColumns.length : 0) +
|
||||||
|
config.columns.length +
|
||||||
|
(config.features?.showDeleteButton ? 1 : 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-auto rounded-md border">
|
<div className="overflow-auto rounded-md border">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="bg-muted/50">
|
<TableRow className="bg-muted/50">
|
||||||
{config.features?.dragSort && <TableHead className="w-8" />}
|
|
||||||
{config.features?.selectable && <TableHead className="w-8" />}
|
{config.features?.selectable && <TableHead className="w-8" />}
|
||||||
{config.features?.showRowNumber && <TableHead className="w-12 text-center">#</TableHead>}
|
{config.features?.showRowNumber && <TableHead className="w-12 text-center">#</TableHead>}
|
||||||
|
|
||||||
|
{/* 모달 표시 컬럼들 (선택한 엔티티 정보 - 읽기 전용) */}
|
||||||
|
{isModalMode && displayColumns.map((col) => (
|
||||||
|
<TableHead key={`display_${col.key}`} className="bg-blue-50/50 min-w-[80px]">
|
||||||
|
{col.label}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 추가 입력 컬럼 */}
|
||||||
{config.columns
|
{config.columns
|
||||||
.filter((col) => col.visible !== false)
|
.filter((col) => col.visible !== false)
|
||||||
.map((col) => (
|
.map((col) => (
|
||||||
<TableHead key={col.key} style={{ width: col.width !== "auto" ? col.width : undefined }}>
|
<TableHead
|
||||||
|
key={col.key}
|
||||||
|
className="min-w-[100px]"
|
||||||
|
style={{ width: col.width !== "auto" ? col.width : undefined }}
|
||||||
|
>
|
||||||
{col.title}
|
{col.title}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
))}
|
))}
|
||||||
{(config.features?.inlineEdit || config.features?.showDeleteButton || config.renderMode === "mixed") && (
|
|
||||||
<TableHead className="w-24 text-center">액션</TableHead>
|
{config.features?.showDeleteButton && (
|
||||||
|
<TableHead className="w-20 text-center">액션</TableHead>
|
||||||
)}
|
)}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|
@ -377,20 +546,21 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
||||||
{data.length === 0 ? (
|
{data.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={
|
colSpan={totalColumns || 1}
|
||||||
config.columns.length +
|
|
||||||
(config.features?.dragSort ? 1 : 0) +
|
|
||||||
(config.features?.selectable ? 1 : 0) +
|
|
||||||
(config.features?.showRowNumber ? 1 : 0) +
|
|
||||||
1
|
|
||||||
}
|
|
||||||
className="text-muted-foreground py-8 text-center"
|
className="text-muted-foreground py-8 text-center"
|
||||||
>
|
>
|
||||||
데이터가 없습니다
|
{isModalMode
|
||||||
|
? `"${config.modal?.buttonText || "검색"}" 버튼을 클릭하여 항목을 추가하세요`
|
||||||
|
: "\"추가\" 버튼을 클릭하여 데이터를 입력하세요"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
data.map((row, index) => (
|
data.map((row, index) => {
|
||||||
|
// FK 값으로 캐시된 표시 정보 가져오기
|
||||||
|
const fkValue = fkColumn ? row[fkColumn] : null;
|
||||||
|
const displayInfo = getEntityDisplayInfo(fkValue);
|
||||||
|
|
||||||
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={row.id || row.objid || index}
|
key={row.id || row.objid || index}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -400,11 +570,6 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
||||||
)}
|
)}
|
||||||
onClick={() => handleRowClick(row, index)}
|
onClick={() => handleRowClick(row, index)}
|
||||||
>
|
>
|
||||||
{config.features?.dragSort && (
|
|
||||||
<TableCell className="w-8 p-1">
|
|
||||||
<GripVertical className="text-muted-foreground h-4 w-4 cursor-grab" />
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
{config.features?.selectable && (
|
{config.features?.selectable && (
|
||||||
<TableCell className="w-8 p-1">
|
<TableCell className="w-8 p-1">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
|
@ -417,11 +582,22 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
||||||
{config.features?.showRowNumber && (
|
{config.features?.showRowNumber && (
|
||||||
<TableCell className="text-muted-foreground w-12 text-center text-xs">{index + 1}</TableCell>
|
<TableCell className="text-muted-foreground w-12 text-center text-xs">{index + 1}</TableCell>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 모달 표시 컬럼들 (읽기 전용 - 캐시에서 조회) */}
|
||||||
|
{isModalMode && displayColumns.map((col) => (
|
||||||
|
<TableCell key={`display_${col.key}`} className="py-2 bg-blue-50/30">
|
||||||
|
<span className="text-sm text-blue-700">
|
||||||
|
{displayInfo?.[col.key] || "-"}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 추가 입력 컬럼 */}
|
||||||
{config.columns
|
{config.columns
|
||||||
.filter((col) => col.visible !== false)
|
.filter((col) => col.visible !== false)
|
||||||
.map((col) => (
|
.map((col) => (
|
||||||
<TableCell key={col.key} className="py-2">
|
<TableCell key={col.key} className="py-2 min-w-[100px]">
|
||||||
{editingRow === index ? (
|
{editingRow === index && col.editable !== false ? (
|
||||||
<Input
|
<Input
|
||||||
value={editedData[col.key] || ""}
|
value={editedData[col.key] || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
|
|
@ -430,71 +606,45 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
||||||
[col.key]: e.target.value,
|
[col.key]: e.target.value,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
className="h-7 text-xs"
|
className="h-7 text-xs min-w-[80px] w-full"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm">{row[col.key]}</span>
|
<span className="text-sm">{row[col.key] || "-"}</span>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
<TableCell className="w-24 p-1 text-center">
|
|
||||||
|
{config.features?.showDeleteButton && (
|
||||||
|
<TableCell className="w-20 p-1 text-center">
|
||||||
<div className="flex items-center justify-center gap-1">
|
<div className="flex items-center justify-center gap-1">
|
||||||
{editingRow === index ? (
|
{editingRow === index && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="icon"
|
||||||
variant="default"
|
variant="ghost"
|
||||||
className="h-6 px-2 text-xs"
|
className="h-6 w-6 text-green-600"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleSaveEdit();
|
handleConfirmEdit();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
저장
|
<Check className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="icon"
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
className="h-6 px-2 text-xs"
|
className="h-6 w-6"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleCancelEdit();
|
handleCancelEdit();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
취소
|
<X className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{config.features?.inlineEdit && (
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-6 w-6"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleEditRow(index);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Edit className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
{config.renderMode === "mixed" && (
|
{editingRow !== index && (
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-6 w-6"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setModalRow(row);
|
|
||||||
setModalOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Eye className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{config.features?.showDeleteButton && (
|
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -507,12 +657,12 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
||||||
<Trash2 className="h-3 w-3" />
|
<Trash2 className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
)}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
@ -537,16 +687,47 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 소스 테이블 표시 컬럼 (라벨 포함) - 이전/새 형식 호환 + 동적 라벨 매핑
|
||||||
|
const sourceDisplayColumns = useMemo(() => {
|
||||||
|
const rawCols = config.modal?.sourceDisplayColumns || [];
|
||||||
|
|
||||||
|
// string[] 형식과 {key, label}[] 형식 모두 지원
|
||||||
|
const result = rawCols
|
||||||
|
.map((col: any, idx: number) => {
|
||||||
|
if (typeof col === "string") {
|
||||||
|
// 컬럼명만 있는 경우: sourceTableColumnMap에서 라벨 조회
|
||||||
|
return {
|
||||||
|
key: col,
|
||||||
|
label: sourceTableColumnMap[col] || col
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// 라벨이 컬럼명과 같으면 sourceTableColumnMap에서 더 좋은 라벨 찾기
|
||||||
|
const dynamicLabel = sourceTableColumnMap[col.key];
|
||||||
|
return {
|
||||||
|
key: col.key || `col_${idx}`,
|
||||||
|
label: (col.label && col.label !== col.key) ? col.label : (dynamicLabel || col.label || col.key || `컬럼${idx + 1}`)
|
||||||
|
};
|
||||||
|
})
|
||||||
|
// 잘못된 컬럼 필터링 (none, 빈값, undefined 등)
|
||||||
|
.filter((col) => col.key && col.key !== "none" && col.key !== "undefined");
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [config.modal?.sourceDisplayColumns, sourceTableColumnMap]);
|
||||||
|
|
||||||
|
const displayColumn = config.dataSource?.displayColumn;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("space-y-2", className)}>
|
<div className={cn("space-y-2", className)}>
|
||||||
{/* 헤더 (추가/삭제 버튼) */}
|
{/* 헤더 */}
|
||||||
{(config.features?.showAddButton || (config.features?.selectable && selectedRows.size > 0)) && (
|
{(config.features?.showAddButton || (config.features?.selectable && selectedRows.size > 0)) && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{config.features?.showAddButton && (
|
{config.features?.showAddButton && (
|
||||||
<Button size="sm" variant="outline" onClick={handleAddRow} className="h-7 text-xs">
|
<Button size="sm" variant="outline" onClick={handleAddRow} className="h-7 text-xs">
|
||||||
<Plus className="mr-1 h-3 w-3" />
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
추가
|
{config.renderMode === "modal" || config.renderMode === "mixed"
|
||||||
|
? config.modal?.buttonText || "검색"
|
||||||
|
: "추가"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{config.features?.selectable && selectedRows.size > 0 && config.features?.showDeleteButton && (
|
{config.features?.selectable && selectedRows.size > 0 && config.features?.showDeleteButton && (
|
||||||
|
|
@ -560,62 +741,106 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 로딩 */}
|
|
||||||
{loading && <div className="text-muted-foreground py-4 text-center text-sm">로딩 중...</div>}
|
|
||||||
|
|
||||||
{/* 메인 컨텐츠 */}
|
{/* 메인 컨텐츠 */}
|
||||||
{!loading && (
|
|
||||||
<>
|
|
||||||
{renderTable()}
|
{renderTable()}
|
||||||
{renderButtonMode()}
|
{renderButtonMode()}
|
||||||
|
|
||||||
{/* mixed 모드에서 버튼도 표시 */}
|
{/* 검색 모달 (modal 모드) */}
|
||||||
{config.renderMode === "mixed" && data.length > 0 && (
|
<Dialog open={searchModalOpen} onOpenChange={setSearchModalOpen}>
|
||||||
<div className="border-t pt-2">
|
<DialogContent className={cn(MODAL_SIZE_MAP[config.modal?.size || "lg"])}>
|
||||||
{data.map((row, index) => (
|
|
||||||
<div key={row.id || row.objid || index} className="mb-1">
|
|
||||||
{renderButtons(row, index)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 모달 */}
|
|
||||||
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
|
||||||
<DialogContent className={cn(MODAL_SIZE_MAP[config.modal?.size || "md"])}>
|
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{getModalTitle(modalRow)}</DialogTitle>
|
<DialogTitle>{config.modal?.title || "항목 검색"}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="py-4">
|
|
||||||
{config.modal?.screenId ? (
|
{/* 검색 입력 */}
|
||||||
// 화면 기반 모달 - 동적 화면 로드
|
<div className="flex items-center gap-2">
|
||||||
<div className="text-muted-foreground text-center text-sm">
|
|
||||||
화면 ID: {config.modal.screenId}
|
|
||||||
{/* TODO: DynamicScreen 컴포넌트로 교체 */}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
// 기본 폼 표시
|
|
||||||
<div className="space-y-3">
|
|
||||||
{config.columns.map((col) => (
|
|
||||||
<div key={col.key} className="space-y-1">
|
|
||||||
<label className="text-sm font-medium">{col.title}</label>
|
|
||||||
<Input
|
<Input
|
||||||
value={modalRow?.[col.key] || ""}
|
value={searchKeyword}
|
||||||
onChange={(e) =>
|
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||||
setModalRow((prev: any) => ({
|
placeholder="검색어 입력..."
|
||||||
...prev,
|
className="flex-1"
|
||||||
[col.key]: e.target.value,
|
onKeyDown={(e) => e.key === "Enter" && searchSourceTable()}
|
||||||
}))
|
|
||||||
}
|
|
||||||
className="h-9"
|
|
||||||
/>
|
/>
|
||||||
|
<Button onClick={searchSourceTable} disabled={searchLoading}>
|
||||||
|
<Search className="mr-1 h-4 w-4" />
|
||||||
|
검색
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
{/* 검색 결과 테이블 */}
|
||||||
|
<div className="max-h-80 overflow-auto rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="bg-muted/50">
|
||||||
|
<TableHead className="w-8" />
|
||||||
|
{sourceDisplayColumns.length > 0 ? (
|
||||||
|
sourceDisplayColumns.map((col) => (
|
||||||
|
<TableHead key={col.key}>{col.label}</TableHead>
|
||||||
|
))
|
||||||
|
) : displayColumn ? (
|
||||||
|
<TableHead>{displayColumn}</TableHead>
|
||||||
|
) : (
|
||||||
|
<TableHead>항목</TableHead>
|
||||||
)}
|
)}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{searchLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={sourceDisplayColumns.length + 1 || 2} className="py-4 text-center">
|
||||||
|
검색 중...
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : searchResults.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={sourceDisplayColumns.length + 1 || 2} className="py-4 text-center text-muted-foreground">
|
||||||
|
검색 결과가 없습니다
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
searchResults.map((item, index) => (
|
||||||
|
<TableRow
|
||||||
|
key={item.id || item.objid || index}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer hover:bg-muted/50",
|
||||||
|
selectedSearchItems.has(index) && "bg-primary/10"
|
||||||
|
)}
|
||||||
|
onClick={() => toggleSearchItemSelection(index)}
|
||||||
|
>
|
||||||
|
<TableCell className="w-8 p-1">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedSearchItems.has(index)}
|
||||||
|
onCheckedChange={() => toggleSearchItemSelection(index)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
{sourceDisplayColumns.length > 0 ? (
|
||||||
|
sourceDisplayColumns.map((col) => (
|
||||||
|
<TableCell key={col.key}>{item[col.key]}</TableCell>
|
||||||
|
))
|
||||||
|
) : displayColumn ? (
|
||||||
|
<TableCell>{item[displayColumn]}</TableCell>
|
||||||
|
) : (
|
||||||
|
<TableCell>{JSON.stringify(item).substring(0, 50)}...</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setSearchModalOpen(false)}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={confirmSearchSelection}
|
||||||
|
disabled={selectedSearchItems.size === 0}
|
||||||
|
>
|
||||||
|
선택 ({selectedSearchItems.size}개)
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -625,4 +850,3 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
||||||
UnifiedRepeater.displayName = "UnifiedRepeater";
|
UnifiedRepeater.displayName = "UnifiedRepeater";
|
||||||
|
|
||||||
export default UnifiedRepeater;
|
export default UnifiedRepeater;
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -18,6 +18,7 @@ import {
|
||||||
UnifiedBiz,
|
UnifiedBiz,
|
||||||
UnifiedHierarchy,
|
UnifiedHierarchy,
|
||||||
} from "@/components/unified";
|
} from "@/components/unified";
|
||||||
|
import { UnifiedRepeater } from "@/components/unified/UnifiedRepeater";
|
||||||
|
|
||||||
// 컴포넌트 렌더러 인터페이스
|
// 컴포넌트 렌더러 인터페이스
|
||||||
export interface ComponentRenderer {
|
export interface ComponentRenderer {
|
||||||
|
|
@ -392,6 +393,42 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case "unified-repeater":
|
||||||
|
return (
|
||||||
|
<UnifiedRepeater
|
||||||
|
config={{
|
||||||
|
renderMode: config.renderMode || "inline",
|
||||||
|
dataSource: config.dataSource || {
|
||||||
|
tableName: "",
|
||||||
|
foreignKey: "",
|
||||||
|
referenceKey: "",
|
||||||
|
},
|
||||||
|
columns: config.columns || [],
|
||||||
|
modal: config.modal,
|
||||||
|
button: config.button,
|
||||||
|
features: config.features || {
|
||||||
|
showAddButton: true,
|
||||||
|
showDeleteButton: true,
|
||||||
|
inlineEdit: false,
|
||||||
|
dragSort: false,
|
||||||
|
showRowNumber: false,
|
||||||
|
selectable: false,
|
||||||
|
multiSelect: false,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
parentId={props.formData?.[config.dataSource?.referenceKey] || props.formData?.id}
|
||||||
|
onDataChange={(data) => {
|
||||||
|
console.log("UnifiedRepeater data changed:", data);
|
||||||
|
}}
|
||||||
|
onRowClick={(row) => {
|
||||||
|
console.log("UnifiedRepeater row clicked:", row);
|
||||||
|
}}
|
||||||
|
onButtonClick={(action, row, buttonConfig) => {
|
||||||
|
console.log("UnifiedRepeater button clicked:", action, row, buttonConfig);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-amber-300 bg-amber-50 p-4">
|
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-amber-300 bg-amber-50 p-4">
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
/**
|
/**
|
||||||
* UnifiedRepeater 컴포넌트 타입 정의
|
* UnifiedRepeater 컴포넌트 타입 정의
|
||||||
*
|
*
|
||||||
* 기존 컴포넌트 통합:
|
* 렌더링 모드:
|
||||||
* - simple-repeater-table: 인라인 모드
|
* - inline: 현재 테이블 컬럼 직접 입력 (simple-repeater-table)
|
||||||
* - modal-repeater-table: 모달 모드
|
* - modal: 소스 테이블에서 검색/선택 후 복사 (modal-repeater-table)
|
||||||
* - repeat-screen-modal: 화면 기반 모달 모드
|
* - button: 버튼으로 관련 화면 열기 (related-data-buttons)
|
||||||
* - related-data-buttons: 버튼 모드
|
* - mixed: inline + modal 혼합
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// 렌더링 모드
|
// 렌더링 모드
|
||||||
|
|
@ -35,6 +35,7 @@ export interface RepeaterColumnConfig {
|
||||||
title: string;
|
title: string;
|
||||||
width: ColumnWidthOption;
|
width: ColumnWidthOption;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
|
editable?: boolean; // 편집 가능 여부 (inline 모드)
|
||||||
isJoinColumn?: boolean;
|
isJoinColumn?: boolean;
|
||||||
sourceTable?: string;
|
sourceTable?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -62,10 +63,25 @@ export interface CommonCodeButtonConfig {
|
||||||
variantMapping?: Record<string, ButtonVariant>; // 코드값별 색상 매핑
|
variantMapping?: Record<string, ButtonVariant>; // 코드값별 색상 매핑
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 모달 표시 컬럼 (라벨 포함)
|
||||||
|
export interface ModalDisplayColumn {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
// 모달 설정
|
// 모달 설정
|
||||||
export interface RepeaterModalConfig {
|
export interface RepeaterModalConfig {
|
||||||
screenId?: number;
|
// 기본 설정
|
||||||
size: ModalSize;
|
size: ModalSize;
|
||||||
|
title?: string; // 모달 제목
|
||||||
|
buttonText?: string; // 검색 버튼 텍스트
|
||||||
|
|
||||||
|
// 소스 테이블 표시 설정 (modal 모드)
|
||||||
|
sourceDisplayColumns?: ModalDisplayColumn[]; // 모달에 표시할 소스 테이블 컬럼 (라벨 포함)
|
||||||
|
searchFields?: string[]; // 검색에 사용할 필드
|
||||||
|
|
||||||
|
// 화면 기반 모달 (옵션)
|
||||||
|
screenId?: number;
|
||||||
titleTemplate?: {
|
titleTemplate?: {
|
||||||
prefix?: string;
|
prefix?: string;
|
||||||
columnKey?: string;
|
columnKey?: string;
|
||||||
|
|
@ -84,25 +100,55 @@ export interface RepeaterFeatureOptions {
|
||||||
multiSelect: boolean;
|
multiSelect: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 데이터 소스 설정
|
||||||
|
export interface RepeaterDataSource {
|
||||||
|
// inline 모드: 현재 테이블 설정은 필요 없음 (컬럼만 선택)
|
||||||
|
|
||||||
|
// modal 모드: 소스 테이블 설정
|
||||||
|
sourceTable?: string; // 검색할 테이블 (엔티티 참조 테이블)
|
||||||
|
foreignKey?: string; // 현재 테이블의 FK 컬럼 (part_objid 등)
|
||||||
|
referenceKey?: string; // 소스 테이블의 PK 컬럼 (id 등)
|
||||||
|
displayColumn?: string; // 표시할 컬럼 (item_name 등)
|
||||||
|
|
||||||
|
// 추가 필터
|
||||||
|
filter?: {
|
||||||
|
column: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컬럼 매핑 (modal 모드에서 소스→타겟 복사용)
|
||||||
|
export interface ColumnMapping {
|
||||||
|
sourceColumn: string;
|
||||||
|
targetColumn: string;
|
||||||
|
transform?: "copy" | "reference"; // copy: 값 복사, reference: ID 참조
|
||||||
|
}
|
||||||
|
|
||||||
|
// 계산 규칙
|
||||||
|
export interface CalculationRule {
|
||||||
|
id: string;
|
||||||
|
targetColumn: string;
|
||||||
|
formula: string; // 예: "quantity * unit_price"
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// 메인 설정 타입
|
// 메인 설정 타입
|
||||||
export interface UnifiedRepeaterConfig {
|
export interface UnifiedRepeaterConfig {
|
||||||
// 렌더링 모드
|
// 렌더링 모드
|
||||||
renderMode: RepeaterRenderMode;
|
renderMode: RepeaterRenderMode;
|
||||||
|
|
||||||
// 데이터 소스 설정
|
// 데이터 소스 설정
|
||||||
dataSource: {
|
dataSource: RepeaterDataSource;
|
||||||
tableName: string; // 데이터 테이블
|
|
||||||
foreignKey: string; // 연결 키 (FK) - 데이터 테이블의 컬럼
|
|
||||||
referenceKey: string; // 상위 키 - 현재 화면 테이블의 컬럼 (부모 ID)
|
|
||||||
filter?: { // 추가 필터 조건
|
|
||||||
column: string;
|
|
||||||
value: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// 컬럼 설정
|
// 컬럼 설정 (inline: 입력 컬럼, modal: 표시 컬럼)
|
||||||
columns: RepeaterColumnConfig[];
|
columns: RepeaterColumnConfig[];
|
||||||
|
|
||||||
|
// 컬럼 매핑 (modal 모드)
|
||||||
|
columnMappings?: ColumnMapping[];
|
||||||
|
|
||||||
|
// 계산 규칙 (modal 모드)
|
||||||
|
calculationRules?: CalculationRule[];
|
||||||
|
|
||||||
// 모달 설정 (modal, mixed 모드)
|
// 모달 설정 (modal, mixed 모드)
|
||||||
modal?: RepeaterModalConfig;
|
modal?: RepeaterModalConfig;
|
||||||
|
|
||||||
|
|
@ -141,14 +187,12 @@ export interface UnifiedRepeaterProps {
|
||||||
// 기본 설정값
|
// 기본 설정값
|
||||||
export const DEFAULT_REPEATER_CONFIG: UnifiedRepeaterConfig = {
|
export const DEFAULT_REPEATER_CONFIG: UnifiedRepeaterConfig = {
|
||||||
renderMode: "inline",
|
renderMode: "inline",
|
||||||
dataSource: {
|
dataSource: {},
|
||||||
tableName: "",
|
|
||||||
foreignKey: "",
|
|
||||||
referenceKey: "",
|
|
||||||
},
|
|
||||||
columns: [],
|
columns: [],
|
||||||
modal: {
|
modal: {
|
||||||
size: "md",
|
size: "lg",
|
||||||
|
sourceDisplayColumns: [],
|
||||||
|
searchFields: [],
|
||||||
},
|
},
|
||||||
button: {
|
button: {
|
||||||
sourceType: "manual",
|
sourceType: "manual",
|
||||||
|
|
@ -159,20 +203,20 @@ export const DEFAULT_REPEATER_CONFIG: UnifiedRepeaterConfig = {
|
||||||
features: {
|
features: {
|
||||||
showAddButton: true,
|
showAddButton: true,
|
||||||
showDeleteButton: true,
|
showDeleteButton: true,
|
||||||
inlineEdit: false,
|
inlineEdit: true,
|
||||||
dragSort: false,
|
dragSort: false,
|
||||||
showRowNumber: false,
|
showRowNumber: false,
|
||||||
selectable: false,
|
selectable: false,
|
||||||
multiSelect: false,
|
multiSelect: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// 고정 옵션들 (콤보박스용)
|
// 고정 옵션들 (콤보박스용)
|
||||||
export const RENDER_MODE_OPTIONS = [
|
export const RENDER_MODE_OPTIONS = [
|
||||||
{ value: "inline", label: "인라인 (테이블)" },
|
{ value: "inline", label: "인라인 (직접 입력)" },
|
||||||
{ value: "modal", label: "모달" },
|
{ value: "modal", label: "모달 (검색 선택)" },
|
||||||
{ value: "button", label: "버튼" },
|
{ value: "button", label: "버튼" },
|
||||||
{ value: "mixed", label: "혼합 (테이블 + 버튼)" },
|
{ value: "mixed", label: "혼합 (입력 + 검색)" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const MODAL_SIZE_OPTIONS = [
|
export const MODAL_SIZE_OPTIONS = [
|
||||||
|
|
@ -227,4 +271,3 @@ export const LABEL_FIELD_OPTIONS = [
|
||||||
{ value: "codeName", label: "코드명" },
|
{ value: "codeName", label: "코드명" },
|
||||||
{ value: "codeValue", label: "코드값" },
|
{ value: "codeValue", label: "코드값" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue