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:
parent
b1831ada04
commit
e16d76936b
|
|
@ -48,6 +48,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
onRowClick,
|
onRowClick,
|
||||||
className,
|
className,
|
||||||
formData: parentFormData,
|
formData: parentFormData,
|
||||||
|
groupedData,
|
||||||
...restProps
|
...restProps
|
||||||
}) => {
|
}) => {
|
||||||
// componentId 결정: 직접 전달 또는 component 객체에서 추출
|
// componentId 결정: 직접 전달 또는 component 객체에서 추출
|
||||||
|
|
@ -419,6 +420,39 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
fkValue,
|
fkValue,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let rows: any[] = [];
|
||||||
|
const useEntityJoinForLoad = config.sourceDetailConfig?.useEntityJoin;
|
||||||
|
|
||||||
|
if (useEntityJoinForLoad) {
|
||||||
|
// 엔티티 조인을 사용하여 데이터 로드 (part_code → item_info 자동 조인)
|
||||||
|
const searchParam = JSON.stringify({ [config.foreignKeyColumn!]: fkValue });
|
||||||
|
const params: Record<string, any> = {
|
||||||
|
page: 1,
|
||||||
|
size: 1000,
|
||||||
|
search: searchParam,
|
||||||
|
enableEntityJoin: true,
|
||||||
|
autoFilter: JSON.stringify({ enabled: true }),
|
||||||
|
};
|
||||||
|
const addJoinCols = config.sourceDetailConfig?.additionalJoinColumns;
|
||||||
|
if (addJoinCols && addJoinCols.length > 0) {
|
||||||
|
params.additionalJoinColumns = JSON.stringify(addJoinCols);
|
||||||
|
}
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/table-management/tables/${config.mainTableName}/data-with-joins`,
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
|
const resultData = response.data?.data;
|
||||||
|
const rawRows = Array.isArray(resultData)
|
||||||
|
? resultData
|
||||||
|
: resultData?.data || resultData?.rows || [];
|
||||||
|
// 엔티티 조인 시 참조 테이블에 중복 레코드가 있으면 행이 늘어날 수 있으므로 id 기준 중복 제거
|
||||||
|
const seenIds = new Set<string>();
|
||||||
|
rows = rawRows.filter((row: any) => {
|
||||||
|
if (!row.id || seenIds.has(row.id)) return false;
|
||||||
|
seenIds.add(row.id);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
`/table-management/tables/${config.mainTableName}/data`,
|
`/table-management/tables/${config.mainTableName}/data`,
|
||||||
{
|
{
|
||||||
|
|
@ -431,12 +465,28 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
autoFilter: true,
|
autoFilter: true,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
rows = response.data?.data?.data || response.data?.data?.rows || response.data?.rows || [];
|
||||||
|
}
|
||||||
|
|
||||||
const rows = response.data?.data?.data || response.data?.data?.rows || response.data?.rows || [];
|
|
||||||
if (Array.isArray(rows) && rows.length > 0) {
|
if (Array.isArray(rows) && rows.length > 0) {
|
||||||
console.log(`✅ [V2Repeater] 기존 데이터 ${rows.length}건 로드 완료`);
|
console.log(`✅ [V2Repeater] 기존 데이터 ${rows.length}건 로드 완료`, useEntityJoinForLoad ? "(엔티티 조인)" : "");
|
||||||
|
|
||||||
// isSourceDisplay 컬럼이 있으면 소스 테이블에서 표시 데이터 보강
|
// 엔티티 조인 사용 시: columnMapping으로 _display_ 필드 보강
|
||||||
|
const columnMapping = config.sourceDetailConfig?.columnMapping;
|
||||||
|
if (useEntityJoinForLoad && columnMapping) {
|
||||||
|
const sourceDisplayColumns = config.columns.filter((col) => col.isSourceDisplay);
|
||||||
|
rows.forEach((row: any) => {
|
||||||
|
sourceDisplayColumns.forEach((col) => {
|
||||||
|
const mappedKey = columnMapping[col.key];
|
||||||
|
const value = mappedKey ? row[mappedKey] : row[col.key];
|
||||||
|
row[`_display_${col.key}`] = value ?? "";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
console.log("✅ [V2Repeater] 엔티티 조인 표시 데이터 보강 완료");
|
||||||
|
}
|
||||||
|
|
||||||
|
// isSourceDisplay 컬럼이 있으면 소스 테이블에서 표시 데이터 보강 (엔티티 조인 미사용 시)
|
||||||
|
if (!useEntityJoinForLoad) {
|
||||||
const sourceDisplayColumns = config.columns.filter((col) => col.isSourceDisplay);
|
const sourceDisplayColumns = config.columns.filter((col) => col.isSourceDisplay);
|
||||||
const sourceTable = config.dataSource?.sourceTable;
|
const sourceTable = config.dataSource?.sourceTable;
|
||||||
const fkColumn = config.dataSource?.foreignKey;
|
const fkColumn = config.dataSource?.foreignKey;
|
||||||
|
|
@ -448,7 +498,6 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
const uniqueValues = [...new Set(fkValues)];
|
const uniqueValues = [...new Set(fkValues)];
|
||||||
|
|
||||||
if (uniqueValues.length > 0) {
|
if (uniqueValues.length > 0) {
|
||||||
// FK 값 기반으로 소스 테이블에서 해당 레코드만 조회
|
|
||||||
const sourcePromises = uniqueValues.map((val) =>
|
const sourcePromises = uniqueValues.map((val) =>
|
||||||
apiClient.post(`/table-management/tables/${sourceTable}/data`, {
|
apiClient.post(`/table-management/tables/${sourceTable}/data`, {
|
||||||
page: 1, size: 1,
|
page: 1, size: 1,
|
||||||
|
|
@ -463,7 +512,6 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
if (sr[refKey]) sourceMap.set(String(sr[refKey]), sr);
|
if (sr[refKey]) sourceMap.set(String(sr[refKey]), sr);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 각 행에 소스 테이블의 표시 데이터 병합
|
|
||||||
rows.forEach((row: any) => {
|
rows.forEach((row: any) => {
|
||||||
const sourceRecord = sourceMap.get(String(row[fkColumn]));
|
const sourceRecord = sourceMap.get(String(row[fkColumn]));
|
||||||
if (sourceRecord) {
|
if (sourceRecord) {
|
||||||
|
|
@ -480,6 +528,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
console.warn("⚠️ [V2Repeater] 소스 테이블 조회 실패 (표시만 영향):", sourceError);
|
console.warn("⚠️ [V2Repeater] 소스 테이블 조회 실패 (표시만 영향):", sourceError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// DB에서 로드된 데이터 중 CATEGORY_ 코드가 있으면 라벨로 변환
|
// DB에서 로드된 데이터 중 CATEGORY_ 코드가 있으면 라벨로 변환
|
||||||
const codesToResolve = new Set<string>();
|
const codesToResolve = new Set<string>();
|
||||||
|
|
@ -964,8 +1013,113 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
// V2Repeater는 자체 데이터 관리 (아이템 선택 모달, useCustomTable 로딩, DataReceiver)를 사용.
|
// sourceDetailConfig가 설정되고 groupedData(모달에서 전달된 마스터 데이터)가 있으면
|
||||||
// EditModal의 groupedData는 메인 테이블 레코드이므로 V2Repeater에서는 사용하지 않음.
|
// 마스터의 키를 추출하여 디테일 테이블에서 행을 조회 → 리피터에 자동 세팅
|
||||||
|
const sourceDetailLoadedRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (sourceDetailLoadedRef.current) return;
|
||||||
|
if (!groupedData || groupedData.length === 0) return;
|
||||||
|
if (!config.sourceDetailConfig) return;
|
||||||
|
|
||||||
|
const { tableName, foreignKey, parentKey } = config.sourceDetailConfig;
|
||||||
|
if (!tableName || !foreignKey || !parentKey) return;
|
||||||
|
|
||||||
|
const parentKeys = groupedData
|
||||||
|
.map((row) => row[parentKey])
|
||||||
|
.filter((v) => v !== undefined && v !== null && v !== "");
|
||||||
|
|
||||||
|
if (parentKeys.length === 0) return;
|
||||||
|
|
||||||
|
sourceDetailLoadedRef.current = true;
|
||||||
|
|
||||||
|
const loadSourceDetails = async () => {
|
||||||
|
try {
|
||||||
|
const uniqueKeys = [...new Set(parentKeys)] as string[];
|
||||||
|
const { useEntityJoin, columnMapping, additionalJoinColumns } = config.sourceDetailConfig!;
|
||||||
|
|
||||||
|
let detailRows: any[] = [];
|
||||||
|
|
||||||
|
if (useEntityJoin) {
|
||||||
|
// data-with-joins GET API 사용 (엔티티 조인 자동 적용)
|
||||||
|
const searchParam = JSON.stringify({ [foreignKey]: uniqueKeys.join("|") });
|
||||||
|
const params: Record<string, any> = {
|
||||||
|
page: 1,
|
||||||
|
size: 9999,
|
||||||
|
search: searchParam,
|
||||||
|
enableEntityJoin: true,
|
||||||
|
autoFilter: JSON.stringify({ enabled: true }),
|
||||||
|
};
|
||||||
|
if (additionalJoinColumns && additionalJoinColumns.length > 0) {
|
||||||
|
params.additionalJoinColumns = JSON.stringify(additionalJoinColumns);
|
||||||
|
}
|
||||||
|
const resp = await apiClient.get(`/table-management/tables/${tableName}/data-with-joins`, { params });
|
||||||
|
const resultData = resp.data?.data;
|
||||||
|
const rawRows = Array.isArray(resultData)
|
||||||
|
? resultData
|
||||||
|
: resultData?.data || resultData?.rows || [];
|
||||||
|
// 엔티티 조인 시 참조 테이블에 중복 레코드가 있으면 행이 늘어나므로 id 기준 중복 제거
|
||||||
|
const seenIds = new Set<string>();
|
||||||
|
detailRows = rawRows.filter((row: any) => {
|
||||||
|
if (!row.id || seenIds.has(row.id)) return false;
|
||||||
|
seenIds.add(row.id);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 기존 POST API 사용
|
||||||
|
const resp = await apiClient.post(`/table-management/tables/${tableName}/data`, {
|
||||||
|
page: 1,
|
||||||
|
size: 9999,
|
||||||
|
search: { [foreignKey]: uniqueKeys },
|
||||||
|
});
|
||||||
|
const resultData = resp.data?.data;
|
||||||
|
detailRows = Array.isArray(resultData)
|
||||||
|
? resultData
|
||||||
|
: resultData?.data || resultData?.rows || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detailRows.length === 0) {
|
||||||
|
console.warn("[V2Repeater] sourceDetail 조회 결과 없음:", { tableName, uniqueKeys });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[V2Repeater] sourceDetail 조회 완료:", detailRows.length, "건", useEntityJoin ? "(엔티티 조인)" : "");
|
||||||
|
|
||||||
|
// 디테일 행을 리피터 컬럼에 매핑
|
||||||
|
const newRows = detailRows.map((detail, index) => {
|
||||||
|
const row: any = { _id: `src_detail_${Date.now()}_${index}` };
|
||||||
|
for (const col of config.columns) {
|
||||||
|
if (col.isSourceDisplay) {
|
||||||
|
// columnMapping이 있으면 조인 alias에서 값 가져오기 (표시용)
|
||||||
|
const mappedKey = columnMapping?.[col.key];
|
||||||
|
const value = mappedKey ? detail[mappedKey] : detail[col.key];
|
||||||
|
row[`_display_${col.key}`] = value ?? "";
|
||||||
|
// 원본 값도 저장 (DB persist용 - _display_ 접두사 없이)
|
||||||
|
if (detail[col.key] !== undefined) {
|
||||||
|
row[col.key] = detail[col.key];
|
||||||
|
}
|
||||||
|
} else if (col.autoFill) {
|
||||||
|
const autoValue = generateAutoFillValueSync(col, index, parentFormData);
|
||||||
|
row[col.key] = autoValue ?? "";
|
||||||
|
} else if (col.sourceKey && detail[col.sourceKey] !== undefined) {
|
||||||
|
row[col.key] = detail[col.sourceKey];
|
||||||
|
} else if (detail[col.key] !== undefined) {
|
||||||
|
row[col.key] = detail[col.key];
|
||||||
|
} else {
|
||||||
|
row[col.key] = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
|
setData(newRows);
|
||||||
|
onDataChange?.(newRows);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[V2Repeater] sourceDetail 조회 실패:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadSourceDetails();
|
||||||
|
}, [groupedData, config.sourceDetailConfig, config.columns, generateAutoFillValueSync, parentFormData, onDataChange]);
|
||||||
|
|
||||||
// parentSequence 컬럼의 부모 필드 값이 변경되면 행 데이터 갱신
|
// parentSequence 컬럼의 부모 필드 값이 변경되면 행 데이터 갱신
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import {
|
||||||
Wand2,
|
Wand2,
|
||||||
Check,
|
Check,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
|
ListTree,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
|
|
@ -983,6 +984,133 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
{/* 소스 디테일 자동 조회 설정 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="enableSourceDetail"
|
||||||
|
checked={!!config.sourceDetailConfig}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
if (checked) {
|
||||||
|
updateConfig({
|
||||||
|
sourceDetailConfig: {
|
||||||
|
tableName: "",
|
||||||
|
foreignKey: "",
|
||||||
|
parentKey: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
updateConfig({ sourceDetailConfig: undefined });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label htmlFor="enableSourceDetail" className="text-xs font-medium flex items-center gap-1">
|
||||||
|
<ListTree className="h-3 w-3" />
|
||||||
|
소스 디테일 자동 조회
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
모달에서 전달받은 마스터 데이터의 디테일 행을 자동으로 조회하여 리피터에 채웁니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{config.sourceDetailConfig && (
|
||||||
|
<div className="space-y-2 rounded border border-violet-200 bg-violet-50 p-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">디테일 테이블</Label>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
className="h-7 w-full justify-between text-xs"
|
||||||
|
>
|
||||||
|
{config.sourceDetailConfig.tableName
|
||||||
|
? (allTables.find(t => t.tableName === config.sourceDetailConfig!.tableName)?.displayName || config.sourceDetailConfig.tableName)
|
||||||
|
: "테이블 선택..."
|
||||||
|
}
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="p-0"
|
||||||
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
||||||
|
<CommandList className="max-h-48">
|
||||||
|
<CommandEmpty className="text-xs py-3 text-center">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{allTables.map((table) => (
|
||||||
|
<CommandItem
|
||||||
|
key={table.tableName}
|
||||||
|
value={`${table.tableName} ${table.displayName}`}
|
||||||
|
onSelect={() => {
|
||||||
|
updateConfig({
|
||||||
|
sourceDetailConfig: {
|
||||||
|
...config.sourceDetailConfig!,
|
||||||
|
tableName: table.tableName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check className={cn("mr-2 h-3 w-3", config.sourceDetailConfig!.tableName === table.tableName ? "opacity-100" : "opacity-0")} />
|
||||||
|
<span>{table.displayName}</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">디테일 FK 컬럼</Label>
|
||||||
|
<Input
|
||||||
|
value={config.sourceDetailConfig.foreignKey || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateConfig({
|
||||||
|
sourceDetailConfig: {
|
||||||
|
...config.sourceDetailConfig!,
|
||||||
|
foreignKey: e.target.value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="예: order_no"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">마스터 키 컬럼</Label>
|
||||||
|
<Input
|
||||||
|
value={config.sourceDetailConfig.parentKey || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateConfig({
|
||||||
|
sourceDetailConfig: {
|
||||||
|
...config.sourceDetailConfig!,
|
||||||
|
parentKey: e.target.value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="예: order_no"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-[10px] text-violet-600">
|
||||||
|
마스터에서 [{config.sourceDetailConfig.parentKey || "?"}] 추출 →
|
||||||
|
{" "}{config.sourceDetailConfig.tableName || "?"}.{config.sourceDetailConfig.foreignKey || "?"} 로 조회
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
{/* 기능 옵션 */}
|
{/* 기능 옵션 */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label className="text-xs font-medium">기능 옵션</Label>
|
<Label className="text-xs font-medium">기능 옵션</Label>
|
||||||
|
|
|
||||||
|
|
@ -553,14 +553,20 @@ export function RepeaterTable({
|
||||||
const isEditing = editingCell?.rowIndex === rowIndex && editingCell?.field === column.field;
|
const isEditing = editingCell?.rowIndex === rowIndex && editingCell?.field === column.field;
|
||||||
const value = row[column.field];
|
const value = row[column.field];
|
||||||
|
|
||||||
// 카테고리 라벨 변환 함수
|
// 카테고리/셀렉트 라벨 변환 함수
|
||||||
const getCategoryDisplayValue = (val: any): string => {
|
const getCategoryDisplayValue = (val: any): string => {
|
||||||
if (!val || typeof val !== "string") return val || "-";
|
if (!val || typeof val !== "string") return val || "-";
|
||||||
|
|
||||||
|
// select 타입 컬럼의 selectOptions에서 라벨 찾기
|
||||||
|
if (column.selectOptions && column.selectOptions.length > 0) {
|
||||||
|
const matchedOption = column.selectOptions.find((opt) => opt.value === val);
|
||||||
|
if (matchedOption) return matchedOption.label;
|
||||||
|
}
|
||||||
|
|
||||||
const fieldName = column.field.replace(/^_display_/, "");
|
const fieldName = column.field.replace(/^_display_/, "");
|
||||||
const isCategoryColumn = categoryColumns.includes(fieldName);
|
const isCategoryColumn = categoryColumns.includes(fieldName);
|
||||||
|
|
||||||
// categoryLabelMap에 직접 매핑이 있으면 바로 변환 (접두사 무관)
|
// categoryLabelMap에 직접 매핑이 있으면 바로 변환
|
||||||
if (categoryLabelMap[val]) return categoryLabelMap[val];
|
if (categoryLabelMap[val]) return categoryLabelMap[val];
|
||||||
|
|
||||||
// 카테고리 컬럼이 아니면 원래 값 반환
|
// 카테고리 컬럼이 아니면 원래 값 반환
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||||
|
|
||||||
export interface SplitPanelLayout2ComponentProps extends ComponentRendererProps {
|
export interface SplitPanelLayout2ComponentProps extends ComponentRendererProps {
|
||||||
// 추가 props
|
// 추가 props
|
||||||
|
|
@ -92,6 +93,9 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
const [leftActiveTab, setLeftActiveTab] = useState<string | null>(null);
|
const [leftActiveTab, setLeftActiveTab] = useState<string | null>(null);
|
||||||
const [rightActiveTab, setRightActiveTab] = useState<string | null>(null);
|
const [rightActiveTab, setRightActiveTab] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 카테고리 코드→라벨 매핑
|
||||||
|
const [categoryLabelMap, setCategoryLabelMap] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
// 프론트엔드 그룹핑 함수
|
// 프론트엔드 그룹핑 함수
|
||||||
const groupData = useCallback(
|
const groupData = useCallback(
|
||||||
(data: Record<string, any>[], groupingConfig: GroupingConfig, columns: ColumnConfig[]): Record<string, any>[] => {
|
(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]) => ({
|
const tabs = Array.from(valueCount.entries()).map(([value, count]) => ({
|
||||||
id: value,
|
id: value,
|
||||||
label: value,
|
label: categoryLabelMap[value] || value,
|
||||||
count: tabConfig.showCount ? count : 0,
|
count: tabConfig.showCount ? count : 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
console.log(`[SplitPanelLayout2] 탭 생성: ${tabs.length}개 탭 (컬럼: ${sourceColumn})`);
|
console.log(`[SplitPanelLayout2] 탭 생성: ${tabs.length}개 탭 (컬럼: ${sourceColumn})`);
|
||||||
return tabs;
|
return tabs;
|
||||||
},
|
},
|
||||||
[],
|
[categoryLabelMap],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 탭으로 필터링된 데이터 반환
|
// 탭으로 필터링된 데이터 반환
|
||||||
|
|
@ -1000,10 +1004,38 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
console.log("[SplitPanelLayout2] 좌측 액션 버튼 모달 열기:", modalScreenId);
|
console.log("[SplitPanelLayout2] 좌측 액션 버튼 모달 열기:", modalScreenId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "edit":
|
case "edit": {
|
||||||
// 좌측 패널에서 수정 (필요시 구현)
|
if (!selectedLeftItem) {
|
||||||
console.log("[SplitPanelLayout2] 좌측 수정 액션:", btn);
|
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;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "delete":
|
case "delete":
|
||||||
// 좌측 패널에서 삭제 (필요시 구현)
|
// 좌측 패널에서 삭제 (필요시 구현)
|
||||||
|
|
@ -1018,7 +1050,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
break;
|
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,
|
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 해제
|
// 컴포넌트 언마운트 시 DataProvider 해제
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
|
@ -1250,6 +1331,23 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
};
|
};
|
||||||
}, [screenContext, component.id]);
|
}, [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 및 엔티티 참조 고려)
|
// 컬럼 값 가져오기 (sourceTable 및 엔티티 참조 고려)
|
||||||
const getColumnValue = useCallback(
|
const getColumnValue = useCallback(
|
||||||
(item: any, col: ColumnConfig): any => {
|
(item: any, col: ColumnConfig): any => {
|
||||||
|
|
@ -1547,7 +1645,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
const displayColumns = config.leftPanel?.displayColumns || [];
|
const displayColumns = config.leftPanel?.displayColumns || [];
|
||||||
const pkColumn = getLeftPrimaryKeyColumn();
|
const pkColumn = getLeftPrimaryKeyColumn();
|
||||||
|
|
||||||
// 값 렌더링 (배지 지원)
|
// 값 렌더링 (배지 지원 + 카테고리 라벨 변환)
|
||||||
const renderCellValue = (item: any, col: ColumnConfig) => {
|
const renderCellValue = (item: any, col: ColumnConfig) => {
|
||||||
const value = item[col.name];
|
const value = item[col.name];
|
||||||
if (value === null || value === undefined) return "-";
|
if (value === null || value === undefined) return "-";
|
||||||
|
|
@ -1558,7 +1656,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{value.map((v, vIdx) => (
|
{value.map((v, vIdx) => (
|
||||||
<Badge key={vIdx} variant="secondary" className="text-xs">
|
<Badge key={vIdx} variant="secondary" className="text-xs">
|
||||||
{formatValue(v, col.format)}
|
{resolveCategoryLabel(v) || formatValue(v, col.format)}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1567,14 +1665,17 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
|
|
||||||
// 배지 타입이지만 단일 값인 경우
|
// 배지 타입이지만 단일 값인 경우
|
||||||
if (col.displayConfig?.displayType === "badge") {
|
if (col.displayConfig?.displayType === "badge") {
|
||||||
|
const label = resolveCategoryLabel(value);
|
||||||
return (
|
return (
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
{formatValue(value, col.format)}
|
{label !== String(value) ? label : formatValue(value, col.format)}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 기본 텍스트
|
// 카테고리 라벨 변환 시도 후 기본 텍스트
|
||||||
|
const label = resolveCategoryLabel(value);
|
||||||
|
if (label !== String(value)) return label;
|
||||||
return formatValue(value, col.format);
|
return formatValue(value, col.format);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1821,9 +1922,12 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
{displayColumns.map((col, colIdx) => (
|
{displayColumns.map((col, colIdx) => {
|
||||||
<TableCell key={colIdx}>{formatValue(getColumnValue(item, col), col.format)}</TableCell>
|
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) && (
|
{(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && (
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
<div className="flex justify-center gap-1">
|
<div className="flex justify-center gap-1">
|
||||||
|
|
@ -2133,7 +2237,12 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
// 새로운 actionButtons 배열 기반 렌더링 (빈 배열이면 버튼 없음)
|
// 새로운 actionButtons 배열 기반 렌더링 (빈 배열이면 버튼 없음)
|
||||||
config.leftPanel.actionButtons.length > 0 && (
|
config.leftPanel.actionButtons.length > 0 && (
|
||||||
<div className="flex items-center gap-2">
|
<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
|
<Button
|
||||||
key={idx}
|
key={idx}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
|
||||||
|
|
@ -514,29 +514,38 @@ export function TableSectionRenderer({
|
||||||
loadColumnLabels();
|
loadColumnLabels();
|
||||||
}, [tableConfig.source.tableName, tableConfig.source.columnLabels]);
|
}, [tableConfig.source.tableName, tableConfig.source.columnLabels]);
|
||||||
|
|
||||||
// 카테고리 타입 컬럼의 옵션 로드
|
// 카테고리 타입 컬럼 + referenceDisplay 소스 카테고리 컬럼의 옵션 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadCategoryOptions = async () => {
|
const loadCategoryOptions = async () => {
|
||||||
const sourceTableName = tableConfig.source.tableName;
|
const sourceTableName = tableConfig.source.tableName;
|
||||||
if (!sourceTableName) return;
|
if (!sourceTableName) return;
|
||||||
if (!tableConfig.columns) return;
|
if (!tableConfig.columns) return;
|
||||||
|
|
||||||
// 카테고리 타입인 컬럼만 필터링
|
|
||||||
const categoryColumns = tableConfig.columns.filter((col) => col.type === "category");
|
|
||||||
if (categoryColumns.length === 0) return;
|
|
||||||
|
|
||||||
const newOptionsMap: Record<string, { value: string; label: string }[]> = {};
|
const newOptionsMap: Record<string, { value: string; label: string }[]> = {};
|
||||||
|
const loadedSourceColumns = new Set<string>();
|
||||||
|
|
||||||
for (const col of categoryColumns) {
|
const { getCategoryValues } = await import("@/lib/api/tableCategoryValue");
|
||||||
// 소스 필드 또는 필드명으로 카테고리 값 조회
|
|
||||||
const actualColumnName = col.sourceField || col.field;
|
for (const col of tableConfig.columns) {
|
||||||
if (!actualColumnName) continue;
|
let sourceColumnName: string | undefined;
|
||||||
|
|
||||||
|
if (col.type === "category") {
|
||||||
|
sourceColumnName = col.sourceField || col.field;
|
||||||
|
} else {
|
||||||
|
// referenceDisplay로 소스 카테고리 컬럼을 참조하는 컬럼도 포함
|
||||||
|
const refSource = (col as any).saveConfig?.referenceDisplay?.sourceColumn;
|
||||||
|
if (refSource && sourceCategoryColumns.includes(refSource)) {
|
||||||
|
sourceColumnName = refSource;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sourceColumnName || loadedSourceColumns.has(`${col.field}:${sourceColumnName}`)) continue;
|
||||||
|
loadedSourceColumns.add(`${col.field}:${sourceColumnName}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { getCategoryValues } = await import("@/lib/api/tableCategoryValue");
|
const result = await getCategoryValues(sourceTableName, sourceColumnName, false);
|
||||||
const result = await getCategoryValues(sourceTableName, actualColumnName, false);
|
|
||||||
|
|
||||||
if (result && result.success && Array.isArray(result.data)) {
|
if (result?.success && Array.isArray(result.data)) {
|
||||||
const options = result.data.map((item: any) => ({
|
const options = result.data.map((item: any) => ({
|
||||||
value: item.valueCode || item.value_code || item.value || "",
|
value: item.valueCode || item.value_code || item.value || "",
|
||||||
label: item.valueLabel || item.displayLabel || item.display_label || item.label || item.valueCode || item.value_code || item.value || "",
|
label: item.valueLabel || item.displayLabel || item.display_label || item.label || item.valueCode || item.value_code || item.value || "",
|
||||||
|
|
@ -548,11 +557,13 @@ export function TableSectionRenderer({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Object.keys(newOptionsMap).length > 0) {
|
||||||
setCategoryOptionsMap((prev) => ({ ...prev, ...newOptionsMap }));
|
setCategoryOptionsMap((prev) => ({ ...prev, ...newOptionsMap }));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadCategoryOptions();
|
loadCategoryOptions();
|
||||||
}, [tableConfig.source.tableName, tableConfig.columns]);
|
}, [tableConfig.source.tableName, tableConfig.columns, sourceCategoryColumns]);
|
||||||
|
|
||||||
// receiveFromParent / internal 매핑으로 넘어오는 formData 값의 라벨 사전 로드
|
// receiveFromParent / internal 매핑으로 넘어오는 formData 값의 라벨 사전 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -630,7 +641,42 @@ export function TableSectionRenderer({
|
||||||
const loadDynamicOptions = async () => {
|
const loadDynamicOptions = async () => {
|
||||||
setDynamicOptionsLoading(true);
|
setDynamicOptionsLoading(true);
|
||||||
try {
|
try {
|
||||||
// DISTINCT 값을 가져오기 위한 API 호출
|
// 카테고리 값이 있는 컬럼인지 확인 (category_values 테이블에서 라벨 해결)
|
||||||
|
let categoryLabelMap: Record<string, string> = {};
|
||||||
|
try {
|
||||||
|
const { getCategoryValues } = await import("@/lib/api/tableCategoryValue");
|
||||||
|
const catResult = await getCategoryValues(tableName, valueColumn, false);
|
||||||
|
if (catResult?.success && Array.isArray(catResult.data)) {
|
||||||
|
for (const item of catResult.data) {
|
||||||
|
const code = item.valueCode || item.value_code || item.value || "";
|
||||||
|
const label = item.valueLabel || item.displayLabel || item.display_label || item.label || code;
|
||||||
|
if (code) categoryLabelMap[code] = label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 카테고리 값이 없으면 무시
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasCategoryValues = Object.keys(categoryLabelMap).length > 0;
|
||||||
|
|
||||||
|
if (hasCategoryValues) {
|
||||||
|
// 카테고리 값이 정의되어 있으면 그대로 옵션으로 사용
|
||||||
|
const options = Object.entries(categoryLabelMap).map(([code, label], index) => ({
|
||||||
|
id: `dynamic_${index}`,
|
||||||
|
value: code,
|
||||||
|
label,
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log("[TableSectionRenderer] 카테고리 기반 옵션 로드 완료:", {
|
||||||
|
tableName,
|
||||||
|
valueColumn,
|
||||||
|
optionCount: options.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
setDynamicOptions(options);
|
||||||
|
dynamicOptionsLoadedRef.current = true;
|
||||||
|
} else {
|
||||||
|
// 카테고리 값이 없으면 기존 방식: DISTINCT 값에서 추출 (쉼표 다중값 분리)
|
||||||
const response = await apiClient.post(`/table-management/tables/${tableName}/data`, {
|
const response = await apiClient.post(`/table-management/tables/${tableName}/data`, {
|
||||||
search: filterCondition ? { _raw: filterCondition } : {},
|
search: filterCondition ? { _raw: filterCondition } : {},
|
||||||
size: 1000,
|
size: 1000,
|
||||||
|
|
@ -640,33 +686,37 @@ export function TableSectionRenderer({
|
||||||
if (response.data.success && response.data.data?.data) {
|
if (response.data.success && response.data.data?.data) {
|
||||||
const rows = response.data.data.data;
|
const rows = response.data.data.data;
|
||||||
|
|
||||||
// 중복 제거하여 고유 값 추출
|
|
||||||
const uniqueValues = new Map<string, string>();
|
const uniqueValues = new Map<string, string>();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const value = row[valueColumn];
|
const rawValue = row[valueColumn];
|
||||||
if (value && !uniqueValues.has(value)) {
|
if (!rawValue) continue;
|
||||||
const label = labelColumn ? row[labelColumn] || value : value;
|
|
||||||
uniqueValues.set(value, label);
|
// 쉼표 구분 다중값을 개별로 분리
|
||||||
|
const values = String(rawValue).split(",").map((v: string) => v.trim()).filter(Boolean);
|
||||||
|
for (const v of values) {
|
||||||
|
if (!uniqueValues.has(v)) {
|
||||||
|
const label = labelColumn ? row[labelColumn] || v : v;
|
||||||
|
uniqueValues.set(v, label);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 옵션 배열로 변환
|
|
||||||
const options = Array.from(uniqueValues.entries()).map(([value, label], index) => ({
|
const options = Array.from(uniqueValues.entries()).map(([value, label], index) => ({
|
||||||
id: `dynamic_${index}`,
|
id: `dynamic_${index}`,
|
||||||
value,
|
value,
|
||||||
label,
|
label,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
console.log("[TableSectionRenderer] 동적 옵션 로드 완료:", {
|
console.log("[TableSectionRenderer] DISTINCT 기반 옵션 로드 완료:", {
|
||||||
tableName,
|
tableName,
|
||||||
valueColumn,
|
valueColumn,
|
||||||
optionCount: options.length,
|
optionCount: options.length,
|
||||||
options,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setDynamicOptions(options);
|
setDynamicOptions(options);
|
||||||
dynamicOptionsLoadedRef.current = true;
|
dynamicOptionsLoadedRef.current = true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[TableSectionRenderer] 동적 옵션 로드 실패:", error);
|
console.error("[TableSectionRenderer] 동적 옵션 로드 실패:", error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -1019,34 +1069,24 @@ export function TableSectionRenderer({
|
||||||
);
|
);
|
||||||
|
|
||||||
// formData에서 초기 테이블 데이터 로드 (수정 모드에서 _groupedData 표시)
|
// formData에서 초기 테이블 데이터 로드 (수정 모드에서 _groupedData 표시)
|
||||||
|
// 조건부 테이블은 별도 useEffect에서 applyConditionalGrouping으로 처리
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 이미 초기화되었으면 스킵
|
|
||||||
if (initialDataLoadedRef.current) return;
|
if (initialDataLoadedRef.current) return;
|
||||||
|
if (isConditionalMode) return;
|
||||||
|
|
||||||
const tableSectionKey = `__tableSection_${sectionId}`;
|
const tableSectionKey = `__tableSection_${sectionId}`;
|
||||||
const initialData = formData[tableSectionKey];
|
const initialData = formData[tableSectionKey];
|
||||||
|
|
||||||
console.log("[TableSectionRenderer] 초기 데이터 확인:", {
|
|
||||||
sectionId,
|
|
||||||
tableSectionKey,
|
|
||||||
hasInitialData: !!initialData,
|
|
||||||
initialDataLength: Array.isArray(initialData) ? initialData.length : 0,
|
|
||||||
formDataKeys: Object.keys(formData).filter(k => k.startsWith("__tableSection_")),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (Array.isArray(initialData) && initialData.length > 0) {
|
if (Array.isArray(initialData) && initialData.length > 0) {
|
||||||
console.log("[TableSectionRenderer] 초기 데이터 로드:", {
|
console.warn("[TableSectionRenderer] 비조건부 초기 데이터 로드:", {
|
||||||
sectionId,
|
sectionId,
|
||||||
itemCount: initialData.length,
|
itemCount: initialData.length,
|
||||||
firstItem: initialData[0],
|
|
||||||
});
|
});
|
||||||
setTableData(initialData);
|
setTableData(initialData);
|
||||||
initialDataLoadedRef.current = true;
|
initialDataLoadedRef.current = true;
|
||||||
|
|
||||||
// 참조 컬럼 값 조회 (saveToTarget: false인 컬럼)
|
|
||||||
loadReferenceColumnValues(initialData);
|
loadReferenceColumnValues(initialData);
|
||||||
}
|
}
|
||||||
}, [sectionId, formData, loadReferenceColumnValues]);
|
}, [sectionId, formData, isConditionalMode, loadReferenceColumnValues]);
|
||||||
|
|
||||||
// RepeaterColumnConfig로 변환 (동적 Select 옵션 반영)
|
// RepeaterColumnConfig로 변환 (동적 Select 옵션 반영)
|
||||||
const columns: RepeaterColumnConfig[] = useMemo(() => {
|
const columns: RepeaterColumnConfig[] = useMemo(() => {
|
||||||
|
|
@ -1068,10 +1108,23 @@ export function TableSectionRenderer({
|
||||||
});
|
});
|
||||||
}, [tableConfig.columns, dynamicSelectOptionsMap, categoryOptionsMap]);
|
}, [tableConfig.columns, dynamicSelectOptionsMap, categoryOptionsMap]);
|
||||||
|
|
||||||
// categoryOptionsMap에서 RepeaterTable용 카테고리 정보 파생
|
// categoryOptionsMap + dynamicOptions에서 RepeaterTable용 카테고리 정보 파생
|
||||||
const tableCategoryColumns = useMemo(() => {
|
const tableCategoryColumns = useMemo(() => {
|
||||||
return Object.keys(categoryOptionsMap);
|
const cols = new Set(Object.keys(categoryOptionsMap));
|
||||||
}, [categoryOptionsMap]);
|
// 조건부 테이블의 conditionColumn과 매핑된 컬럼도 카테고리 컬럼으로 추가
|
||||||
|
if (isConditionalMode && conditionalConfig?.conditionColumn && dynamicOptions.length > 0) {
|
||||||
|
// 조건 컬럼 자체
|
||||||
|
cols.add(conditionalConfig.conditionColumn);
|
||||||
|
// referenceDisplay로 조건 컬럼의 소스를 참조하는 컬럼도 추가
|
||||||
|
for (const col of tableConfig.columns || []) {
|
||||||
|
const refDisplay = (col as any).saveConfig?.referenceDisplay;
|
||||||
|
if (refDisplay?.sourceColumn === conditionalConfig.conditionColumn) {
|
||||||
|
cols.add(col.field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(cols);
|
||||||
|
}, [categoryOptionsMap, isConditionalMode, conditionalConfig?.conditionColumn, dynamicOptions, tableConfig.columns]);
|
||||||
|
|
||||||
const tableCategoryLabelMap = useMemo(() => {
|
const tableCategoryLabelMap = useMemo(() => {
|
||||||
const map: Record<string, string> = {};
|
const map: Record<string, string> = {};
|
||||||
|
|
@ -1082,8 +1135,14 @@ export function TableSectionRenderer({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 조건부 테이블 동적 옵션의 카테고리 코드→라벨 매핑도 추가
|
||||||
|
for (const opt of dynamicOptions) {
|
||||||
|
if (opt.value && opt.label && opt.value !== opt.label) {
|
||||||
|
map[opt.value] = opt.label;
|
||||||
|
}
|
||||||
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [categoryOptionsMap]);
|
}, [categoryOptionsMap, dynamicOptions]);
|
||||||
|
|
||||||
// 원본 계산 규칙 (조건부 계산 포함)
|
// 원본 계산 규칙 (조건부 계산 포함)
|
||||||
const originalCalculationRules: TableCalculationRule[] = useMemo(
|
const originalCalculationRules: TableCalculationRule[] = useMemo(
|
||||||
|
|
@ -1606,10 +1665,9 @@ export function TableSectionRenderer({
|
||||||
const multiSelect = uiConfig?.multiSelect ?? true;
|
const multiSelect = uiConfig?.multiSelect ?? true;
|
||||||
|
|
||||||
// 버튼 표시 설정 (두 버튼 동시 표시 가능)
|
// 버튼 표시 설정 (두 버튼 동시 표시 가능)
|
||||||
// 레거시 호환: 기존 addButtonType 설정이 있으면 그에 맞게 변환
|
// showSearchButton/showAddRowButton 신규 필드 우선, 레거시 addButtonType은 신규 필드 없을 때만 참고
|
||||||
const legacyAddButtonType = uiConfig?.addButtonType;
|
const showSearchButton = uiConfig?.showSearchButton ?? true;
|
||||||
const showSearchButton = legacyAddButtonType === "addRow" ? false : (uiConfig?.showSearchButton ?? true);
|
const showAddRowButton = uiConfig?.showAddRowButton ?? false;
|
||||||
const showAddRowButton = legacyAddButtonType === "addRow" ? true : (uiConfig?.showAddRowButton ?? false);
|
|
||||||
const searchButtonText = uiConfig?.searchButtonText || uiConfig?.addButtonText || "품목 검색";
|
const searchButtonText = uiConfig?.searchButtonText || uiConfig?.addButtonText || "품목 검색";
|
||||||
const addRowButtonText = uiConfig?.addRowButtonText || "직접 입력";
|
const addRowButtonText = uiConfig?.addRowButtonText || "직접 입력";
|
||||||
|
|
||||||
|
|
@ -1641,8 +1699,9 @@ export function TableSectionRenderer({
|
||||||
const filter = { ...baseFilterCondition };
|
const filter = { ...baseFilterCondition };
|
||||||
|
|
||||||
// 조건부 테이블의 소스 필터 설정이 있고, 모달에서 선택된 조건이 있으면 적용
|
// 조건부 테이블의 소스 필터 설정이 있고, 모달에서 선택된 조건이 있으면 적용
|
||||||
|
// __like 연산자로 ILIKE 포함 검색 (쉼표 구분 다중값 매칭 지원)
|
||||||
if (conditionalConfig?.sourceFilter?.enabled && modalCondition) {
|
if (conditionalConfig?.sourceFilter?.enabled && modalCondition) {
|
||||||
filter[conditionalConfig.sourceFilter.filterColumn] = modalCondition;
|
filter[`${conditionalConfig.sourceFilter.filterColumn}__like`] = modalCondition;
|
||||||
}
|
}
|
||||||
|
|
||||||
return filter;
|
return filter;
|
||||||
|
|
@ -1771,7 +1830,29 @@ export function TableSectionRenderer({
|
||||||
async (items: any[]) => {
|
async (items: any[]) => {
|
||||||
if (!modalCondition) return;
|
if (!modalCondition) return;
|
||||||
|
|
||||||
// 기존 handleAddItems 로직을 재사용하여 매핑된 아이템 생성
|
// autoFillColumns 매핑 빌드: targetField → sourceColumn
|
||||||
|
const autoFillMap: Record<string, string> = {};
|
||||||
|
for (const col of tableConfig.columns) {
|
||||||
|
const dso = (col as any).dynamicSelectOptions;
|
||||||
|
if (dso?.sourceField) {
|
||||||
|
autoFillMap[col.field] = dso.sourceField;
|
||||||
|
}
|
||||||
|
if (dso?.rowSelectionMode?.autoFillColumns) {
|
||||||
|
for (const af of dso.rowSelectionMode.autoFillColumns) {
|
||||||
|
autoFillMap[af.targetField] = af.sourceColumn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// referenceDisplay에서도 매핑 추가
|
||||||
|
for (const col of tableConfig.columns) {
|
||||||
|
if (!autoFillMap[col.field]) {
|
||||||
|
const refDisplay = (col as any).saveConfig?.referenceDisplay;
|
||||||
|
if (refDisplay?.sourceColumn) {
|
||||||
|
autoFillMap[col.field] = refDisplay.sourceColumn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const mappedItems = await Promise.all(
|
const mappedItems = await Promise.all(
|
||||||
items.map(async (sourceItem) => {
|
items.map(async (sourceItem) => {
|
||||||
const newItem: any = {};
|
const newItem: any = {};
|
||||||
|
|
@ -1779,6 +1860,15 @@ export function TableSectionRenderer({
|
||||||
for (const col of tableConfig.columns) {
|
for (const col of tableConfig.columns) {
|
||||||
const mapping = col.valueMapping;
|
const mapping = col.valueMapping;
|
||||||
|
|
||||||
|
// autoFill 또는 referenceDisplay 매핑이 있으면 우선 사용
|
||||||
|
const autoFillSource = autoFillMap[col.field];
|
||||||
|
if (!mapping && autoFillSource) {
|
||||||
|
if (sourceItem[autoFillSource] !== undefined) {
|
||||||
|
newItem[col.field] = sourceItem[autoFillSource];
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// 소스 필드에서 값 복사 (기본)
|
// 소스 필드에서 값 복사 (기본)
|
||||||
if (!mapping) {
|
if (!mapping) {
|
||||||
const sourceField = col.sourceField || col.field;
|
const sourceField = col.sourceField || col.field;
|
||||||
|
|
@ -1896,23 +1986,20 @@ export function TableSectionRenderer({
|
||||||
[addEmptyRowToCondition],
|
[addEmptyRowToCondition],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 조건부 테이블: 초기 데이터 로드 (수정 모드)
|
// 조건부 테이블: 초기 데이터를 그룹핑하여 표시하는 헬퍼
|
||||||
useEffect(() => {
|
const applyConditionalGrouping = useCallback((data: any[]) => {
|
||||||
if (!isConditionalMode) return;
|
|
||||||
if (initialDataLoadedRef.current) return;
|
|
||||||
|
|
||||||
const tableSectionKey = `_tableSection_${sectionId}`;
|
|
||||||
const initialData = formData[tableSectionKey];
|
|
||||||
|
|
||||||
if (Array.isArray(initialData) && initialData.length > 0) {
|
|
||||||
const conditionColumn = conditionalConfig?.conditionColumn;
|
const conditionColumn = conditionalConfig?.conditionColumn;
|
||||||
|
console.warn(`[applyConditionalGrouping] 호출됨:`, {
|
||||||
|
conditionColumn,
|
||||||
|
dataLength: data.length,
|
||||||
|
sampleConditions: data.slice(0, 3).map(r => r[conditionColumn || ""]),
|
||||||
|
});
|
||||||
|
if (!conditionColumn || data.length === 0) return;
|
||||||
|
|
||||||
if (conditionColumn) {
|
|
||||||
// 조건별로 데이터 그룹핑
|
|
||||||
const grouped: ConditionalTableData = {};
|
const grouped: ConditionalTableData = {};
|
||||||
const conditions = new Set<string>();
|
const conditions = new Set<string>();
|
||||||
|
|
||||||
for (const row of initialData) {
|
for (const row of data) {
|
||||||
const conditionValue = row[conditionColumn] || "";
|
const conditionValue = row[conditionColumn] || "";
|
||||||
if (conditionValue) {
|
if (conditionValue) {
|
||||||
if (!grouped[conditionValue]) {
|
if (!grouped[conditionValue]) {
|
||||||
|
|
@ -1926,15 +2013,119 @@ export function TableSectionRenderer({
|
||||||
setConditionalTableData(grouped);
|
setConditionalTableData(grouped);
|
||||||
setSelectedConditions(Array.from(conditions));
|
setSelectedConditions(Array.from(conditions));
|
||||||
|
|
||||||
// 첫 번째 조건을 활성 탭으로 설정
|
|
||||||
if (conditions.size > 0) {
|
if (conditions.size > 0) {
|
||||||
setActiveConditionTab(Array.from(conditions)[0]);
|
setActiveConditionTab(Array.from(conditions)[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
initialDataLoadedRef.current = true;
|
initialDataLoadedRef.current = true;
|
||||||
|
}, [conditionalConfig?.conditionColumn]);
|
||||||
|
|
||||||
|
// 조건부 테이블: 초기 데이터 로드 (수정 모드)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isConditionalMode) return;
|
||||||
|
if (initialDataLoadedRef.current) return;
|
||||||
|
|
||||||
|
const initialData =
|
||||||
|
formData[`_tableSection_${sectionId}`] ||
|
||||||
|
formData[`__tableSection_${sectionId}`];
|
||||||
|
|
||||||
|
console.warn(`[TableSectionRenderer] 초기 데이터 로드 체크:`, {
|
||||||
|
sectionId,
|
||||||
|
hasUnderscoreData: !!formData[`_tableSection_${sectionId}`],
|
||||||
|
hasDoubleUnderscoreData: !!formData[`__tableSection_${sectionId}`],
|
||||||
|
dataLength: Array.isArray(initialData) ? initialData.length : "not array",
|
||||||
|
initialDataLoaded: initialDataLoadedRef.current,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Array.isArray(initialData) && initialData.length > 0) {
|
||||||
|
applyConditionalGrouping(initialData);
|
||||||
}
|
}
|
||||||
|
}, [isConditionalMode, sectionId, formData, applyConditionalGrouping]);
|
||||||
|
|
||||||
|
// 조건부 테이블: formData에 데이터가 없으면 editConfig 기반으로 직접 API 로드
|
||||||
|
const selfLoadAttemptedRef = React.useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isConditionalMode) return;
|
||||||
|
if (initialDataLoadedRef.current) return;
|
||||||
|
if (selfLoadAttemptedRef.current) return;
|
||||||
|
|
||||||
|
const editConfig = (tableConfig as any).editConfig;
|
||||||
|
const saveConfig = tableConfig.saveConfig;
|
||||||
|
const linkColumn = editConfig?.linkColumn;
|
||||||
|
const targetTable = saveConfig?.targetTable;
|
||||||
|
|
||||||
|
console.warn(`[TableSectionRenderer] 자체 로드 체크:`, {
|
||||||
|
sectionId,
|
||||||
|
hasEditConfig: !!editConfig,
|
||||||
|
linkColumn,
|
||||||
|
targetTable,
|
||||||
|
masterField: linkColumn?.masterField,
|
||||||
|
masterValue: linkColumn?.masterField ? formData[linkColumn.masterField] : "N/A",
|
||||||
|
formDataKeys: Object.keys(formData).slice(0, 15),
|
||||||
|
initialDataLoaded: initialDataLoadedRef.current,
|
||||||
|
selfLoadAttempted: selfLoadAttemptedRef.current,
|
||||||
|
existingTableData_: !!formData[`_tableSection_${sectionId}`],
|
||||||
|
existingTableData__: !!formData[`__tableSection_${sectionId}`],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!linkColumn?.masterField || !linkColumn?.detailField || !targetTable) {
|
||||||
|
console.warn(`[TableSectionRenderer] 자체 로드 스킵: linkColumn/targetTable 미설정`);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, [isConditionalMode, sectionId, formData, conditionalConfig?.conditionColumn]);
|
|
||||||
|
const masterValue = formData[linkColumn.masterField];
|
||||||
|
if (!masterValue) {
|
||||||
|
console.warn(`[TableSectionRenderer] 자체 로드 대기: masterField=${linkColumn.masterField} 값 없음`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// formData에 테이블 섹션 데이터가 이미 있으면 해당 데이터 사용
|
||||||
|
const existingData =
|
||||||
|
formData[`_tableSection_${sectionId}`] ||
|
||||||
|
formData[`__tableSection_${sectionId}`];
|
||||||
|
if (Array.isArray(existingData) && existingData.length > 0) {
|
||||||
|
console.warn(`[TableSectionRenderer] 기존 데이터 발견, applyConditionalGrouping 호출: ${existingData.length}건`);
|
||||||
|
applyConditionalGrouping(existingData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selfLoadAttemptedRef.current = true;
|
||||||
|
console.warn(`[TableSectionRenderer] 자체 API 로드 시작: ${targetTable}, ${linkColumn.detailField}=${masterValue}`);
|
||||||
|
|
||||||
|
const loadDetailData = async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post(`/table-management/tables/${targetTable}/data`, {
|
||||||
|
search: {
|
||||||
|
[linkColumn.detailField]: { value: masterValue, operator: "equals" },
|
||||||
|
},
|
||||||
|
page: 1,
|
||||||
|
size: 1000,
|
||||||
|
autoFilter: { enabled: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data?.success) {
|
||||||
|
let items: any[] = [];
|
||||||
|
const data = response.data.data;
|
||||||
|
if (Array.isArray(data)) items = data;
|
||||||
|
else if (data?.items && Array.isArray(data.items)) items = data.items;
|
||||||
|
else if (data?.rows && Array.isArray(data.rows)) items = data.rows;
|
||||||
|
else if (data?.data && Array.isArray(data.data)) items = data.data;
|
||||||
|
|
||||||
|
console.warn(`[TableSectionRenderer] 자체 데이터 로드 완료: ${items.length}건`);
|
||||||
|
|
||||||
|
if (items.length > 0) {
|
||||||
|
applyConditionalGrouping(items);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(`[TableSectionRenderer] API 응답 실패:`, response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[TableSectionRenderer] 자체 데이터 로드 실패:`, error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadDetailData();
|
||||||
|
}, [isConditionalMode, sectionId, formData, tableConfig, applyConditionalGrouping]);
|
||||||
|
|
||||||
// 조건부 테이블: 전체 항목 수 계산
|
// 조건부 테이블: 전체 항목 수 계산
|
||||||
const totalConditionalItems = useMemo(() => {
|
const totalConditionalItems = useMemo(() => {
|
||||||
|
|
|
||||||
|
|
@ -224,23 +224,38 @@ export function UniversalFormModalComponent({
|
||||||
// 설정 병합
|
// 설정 병합
|
||||||
const config: UniversalFormModalConfig = useMemo(() => {
|
const config: UniversalFormModalConfig = useMemo(() => {
|
||||||
const componentConfig = component?.config || {};
|
const componentConfig = component?.config || {};
|
||||||
|
|
||||||
|
// V2 레이아웃에서 overrides 전체가 config로 전달되는 경우
|
||||||
|
// 실제 설정이 propConfig.componentConfig에 이중 중첩되어 있을 수 있음
|
||||||
|
const nestedPropConfig = propConfig?.componentConfig;
|
||||||
|
const hasFlatPropConfig = propConfig?.modal !== undefined || propConfig?.sections !== undefined;
|
||||||
|
const effectivePropConfig = hasFlatPropConfig
|
||||||
|
? propConfig
|
||||||
|
: (nestedPropConfig?.modal ? nestedPropConfig : propConfig);
|
||||||
|
|
||||||
|
const nestedCompConfig = componentConfig?.componentConfig;
|
||||||
|
const hasFlatCompConfig = componentConfig?.modal !== undefined || componentConfig?.sections !== undefined;
|
||||||
|
const effectiveCompConfig = hasFlatCompConfig
|
||||||
|
? componentConfig
|
||||||
|
: (nestedCompConfig?.modal ? nestedCompConfig : componentConfig);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...defaultConfig,
|
...defaultConfig,
|
||||||
...propConfig,
|
...effectivePropConfig,
|
||||||
...componentConfig,
|
...effectiveCompConfig,
|
||||||
modal: {
|
modal: {
|
||||||
...defaultConfig.modal,
|
...defaultConfig.modal,
|
||||||
...propConfig?.modal,
|
...effectivePropConfig?.modal,
|
||||||
...componentConfig.modal,
|
...effectiveCompConfig?.modal,
|
||||||
},
|
},
|
||||||
saveConfig: {
|
saveConfig: {
|
||||||
...defaultConfig.saveConfig,
|
...defaultConfig.saveConfig,
|
||||||
...propConfig?.saveConfig,
|
...effectivePropConfig?.saveConfig,
|
||||||
...componentConfig.saveConfig,
|
...effectiveCompConfig?.saveConfig,
|
||||||
afterSave: {
|
afterSave: {
|
||||||
...defaultConfig.saveConfig.afterSave,
|
...defaultConfig.saveConfig.afterSave,
|
||||||
...propConfig?.saveConfig?.afterSave,
|
...effectivePropConfig?.saveConfig?.afterSave,
|
||||||
...componentConfig.saveConfig?.afterSave,
|
...effectiveCompConfig?.saveConfig?.afterSave,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -295,6 +310,7 @@ export function UniversalFormModalComponent({
|
||||||
const hasInitialized = useRef(false);
|
const hasInitialized = useRef(false);
|
||||||
// 마지막으로 초기화된 데이터의 ID를 추적 (수정 모달에서 다른 항목 선택 시 재초기화 필요)
|
// 마지막으로 초기화된 데이터의 ID를 추적 (수정 모달에서 다른 항목 선택 시 재초기화 필요)
|
||||||
const lastInitializedId = useRef<string | undefined>(undefined);
|
const lastInitializedId = useRef<string | undefined>(undefined);
|
||||||
|
const tableSectionLoadedRef = useRef(false);
|
||||||
|
|
||||||
// 초기화 - 최초 마운트 시 또는 initialData가 변경되었을 때 실행
|
// 초기화 - 최초 마운트 시 또는 initialData가 변경되었을 때 실행
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -316,7 +332,7 @@ export function UniversalFormModalComponent({
|
||||||
if (hasInitialized.current && lastInitializedId.current === currentIdString) {
|
if (hasInitialized.current && lastInitializedId.current === currentIdString) {
|
||||||
// 생성 모드에서 데이터가 새로 전달된 경우는 재초기화 필요
|
// 생성 모드에서 데이터가 새로 전달된 경우는 재초기화 필요
|
||||||
if (!createModeDataHash || capturedInitialData.current) {
|
if (!createModeDataHash || capturedInitialData.current) {
|
||||||
// console.log("[UniversalFormModal] 초기화 스킵 - 이미 초기화됨");
|
// console.log("[UniversalFormModal] 초기화 스킵 - 이미 초기화됨", { currentIdString });
|
||||||
// 🆕 채번 플래그가 true인데 formData에 값이 없으면 재생성 필요
|
// 🆕 채번 플래그가 true인데 formData에 값이 없으면 재생성 필요
|
||||||
// (컴포넌트 remount로 인해 state가 초기화된 경우)
|
// (컴포넌트 remount로 인해 state가 초기화된 경우)
|
||||||
return;
|
return;
|
||||||
|
|
@ -350,21 +366,13 @@ export function UniversalFormModalComponent({
|
||||||
// console.log("[UniversalFormModal] 초기 데이터 캡처:", capturedInitialData.current);
|
// console.log("[UniversalFormModal] 초기 데이터 캡처:", capturedInitialData.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
// console.log("[UniversalFormModal] initializeForm 호출 예정");
|
// console.log("[UniversalFormModal] initializeForm 호출 예정", { currentIdString });
|
||||||
hasInitialized.current = true;
|
hasInitialized.current = true;
|
||||||
|
tableSectionLoadedRef.current = false;
|
||||||
initializeForm();
|
initializeForm();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [initialData]); // initialData 전체 변경 시 재초기화
|
}, [initialData]); // initialData 전체 변경 시 재초기화
|
||||||
|
|
||||||
// config 변경 시에만 재초기화 (initialData 변경은 무시) - 채번규칙 제외
|
|
||||||
useEffect(() => {
|
|
||||||
if (!hasInitialized.current) return; // 최초 초기화 전이면 스킵
|
|
||||||
|
|
||||||
// console.log("[useEffect config 변경] 재초기화 스킵 (채번 중복 방지)");
|
|
||||||
// initializeForm(); // 주석 처리 - config 변경 시 재초기화 안 함 (채번 중복 방지)
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [config]);
|
|
||||||
|
|
||||||
// 컴포넌트 unmount 시 채번 플래그 초기화
|
// 컴포넌트 unmount 시 채번 플래그 초기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
|
@ -728,9 +736,13 @@ export function UniversalFormModalComponent({
|
||||||
// 🆕 테이블 섹션(type: "table") 디테일 데이터 로드 (마스터-디테일 구조)
|
// 🆕 테이블 섹션(type: "table") 디테일 데이터 로드 (마스터-디테일 구조)
|
||||||
// 수정 모드일 때 디테일 테이블에서 데이터 가져오기
|
// 수정 모드일 때 디테일 테이블에서 데이터 가져오기
|
||||||
if (effectiveInitialData) {
|
if (effectiveInitialData) {
|
||||||
console.log("[initializeForm] 테이블 섹션 디테일 로드 시작", {
|
// console.log("[initializeForm] 테이블 섹션 디테일 로드 시작", { sectionsCount: config.sections.length });
|
||||||
sectionsCount: config.sections.length,
|
|
||||||
effectiveInitialDataKeys: Object.keys(effectiveInitialData),
|
console.warn("[initializeForm] 테이블 섹션 순회 시작:", {
|
||||||
|
sectionCount: config.sections.length,
|
||||||
|
tableSections: config.sections.filter(s => s.type === "table").map(s => s.id),
|
||||||
|
hasInitialData: !!effectiveInitialData,
|
||||||
|
initialDataKeys: effectiveInitialData ? Object.keys(effectiveInitialData).slice(0, 10) : [],
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const section of config.sections) {
|
for (const section of config.sections) {
|
||||||
|
|
@ -739,16 +751,14 @@ export function UniversalFormModalComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
const tableConfig = section.tableConfig;
|
const tableConfig = section.tableConfig;
|
||||||
// editConfig는 타입에 정의되지 않았지만 런타임에 존재할 수 있음
|
|
||||||
const editConfig = (tableConfig as any).editConfig;
|
const editConfig = (tableConfig as any).editConfig;
|
||||||
const saveConfig = tableConfig.saveConfig;
|
const saveConfig = tableConfig.saveConfig;
|
||||||
|
|
||||||
console.log(`[initializeForm] 테이블 섹션 ${section.id} 검사:`, {
|
console.warn(`[initializeForm] 테이블 섹션 ${section.id}:`, {
|
||||||
hasEditConfig: !!editConfig,
|
editConfig,
|
||||||
loadOnEdit: editConfig?.loadOnEdit,
|
|
||||||
hasSaveConfig: !!saveConfig,
|
|
||||||
targetTable: saveConfig?.targetTable,
|
targetTable: saveConfig?.targetTable,
|
||||||
linkColumn: editConfig?.linkColumn,
|
masterField: editConfig?.linkColumn?.masterField,
|
||||||
|
masterValue: effectiveInitialData?.[editConfig?.linkColumn?.masterField],
|
||||||
});
|
});
|
||||||
|
|
||||||
// 수정 모드 로드 설정 확인 (기본값: true)
|
// 수정 모드 로드 설정 확인 (기본값: true)
|
||||||
|
|
@ -1073,6 +1083,25 @@ export function UniversalFormModalComponent({
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [config]); // initialData는 의존성에서 제거 (capturedInitialData.current 사용)
|
}, [config]); // initialData는 의존성에서 제거 (capturedInitialData.current 사용)
|
||||||
|
|
||||||
|
// config 변경 시 테이블 섹션 데이터 로드 보완
|
||||||
|
// initializeForm은 initialData useEffect에서 호출되지만, config(화면 설정)이
|
||||||
|
// 비동기 로드로 늦게 도착하면 테이블 섹션 로드를 놓칠 수 있음
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasInitialized.current) return;
|
||||||
|
|
||||||
|
const hasTableSection = config.sections.some(s => s.type === "table" && s.tableConfig?.saveConfig?.targetTable);
|
||||||
|
if (!hasTableSection) return;
|
||||||
|
|
||||||
|
const editData = capturedInitialData.current || initialData;
|
||||||
|
if (!editData || Object.keys(editData).length === 0) return;
|
||||||
|
|
||||||
|
if (tableSectionLoadedRef.current) return;
|
||||||
|
|
||||||
|
tableSectionLoadedRef.current = true;
|
||||||
|
initializeForm();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [config.sections, initializeForm]);
|
||||||
|
|
||||||
// 반복 섹션 아이템 생성
|
// 반복 섹션 아이템 생성
|
||||||
const createRepeatItem = (section: FormSectionConfig, index: number): RepeatSectionItem => {
|
const createRepeatItem = (section: FormSectionConfig, index: number): RepeatSectionItem => {
|
||||||
const item: RepeatSectionItem = {
|
const item: RepeatSectionItem = {
|
||||||
|
|
|
||||||
|
|
@ -47,14 +47,22 @@ export function UniversalFormModalConfigPanel({
|
||||||
onChange,
|
onChange,
|
||||||
allComponents = [],
|
allComponents = [],
|
||||||
}: UniversalFormModalConfigPanelProps) {
|
}: UniversalFormModalConfigPanelProps) {
|
||||||
// config가 불완전할 수 있으므로 defaultConfig와 병합하여 안전하게 사용
|
// V2 레이아웃에서 overrides 전체가 componentConfig로 전달되는 경우
|
||||||
|
// 실제 설정이 rawConfig.componentConfig에 이중 중첩되어 있을 수 있음
|
||||||
|
// 평탄화된 구조(save 후)가 있으면 우선, 아니면 중첩 구조에서 추출
|
||||||
|
const nestedConfig = rawConfig?.componentConfig;
|
||||||
|
const hasFlatConfig = rawConfig?.modal !== undefined || rawConfig?.sections !== undefined;
|
||||||
|
const effectiveConfig = hasFlatConfig
|
||||||
|
? rawConfig
|
||||||
|
: (nestedConfig?.modal ? nestedConfig : rawConfig);
|
||||||
|
|
||||||
const config: UniversalFormModalConfig = {
|
const config: UniversalFormModalConfig = {
|
||||||
...defaultConfig,
|
...defaultConfig,
|
||||||
...rawConfig,
|
...effectiveConfig,
|
||||||
modal: { ...defaultConfig.modal, ...rawConfig?.modal },
|
modal: { ...defaultConfig.modal, ...effectiveConfig?.modal },
|
||||||
sections: rawConfig?.sections ?? defaultConfig.sections,
|
sections: effectiveConfig?.sections ?? defaultConfig.sections,
|
||||||
saveConfig: { ...defaultConfig.saveConfig, ...rawConfig?.saveConfig },
|
saveConfig: { ...defaultConfig.saveConfig, ...effectiveConfig?.saveConfig },
|
||||||
editMode: { ...defaultConfig.editMode, ...rawConfig?.editMode },
|
editMode: { ...defaultConfig.editMode, ...effectiveConfig?.editMode },
|
||||||
};
|
};
|
||||||
|
|
||||||
// 테이블 목록
|
// 테이블 목록
|
||||||
|
|
|
||||||
|
|
@ -2721,9 +2721,12 @@ export function TableSectionSettingsModal({
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateUiConfig = (updates: Partial<NonNullable<TableSectionConfig["uiConfig"]>>) => {
|
const updateUiConfig = (updates: Partial<NonNullable<TableSectionConfig["uiConfig"]>>) => {
|
||||||
updateTableConfig({
|
const newUiConfig = { ...tableConfig.uiConfig, ...updates };
|
||||||
uiConfig: { ...tableConfig.uiConfig, ...updates },
|
// 새 버튼 설정이 사용되면 레거시 addButtonType 제거
|
||||||
});
|
if ("showSearchButton" in updates || "showAddRowButton" in updates) {
|
||||||
|
delete (newUiConfig as any).addButtonType;
|
||||||
|
}
|
||||||
|
updateTableConfig({ uiConfig: newUiConfig });
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateSaveConfig = (updates: Partial<NonNullable<TableSectionConfig["saveConfig"]>>) => {
|
const updateSaveConfig = (updates: Partial<NonNullable<TableSectionConfig["saveConfig"]>>) => {
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,14 @@ export function DetailFormModal({
|
||||||
if (type === "input" && !formData.content?.trim()) return;
|
if (type === "input" && !formData.content?.trim()) return;
|
||||||
if (type === "info" && !formData.lookup_target) return;
|
if (type === "info" && !formData.lookup_target) return;
|
||||||
|
|
||||||
onSubmit(formData);
|
const submitData = { ...formData };
|
||||||
|
|
||||||
|
if (type === "info" && !submitData.content?.trim()) {
|
||||||
|
const targetLabel = LOOKUP_TARGETS.find(t => t.value === submitData.lookup_target)?.label || submitData.lookup_target;
|
||||||
|
submitData.content = `${targetLabel} 조회`;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit(submitData);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ interface V2RepeaterRendererProps {
|
||||||
onButtonClick?: (action: string, row?: any, buttonConfig?: any) => void;
|
onButtonClick?: (action: string, row?: any, buttonConfig?: any) => void;
|
||||||
parentId?: string | number;
|
parentId?: string | number;
|
||||||
formData?: Record<string, any>;
|
formData?: Record<string, any>;
|
||||||
|
groupedData?: Record<string, any>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const V2RepeaterRenderer: React.FC<V2RepeaterRendererProps> = ({
|
const V2RepeaterRenderer: React.FC<V2RepeaterRendererProps> = ({
|
||||||
|
|
@ -33,6 +34,7 @@ const V2RepeaterRenderer: React.FC<V2RepeaterRendererProps> = ({
|
||||||
onButtonClick,
|
onButtonClick,
|
||||||
parentId,
|
parentId,
|
||||||
formData,
|
formData,
|
||||||
|
groupedData,
|
||||||
}) => {
|
}) => {
|
||||||
// component.componentConfig 또는 component.config에서 V2RepeaterConfig 추출
|
// component.componentConfig 또는 component.config에서 V2RepeaterConfig 추출
|
||||||
const config: V2RepeaterConfig = React.useMemo(() => {
|
const config: V2RepeaterConfig = React.useMemo(() => {
|
||||||
|
|
@ -105,6 +107,7 @@ const V2RepeaterRenderer: React.FC<V2RepeaterRendererProps> = ({
|
||||||
onButtonClick={onButtonClick}
|
onButtonClick={onButtonClick}
|
||||||
className={component?.className}
|
className={component?.className}
|
||||||
formData={formData}
|
formData={formData}
|
||||||
|
groupedData={groupedData}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -50,11 +50,13 @@ export interface RepeaterColumnConfig {
|
||||||
width: ColumnWidthOption;
|
width: ColumnWidthOption;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
editable?: boolean; // 편집 가능 여부 (inline 모드)
|
editable?: boolean; // 편집 가능 여부 (inline 모드)
|
||||||
hidden?: boolean; // 🆕 히든 처리 (화면에 안 보이지만 저장됨)
|
hidden?: boolean; // 히든 처리 (화면에 안 보이지만 저장됨)
|
||||||
isJoinColumn?: boolean;
|
isJoinColumn?: boolean;
|
||||||
sourceTable?: string;
|
sourceTable?: string;
|
||||||
// 🆕 소스 테이블 표시 컬럼 여부 (modal 모드에서 읽기 전용으로 표시)
|
// 소스 테이블 표시 컬럼 여부 (modal 모드에서 읽기 전용으로 표시)
|
||||||
isSourceDisplay?: boolean;
|
isSourceDisplay?: boolean;
|
||||||
|
// 소스 데이터의 다른 컬럼명에서 값을 매핑 (예: qty ← order_qty)
|
||||||
|
sourceKey?: string;
|
||||||
// 입력 타입 (테이블 타입 관리의 inputType을 따름)
|
// 입력 타입 (테이블 타입 관리의 inputType을 따름)
|
||||||
inputType?: string; // text, number, date, code, entity 등
|
inputType?: string; // text, number, date, code, entity 등
|
||||||
// 🆕 자동 입력 설정
|
// 🆕 자동 입력 설정
|
||||||
|
|
@ -140,6 +142,20 @@ export interface CalculationRule {
|
||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 소스 디테일 설정 (모달에서 전달받은 마스터 데이터의 디테일을 자동 조회)
|
||||||
|
export interface SourceDetailConfig {
|
||||||
|
tableName: string; // 디테일 테이블명 (예: "sales_order_detail")
|
||||||
|
foreignKey: string; // 디테일 테이블의 FK 컬럼 (예: "order_no")
|
||||||
|
parentKey: string; // 전달받은 마스터 데이터에서 추출할 키 (예: "order_no")
|
||||||
|
useEntityJoin?: boolean; // 엔티티 조인 사용 여부 (data-with-joins API)
|
||||||
|
columnMapping?: Record<string, string>; // 리피터 컬럼 ← 조인 alias 매핑 (예: { "part_name": "part_code_item_name" })
|
||||||
|
additionalJoinColumns?: Array<{
|
||||||
|
sourceColumn: string;
|
||||||
|
sourceTable: string;
|
||||||
|
joinAlias: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
// 메인 설정 타입
|
// 메인 설정 타입
|
||||||
export interface V2RepeaterConfig {
|
export interface V2RepeaterConfig {
|
||||||
// 렌더링 모드
|
// 렌더링 모드
|
||||||
|
|
@ -151,6 +167,9 @@ export interface V2RepeaterConfig {
|
||||||
foreignKeyColumn?: string; // 마스터 테이블과 연결할 FK 컬럼명 (예: receiving_id)
|
foreignKeyColumn?: string; // 마스터 테이블과 연결할 FK 컬럼명 (예: receiving_id)
|
||||||
foreignKeySourceColumn?: string; // 마스터 테이블의 PK 컬럼명 (예: id) - 자동 연결용
|
foreignKeySourceColumn?: string; // 마스터 테이블의 PK 컬럼명 (예: id) - 자동 연결용
|
||||||
|
|
||||||
|
// 소스 디테일 자동 조회 설정 (선택된 마스터의 디테일 행을 리피터로 로드)
|
||||||
|
sourceDetailConfig?: SourceDetailConfig;
|
||||||
|
|
||||||
// 데이터 소스 설정
|
// 데이터 소스 설정
|
||||||
dataSource: RepeaterDataSource;
|
dataSource: RepeaterDataSource;
|
||||||
|
|
||||||
|
|
@ -189,6 +208,7 @@ export interface V2RepeaterProps {
|
||||||
onButtonClick?: (action: string, row?: any, buttonConfig?: any) => void;
|
onButtonClick?: (action: string, row?: any, buttonConfig?: any) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
formData?: Record<string, any>; // 수정 모드에서 FK 기반 데이터 로드용
|
formData?: Record<string, any>; // 수정 모드에서 FK 기반 데이터 로드용
|
||||||
|
groupedData?: Record<string, any>[]; // 모달에서 전달받은 선택 데이터 (소스 디테일 조회용)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 기본 설정값
|
// 기본 설정값
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue