feat: Enhance V2Repeater and configuration panel with source detail auto-fetching

- Added support for automatic fetching of detail rows from the master data in the V2Repeater component, improving data management.
- Introduced a new configuration option in the V2RepeaterConfigPanel to enable source detail auto-fetching, allowing users to specify detail table and foreign key settings.
- Enhanced the V2Repeater component to handle entity joins for loading data, optimizing data retrieval processes.
- Updated the V2RepeaterProps and V2RepeaterConfig interfaces to include new properties for grouped data and source detail configuration, ensuring type safety and clarity in component usage.
- Improved logging for data loading processes to provide better insights during development and debugging.
This commit is contained in:
kjs
2026-02-28 14:33:18 +09:00
parent b1831ada04
commit e16d76936b
11 changed files with 858 additions and 200 deletions

View File

@@ -36,6 +36,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { toast } from "sonner";
import { useScreenContextOptional } from "@/contexts/ScreenContext";
import { apiClient } from "@/lib/api/client";
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
export interface SplitPanelLayout2ComponentProps extends ComponentRendererProps {
// 추가 props
@@ -92,6 +93,9 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
const [leftActiveTab, setLeftActiveTab] = useState<string | null>(null);
const [rightActiveTab, setRightActiveTab] = useState<string | null>(null);
// 카테고리 코드→라벨 매핑
const [categoryLabelMap, setCategoryLabelMap] = useState<Record<string, string>>({});
// 프론트엔드 그룹핑 함수
const groupData = useCallback(
(data: Record<string, any>[], groupingConfig: GroupingConfig, columns: ColumnConfig[]): Record<string, any>[] => {
@@ -185,17 +189,17 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
}
});
// 탭 목록 생성
// 탭 목록 생성 (카테고리 라벨 변환 적용)
const tabs = Array.from(valueCount.entries()).map(([value, count]) => ({
id: value,
label: value,
label: categoryLabelMap[value] || value,
count: tabConfig.showCount ? count : 0,
}));
console.log(`[SplitPanelLayout2] 탭 생성: ${tabs.length}개 탭 (컬럼: ${sourceColumn})`);
return tabs;
},
[],
[categoryLabelMap],
);
// 탭으로 필터링된 데이터 반환
@@ -1000,10 +1004,38 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
console.log("[SplitPanelLayout2] 좌측 액션 버튼 모달 열기:", modalScreenId);
break;
case "edit":
// 좌측 패널에서 수정 (필요시 구현)
console.log("[SplitPanelLayout2] 좌측 수정 액션:", btn);
case "edit": {
if (!selectedLeftItem) {
toast.error("수정할 항목을 선택해주세요.");
return;
}
const editModalScreenId = btn.modalScreenId || config.leftPanel?.editModalScreenId || config.leftPanel?.addModalScreenId;
if (!editModalScreenId) {
toast.error("연결된 모달 화면이 없습니다.");
return;
}
const editEvent = new CustomEvent("openEditModal", {
detail: {
screenId: editModalScreenId,
title: btn.label || "수정",
modalSize: "lg",
editData: selectedLeftItem,
isCreateMode: false,
onSave: () => {
loadLeftData();
if (selectedLeftItem) {
loadRightData(selectedLeftItem);
}
},
},
});
window.dispatchEvent(editEvent);
console.log("[SplitPanelLayout2] 좌측 수정 모달 열기:", selectedLeftItem);
break;
}
case "delete":
// 좌측 패널에서 삭제 (필요시 구현)
@@ -1018,7 +1050,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
break;
}
},
[config.leftPanel?.addModalScreenId, loadLeftData],
[config.leftPanel?.addModalScreenId, config.leftPanel?.editModalScreenId, loadLeftData, loadRightData, selectedLeftItem],
);
// 컬럼 라벨 로드
@@ -1241,6 +1273,55 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
config.rightPanel?.tableName,
]);
// 카테고리 컬럼에 대한 라벨 매핑 로드
useEffect(() => {
if (isDesignMode) return;
const loadCategoryLabels = async () => {
const allColumns = new Set<string>();
const tableName = config.leftPanel?.tableName || config.rightPanel?.tableName;
if (!tableName) return;
// 좌우 패널의 표시 컬럼에서 카테고리 후보 수집
for (const col of config.leftPanel?.displayColumns || []) {
allColumns.add(col.name);
}
for (const col of config.rightPanel?.displayColumns || []) {
allColumns.add(col.name);
}
// 탭 소스 컬럼도 추가
if (config.rightPanel?.tabConfig?.tabSourceColumn) {
allColumns.add(config.rightPanel.tabConfig.tabSourceColumn);
}
if (config.leftPanel?.tabConfig?.tabSourceColumn) {
allColumns.add(config.leftPanel.tabConfig.tabSourceColumn);
}
const labelMap: Record<string, string> = {};
for (const columnName of allColumns) {
try {
const result = await getCategoryValues(tableName, columnName);
if (result.success && Array.isArray(result.data) && result.data.length > 0) {
for (const item of result.data) {
if (item.valueCode && item.valueLabel) {
labelMap[item.valueCode] = item.valueLabel;
}
}
}
} catch {
// 카테고리가 아닌 컬럼은 무시
}
}
if (Object.keys(labelMap).length > 0) {
setCategoryLabelMap(labelMap);
}
};
loadCategoryLabels();
}, [isDesignMode, config.leftPanel?.tableName, config.rightPanel?.tableName, config.leftPanel?.displayColumns, config.rightPanel?.displayColumns, config.rightPanel?.tabConfig?.tabSourceColumn, config.leftPanel?.tabConfig?.tabSourceColumn]);
// 컴포넌트 언마운트 시 DataProvider 해제
useEffect(() => {
return () => {
@@ -1250,6 +1331,23 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
};
}, [screenContext, component.id]);
// 카테고리 코드를 라벨로 변환
const resolveCategoryLabel = useCallback(
(value: any): string => {
if (value === null || value === undefined) return "";
const strVal = String(value);
if (categoryLabelMap[strVal]) return categoryLabelMap[strVal];
// 콤마 구분 다중 값 처리
if (strVal.includes(",")) {
const codes = strVal.split(",").map((c) => c.trim()).filter(Boolean);
const labels = codes.map((code) => categoryLabelMap[code] || code);
return labels.join(", ");
}
return strVal;
},
[categoryLabelMap],
);
// 컬럼 값 가져오기 (sourceTable 및 엔티티 참조 고려)
const getColumnValue = useCallback(
(item: any, col: ColumnConfig): any => {
@@ -1547,7 +1645,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
const displayColumns = config.leftPanel?.displayColumns || [];
const pkColumn = getLeftPrimaryKeyColumn();
// 값 렌더링 (배지 지원)
// 값 렌더링 (배지 지원 + 카테고리 라벨 변환)
const renderCellValue = (item: any, col: ColumnConfig) => {
const value = item[col.name];
if (value === null || value === undefined) return "-";
@@ -1558,7 +1656,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
<div className="flex flex-wrap gap-1">
{value.map((v, vIdx) => (
<Badge key={vIdx} variant="secondary" className="text-xs">
{formatValue(v, col.format)}
{resolveCategoryLabel(v) || formatValue(v, col.format)}
</Badge>
))}
</div>
@@ -1567,14 +1665,17 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
// 배지 타입이지만 단일 값인 경우
if (col.displayConfig?.displayType === "badge") {
const label = resolveCategoryLabel(value);
return (
<Badge variant="secondary" className="text-xs">
{formatValue(value, col.format)}
{label !== String(value) ? label : formatValue(value, col.format)}
</Badge>
);
}
// 기본 텍스트
// 카테고리 라벨 변환 시도 후 기본 텍스트
const label = resolveCategoryLabel(value);
if (label !== String(value)) return label;
return formatValue(value, col.format);
};
@@ -1821,9 +1922,12 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
/>
</TableCell>
)}
{displayColumns.map((col, colIdx) => (
<TableCell key={colIdx}>{formatValue(getColumnValue(item, col), col.format)}</TableCell>
))}
{displayColumns.map((col, colIdx) => {
const rawVal = getColumnValue(item, col);
const resolved = resolveCategoryLabel(rawVal);
const display = resolved !== String(rawVal ?? "") ? resolved : formatValue(rawVal, col.format);
return <TableCell key={colIdx}>{display || "-"}</TableCell>;
})}
{(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && (
<TableCell className="text-center">
<div className="flex justify-center gap-1">
@@ -2133,7 +2237,12 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
// 새로운 actionButtons 배열 기반 렌더링 (빈 배열이면 버튼 없음)
config.leftPanel.actionButtons.length > 0 && (
<div className="flex items-center gap-2">
{config.leftPanel.actionButtons.map((btn, idx) => (
{config.leftPanel.actionButtons
.filter((btn) => {
if (btn.showCondition === "selected") return !!selectedLeftItem;
return true;
})
.map((btn, idx) => (
<Button
key={idx}
size="sm"