Compare commits
7 Commits
ba2a281245
...
7fc341bca8
| Author | SHA1 | Date |
|---|---|---|
|
|
7fc341bca8 | |
|
|
23c9604672 | |
|
|
64c6942de3 | |
|
|
217e390fe9 | |
|
|
363ef44586 | |
|
|
48aa004a7f | |
|
|
34e48993e4 |
|
|
@ -30,6 +30,7 @@ export class EntityJoinController {
|
||||||
autoFilter, // 🔒 멀티테넌시 자동 필터
|
autoFilter, // 🔒 멀티테넌시 자동 필터
|
||||||
dataFilter, // 🆕 데이터 필터 (JSON 문자열)
|
dataFilter, // 🆕 데이터 필터 (JSON 문자열)
|
||||||
excludeFilter, // 🆕 제외 필터 (JSON 문자열) - 다른 테이블에 이미 존재하는 데이터 제외
|
excludeFilter, // 🆕 제외 필터 (JSON 문자열) - 다른 테이블에 이미 존재하는 데이터 제외
|
||||||
|
deduplication, // 🆕 중복 제거 설정 (JSON 문자열)
|
||||||
userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함
|
userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함
|
||||||
...otherParams
|
...otherParams
|
||||||
} = req.query;
|
} = req.query;
|
||||||
|
|
@ -139,6 +140,24 @@ export class EntityJoinController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 중복 제거 설정 처리
|
||||||
|
let parsedDeduplication: {
|
||||||
|
enabled: boolean;
|
||||||
|
groupByColumn: string;
|
||||||
|
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
|
||||||
|
sortColumn?: string;
|
||||||
|
} | undefined = undefined;
|
||||||
|
if (deduplication) {
|
||||||
|
try {
|
||||||
|
parsedDeduplication =
|
||||||
|
typeof deduplication === "string" ? JSON.parse(deduplication) : deduplication;
|
||||||
|
logger.info("중복 제거 설정 파싱 완료:", parsedDeduplication);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("중복 제거 설정 파싱 오류:", error);
|
||||||
|
parsedDeduplication = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const result = await tableManagementService.getTableDataWithEntityJoins(
|
const result = await tableManagementService.getTableDataWithEntityJoins(
|
||||||
tableName,
|
tableName,
|
||||||
{
|
{
|
||||||
|
|
@ -156,13 +175,26 @@ export class EntityJoinController {
|
||||||
screenEntityConfigs: parsedScreenEntityConfigs,
|
screenEntityConfigs: parsedScreenEntityConfigs,
|
||||||
dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달
|
dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달
|
||||||
excludeFilter: parsedExcludeFilter, // 🆕 제외 필터 전달
|
excludeFilter: parsedExcludeFilter, // 🆕 제외 필터 전달
|
||||||
|
deduplication: parsedDeduplication, // 🆕 중복 제거 설정 전달
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 🆕 중복 제거 처리 (결과 데이터에 적용)
|
||||||
|
let finalData = result;
|
||||||
|
if (parsedDeduplication?.enabled && parsedDeduplication.groupByColumn && Array.isArray(result.data)) {
|
||||||
|
logger.info(`🔄 중복 제거 시작: 기준 컬럼 = ${parsedDeduplication.groupByColumn}, 전략 = ${parsedDeduplication.keepStrategy}`);
|
||||||
|
const originalCount = result.data.length;
|
||||||
|
finalData = {
|
||||||
|
...result,
|
||||||
|
data: this.deduplicateData(result.data, parsedDeduplication),
|
||||||
|
};
|
||||||
|
logger.info(`✅ 중복 제거 완료: ${originalCount}개 → ${finalData.data.length}개`);
|
||||||
|
}
|
||||||
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "Entity 조인 데이터 조회 성공",
|
message: "Entity 조인 데이터 조회 성공",
|
||||||
data: result,
|
data: finalData,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Entity 조인 데이터 조회 실패", error);
|
logger.error("Entity 조인 데이터 조회 실패", error);
|
||||||
|
|
@ -537,6 +569,98 @@ export class EntityJoinController {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 중복 데이터 제거 (메모리 내 처리)
|
||||||
|
*/
|
||||||
|
private deduplicateData(
|
||||||
|
data: any[],
|
||||||
|
config: {
|
||||||
|
groupByColumn: string;
|
||||||
|
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
|
||||||
|
sortColumn?: string;
|
||||||
|
}
|
||||||
|
): any[] {
|
||||||
|
if (!data || data.length === 0) return data;
|
||||||
|
|
||||||
|
// 그룹별로 데이터 분류
|
||||||
|
const groups: Record<string, any[]> = {};
|
||||||
|
|
||||||
|
for (const row of data) {
|
||||||
|
const groupKey = row[config.groupByColumn];
|
||||||
|
if (groupKey === undefined || groupKey === null) continue;
|
||||||
|
|
||||||
|
if (!groups[groupKey]) {
|
||||||
|
groups[groupKey] = [];
|
||||||
|
}
|
||||||
|
groups[groupKey].push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 각 그룹에서 하나의 행만 선택
|
||||||
|
const result: any[] = [];
|
||||||
|
|
||||||
|
for (const [groupKey, rows] of Object.entries(groups)) {
|
||||||
|
if (rows.length === 0) continue;
|
||||||
|
|
||||||
|
let selectedRow: any;
|
||||||
|
|
||||||
|
switch (config.keepStrategy) {
|
||||||
|
case "latest":
|
||||||
|
// 정렬 컬럼 기준 최신 (가장 큰 값)
|
||||||
|
if (config.sortColumn) {
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
const aVal = a[config.sortColumn!];
|
||||||
|
const bVal = b[config.sortColumn!];
|
||||||
|
if (aVal === bVal) return 0;
|
||||||
|
if (aVal > bVal) return -1;
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
selectedRow = rows[0];
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "earliest":
|
||||||
|
// 정렬 컬럼 기준 최초 (가장 작은 값)
|
||||||
|
if (config.sortColumn) {
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
const aVal = a[config.sortColumn!];
|
||||||
|
const bVal = b[config.sortColumn!];
|
||||||
|
if (aVal === bVal) return 0;
|
||||||
|
if (aVal < bVal) return -1;
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
selectedRow = rows[0];
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "base_price":
|
||||||
|
// base_price가 true인 행 선택
|
||||||
|
selectedRow = rows.find((r) => r.base_price === true || r.base_price === "true") || rows[0];
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "current_date":
|
||||||
|
// 오늘 날짜 기준 유효 기간 내 행 선택
|
||||||
|
const today = new Date().toISOString().split("T")[0];
|
||||||
|
selectedRow = rows.find((r) => {
|
||||||
|
const startDate = r.start_date;
|
||||||
|
const endDate = r.end_date;
|
||||||
|
if (!startDate) return true;
|
||||||
|
if (startDate <= today && (!endDate || endDate >= today)) return true;
|
||||||
|
return false;
|
||||||
|
}) || rows[0];
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
selectedRow = rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedRow) {
|
||||||
|
result.push(selectedRow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const entityJoinController = new EntityJoinController();
|
export const entityJoinController = new EntityJoinController();
|
||||||
|
|
|
||||||
|
|
@ -3800,6 +3800,15 @@ export class TableManagementService {
|
||||||
const cacheableJoins: EntityJoinConfig[] = [];
|
const cacheableJoins: EntityJoinConfig[] = [];
|
||||||
const dbJoins: EntityJoinConfig[] = [];
|
const dbJoins: EntityJoinConfig[] = [];
|
||||||
|
|
||||||
|
// 🔒 멀티테넌시: 회사별 데이터 테이블은 캐시 사용 불가 (company_code 필터링 필요)
|
||||||
|
const companySpecificTables = [
|
||||||
|
"supplier_mng",
|
||||||
|
"customer_mng",
|
||||||
|
"item_info",
|
||||||
|
"dept_info",
|
||||||
|
// 필요시 추가
|
||||||
|
];
|
||||||
|
|
||||||
for (const config of joinConfigs) {
|
for (const config of joinConfigs) {
|
||||||
// table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인
|
// table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인
|
||||||
if (config.referenceTable === "table_column_category_values") {
|
if (config.referenceTable === "table_column_category_values") {
|
||||||
|
|
@ -3808,6 +3817,13 @@ export class TableManagementService {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔒 회사별 데이터 테이블은 캐시 사용 불가 (멀티테넌시)
|
||||||
|
if (companySpecificTables.includes(config.referenceTable)) {
|
||||||
|
dbJoins.push(config);
|
||||||
|
console.log(`🔗 DB 조인 (멀티테넌시): ${config.referenceTable}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// 캐시 가능성 확인
|
// 캐시 가능성 확인
|
||||||
const cachedData = await referenceCacheService.getCachedReference(
|
const cachedData = await referenceCacheService.getCachedReference(
|
||||||
config.referenceTable,
|
config.referenceTable,
|
||||||
|
|
|
||||||
|
|
@ -174,8 +174,16 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
// 🆕 editData가 있으면 formData와 originalData로 설정 (수정 모드)
|
// 🆕 editData가 있으면 formData와 originalData로 설정 (수정 모드)
|
||||||
if (editData) {
|
if (editData) {
|
||||||
console.log("📝 [ScreenModal] 수정 데이터 설정:", editData);
|
console.log("📝 [ScreenModal] 수정 데이터 설정:", editData);
|
||||||
setFormData(editData);
|
|
||||||
setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
|
// 🆕 배열인 경우 (그룹 레코드) vs 단일 객체 처리
|
||||||
|
if (Array.isArray(editData)) {
|
||||||
|
console.log(`📝 [ScreenModal] 그룹 레코드 ${editData.length}개 설정`);
|
||||||
|
setFormData(editData as any); // 배열 그대로 전달 (SelectedItemsDetailInput에서 처리)
|
||||||
|
setOriginalData(editData[0] || null); // 첫 번째 레코드를 원본으로 저장
|
||||||
|
} else {
|
||||||
|
setFormData(editData);
|
||||||
|
setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// 🆕 신규 등록 모드: 분할 패널 부모 데이터가 있으면 미리 설정
|
// 🆕 신규 등록 모드: 분할 패널 부모 데이터가 있으면 미리 설정
|
||||||
// 🔧 중요: 신규 등록 시에는 연결 필드(equipment_code 등)만 전달해야 함
|
// 🔧 중요: 신규 등록 시에는 연결 필드(equipment_code 등)만 전달해야 함
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,12 @@ export const entityJoinApi = {
|
||||||
filterColumn?: string;
|
filterColumn?: string;
|
||||||
filterValue?: any;
|
filterValue?: any;
|
||||||
}; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외)
|
}; // 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외)
|
||||||
|
deduplication?: {
|
||||||
|
enabled: boolean;
|
||||||
|
groupByColumn: string;
|
||||||
|
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
|
||||||
|
sortColumn?: string;
|
||||||
|
}; // 🆕 중복 제거 설정
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<EntityJoinResponse> => {
|
): Promise<EntityJoinResponse> => {
|
||||||
// 🔒 멀티테넌시: company_code 자동 필터링 활성화
|
// 🔒 멀티테넌시: company_code 자동 필터링 활성화
|
||||||
|
|
@ -99,6 +105,7 @@ export const entityJoinApi = {
|
||||||
autoFilter: JSON.stringify(autoFilter), // 🔒 멀티테넌시 필터링
|
autoFilter: JSON.stringify(autoFilter), // 🔒 멀티테넌시 필터링
|
||||||
dataFilter: params.dataFilter ? JSON.stringify(params.dataFilter) : undefined, // 🆕 데이터 필터
|
dataFilter: params.dataFilter ? JSON.stringify(params.dataFilter) : undefined, // 🆕 데이터 필터
|
||||||
excludeFilter: params.excludeFilter ? JSON.stringify(params.excludeFilter) : undefined, // 🆕 제외 필터
|
excludeFilter: params.excludeFilter ? JSON.stringify(params.excludeFilter) : undefined, // 🆕 제외 필터
|
||||||
|
deduplication: params.deduplication ? JSON.stringify(params.deduplication) : undefined, // 🆕 중복 제거 설정
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import {
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
||||||
import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-options";
|
import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-options";
|
||||||
|
|
@ -169,6 +170,11 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
const [rightSearchQuery, setRightSearchQuery] = useState("");
|
const [rightSearchQuery, setRightSearchQuery] = useState("");
|
||||||
const [isLoadingLeft, setIsLoadingLeft] = useState(false);
|
const [isLoadingLeft, setIsLoadingLeft] = useState(false);
|
||||||
const [isLoadingRight, setIsLoadingRight] = useState(false);
|
const [isLoadingRight, setIsLoadingRight] = useState(false);
|
||||||
|
|
||||||
|
// 🆕 추가 탭 관련 상태
|
||||||
|
const [activeTabIndex, setActiveTabIndex] = useState(0); // 0 = 기본 탭 (우측 패널), 1+ = 추가 탭
|
||||||
|
const [tabsData, setTabsData] = useState<Record<number, any[]>>({}); // 탭별 데이터 캐시
|
||||||
|
const [tabsLoading, setTabsLoading] = useState<Record<number, boolean>>({}); // 탭별 로딩 상태
|
||||||
const [rightTableColumns, setRightTableColumns] = useState<any[]>([]); // 우측 테이블 컬럼 정보
|
const [rightTableColumns, setRightTableColumns] = useState<any[]>([]); // 우측 테이블 컬럼 정보
|
||||||
const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들
|
const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들
|
||||||
const [leftColumnLabels, setLeftColumnLabels] = useState<Record<string, string>>({}); // 좌측 컬럼 라벨
|
const [leftColumnLabels, setLeftColumnLabels] = useState<Record<string, string>>({}); // 좌측 컬럼 라벨
|
||||||
|
|
@ -610,6 +616,41 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
return result;
|
return result;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 🆕 간단한 값 포맷팅 함수 (추가 탭용)
|
||||||
|
const formatValue = useCallback(
|
||||||
|
(
|
||||||
|
value: any,
|
||||||
|
format?: {
|
||||||
|
type?: "number" | "currency" | "date" | "text";
|
||||||
|
thousandSeparator?: boolean;
|
||||||
|
decimalPlaces?: number;
|
||||||
|
prefix?: string;
|
||||||
|
suffix?: string;
|
||||||
|
dateFormat?: string;
|
||||||
|
},
|
||||||
|
): string => {
|
||||||
|
if (value === null || value === undefined) return "-";
|
||||||
|
|
||||||
|
// 날짜 포맷
|
||||||
|
if (format?.type === "date" || format?.dateFormat) {
|
||||||
|
return formatDateValue(value, format?.dateFormat || "YYYY-MM-DD");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 숫자 포맷
|
||||||
|
if (
|
||||||
|
format?.type === "number" ||
|
||||||
|
format?.type === "currency" ||
|
||||||
|
format?.thousandSeparator ||
|
||||||
|
format?.decimalPlaces !== undefined
|
||||||
|
) {
|
||||||
|
return formatNumberValue(value, format);
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value);
|
||||||
|
},
|
||||||
|
[formatDateValue, formatNumberValue],
|
||||||
|
);
|
||||||
|
|
||||||
// 셀 값 포맷팅 함수 (카테고리 타입 처리 + 날짜/숫자 포맷)
|
// 셀 값 포맷팅 함수 (카테고리 타입 처리 + 날짜/숫자 포맷)
|
||||||
const formatCellValue = useCallback(
|
const formatCellValue = useCallback(
|
||||||
(
|
(
|
||||||
|
|
@ -960,11 +1001,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
|
|
||||||
console.log("🔗 [분할패널] 복합키 조건:", searchConditions);
|
console.log("🔗 [분할패널] 복합키 조건:", searchConditions);
|
||||||
|
|
||||||
// 엔티티 조인 API로 데이터 조회
|
// 엔티티 조인 API로 데이터 조회 (🆕 deduplication 전달)
|
||||||
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
|
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
|
||||||
search: searchConditions,
|
search: searchConditions,
|
||||||
enableEntityJoin: true,
|
enableEntityJoin: true,
|
||||||
size: 1000,
|
size: 1000,
|
||||||
|
deduplication: componentConfig.rightPanel?.deduplication, // 🆕 중복 제거 설정 전달
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("🔗 [분할패널] 복합키 조회 결과:", result);
|
console.log("🔗 [분할패널] 복합키 조회 결과:", result);
|
||||||
|
|
@ -1037,12 +1079,137 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 🆕 추가 탭 데이터 로딩 함수
|
||||||
|
const loadTabData = useCallback(
|
||||||
|
async (tabIndex: number, leftItem: any) => {
|
||||||
|
const tabConfig = componentConfig.rightPanel?.additionalTabs?.[tabIndex - 1];
|
||||||
|
if (!tabConfig || !leftItem || isDesignMode) return;
|
||||||
|
|
||||||
|
const tabTableName = tabConfig.tableName;
|
||||||
|
if (!tabTableName) return;
|
||||||
|
|
||||||
|
setTabsLoading((prev) => ({ ...prev, [tabIndex]: true }));
|
||||||
|
try {
|
||||||
|
// 조인 키 확인
|
||||||
|
const keys = tabConfig.relation?.keys;
|
||||||
|
const leftColumn = tabConfig.relation?.leftColumn || keys?.[0]?.leftColumn;
|
||||||
|
const rightColumn = tabConfig.relation?.foreignKey || keys?.[0]?.rightColumn;
|
||||||
|
|
||||||
|
let resultData: any[] = [];
|
||||||
|
|
||||||
|
if (leftColumn && rightColumn) {
|
||||||
|
// 조인 조건이 있는 경우
|
||||||
|
const { entityJoinApi } = await import("@/lib/api/entityJoin");
|
||||||
|
const searchConditions: Record<string, any> = {};
|
||||||
|
|
||||||
|
if (keys && keys.length > 0) {
|
||||||
|
// 복합키
|
||||||
|
keys.forEach((key) => {
|
||||||
|
if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) {
|
||||||
|
searchConditions[key.rightColumn] = leftItem[key.leftColumn];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 단일키
|
||||||
|
const leftValue = leftItem[leftColumn];
|
||||||
|
if (leftValue !== undefined) {
|
||||||
|
searchConditions[rightColumn] = leftValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🔗 [추가탭 ${tabIndex}] 조회 조건:`, searchConditions);
|
||||||
|
|
||||||
|
const result = await entityJoinApi.getTableDataWithJoins(tabTableName, {
|
||||||
|
search: searchConditions,
|
||||||
|
enableEntityJoin: true,
|
||||||
|
size: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
resultData = result.data || [];
|
||||||
|
} else {
|
||||||
|
// 조인 조건이 없는 경우: 전체 데이터 조회 (독립 탭)
|
||||||
|
const { entityJoinApi } = await import("@/lib/api/entityJoin");
|
||||||
|
const result = await entityJoinApi.getTableDataWithJoins(tabTableName, {
|
||||||
|
enableEntityJoin: true,
|
||||||
|
size: 1000,
|
||||||
|
});
|
||||||
|
resultData = result.data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터 필터 적용
|
||||||
|
const dataFilter = tabConfig.dataFilter;
|
||||||
|
if (dataFilter?.enabled && dataFilter.conditions?.length > 0) {
|
||||||
|
resultData = resultData.filter((item: any) => {
|
||||||
|
return dataFilter.conditions.every((cond: any) => {
|
||||||
|
const value = item[cond.column];
|
||||||
|
const condValue = cond.value;
|
||||||
|
switch (cond.operator) {
|
||||||
|
case "equals":
|
||||||
|
return value === condValue;
|
||||||
|
case "notEquals":
|
||||||
|
return value !== condValue;
|
||||||
|
case "contains":
|
||||||
|
return String(value).includes(String(condValue));
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 중복 제거 적용
|
||||||
|
const deduplication = tabConfig.deduplication;
|
||||||
|
if (deduplication?.enabled && deduplication.groupByColumn) {
|
||||||
|
const groupedMap = new Map<string, any>();
|
||||||
|
resultData.forEach((item) => {
|
||||||
|
const key = String(item[deduplication.groupByColumn] || "");
|
||||||
|
const existing = groupedMap.get(key);
|
||||||
|
if (!existing) {
|
||||||
|
groupedMap.set(key, item);
|
||||||
|
} else {
|
||||||
|
// keepStrategy에 따라 유지할 항목 결정
|
||||||
|
const sortCol = deduplication.sortColumn || "start_date";
|
||||||
|
const existingVal = existing[sortCol];
|
||||||
|
const newVal = item[sortCol];
|
||||||
|
if (deduplication.keepStrategy === "latest" && newVal > existingVal) {
|
||||||
|
groupedMap.set(key, item);
|
||||||
|
} else if (deduplication.keepStrategy === "earliest" && newVal < existingVal) {
|
||||||
|
groupedMap.set(key, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
resultData = Array.from(groupedMap.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🔗 [추가탭 ${tabIndex}] 결과 데이터:`, resultData.length);
|
||||||
|
setTabsData((prev) => ({ ...prev, [tabIndex]: resultData }));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`추가탭 ${tabIndex} 데이터 로드 실패:`, error);
|
||||||
|
toast({
|
||||||
|
title: "데이터 로드 실패",
|
||||||
|
description: `탭 데이터를 불러올 수 없습니다.`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setTabsLoading((prev) => ({ ...prev, [tabIndex]: false }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[componentConfig.rightPanel?.additionalTabs, isDesignMode, toast],
|
||||||
|
);
|
||||||
|
|
||||||
// 좌측 항목 선택 핸들러
|
// 좌측 항목 선택 핸들러
|
||||||
const handleLeftItemSelect = useCallback(
|
const handleLeftItemSelect = useCallback(
|
||||||
(item: any) => {
|
(item: any) => {
|
||||||
setSelectedLeftItem(item);
|
setSelectedLeftItem(item);
|
||||||
setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화
|
setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화
|
||||||
loadRightData(item);
|
setTabsData({}); // 모든 탭 데이터 초기화
|
||||||
|
|
||||||
|
// 현재 활성 탭에 따라 데이터 로드
|
||||||
|
if (activeTabIndex === 0) {
|
||||||
|
loadRightData(item);
|
||||||
|
} else {
|
||||||
|
loadTabData(activeTabIndex, item);
|
||||||
|
}
|
||||||
|
|
||||||
// 🆕 modalDataStore에 선택된 좌측 항목 저장 (단일 선택)
|
// 🆕 modalDataStore에 선택된 좌측 항목 저장 (단일 선택)
|
||||||
const leftTableName = componentConfig.leftPanel?.tableName;
|
const leftTableName = componentConfig.leftPanel?.tableName;
|
||||||
|
|
@ -1053,7 +1220,30 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[loadRightData, componentConfig.leftPanel?.tableName, isDesignMode],
|
[loadRightData, loadTabData, activeTabIndex, componentConfig.leftPanel?.tableName, isDesignMode],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 🆕 탭 변경 핸들러
|
||||||
|
const handleTabChange = useCallback(
|
||||||
|
(newTabIndex: number) => {
|
||||||
|
setActiveTabIndex(newTabIndex);
|
||||||
|
|
||||||
|
// 선택된 좌측 항목이 있으면 해당 탭의 데이터 로드
|
||||||
|
if (selectedLeftItem) {
|
||||||
|
if (newTabIndex === 0) {
|
||||||
|
// 기본 탭: 우측 패널 데이터가 없으면 로드
|
||||||
|
if (!rightData || (Array.isArray(rightData) && rightData.length === 0)) {
|
||||||
|
loadRightData(selectedLeftItem);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 추가 탭: 해당 탭 데이터가 없으면 로드
|
||||||
|
if (!tabsData[newTabIndex]) {
|
||||||
|
loadTabData(newTabIndex, selectedLeftItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectedLeftItem, rightData, tabsData, loadRightData, loadTabData],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 우측 항목 확장/축소 토글
|
// 우측 항목 확장/축소 토글
|
||||||
|
|
@ -1449,14 +1639,19 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
|
|
||||||
// 수정 버튼 핸들러
|
// 수정 버튼 핸들러
|
||||||
const handleEditClick = useCallback(
|
const handleEditClick = useCallback(
|
||||||
(panel: "left" | "right", item: any) => {
|
async (panel: "left" | "right", item: any) => {
|
||||||
|
// 🆕 현재 활성 탭의 설정 가져오기
|
||||||
|
const currentTabConfig =
|
||||||
|
activeTabIndex === 0
|
||||||
|
? componentConfig.rightPanel
|
||||||
|
: componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1];
|
||||||
// 🆕 우측 패널 수정 버튼 설정 확인
|
// 🆕 우측 패널 수정 버튼 설정 확인
|
||||||
if (panel === "right" && componentConfig.rightPanel?.editButton?.mode === "modal") {
|
if (panel === "right" && currentTabConfig?.editButton?.mode === "modal") {
|
||||||
const modalScreenId = componentConfig.rightPanel?.editButton?.modalScreenId;
|
const modalScreenId = currentTabConfig?.editButton?.modalScreenId;
|
||||||
|
|
||||||
if (modalScreenId) {
|
if (modalScreenId) {
|
||||||
// 커스텀 모달 화면 열기
|
// 커스텀 모달 화면 열기
|
||||||
const rightTableName = componentConfig.rightPanel?.tableName || "";
|
const rightTableName = currentTabConfig?.tableName || "";
|
||||||
|
|
||||||
console.log("✅ 수정 모달 열기:", {
|
console.log("✅ 수정 모달 열기:", {
|
||||||
tableName: rightTableName,
|
tableName: rightTableName,
|
||||||
|
|
@ -1470,33 +1665,108 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🆕 groupByColumns 추출
|
// 🆕 groupByColumns 추출
|
||||||
const groupByColumns = componentConfig.rightPanel?.editButton?.groupByColumns || [];
|
const groupByColumns = currentTabConfig?.editButton?.groupByColumns || [];
|
||||||
|
|
||||||
console.log("🔧 [SplitPanel] 수정 버튼 클릭 - groupByColumns 확인:", {
|
console.log("🔧 [SplitPanel] 수정 버튼 클릭 - groupByColumns 확인:", {
|
||||||
groupByColumns,
|
groupByColumns,
|
||||||
editButtonConfig: componentConfig.rightPanel?.editButton,
|
editButtonConfig: currentTabConfig?.editButton,
|
||||||
hasGroupByColumns: groupByColumns.length > 0,
|
hasGroupByColumns: groupByColumns.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 🆕 groupByColumns 기준으로 모든 관련 레코드 조회 (API 직접 호출)
|
||||||
|
let allRelatedRecords = [item]; // 기본값: 현재 아이템만
|
||||||
|
|
||||||
|
if (groupByColumns.length > 0) {
|
||||||
|
// groupByColumns 값으로 검색 조건 생성
|
||||||
|
const matchConditions: Record<string, any> = {};
|
||||||
|
groupByColumns.forEach((col: string) => {
|
||||||
|
if (item[col] !== undefined && item[col] !== null) {
|
||||||
|
matchConditions[col] = item[col];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("🔍 [SplitPanel] 그룹 레코드 조회 시작:", {
|
||||||
|
테이블: rightTableName,
|
||||||
|
조건: matchConditions,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Object.keys(matchConditions).length > 0) {
|
||||||
|
// 🆕 deduplication 없이 원본 데이터 다시 조회 (API 직접 호출)
|
||||||
|
try {
|
||||||
|
const { entityJoinApi } = await import("@/lib/api/entityJoin");
|
||||||
|
|
||||||
|
// 🔧 dataFilter로 정확 매칭 조건 생성 (search는 LIKE 검색이라 부정확)
|
||||||
|
const exactMatchFilters = Object.entries(matchConditions).map(([key, value]) => ({
|
||||||
|
id: `exact-${key}`,
|
||||||
|
columnName: key,
|
||||||
|
operator: "equals",
|
||||||
|
value: value,
|
||||||
|
valueType: "text",
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log("🔍 [SplitPanel] 정확 매칭 필터:", exactMatchFilters);
|
||||||
|
|
||||||
|
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
|
||||||
|
// search 대신 dataFilter 사용 (정확 매칭)
|
||||||
|
dataFilter: {
|
||||||
|
enabled: true,
|
||||||
|
matchType: "all",
|
||||||
|
filters: exactMatchFilters,
|
||||||
|
},
|
||||||
|
enableEntityJoin: true,
|
||||||
|
size: 1000,
|
||||||
|
// 🔧 명시적으로 deduplication 비활성화 (모든 레코드 가져오기)
|
||||||
|
deduplication: { enabled: false, groupByColumn: "", keepStrategy: "latest" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🔍 디버깅: API 응답 구조 확인
|
||||||
|
console.log("🔍 [SplitPanel] API 응답 전체:", result);
|
||||||
|
console.log("🔍 [SplitPanel] result.data:", result.data);
|
||||||
|
console.log("🔍 [SplitPanel] result 타입:", typeof result);
|
||||||
|
|
||||||
|
// result 자체가 배열일 수도 있음 (entityJoinApi 응답 구조에 따라)
|
||||||
|
const dataArray = Array.isArray(result) ? result : (result.data || []);
|
||||||
|
|
||||||
|
if (dataArray.length > 0) {
|
||||||
|
allRelatedRecords = dataArray;
|
||||||
|
console.log("✅ [SplitPanel] 그룹 레코드 조회 완료:", {
|
||||||
|
조건: matchConditions,
|
||||||
|
결과수: allRelatedRecords.length,
|
||||||
|
레코드들: allRelatedRecords.map((r: any) => ({ id: r.id, supplier_item_code: r.supplier_item_code })),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn("⚠️ [SplitPanel] 그룹 레코드 조회 결과 없음, 현재 아이템만 사용");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ [SplitPanel] 그룹 레코드 조회 실패:", error);
|
||||||
|
allRelatedRecords = [item];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn("⚠️ [SplitPanel] groupByColumns 값이 없음, 현재 아이템만 사용");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 🔧 수정: URL 파라미터 대신 editData로 직접 전달
|
// 🔧 수정: URL 파라미터 대신 editData로 직접 전달
|
||||||
// 이렇게 하면 테이블의 Primary Key가 무엇이든 상관없이 데이터가 정확히 전달됨
|
// 이렇게 하면 테이블의 Primary Key가 무엇이든 상관없이 데이터가 정확히 전달됨
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent("openScreenModal", {
|
new CustomEvent("openScreenModal", {
|
||||||
detail: {
|
detail: {
|
||||||
screenId: modalScreenId,
|
screenId: modalScreenId,
|
||||||
editData: item, // 전체 데이터를 직접 전달
|
editData: allRelatedRecords, // 🆕 모든 관련 레코드 전달 (배열)
|
||||||
...(groupByColumns.length > 0 && {
|
urlParams: {
|
||||||
urlParams: {
|
mode: "edit", // 🆕 수정 모드 표시
|
||||||
|
...(groupByColumns.length > 0 && {
|
||||||
groupByColumns: JSON.stringify(groupByColumns),
|
groupByColumns: JSON.stringify(groupByColumns),
|
||||||
},
|
}),
|
||||||
}),
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log("✅ [SplitPanel] openScreenModal 이벤트 발생 (editData 직접 전달):", {
|
console.log("✅ [SplitPanel] openScreenModal 이벤트 발생 (editData 직접 전달):", {
|
||||||
screenId: modalScreenId,
|
screenId: modalScreenId,
|
||||||
editData: item,
|
editData: allRelatedRecords,
|
||||||
|
recordCount: allRelatedRecords.length,
|
||||||
groupByColumns: groupByColumns.length > 0 ? JSON.stringify(groupByColumns) : "없음",
|
groupByColumns: groupByColumns.length > 0 ? JSON.stringify(groupByColumns) : "없음",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1510,7 +1780,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
setEditModalFormData({ ...item });
|
setEditModalFormData({ ...item });
|
||||||
setShowEditModal(true);
|
setShowEditModal(true);
|
||||||
},
|
},
|
||||||
[componentConfig],
|
[componentConfig, activeTabIndex],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 수정 모달 저장
|
// 수정 모달 저장
|
||||||
|
|
@ -1610,13 +1880,19 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
|
|
||||||
// 삭제 확인
|
// 삭제 확인
|
||||||
const handleDeleteConfirm = useCallback(async () => {
|
const handleDeleteConfirm = useCallback(async () => {
|
||||||
|
// 🆕 현재 활성 탭의 설정 가져오기
|
||||||
|
const currentTabConfig =
|
||||||
|
activeTabIndex === 0
|
||||||
|
? componentConfig.rightPanel
|
||||||
|
: componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1];
|
||||||
|
|
||||||
// 우측 패널 삭제 시 중계 테이블 확인
|
// 우측 패널 삭제 시 중계 테이블 확인
|
||||||
let tableName =
|
let tableName =
|
||||||
deleteModalPanel === "left" ? componentConfig.leftPanel?.tableName : componentConfig.rightPanel?.tableName;
|
deleteModalPanel === "left" ? componentConfig.leftPanel?.tableName : currentTabConfig?.tableName;
|
||||||
|
|
||||||
// 우측 패널 + 중계 테이블 모드인 경우
|
// 우측 패널 + 중계 테이블 모드인 경우
|
||||||
if (deleteModalPanel === "right" && componentConfig.rightPanel?.addConfig?.targetTable) {
|
if (deleteModalPanel === "right" && currentTabConfig?.addConfig?.targetTable) {
|
||||||
tableName = componentConfig.rightPanel.addConfig.targetTable;
|
tableName = currentTabConfig.addConfig.targetTable;
|
||||||
console.log("🔗 중계 테이블 모드: 삭제 대상 테이블 =", tableName);
|
console.log("🔗 중계 테이블 모드: 삭제 대상 테이블 =", tableName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1746,7 +2022,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
setRightData(null);
|
setRightData(null);
|
||||||
}
|
}
|
||||||
} else if (deleteModalPanel === "right" && selectedLeftItem) {
|
} else if (deleteModalPanel === "right" && selectedLeftItem) {
|
||||||
loadRightData(selectedLeftItem);
|
// 🆕 현재 활성 탭에 따라 새로고침
|
||||||
|
if (activeTabIndex === 0) {
|
||||||
|
loadRightData(selectedLeftItem);
|
||||||
|
} else {
|
||||||
|
loadTabData(activeTabIndex, selectedLeftItem);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
toast({
|
toast({
|
||||||
|
|
@ -1770,7 +2051,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [deleteModalPanel, componentConfig, deleteModalItem, toast, selectedLeftItem, loadLeftData, loadRightData]);
|
}, [deleteModalPanel, componentConfig, deleteModalItem, toast, selectedLeftItem, loadLeftData, loadRightData, activeTabIndex, loadTabData]);
|
||||||
|
|
||||||
// 항목별 추가 버튼 핸들러 (좌측 항목의 + 버튼 - 하위 항목 추가)
|
// 항목별 추가 버튼 핸들러 (좌측 항목의 + 버튼 - 하위 항목 추가)
|
||||||
const handleItemAddClick = useCallback(
|
const handleItemAddClick = useCallback(
|
||||||
|
|
@ -2591,6 +2872,34 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
className="flex flex-shrink-0 flex-col"
|
className="flex flex-shrink-0 flex-col"
|
||||||
>
|
>
|
||||||
<Card className="flex flex-col border-0 shadow-none" style={{ height: "100%" }}>
|
<Card className="flex flex-col border-0 shadow-none" style={{ height: "100%" }}>
|
||||||
|
{/* 🆕 탭 바 (추가 탭이 있을 때만 표시) */}
|
||||||
|
{(componentConfig.rightPanel?.additionalTabs?.length || 0) > 0 && (
|
||||||
|
<div className="flex-shrink-0 border-b">
|
||||||
|
<Tabs
|
||||||
|
value={String(activeTabIndex)}
|
||||||
|
onValueChange={(value) => handleTabChange(Number(value))}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<TabsList className="h-9 w-full justify-start rounded-none border-b-0 bg-transparent p-0 px-2">
|
||||||
|
<TabsTrigger
|
||||||
|
value="0"
|
||||||
|
className="h-8 rounded-none border-b-2 border-transparent px-4 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
|
||||||
|
>
|
||||||
|
{componentConfig.rightPanel?.title || "기본"}
|
||||||
|
</TabsTrigger>
|
||||||
|
{componentConfig.rightPanel?.additionalTabs?.map((tab, index) => (
|
||||||
|
<TabsTrigger
|
||||||
|
key={tab.tabId}
|
||||||
|
value={String(index + 1)}
|
||||||
|
className="h-8 rounded-none border-b-2 border-transparent px-4 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
|
||||||
|
>
|
||||||
|
{tab.label || `탭 ${index + 1}`}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<CardHeader
|
<CardHeader
|
||||||
className="flex-shrink-0 border-b"
|
className="flex-shrink-0 border-b"
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -2603,16 +2912,28 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
>
|
>
|
||||||
<div className="flex w-full items-center justify-between">
|
<div className="flex w-full items-center justify-between">
|
||||||
<CardTitle className="text-base font-semibold">
|
<CardTitle className="text-base font-semibold">
|
||||||
{componentConfig.rightPanel?.title || "우측 패널"}
|
{activeTabIndex === 0
|
||||||
|
? componentConfig.rightPanel?.title || "우측 패널"
|
||||||
|
: componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]?.title ||
|
||||||
|
componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]?.label ||
|
||||||
|
"우측 패널"}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
{!isDesignMode && (
|
{!isDesignMode && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{componentConfig.rightPanel?.showAdd && (
|
{/* 현재 활성 탭에 따른 추가 버튼 */}
|
||||||
<Button size="sm" variant="outline" onClick={() => handleAddClick("right")}>
|
{activeTabIndex === 0
|
||||||
<Plus className="mr-1 h-4 w-4" />
|
? componentConfig.rightPanel?.showAdd && (
|
||||||
추가
|
<Button size="sm" variant="outline" onClick={() => handleAddClick("right")}>
|
||||||
</Button>
|
<Plus className="mr-1 h-4 w-4" />
|
||||||
)}
|
추가
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
: componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]?.showAdd && (
|
||||||
|
<Button size="sm" variant="outline" onClick={() => handleAddClick("right")}>
|
||||||
|
<Plus className="mr-1 h-4 w-4" />
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{/* 우측 패널 수정/삭제는 각 카드에서 처리 */}
|
{/* 우측 패널 수정/삭제는 각 카드에서 처리 */}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -2632,16 +2953,228 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<CardContent className="flex-1 overflow-auto p-4">
|
<CardContent className="flex-1 overflow-auto p-4">
|
||||||
{/* 우측 데이터 */}
|
{/* 🆕 추가 탭 데이터 렌더링 */}
|
||||||
{isLoadingRight ? (
|
{activeTabIndex > 0 ? (
|
||||||
// 로딩 중
|
// 추가 탭 컨텐츠
|
||||||
<div className="flex h-full items-center justify-center">
|
(() => {
|
||||||
<div className="text-center">
|
const currentTabConfig = componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1];
|
||||||
<Loader2 className="text-primary mx-auto h-8 w-8 animate-spin" />
|
const currentTabData = tabsData[activeTabIndex] || [];
|
||||||
<p className="text-muted-foreground mt-2 text-sm">데이터를 불러오는 중...</p>
|
const isTabLoading = tabsLoading[activeTabIndex];
|
||||||
</div>
|
|
||||||
</div>
|
if (isTabLoading) {
|
||||||
) : rightData ? (
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<Loader2 className="text-primary mx-auto h-8 w-8 animate-spin" />
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm">데이터를 불러오는 중...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedLeftItem) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<p className="text-muted-foreground text-sm">좌측에서 항목을 선택하세요</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentTabData.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<p className="text-muted-foreground text-sm">데이터가 없습니다</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 탭 데이터 렌더링 (목록/테이블 모드)
|
||||||
|
const isTableMode = currentTabConfig?.displayMode === "table";
|
||||||
|
|
||||||
|
if (isTableMode) {
|
||||||
|
// 테이블 모드
|
||||||
|
const displayColumns = currentTabConfig?.columns || [];
|
||||||
|
const columnsToShow =
|
||||||
|
displayColumns.length > 0
|
||||||
|
? displayColumns.map((col) => ({
|
||||||
|
...col,
|
||||||
|
label: col.label || col.name,
|
||||||
|
}))
|
||||||
|
: Object.keys(currentTabData[0] || {})
|
||||||
|
.filter(shouldShowField)
|
||||||
|
.slice(0, 8)
|
||||||
|
.map((key) => ({ name: key, label: key }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-auto rounded-lg border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted/50 sticky top-0">
|
||||||
|
<tr>
|
||||||
|
{columnsToShow.map((col: any) => (
|
||||||
|
<th
|
||||||
|
key={col.name}
|
||||||
|
className="px-3 py-2 text-left font-medium"
|
||||||
|
style={{ width: col.width ? `${col.width}px` : "auto" }}
|
||||||
|
>
|
||||||
|
{col.label}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
{(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && (
|
||||||
|
<th className="w-20 px-3 py-2 text-center font-medium">작업</th>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{currentTabData.map((item: any, idx: number) => (
|
||||||
|
<tr key={item.id || idx} className="hover:bg-muted/30 border-t">
|
||||||
|
{columnsToShow.map((col: any) => (
|
||||||
|
<td key={col.name} className="px-3 py-2">
|
||||||
|
{formatValue(item[col.name], col.format)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
{(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && (
|
||||||
|
<td className="px-3 py-2 text-center">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
{currentTabConfig?.showEdit && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0"
|
||||||
|
onClick={() => handleEditClick("right", item)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{currentTabConfig?.showDelete && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0 text-red-500 hover:text-red-600"
|
||||||
|
onClick={() => handleDeleteClick("right", item)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 목록 (카드) 모드
|
||||||
|
const displayColumns = currentTabConfig?.columns || [];
|
||||||
|
const summaryCount = currentTabConfig?.summaryColumnCount ?? 3;
|
||||||
|
const showLabel = currentTabConfig?.summaryShowLabel ?? true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{currentTabData.map((item: any, idx: number) => {
|
||||||
|
const itemId = item.id || idx;
|
||||||
|
const isExpanded = expandedRightItems.has(itemId);
|
||||||
|
|
||||||
|
// 표시할 컬럼 결정
|
||||||
|
const columnsToShow =
|
||||||
|
displayColumns.length > 0
|
||||||
|
? displayColumns
|
||||||
|
: Object.keys(item)
|
||||||
|
.filter(shouldShowField)
|
||||||
|
.slice(0, 8)
|
||||||
|
.map((key) => ({ name: key, label: key }));
|
||||||
|
|
||||||
|
const summaryColumns = columnsToShow.slice(0, summaryCount);
|
||||||
|
const detailColumns = columnsToShow.slice(summaryCount);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={itemId} className="rounded-lg border bg-white p-3">
|
||||||
|
<div
|
||||||
|
className="flex cursor-pointer items-start justify-between"
|
||||||
|
onClick={() => toggleRightItemExpansion(itemId)}
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||||
|
{summaryColumns.map((col: any) => (
|
||||||
|
<div key={col.name} className="text-sm">
|
||||||
|
{showLabel && (
|
||||||
|
<span className="text-muted-foreground mr-1">{col.label}:</span>
|
||||||
|
)}
|
||||||
|
<span className={col.bold ? "font-semibold" : ""}>
|
||||||
|
{formatValue(item[col.name], col.format)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ml-2 flex items-center gap-1">
|
||||||
|
{currentTabConfig?.showEdit && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleEditClick("right", item);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{currentTabConfig?.showDelete && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0 text-red-500 hover:text-red-600"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDeleteClick("right", item);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{detailColumns.length > 0 && (
|
||||||
|
isExpanded ? (
|
||||||
|
<ChevronUp className="h-4 w-4 text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4 text-gray-400" />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isExpanded && detailColumns.length > 0 && (
|
||||||
|
<div className="mt-2 border-t pt-2">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{detailColumns.map((col: any) => (
|
||||||
|
<div key={col.name} className="text-sm">
|
||||||
|
<span className="text-muted-foreground">{col.label}:</span>
|
||||||
|
<span className="ml-1">{formatValue(item[col.name], col.format)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
) : (
|
||||||
|
/* 기본 탭 (우측 패널) 데이터 */
|
||||||
|
<>
|
||||||
|
{isLoadingRight ? (
|
||||||
|
// 로딩 중
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<Loader2 className="text-primary mx-auto h-8 w-8 animate-spin" />
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm">데이터를 불러오는 중...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : rightData ? (
|
||||||
// 실제 데이터 표시
|
// 실제 데이터 표시
|
||||||
Array.isArray(rightData) ? (
|
Array.isArray(rightData) ? (
|
||||||
// 조인 모드: 여러 데이터를 테이블/리스트로 표시
|
// 조인 모드: 여러 데이터를 테이블/리스트로 표시
|
||||||
|
|
@ -3084,6 +3617,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,11 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
// Accordion 제거 - 단순 섹션으로 변경
|
// Accordion 제거 - 단순 섹션으로 변경
|
||||||
import { Check, ChevronsUpDown, ArrowRight, Plus, X, ArrowUp, ArrowDown } from "lucide-react";
|
import { Check, ChevronsUpDown, ArrowRight, Plus, X, ArrowUp, ArrowDown, Trash2, GripVertical } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { SplitPanelLayoutConfig } from "./types";
|
import { SplitPanelLayoutConfig, AdditionalTabConfig } from "./types";
|
||||||
import { TableInfo, ColumnInfo } from "@/types/screen";
|
import { TableInfo, ColumnInfo } from "@/types/screen";
|
||||||
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||||
import { tableTypeApi } from "@/lib/api/screen";
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel";
|
import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel";
|
||||||
|
|
||||||
|
|
@ -189,6 +190,848 @@ const ScreenSelector: React.FC<{
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 추가 탭 설정 패널 (우측 패널과 동일한 구조)
|
||||||
|
*/
|
||||||
|
interface AdditionalTabConfigPanelProps {
|
||||||
|
tab: AdditionalTabConfig;
|
||||||
|
tabIndex: number;
|
||||||
|
config: SplitPanelLayoutConfig;
|
||||||
|
updateRightPanel: (updates: Partial<SplitPanelLayoutConfig["rightPanel"]>) => void;
|
||||||
|
availableRightTables: TableInfo[];
|
||||||
|
leftTableColumns: ColumnInfo[];
|
||||||
|
menuObjid?: number;
|
||||||
|
// 공유 컬럼 로드 상태
|
||||||
|
loadedTableColumns: Record<string, ColumnInfo[]>;
|
||||||
|
loadTableColumns: (tableName: string) => Promise<void>;
|
||||||
|
loadingColumns: Record<string, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
|
||||||
|
tab,
|
||||||
|
tabIndex,
|
||||||
|
config,
|
||||||
|
updateRightPanel,
|
||||||
|
availableRightTables,
|
||||||
|
leftTableColumns,
|
||||||
|
menuObjid,
|
||||||
|
loadedTableColumns,
|
||||||
|
loadTableColumns,
|
||||||
|
loadingColumns,
|
||||||
|
}) => {
|
||||||
|
// 탭 테이블 변경 시 컬럼 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (tab.tableName && !loadedTableColumns[tab.tableName] && !loadingColumns[tab.tableName]) {
|
||||||
|
loadTableColumns(tab.tableName);
|
||||||
|
}
|
||||||
|
}, [tab.tableName, loadedTableColumns, loadingColumns, loadTableColumns]);
|
||||||
|
|
||||||
|
// 현재 탭의 컬럼 목록
|
||||||
|
const tabColumns = useMemo(() => {
|
||||||
|
return tab.tableName ? loadedTableColumns[tab.tableName] || [] : [];
|
||||||
|
}, [tab.tableName, loadedTableColumns]);
|
||||||
|
|
||||||
|
// 로딩 상태
|
||||||
|
const loadingTabColumns = tab.tableName ? loadingColumns[tab.tableName] || false : false;
|
||||||
|
|
||||||
|
// 탭 업데이트 헬퍼
|
||||||
|
const updateTab = (updates: Partial<AdditionalTabConfig>) => {
|
||||||
|
const newTabs = [...(config.rightPanel?.additionalTabs || [])];
|
||||||
|
newTabs[tabIndex] = { ...tab, ...updates };
|
||||||
|
updateRightPanel({ additionalTabs: newTabs });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AccordionItem
|
||||||
|
key={tab.tabId}
|
||||||
|
value={tab.tabId}
|
||||||
|
className="rounded-lg border bg-gray-50"
|
||||||
|
>
|
||||||
|
<AccordionTrigger className="px-3 py-2 hover:no-underline">
|
||||||
|
<div className="flex flex-1 items-center gap-2">
|
||||||
|
<GripVertical className="h-4 w-4 text-gray-400" />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{tab.label || `탭 ${tabIndex + 1}`}
|
||||||
|
</span>
|
||||||
|
{tab.tableName && (
|
||||||
|
<span className="text-xs text-gray-500">({tab.tableName})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-3 pb-3">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* ===== 1. 기본 정보 ===== */}
|
||||||
|
<div className="space-y-3 rounded-lg border bg-white p-3">
|
||||||
|
<Label className="text-xs font-semibold text-blue-600">기본 정보</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">탭 라벨</Label>
|
||||||
|
<Input
|
||||||
|
value={tab.label}
|
||||||
|
onChange={(e) => updateTab({ label: e.target.value })}
|
||||||
|
placeholder="탭 이름"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">패널 제목</Label>
|
||||||
|
<Input
|
||||||
|
value={tab.title}
|
||||||
|
onChange={(e) => updateTab({ title: e.target.value })}
|
||||||
|
placeholder="패널 제목"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">패널 헤더 높이</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={tab.panelHeaderHeight ?? 48}
|
||||||
|
onChange={(e) => updateTab({ panelHeaderHeight: parseInt(e.target.value) || 48 })}
|
||||||
|
placeholder="48"
|
||||||
|
className="h-8 w-24 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== 2. 테이블 선택 ===== */}
|
||||||
|
<div className="space-y-3 rounded-lg border bg-white p-3">
|
||||||
|
<Label className="text-xs font-semibold text-blue-600">테이블 설정</Label>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">테이블 선택</Label>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
className="h-8 w-full justify-between text-xs"
|
||||||
|
>
|
||||||
|
{tab.tableName || "테이블을 선택하세요"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-full p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
||||||
|
<CommandEmpty>테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||||
|
{availableRightTables.map((table) => (
|
||||||
|
<CommandItem
|
||||||
|
key={table.tableName}
|
||||||
|
value={`${table.displayName || ""} ${table.tableName}`}
|
||||||
|
onSelect={() => updateTab({ tableName: table.tableName, columns: [] })}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
tab.tableName === table.tableName ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{table.displayName || table.tableName}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== 3. 표시 모드 ===== */}
|
||||||
|
<div className="space-y-3 rounded-lg border bg-white p-3">
|
||||||
|
<Label className="text-xs font-semibold text-blue-600">표시 설정</Label>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">표시 모드</Label>
|
||||||
|
<Select
|
||||||
|
value={tab.displayMode || "list"}
|
||||||
|
onValueChange={(value: "list" | "table") => updateTab({ displayMode: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="list">목록 (카드)</SelectItem>
|
||||||
|
<SelectItem value="table">테이블</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 요약 설정 (목록 모드) */}
|
||||||
|
{tab.displayMode === "list" && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">요약 컬럼 수</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={tab.summaryColumnCount ?? 3}
|
||||||
|
onChange={(e) => updateTab({ summaryColumnCount: parseInt(e.target.value) || 3 })}
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 pt-5">
|
||||||
|
<Checkbox
|
||||||
|
id={`tab-${tabIndex}-summary-label`}
|
||||||
|
checked={tab.summaryShowLabel ?? true}
|
||||||
|
onCheckedChange={(checked) => updateTab({ summaryShowLabel: !!checked })}
|
||||||
|
/>
|
||||||
|
<label htmlFor={`tab-${tabIndex}-summary-label`} className="text-xs">라벨 표시</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== 4. 컬럼 매핑 (조인 키) ===== */}
|
||||||
|
<div className="space-y-3 rounded-lg border bg-white p-3">
|
||||||
|
<Label className="text-xs font-semibold text-blue-600">컬럼 매핑 (조인 키)</Label>
|
||||||
|
<p className="text-[10px] text-gray-500">
|
||||||
|
좌측 패널 선택 시 관련 데이터만 표시합니다
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">좌측 컬럼</Label>
|
||||||
|
<Select
|
||||||
|
value={tab.relation?.keys?.[0]?.leftColumn || tab.relation?.leftColumn || ""}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
updateTab({
|
||||||
|
relation: {
|
||||||
|
...tab.relation,
|
||||||
|
type: "join",
|
||||||
|
keys: [{ leftColumn: value, rightColumn: tab.relation?.keys?.[0]?.rightColumn || "" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{leftTableColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">우측 컬럼</Label>
|
||||||
|
<Select
|
||||||
|
value={tab.relation?.keys?.[0]?.rightColumn || tab.relation?.foreignKey || ""}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
updateTab({
|
||||||
|
relation: {
|
||||||
|
...tab.relation,
|
||||||
|
type: "join",
|
||||||
|
keys: [{ leftColumn: tab.relation?.keys?.[0]?.leftColumn || "", rightColumn: value }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tabColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== 5. 기능 버튼 ===== */}
|
||||||
|
<div className="space-y-3 rounded-lg border bg-white p-3">
|
||||||
|
<Label className="text-xs font-semibold text-blue-600">기능 버튼</Label>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Checkbox
|
||||||
|
id={`tab-${tabIndex}-search`}
|
||||||
|
checked={tab.showSearch}
|
||||||
|
onCheckedChange={(checked) => updateTab({ showSearch: !!checked })}
|
||||||
|
/>
|
||||||
|
<label htmlFor={`tab-${tabIndex}-search`} className="text-xs">검색</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Checkbox
|
||||||
|
id={`tab-${tabIndex}-add`}
|
||||||
|
checked={tab.showAdd}
|
||||||
|
onCheckedChange={(checked) => updateTab({ showAdd: !!checked })}
|
||||||
|
/>
|
||||||
|
<label htmlFor={`tab-${tabIndex}-add`} className="text-xs">추가</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Checkbox
|
||||||
|
id={`tab-${tabIndex}-edit`}
|
||||||
|
checked={tab.showEdit}
|
||||||
|
onCheckedChange={(checked) => updateTab({ showEdit: !!checked })}
|
||||||
|
/>
|
||||||
|
<label htmlFor={`tab-${tabIndex}-edit`} className="text-xs">수정</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Checkbox
|
||||||
|
id={`tab-${tabIndex}-delete`}
|
||||||
|
checked={tab.showDelete}
|
||||||
|
onCheckedChange={(checked) => updateTab({ showDelete: !!checked })}
|
||||||
|
/>
|
||||||
|
<label htmlFor={`tab-${tabIndex}-delete`} className="text-xs">삭제</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== 6. 표시 컬럼 설정 ===== */}
|
||||||
|
<div className="space-y-3 rounded-lg border border-green-200 bg-green-50 p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs font-semibold text-green-700">표시할 컬럼 선택</Label>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
const currentColumns = tab.columns || [];
|
||||||
|
const newColumns = [...currentColumns, { name: "", label: "", width: 100 }];
|
||||||
|
updateTab({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
disabled={!tab.tableName || loadingTabColumns}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
컬럼 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-gray-600">
|
||||||
|
표시할 컬럼을 선택하세요. 선택하지 않으면 모든 컬럼이 표시됩니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 테이블 미선택 상태 */}
|
||||||
|
{!tab.tableName && (
|
||||||
|
<div className="rounded-md border border-dashed border-gray-300 bg-white p-3 text-center">
|
||||||
|
<p className="text-xs text-gray-500">먼저 테이블을 선택하세요</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 테이블 선택됨 - 컬럼 목록 */}
|
||||||
|
{tab.tableName && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* 로딩 상태 */}
|
||||||
|
{loadingTabColumns && (
|
||||||
|
<div className="rounded-md border border-dashed border-gray-300 bg-white p-3 text-center">
|
||||||
|
<p className="text-xs text-gray-500">컬럼을 불러오는 중...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 설정된 컬럼이 없을 때 */}
|
||||||
|
{!loadingTabColumns && (tab.columns || []).length === 0 && (
|
||||||
|
<div className="rounded-md border border-dashed border-gray-300 bg-white p-3 text-center">
|
||||||
|
<p className="text-xs text-gray-500">설정된 컬럼이 없습니다</p>
|
||||||
|
<p className="mt-1 text-[10px] text-gray-400">컬럼을 추가하지 않으면 모든 컬럼이 표시됩니다</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 설정된 컬럼 목록 */}
|
||||||
|
{!loadingTabColumns && (tab.columns || []).length > 0 && (
|
||||||
|
(tab.columns || []).map((col, colIndex) => (
|
||||||
|
<div key={colIndex} className="space-y-2 rounded-md border bg-white p-3">
|
||||||
|
{/* 상단: 순서 변경 + 삭제 버튼 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
if (colIndex === 0) return;
|
||||||
|
const newColumns = [...(tab.columns || [])];
|
||||||
|
[newColumns[colIndex - 1], newColumns[colIndex]] = [newColumns[colIndex], newColumns[colIndex - 1]];
|
||||||
|
updateTab({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
disabled={colIndex === 0}
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
>
|
||||||
|
<ArrowUp className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
const columns = tab.columns || [];
|
||||||
|
if (colIndex === columns.length - 1) return;
|
||||||
|
const newColumns = [...columns];
|
||||||
|
[newColumns[colIndex], newColumns[colIndex + 1]] = [newColumns[colIndex + 1], newColumns[colIndex]];
|
||||||
|
updateTab({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
disabled={colIndex === (tab.columns || []).length - 1}
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
>
|
||||||
|
<ArrowDown className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<span className="text-[10px] text-gray-400">#{colIndex + 1}</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
const newColumns = (tab.columns || []).filter((_, i) => i !== colIndex);
|
||||||
|
updateTab({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
className="h-6 w-6 p-0 text-red-500 hover:bg-red-50 hover:text-red-600"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 컬럼 선택 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px] text-gray-500">컬럼</Label>
|
||||||
|
<Select
|
||||||
|
value={col.name}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const selectedCol = tabColumns.find((c) => c.columnName === value);
|
||||||
|
const newColumns = [...(tab.columns || [])];
|
||||||
|
newColumns[colIndex] = {
|
||||||
|
...col,
|
||||||
|
name: value,
|
||||||
|
label: selectedCol?.columnLabel || value,
|
||||||
|
};
|
||||||
|
updateTab({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="컬럼을 선택하세요" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tabColumns.map((column) => (
|
||||||
|
<SelectItem key={column.columnName} value={column.columnName}>
|
||||||
|
{column.columnLabel || column.columnName}
|
||||||
|
<span className="ml-1 text-[10px] text-gray-400">({column.columnName})</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 라벨 + 너비 */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px] text-gray-500">라벨</Label>
|
||||||
|
<Input
|
||||||
|
value={col.label}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newColumns = [...(tab.columns || [])];
|
||||||
|
newColumns[colIndex] = { ...col, label: e.target.value };
|
||||||
|
updateTab({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
placeholder="표시 라벨"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px] text-gray-500">너비 (px)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={col.width || 100}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newColumns = [...(tab.columns || [])];
|
||||||
|
newColumns[colIndex] = { ...col, width: parseInt(e.target.value) || 100 };
|
||||||
|
updateTab({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
placeholder="100"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== 7. 추가 모달 컬럼 설정 (showAdd일 때) ===== */}
|
||||||
|
{tab.showAdd && (
|
||||||
|
<div className="space-y-3 rounded-lg border border-purple-200 bg-purple-50 p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs font-semibold text-purple-700">추가 모달 컬럼 설정</Label>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
const currentColumns = tab.addModalColumns || [];
|
||||||
|
const newColumns = [...currentColumns, { name: "", label: "", required: false }];
|
||||||
|
updateTab({ addModalColumns: newColumns });
|
||||||
|
}}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
disabled={!tab.tableName}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
컬럼 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(tab.addModalColumns || []).length === 0 ? (
|
||||||
|
<div className="rounded-md border border-dashed border-gray-300 bg-white p-3 text-center">
|
||||||
|
<p className="text-xs text-gray-500">추가 모달에 표시할 컬럼을 설정하세요</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
(tab.addModalColumns || []).map((col, colIndex) => (
|
||||||
|
<div key={colIndex} className="flex items-center gap-2 rounded-md border bg-white p-2">
|
||||||
|
<Select
|
||||||
|
value={col.name}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const selectedCol = tabColumns.find((c) => c.columnName === value);
|
||||||
|
const newColumns = [...(tab.addModalColumns || [])];
|
||||||
|
newColumns[colIndex] = {
|
||||||
|
...col,
|
||||||
|
name: value,
|
||||||
|
label: selectedCol?.columnLabel || value,
|
||||||
|
};
|
||||||
|
updateTab({ addModalColumns: newColumns });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 flex-1 text-xs">
|
||||||
|
<SelectValue placeholder="컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tabColumns.map((column) => (
|
||||||
|
<SelectItem key={column.columnName} value={column.columnName}>
|
||||||
|
{column.columnLabel || column.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Input
|
||||||
|
value={col.label}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newColumns = [...(tab.addModalColumns || [])];
|
||||||
|
newColumns[colIndex] = { ...col, label: e.target.value };
|
||||||
|
updateTab({ addModalColumns: newColumns });
|
||||||
|
}}
|
||||||
|
placeholder="라벨"
|
||||||
|
className="h-8 w-24 text-xs"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Checkbox
|
||||||
|
checked={col.required}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
const newColumns = [...(tab.addModalColumns || [])];
|
||||||
|
newColumns[colIndex] = { ...col, required: !!checked };
|
||||||
|
updateTab({ addModalColumns: newColumns });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-[10px]">필수</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
const newColumns = (tab.addModalColumns || []).filter((_, i) => i !== colIndex);
|
||||||
|
updateTab({ addModalColumns: newColumns });
|
||||||
|
}}
|
||||||
|
className="h-8 w-8 p-0 text-red-500"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ===== 8. 데이터 필터링 ===== */}
|
||||||
|
<div className="space-y-3 rounded-lg border bg-white p-3">
|
||||||
|
<Label className="text-xs font-semibold text-blue-600">데이터 필터링</Label>
|
||||||
|
<DataFilterConfigPanel
|
||||||
|
tableName={tab.tableName}
|
||||||
|
columns={tabColumns}
|
||||||
|
config={tab.dataFilter}
|
||||||
|
onConfigChange={(dataFilter) => updateTab({ dataFilter })}
|
||||||
|
menuObjid={menuObjid}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== 9. 중복 데이터 제거 ===== */}
|
||||||
|
<div className="space-y-3 rounded-lg border bg-white p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs font-semibold text-blue-600">중복 데이터 제거</Label>
|
||||||
|
<Switch
|
||||||
|
checked={tab.deduplication?.enabled ?? false}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
if (checked) {
|
||||||
|
updateTab({
|
||||||
|
deduplication: {
|
||||||
|
enabled: true,
|
||||||
|
groupByColumn: "",
|
||||||
|
keepStrategy: "latest",
|
||||||
|
sortColumn: "start_date",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
updateTab({ deduplication: undefined });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{tab.deduplication?.enabled && (
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">기준 컬럼</Label>
|
||||||
|
<Select
|
||||||
|
value={tab.deduplication?.groupByColumn || ""}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
updateTab({
|
||||||
|
deduplication: { ...tab.deduplication!, groupByColumn: value },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tabColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">정렬 컬럼</Label>
|
||||||
|
<Select
|
||||||
|
value={tab.deduplication?.sortColumn || ""}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
updateTab({
|
||||||
|
deduplication: { ...tab.deduplication!, sortColumn: value },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tabColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">유지 전략</Label>
|
||||||
|
<Select
|
||||||
|
value={tab.deduplication?.keepStrategy || "latest"}
|
||||||
|
onValueChange={(value: "latest" | "earliest" | "base_price" | "current_date") => {
|
||||||
|
updateTab({
|
||||||
|
deduplication: { ...tab.deduplication!, keepStrategy: value },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="latest">최신</SelectItem>
|
||||||
|
<SelectItem value="earliest">가장 오래된</SelectItem>
|
||||||
|
<SelectItem value="current_date">현재 날짜 기준</SelectItem>
|
||||||
|
<SelectItem value="base_price">기준가</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== 10. 수정 버튼 설정 ===== */}
|
||||||
|
{tab.showEdit && (
|
||||||
|
<div className="space-y-3 rounded-lg border border-blue-200 bg-blue-50 p-3">
|
||||||
|
<Label className="text-xs font-semibold text-blue-700">수정 버튼 설정</Label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">수정 모드</Label>
|
||||||
|
<Select
|
||||||
|
value={tab.editButton?.mode || "auto"}
|
||||||
|
onValueChange={(value: "auto" | "modal") => {
|
||||||
|
updateTab({
|
||||||
|
editButton: { ...tab.editButton, enabled: true, mode: value },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="auto">자동 (인라인)</SelectItem>
|
||||||
|
<SelectItem value="modal">모달 화면</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab.editButton?.mode === "modal" && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">수정 모달 화면</Label>
|
||||||
|
<ScreenSelector
|
||||||
|
value={tab.editButton?.modalScreenId}
|
||||||
|
onChange={(screenId) => {
|
||||||
|
updateTab({
|
||||||
|
editButton: { ...tab.editButton, enabled: true, mode: "modal", modalScreenId: screenId },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">버튼 라벨</Label>
|
||||||
|
<Input
|
||||||
|
value={tab.editButton?.buttonLabel || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
updateTab({
|
||||||
|
editButton: { ...tab.editButton, enabled: true, buttonLabel: e.target.value || undefined },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="수정"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">버튼 스타일</Label>
|
||||||
|
<Select
|
||||||
|
value={tab.editButton?.buttonVariant || "ghost"}
|
||||||
|
onValueChange={(value: "default" | "outline" | "ghost") => {
|
||||||
|
updateTab({
|
||||||
|
editButton: { ...tab.editButton, enabled: true, buttonVariant: value },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="ghost">Ghost (기본)</SelectItem>
|
||||||
|
<SelectItem value="outline">Outline</SelectItem>
|
||||||
|
<SelectItem value="default">Default</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 그룹핑 기준 컬럼 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">그룹핑 기준 컬럼</Label>
|
||||||
|
<p className="text-[9px] text-gray-500">수정 시 같은 값을 가진 레코드를 함께 불러옵니다</p>
|
||||||
|
<div className="max-h-[120px] space-y-1 overflow-y-auto rounded-md border bg-white p-2">
|
||||||
|
{tabColumns.map((col) => (
|
||||||
|
<div key={col.columnName} className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`tab-${tabIndex}-groupby-${col.columnName}`}
|
||||||
|
checked={(tab.editButton?.groupByColumns || []).includes(col.columnName)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
const current = tab.editButton?.groupByColumns || [];
|
||||||
|
const newColumns = checked
|
||||||
|
? [...current, col.columnName]
|
||||||
|
: current.filter((c) => c !== col.columnName);
|
||||||
|
updateTab({
|
||||||
|
editButton: { ...tab.editButton, enabled: true, groupByColumns: newColumns },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label htmlFor={`tab-${tabIndex}-groupby-${col.columnName}`} className="text-[10px]">
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ===== 11. 삭제 버튼 설정 ===== */}
|
||||||
|
{tab.showDelete && (
|
||||||
|
<div className="space-y-3 rounded-lg border border-red-200 bg-red-50 p-3">
|
||||||
|
<Label className="text-xs font-semibold text-red-700">삭제 버튼 설정</Label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">버튼 라벨</Label>
|
||||||
|
<Input
|
||||||
|
value={tab.deleteButton?.buttonLabel || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
updateTab({
|
||||||
|
deleteButton: { ...tab.deleteButton, enabled: true, buttonLabel: e.target.value || undefined },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="삭제"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">버튼 스타일</Label>
|
||||||
|
<Select
|
||||||
|
value={tab.deleteButton?.buttonVariant || "ghost"}
|
||||||
|
onValueChange={(value: "default" | "outline" | "ghost" | "destructive") => {
|
||||||
|
updateTab({
|
||||||
|
deleteButton: { ...tab.deleteButton, enabled: true, buttonVariant: value },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="ghost">Ghost (기본)</SelectItem>
|
||||||
|
<SelectItem value="outline">Outline</SelectItem>
|
||||||
|
<SelectItem value="default">Default</SelectItem>
|
||||||
|
<SelectItem value="destructive">Destructive (빨강)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">삭제 확인 메시지</Label>
|
||||||
|
<Input
|
||||||
|
value={tab.deleteButton?.confirmMessage || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
updateTab({
|
||||||
|
deleteButton: { ...tab.deleteButton, enabled: true, confirmMessage: e.target.value || undefined },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="정말 삭제하시겠습니까?"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ===== 탭 삭제 버튼 ===== */}
|
||||||
|
<div className="flex justify-end border-t pt-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-red-500 hover:bg-red-50 hover:text-red-600"
|
||||||
|
onClick={() => {
|
||||||
|
const newTabs = config.rightPanel?.additionalTabs?.filter((_, i) => i !== tabIndex) || [];
|
||||||
|
updateRightPanel({ additionalTabs: newTabs });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-1 h-3 w-3" />
|
||||||
|
탭 삭제
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SplitPanelLayout 설정 패널
|
* SplitPanelLayout 설정 패널
|
||||||
*/
|
*/
|
||||||
|
|
@ -2854,6 +3697,72 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ======================================== */}
|
||||||
|
{/* 추가 탭 설정 (우측 패널과 동일한 구조) */}
|
||||||
|
{/* ======================================== */}
|
||||||
|
<div className="mt-4 space-y-4 border-t pt-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">추가 탭</h3>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
우측 패널에 다른 테이블 데이터를 탭으로 추가합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const newTab: AdditionalTabConfig = {
|
||||||
|
tabId: `tab_${Date.now()}`,
|
||||||
|
label: `탭 ${(config.rightPanel?.additionalTabs?.length || 0) + 1}`,
|
||||||
|
title: "",
|
||||||
|
tableName: "",
|
||||||
|
displayMode: "list",
|
||||||
|
showSearch: false,
|
||||||
|
showAdd: false,
|
||||||
|
showEdit: true,
|
||||||
|
showDelete: true,
|
||||||
|
summaryColumnCount: 3,
|
||||||
|
summaryShowLabel: true,
|
||||||
|
};
|
||||||
|
updateRightPanel({
|
||||||
|
additionalTabs: [...(config.rightPanel?.additionalTabs || []), newTab],
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
탭 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 추가된 탭 목록 */}
|
||||||
|
{(config.rightPanel?.additionalTabs?.length || 0) > 0 ? (
|
||||||
|
<Accordion type="multiple" className="space-y-2">
|
||||||
|
{config.rightPanel?.additionalTabs?.map((tab, tabIndex) => (
|
||||||
|
<AdditionalTabConfigPanel
|
||||||
|
key={tab.tabId}
|
||||||
|
tab={tab}
|
||||||
|
tabIndex={tabIndex}
|
||||||
|
config={config}
|
||||||
|
updateRightPanel={updateRightPanel}
|
||||||
|
availableRightTables={availableRightTables}
|
||||||
|
leftTableColumns={leftTableColumns}
|
||||||
|
menuObjid={menuObjid}
|
||||||
|
loadedTableColumns={loadedTableColumns}
|
||||||
|
loadTableColumns={loadTableColumns}
|
||||||
|
loadingColumns={loadingColumns}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Accordion>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-dashed p-4 text-center">
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
추가된 탭이 없습니다. [탭 추가] 버튼을 클릭하여 새 탭을 추가하세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 레이아웃 설정 */}
|
{/* 레이아웃 설정 */}
|
||||||
<div className="mt-4 space-y-4 border-t pt-4">
|
<div className="mt-4 space-y-4 border-t pt-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,105 @@
|
||||||
|
|
||||||
import { DataFilterConfig } from "@/types/screen-management";
|
import { DataFilterConfig } from "@/types/screen-management";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 추가 탭 설정 (우측 패널과 동일한 구조 + tabId, label)
|
||||||
|
*/
|
||||||
|
export interface AdditionalTabConfig {
|
||||||
|
// 탭 고유 정보
|
||||||
|
tabId: string;
|
||||||
|
label: string;
|
||||||
|
|
||||||
|
// === 우측 패널과 동일한 설정 ===
|
||||||
|
title: string;
|
||||||
|
panelHeaderHeight?: number;
|
||||||
|
tableName?: string;
|
||||||
|
dataSource?: string;
|
||||||
|
displayMode?: "list" | "table";
|
||||||
|
showSearch?: boolean;
|
||||||
|
showAdd?: boolean;
|
||||||
|
showEdit?: boolean;
|
||||||
|
showDelete?: boolean;
|
||||||
|
summaryColumnCount?: number;
|
||||||
|
summaryShowLabel?: boolean;
|
||||||
|
|
||||||
|
columns?: Array<{
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
width?: number;
|
||||||
|
sortable?: boolean;
|
||||||
|
align?: "left" | "center" | "right";
|
||||||
|
bold?: boolean;
|
||||||
|
format?: {
|
||||||
|
type?: "number" | "currency" | "date" | "text";
|
||||||
|
thousandSeparator?: boolean;
|
||||||
|
decimalPlaces?: number;
|
||||||
|
prefix?: string;
|
||||||
|
suffix?: string;
|
||||||
|
dateFormat?: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
|
||||||
|
addModalColumns?: Array<{
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
required?: boolean;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
relation?: {
|
||||||
|
type?: "join" | "detail";
|
||||||
|
leftColumn?: string;
|
||||||
|
rightColumn?: string;
|
||||||
|
foreignKey?: string;
|
||||||
|
keys?: Array<{
|
||||||
|
leftColumn: string;
|
||||||
|
rightColumn: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
addConfig?: {
|
||||||
|
targetTable?: string;
|
||||||
|
autoFillColumns?: Record<string, any>;
|
||||||
|
leftPanelColumn?: string;
|
||||||
|
targetColumn?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
tableConfig?: {
|
||||||
|
showCheckbox?: boolean;
|
||||||
|
showRowNumber?: boolean;
|
||||||
|
rowHeight?: number;
|
||||||
|
headerHeight?: number;
|
||||||
|
striped?: boolean;
|
||||||
|
bordered?: boolean;
|
||||||
|
hoverable?: boolean;
|
||||||
|
stickyHeader?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
dataFilter?: DataFilterConfig;
|
||||||
|
|
||||||
|
deduplication?: {
|
||||||
|
enabled: boolean;
|
||||||
|
groupByColumn: string;
|
||||||
|
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
|
||||||
|
sortColumn?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
editButton?: {
|
||||||
|
enabled: boolean;
|
||||||
|
mode: "auto" | "modal";
|
||||||
|
modalScreenId?: number;
|
||||||
|
buttonLabel?: string;
|
||||||
|
buttonVariant?: "default" | "outline" | "ghost";
|
||||||
|
groupByColumns?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
deleteButton?: {
|
||||||
|
enabled: boolean;
|
||||||
|
buttonLabel?: string;
|
||||||
|
buttonVariant?: "default" | "outline" | "ghost" | "destructive";
|
||||||
|
confirmMessage?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface SplitPanelLayoutConfig {
|
export interface SplitPanelLayoutConfig {
|
||||||
// 좌측 패널 설정
|
// 좌측 패널 설정
|
||||||
leftPanel: {
|
leftPanel: {
|
||||||
|
|
@ -165,6 +264,9 @@ export interface SplitPanelLayoutConfig {
|
||||||
buttonVariant?: "default" | "outline" | "ghost" | "destructive"; // 버튼 스타일 (기본: "ghost")
|
buttonVariant?: "default" | "outline" | "ghost" | "destructive"; // 버튼 스타일 (기본: "ghost")
|
||||||
confirmMessage?: string; // 삭제 확인 메시지
|
confirmMessage?: string; // 삭제 확인 메시지
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 🆕 추가 탭 설정 (멀티 테이블 탭)
|
||||||
|
additionalTabs?: AdditionalTabConfig[];
|
||||||
};
|
};
|
||||||
|
|
||||||
// 레이아웃 설정
|
// 레이아웃 설정
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,10 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
const [rightTableOpen, setRightTableOpen] = useState(false);
|
const [rightTableOpen, setRightTableOpen] = useState(false);
|
||||||
const [leftModalOpen, setLeftModalOpen] = useState(false);
|
const [leftModalOpen, setLeftModalOpen] = useState(false);
|
||||||
const [rightModalOpen, setRightModalOpen] = useState(false);
|
const [rightModalOpen, setRightModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// 개별 수정 모달 화면 선택 Popover 상태
|
||||||
|
const [leftEditModalOpen, setLeftEditModalOpen] = useState(false);
|
||||||
|
const [rightEditModalOpen, setRightEditModalOpen] = useState(false);
|
||||||
|
|
||||||
// 컬럼 세부설정 모달 상태 (기존 - 하위 호환성)
|
// 컬럼 세부설정 모달 상태 (기존 - 하위 호환성)
|
||||||
const [columnConfigModalOpen, setColumnConfigModalOpen] = useState(false);
|
const [columnConfigModalOpen, setColumnConfigModalOpen] = useState(false);
|
||||||
|
|
@ -1004,6 +1008,19 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
onCheckedChange={(checked) => updateConfig("leftPanel.showEditButton", checked)}
|
onCheckedChange={(checked) => updateConfig("leftPanel.showEditButton", checked)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{/* 수정 버튼이 켜져 있을 때 모달 화면 선택 */}
|
||||||
|
{config.leftPanel?.showEditButton && (
|
||||||
|
<div className="ml-4 mt-1">
|
||||||
|
<Label className="text-xs">수정 모달 화면</Label>
|
||||||
|
<ScreenSelect
|
||||||
|
value={config.leftPanel?.editModalScreenId}
|
||||||
|
onValueChange={(value) => updateConfig("leftPanel.editModalScreenId", value)}
|
||||||
|
placeholder="수정 모달 화면 선택"
|
||||||
|
open={leftEditModalOpen}
|
||||||
|
onOpenChange={setLeftEditModalOpen}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-xs">삭제 버튼</Label>
|
<Label className="text-xs">삭제 버튼</Label>
|
||||||
<Switch
|
<Switch
|
||||||
|
|
@ -1322,6 +1339,19 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
|
||||||
onCheckedChange={(checked) => updateConfig("rightPanel.showEditButton", checked)}
|
onCheckedChange={(checked) => updateConfig("rightPanel.showEditButton", checked)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{/* 수정 버튼이 켜져 있을 때 모달 화면 선택 */}
|
||||||
|
{config.rightPanel?.showEditButton && (
|
||||||
|
<div className="ml-4 mt-1">
|
||||||
|
<Label className="text-xs">수정 모달 화면</Label>
|
||||||
|
<ScreenSelect
|
||||||
|
value={config.rightPanel?.editModalScreenId}
|
||||||
|
onValueChange={(value) => updateConfig("rightPanel.editModalScreenId", value)}
|
||||||
|
placeholder="수정 모달 화면 선택"
|
||||||
|
open={rightEditModalOpen}
|
||||||
|
onOpenChange={setRightEditModalOpen}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-xs">삭제 버튼</Label>
|
<Label className="text-xs">삭제 버튼</Label>
|
||||||
<Switch
|
<Switch
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue