Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/v2-unified-renewal

This commit is contained in:
kjs 2026-01-21 13:58:29 +09:00
commit e46d216aae
19 changed files with 1258 additions and 217 deletions

View File

@ -606,7 +606,7 @@ router.get(
}); });
} }
const { enableEntityJoin, groupByColumns } = req.query; const { enableEntityJoin, groupByColumns, primaryKeyColumn } = req.query;
const enableEntityJoinFlag = const enableEntityJoinFlag =
enableEntityJoin === "true" || enableEntityJoin === "true" ||
(typeof enableEntityJoin === "boolean" && enableEntityJoin); (typeof enableEntityJoin === "boolean" && enableEntityJoin);
@ -626,17 +626,22 @@ router.get(
} }
} }
// 🆕 primaryKeyColumn 파싱
const primaryKeyColumnStr = typeof primaryKeyColumn === "string" ? primaryKeyColumn : undefined;
console.log(`🔍 레코드 상세 조회: ${tableName}/${id}`, { console.log(`🔍 레코드 상세 조회: ${tableName}/${id}`, {
enableEntityJoin: enableEntityJoinFlag, enableEntityJoin: enableEntityJoinFlag,
groupByColumns: groupByColumnsArray, groupByColumns: groupByColumnsArray,
primaryKeyColumn: primaryKeyColumnStr,
}); });
// 레코드 상세 조회 (Entity Join 옵션 + 그룹핑 옵션 포함) // 레코드 상세 조회 (Entity Join 옵션 + 그룹핑 옵션 + Primary Key 컬럼 포함)
const result = await dataService.getRecordDetail( const result = await dataService.getRecordDetail(
tableName, tableName,
id, id,
enableEntityJoinFlag, enableEntityJoinFlag,
groupByColumnsArray groupByColumnsArray,
primaryKeyColumnStr // 🆕 Primary Key 컬럼명 전달
); );
if (!result.success) { if (!result.success) {

View File

@ -490,7 +490,8 @@ class DataService {
tableName: string, tableName: string,
id: string | number, id: string | number,
enableEntityJoin: boolean = false, enableEntityJoin: boolean = false,
groupByColumns: string[] = [] groupByColumns: string[] = [],
primaryKeyColumn?: string // 🆕 클라이언트에서 전달한 Primary Key 컬럼명
): Promise<ServiceResponse<any>> { ): Promise<ServiceResponse<any>> {
try { try {
// 테이블 접근 검증 // 테이블 접근 검증
@ -499,7 +500,11 @@ class DataService {
return validation.error!; return validation.error!;
} }
// Primary Key 컬럼 찾기 // 🆕 클라이언트에서 전달한 Primary Key 컬럼이 있으면 우선 사용
let pkColumn = primaryKeyColumn || "";
// Primary Key 컬럼이 없으면 자동 감지
if (!pkColumn) {
const pkResult = await query<{ attname: string }>( const pkResult = await query<{ attname: string }>(
`SELECT a.attname `SELECT a.attname
FROM pg_index i FROM pg_index i
@ -508,10 +513,16 @@ class DataService {
[tableName] [tableName]
); );
let pkColumn = "id"; // 기본값 pkColumn = "id"; // 기본값
if (pkResult.length > 0) { if (pkResult.length > 0) {
pkColumn = pkResult[0].attname; pkColumn = pkResult[0].attname;
} }
console.log(`🔑 [getRecordDetail] 자동 감지된 Primary Key:`, pkResult);
} else {
console.log(`🔑 [getRecordDetail] 클라이언트 제공 Primary Key: ${pkColumn}`);
}
console.log(`🔑 [getRecordDetail] 테이블: ${tableName}, Primary Key 컬럼: ${pkColumn}, 조회 ID: ${id}`);
// 🆕 Entity Join이 활성화된 경우 // 🆕 Entity Join이 활성화된 경우
if (enableEntityJoin) { if (enableEntityJoin) {

View File

@ -334,6 +334,10 @@ export class EntityJoinService {
); );
}); });
// 🔧 _label 별칭 중복 방지를 위한 Set
// 같은 sourceColumn에서 여러 조인 설정이 있을 때 _label은 첫 번째만 생성
const generatedLabelAliases = new Set<string>();
const joinColumns = joinConfigs const joinColumns = joinConfigs
.map((config) => { .map((config) => {
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`; const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
@ -368,16 +372,26 @@ export class EntityJoinService {
// _label 필드도 함께 SELECT (프론트엔드 getColumnUniqueValues용) // _label 필드도 함께 SELECT (프론트엔드 getColumnUniqueValues용)
// sourceColumn_label 형식으로 추가 // sourceColumn_label 형식으로 추가
// 🔧 중복 방지: 같은 sourceColumn에서 _label은 첫 번째만 생성
const labelAlias = `${config.sourceColumn}_label`;
if (!generatedLabelAliases.has(labelAlias)) {
resultColumns.push( resultColumns.push(
`COALESCE(${alias}.${col}::TEXT, '') AS ${config.sourceColumn}_label` `COALESCE(${alias}.${col}::TEXT, '') AS ${labelAlias}`
); );
generatedLabelAliases.add(labelAlias);
}
// 🆕 referenceColumn (PK)도 항상 SELECT (parentDataMapping용) // 🆕 referenceColumn (PK)도 항상 SELECT (parentDataMapping용)
// 예: customer_code, item_number 등 // 예: customer_code, item_number 등
// col과 동일해도 별도의 alias로 추가 (customer_code as customer_code) // col과 동일해도 별도의 alias로 추가 (customer_code as customer_code)
// 🔧 중복 방지: referenceColumn도 한 번만 추가
const refColAlias = config.referenceColumn;
if (!generatedLabelAliases.has(refColAlias)) {
resultColumns.push( resultColumns.push(
`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.referenceColumn}` `COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${refColAlias}`
); );
generatedLabelAliases.add(refColAlias);
}
} else { } else {
resultColumns.push( resultColumns.push(
`COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}` `COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}`
@ -392,6 +406,11 @@ export class EntityJoinService {
const individualAlias = `${config.sourceColumn}_${col}`; const individualAlias = `${config.sourceColumn}_${col}`;
// 🔧 중복 방지: 같은 alias가 이미 생성되었으면 스킵
if (generatedLabelAliases.has(individualAlias)) {
return;
}
if (isJoinTableColumn) { if (isJoinTableColumn) {
// 조인 테이블 컬럼은 조인 별칭 사용 // 조인 테이블 컬럼은 조인 별칭 사용
resultColumns.push( resultColumns.push(
@ -403,6 +422,7 @@ export class EntityJoinService {
`COALESCE(main.${col}::TEXT, '') AS ${individualAlias}` `COALESCE(main.${col}::TEXT, '') AS ${individualAlias}`
); );
} }
generatedLabelAliases.add(individualAlias);
}); });
// 🆕 referenceColumn (PK)도 함께 SELECT (parentDataMapping용) // 🆕 referenceColumn (PK)도 함께 SELECT (parentDataMapping용)
@ -410,11 +430,13 @@ export class EntityJoinService {
config.referenceTable && config.referenceTable !== tableName; config.referenceTable && config.referenceTable !== tableName;
if ( if (
isJoinTableColumn && isJoinTableColumn &&
!displayColumns.includes(config.referenceColumn) !displayColumns.includes(config.referenceColumn) &&
!generatedLabelAliases.has(config.referenceColumn) // 🔧 중복 방지
) { ) {
resultColumns.push( resultColumns.push(
`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.referenceColumn}` `COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.referenceColumn}`
); );
generatedLabelAliases.add(config.referenceColumn);
} }
} }

View File

@ -335,6 +335,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
const editId = urlParams.get("editId"); const editId = urlParams.get("editId");
const tableName = urlParams.get("tableName") || screenInfo.tableName; const tableName = urlParams.get("tableName") || screenInfo.tableName;
const groupByColumnsParam = urlParams.get("groupByColumns"); const groupByColumnsParam = urlParams.get("groupByColumns");
const primaryKeyColumn = urlParams.get("primaryKeyColumn"); // 🆕 Primary Key 컬럼명
console.log("📋 URL 파라미터 확인:", { mode, editId, tableName, groupByColumnsParam, primaryKeyColumn });
// 수정 모드이고 editId가 있으면 해당 레코드 조회 // 수정 모드이고 editId가 있으면 해당 레코드 조회
if (mode === "edit" && editId && tableName) { if (mode === "edit" && editId && tableName) {
@ -357,6 +360,16 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
if (groupByColumns.length > 0) { if (groupByColumns.length > 0) {
params.groupByColumns = JSON.stringify(groupByColumns); params.groupByColumns = JSON.stringify(groupByColumns);
} }
// 🆕 Primary Key 컬럼명 전달 (백엔드 자동 감지 실패 시 사용)
if (primaryKeyColumn) {
params.primaryKeyColumn = primaryKeyColumn;
console.log("✅ [ScreenModal] primaryKeyColumn을 params에 추가:", primaryKeyColumn);
}
console.log("📡 [ScreenModal] 실제 API 요청:", {
url: `/data/${tableName}/${editId}`,
params,
});
const apiResponse = await apiClient.get(`/data/${tableName}/${editId}`, { params }); const apiResponse = await apiClient.get(`/data/${tableName}/${editId}`, { params });
const response = apiResponse.data; const response = apiResponse.data;

View File

@ -309,6 +309,19 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
_subDataMaxValue: maxValue, _subDataMaxValue: maxValue,
}; };
// fieldMappings가 설정되어 있으면 매핑에 따라 값 복사
if (subDataLookup.lookup.fieldMappings && subDataLookup.lookup.fieldMappings.length > 0) {
subDataLookup.lookup.fieldMappings.forEach((mapping) => {
if (mapping.targetField && mapping.targetField !== "") {
// 매핑된 타겟 필드에 소스 컬럼 값 복사
const sourceValue = selectedItem[mapping.sourceColumn];
if (sourceValue !== undefined) {
newItems[itemIndex][mapping.targetField] = sourceValue;
}
}
});
} else {
// fieldMappings가 없으면 기존 로직 (하위 호환성)
// 선택된 하위 데이터의 필드 값을 상위 item에 복사 (설정된 경우) // 선택된 하위 데이터의 필드 값을 상위 item에 복사 (설정된 경우)
// 예: warehouse_code, location_code 등 // 예: warehouse_code, location_code 등
if (subDataLookup.lookup.displayColumns) { if (subDataLookup.lookup.displayColumns) {
@ -322,6 +335,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
} }
}); });
} }
}
setItems(newItems); setItems(newItems);
@ -894,6 +908,10 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
// 하위 데이터 조회 설정이 있으면 연결 컬럼 찾기 // 하위 데이터 조회 설정이 있으면 연결 컬럼 찾기
const linkColumn = subDataLookup?.lookup?.linkColumn; const linkColumn = subDataLookup?.lookup?.linkColumn;
// hidden이 아닌 필드만 표시
// isHidden이 true이거나 displayMode가 hidden인 필드는 제외 (하위 호환성 유지)
const visibleFields = fields.filter((f) => !f.isHidden && f.displayMode !== "hidden");
return ( return (
<div className="bg-card"> <div className="bg-card">
<Table> <Table>
@ -905,7 +923,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
{allowReorder && ( {allowReorder && (
<TableHead className="h-10 w-10 px-2.5 py-2 text-center text-sm font-semibold"></TableHead> <TableHead className="h-10 w-10 px-2.5 py-2 text-center text-sm font-semibold"></TableHead>
)} )}
{fields.map((field) => ( {visibleFields.map((field) => (
<TableHead key={field.name} className="h-10 px-2.5 py-2 text-sm font-semibold"> <TableHead key={field.name} className="h-10 px-2.5 py-2 text-sm font-semibold">
{field.label} {field.label}
{field.required && <span className="text-destructive ml-1">*</span>} {field.required && <span className="text-destructive ml-1">*</span>}
@ -944,8 +962,8 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
</TableCell> </TableCell>
)} )}
{/* 필드들 */} {/* 필드들 (hidden 제외) */}
{fields.map((field) => ( {visibleFields.map((field) => (
<TableCell key={field.name} className="h-12 px-2.5 py-2"> <TableCell key={field.name} className="h-12 px-2.5 py-2">
{renderField(field, itemIndex, item[field.name])} {renderField(field, itemIndex, item[field.name])}
</TableCell> </TableCell>
@ -973,7 +991,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
<TableRow className="bg-gray-50/50"> <TableRow className="bg-gray-50/50">
<TableCell <TableCell
colSpan={ colSpan={
fields.length + (showIndex ? 1 : 0) + (allowReorder && !readonly && !disabled ? 1 : 0) + 1 visibleFields.length + (showIndex ? 1 : 0) + (allowReorder && !readonly && !disabled ? 1 : 0) + 1
} }
className="px-2.5 py-2" className="px-2.5 py-2"
> >
@ -1003,6 +1021,10 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
// 하위 데이터 조회 설정이 있으면 연결 컬럼 찾기 // 하위 데이터 조회 설정이 있으면 연결 컬럼 찾기
const linkColumn = subDataLookup?.lookup?.linkColumn; const linkColumn = subDataLookup?.lookup?.linkColumn;
// hidden이 아닌 필드만 표시
// isHidden이 true이거나 displayMode가 hidden인 필드는 제외 (하위 호환성 유지)
const visibleFields = fields.filter((f) => !f.isHidden && f.displayMode !== "hidden");
return ( return (
<> <>
{items.map((item, itemIndex) => { {items.map((item, itemIndex) => {
@ -1070,7 +1092,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
{!isCollapsed && ( {!isCollapsed && (
<CardContent> <CardContent>
<div className={getFieldsLayoutClass()}> <div className={getFieldsLayoutClass()}>
{fields.map((field) => ( {visibleFields.map((field) => (
<div key={field.name} className="space-y-1" style={{ width: field.width }}> <div key={field.name} className="space-y-1" style={{ width: field.width }}>
<label className="text-foreground text-sm font-medium"> <label className="text-foreground text-sm font-medium">
{field.label} {field.label}

View File

@ -319,6 +319,74 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
}); });
}; };
// 표시 컬럼 순서 가져오기 (columnOrder가 있으면 사용, 없으면 displayColumns 순서)
const getOrderedDisplayColumns = (): string[] => {
const displayColumns = config.subDataLookup?.lookup?.displayColumns || [];
const columnOrder = config.subDataLookup?.lookup?.columnOrder;
if (columnOrder && columnOrder.length > 0) {
// columnOrder에 있는 컬럼만, 순서대로 반환 (displayColumns에 있는 것만)
const orderedCols = columnOrder.filter(col => displayColumns.includes(col));
// columnOrder에 없지만 displayColumns에 있는 컬럼 추가
const remainingCols = displayColumns.filter(col => !columnOrder.includes(col));
return [...orderedCols, ...remainingCols];
}
return displayColumns;
};
// 표시 컬럼 순서 변경 핸들러 (위로)
const handleDisplayColumnMoveUp = (columnName: string) => {
const orderedColumns = getOrderedDisplayColumns();
const index = orderedColumns.indexOf(columnName);
if (index <= 0) return;
const newOrder = [...orderedColumns];
[newOrder[index - 1], newOrder[index]] = [newOrder[index], newOrder[index - 1]];
handleSubDataLookupChange("lookup.columnOrder", newOrder);
};
// 표시 컬럼 순서 변경 핸들러 (아래로)
const handleDisplayColumnMoveDown = (columnName: string) => {
const orderedColumns = getOrderedDisplayColumns();
const index = orderedColumns.indexOf(columnName);
if (index < 0 || index >= orderedColumns.length - 1) return;
const newOrder = [...orderedColumns];
[newOrder[index], newOrder[index + 1]] = [newOrder[index + 1], newOrder[index]];
handleSubDataLookupChange("lookup.columnOrder", newOrder);
};
// 표시 컬럼 토글 시 columnOrder도 업데이트
const handleDisplayColumnToggleWithOrder = (columnName: string, checked: boolean) => {
const currentColumns = config.subDataLookup?.lookup?.displayColumns || [];
const currentOrder = config.subDataLookup?.lookup?.columnOrder || [];
let newColumns: string[];
let newOrder: string[];
if (checked) {
newColumns = [...currentColumns, columnName];
newOrder = [...currentOrder, columnName];
} else {
newColumns = currentColumns.filter((c) => c !== columnName);
newOrder = currentOrder.filter((c) => c !== columnName);
}
// displayColumns, columnOrder 함께 업데이트
const newConfig = { ...config.subDataLookup } as SubDataLookupConfig;
if (!newConfig.lookup) {
newConfig.lookup = { tableName: "", linkColumn: "", displayColumns: [] };
}
newConfig.lookup.displayColumns = newColumns;
newConfig.lookup.columnOrder = newOrder;
onChange({
...config,
subDataLookup: newConfig,
});
};
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* 대상 테이블 선택 */} {/* 대상 테이블 선택 */}
@ -588,7 +656,7 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<Checkbox <Checkbox
id={`display-col-${col.columnName}`} id={`display-col-${col.columnName}`}
checked={isSelected} checked={isSelected}
onCheckedChange={(checked) => handleDisplayColumnToggle(col.columnName, checked as boolean)} onCheckedChange={(checked) => handleDisplayColumnToggleWithOrder(col.columnName, checked as boolean)}
/> />
<Label <Label
htmlFor={`display-col-${col.columnName}`} htmlFor={`display-col-${col.columnName}`}
@ -605,6 +673,78 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
</div> </div>
)} )}
{/* 컬럼 설정 (순서 + 라벨 + 저장 컬럼) */}
{(config.subDataLookup?.lookup?.displayColumns?.length || 0) > 0 && (
<div className="space-y-2">
<Label className="text-xs font-medium text-purple-700"> </Label>
<p className="text-[10px] text-purple-500">, , </p>
<div className="space-y-1.5 rounded border bg-white p-2">
{getOrderedDisplayColumns().map((colName, index) => {
const col = subDataTableColumns.find((c) => c.columnName === colName);
const currentLabel = config.subDataLookup?.lookup?.columnLabels?.[colName] || "";
const orderedColumns = getOrderedDisplayColumns();
const isFirst = index === 0;
const isLast = index === orderedColumns.length - 1;
return (
<div key={colName} className="rounded bg-purple-50 p-2">
{/* 상단: 순서 버튼 + 번호 + 컬럼명 */}
<div className="flex items-center gap-2">
{/* 순서 변경 버튼 */}
<div className="flex items-center gap-0.5">
<Button
type="button"
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
onClick={() => handleDisplayColumnMoveUp(colName)}
disabled={isFirst}
>
<ArrowUp className={cn("h-3 w-3", isFirst ? "text-gray-300" : "text-purple-600")} />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
onClick={() => handleDisplayColumnMoveDown(colName)}
disabled={isLast}
>
<ArrowDown className={cn("h-3 w-3", isLast ? "text-gray-300" : "text-purple-600")} />
</Button>
</div>
{/* 순서 번호 */}
<span className="w-4 text-center text-xs font-medium text-purple-600">{index + 1}</span>
{/* 컬럼명 */}
<div className="flex-1 text-xs">
<span className="font-medium">{col?.columnLabel || colName}</span>
<span className="ml-1 text-gray-400">({colName})</span>
</div>
</div>
{/* 중단: 라벨 입력 */}
<div className="mt-1.5 flex items-center gap-2">
<span className="text-[10px] text-gray-500 whitespace-nowrap"> :</span>
<Input
value={currentLabel}
onChange={(e) => handleColumnLabelChange(colName, e.target.value)}
placeholder={col?.columnLabel || colName}
className="h-6 flex-1 text-xs"
/>
</div>
</div>
);
})}
</div>
<p className="text-[10px] text-purple-500">
* "하위 데이터 조회에서 값 가져오기"
</p>
</div>
)}
{/* 선택 설정 */} {/* 선택 설정 */}
{(config.subDataLookup?.lookup?.displayColumns?.length || 0) > 0 && ( {(config.subDataLookup?.lookup?.displayColumns?.length || 0) > 0 && (
<div className="space-y-3 border-t border-purple-200 pt-3"> <div className="space-y-3 border-t border-purple-200 pt-3">
@ -1351,6 +1491,7 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
{/* 카테고리 타입이 아닐 때만 표시 모드 선택 */} {/* 카테고리 타입이 아닐 때만 표시 모드 선택 */}
{field.type !== "category" && ( {field.type !== "category" && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs"> </Label> <Label className="text-xs"> </Label>
@ -1381,6 +1522,76 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
</div> </div>
</div> </div>
</div> </div>
{/* 숨김 체크박스 */}
<div className="flex items-center space-x-2">
<Checkbox
id={`hidden-${index}`}
checked={field.isHidden ?? false}
onCheckedChange={(checked) => updateField(index, { isHidden: checked as boolean })}
/>
<Label htmlFor={`hidden-${index}`} className="cursor-pointer text-xs font-normal">
( )
</Label>
</div>
{/* 하위 데이터 조회에서 값 가져오기 */}
{config.subDataLookup?.enabled && (
<div className="space-y-2 rounded border border-purple-200 bg-purple-50 p-2">
<div className="flex items-center space-x-2">
<Checkbox
id={`subdata-${index}`}
checked={field.subDataSource?.enabled ?? false}
onCheckedChange={(checked) => {
updateField(index, {
subDataSource: {
enabled: checked as boolean,
sourceColumn: field.subDataSource?.sourceColumn || "",
},
});
}}
/>
<Label htmlFor={`subdata-${index}`} className="cursor-pointer text-xs font-normal text-purple-700">
</Label>
</div>
{field.subDataSource?.enabled && (
<div className="ml-5 space-y-1">
<Label className="text-[10px] text-purple-600"> </Label>
<Select
value={field.subDataSource?.sourceColumn || ""}
onValueChange={(value) => {
updateField(index, {
subDataSource: {
enabled: true,
sourceColumn: value,
},
});
}}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{(config.subDataLookup?.lookup?.displayColumns || []).map((colName) => {
const label = config.subDataLookup?.lookup?.columnLabels?.[colName] || colName;
return (
<SelectItem key={colName} value={colName} className="text-xs">
{label} ({colName})
</SelectItem>
);
})}
</SelectContent>
</Select>
<p className="text-[10px] text-purple-500">
</p>
</div>
)}
</div>
)}
</div>
)} )}
{/* 카테고리 타입일 때는 필수만 표시 */} {/* 카테고리 타입일 때는 필수만 표시 */}

View File

@ -11,6 +11,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { dynamicFormApi } from "@/lib/api/dynamicForm"; import { dynamicFormApi } from "@/lib/api/dynamicForm";
import { cascadingRelationApi } from "@/lib/api/cascadingRelation"; import { cascadingRelationApi } from "@/lib/api/cascadingRelation";
import { AutoFillMapping } from "./config";
export function EntitySearchInputComponent({ export function EntitySearchInputComponent({
tableName, tableName,
@ -37,6 +38,8 @@ export function EntitySearchInputComponent({
formData, formData,
// 다중선택 props // 다중선택 props
multiple: multipleProp, multiple: multipleProp,
// 자동 채움 매핑 props
autoFillMappings: autoFillMappingsProp,
// 추가 props // 추가 props
component, component,
isInteractive, isInteractive,
@ -47,6 +50,7 @@ export function EntitySearchInputComponent({
isInteractive?: boolean; isInteractive?: boolean;
onFormDataChange?: (fieldName: string, value: any) => void; onFormDataChange?: (fieldName: string, value: any) => void;
webTypeConfig?: any; // 웹타입 설정 (연쇄관계 등) webTypeConfig?: any; // 웹타입 설정 (연쇄관계 등)
autoFillMappings?: AutoFillMapping[]; // 자동 채움 매핑
}) { }) {
// uiMode가 있으면 우선 사용, 없으면 modeProp 사용, 기본값 "combo" // uiMode가 있으면 우선 사용, 없으면 modeProp 사용, 기본값 "combo"
const mode = (uiMode || modeProp || "combo") as "select" | "modal" | "combo" | "autocomplete"; const mode = (uiMode || modeProp || "combo") as "select" | "modal" | "combo" | "autocomplete";
@ -55,6 +59,18 @@ export function EntitySearchInputComponent({
const config = component?.componentConfig || component?.webTypeConfig || {}; const config = component?.componentConfig || component?.webTypeConfig || {};
const isMultiple = multipleProp ?? config.multiple ?? false; const isMultiple = multipleProp ?? config.multiple ?? false;
// 자동 채움 매핑 설정 (props > config)
const autoFillMappings: AutoFillMapping[] = autoFillMappingsProp ?? config.autoFillMappings ?? [];
// 디버그: 자동 채움 매핑 설정 확인
console.log("🔧 [EntitySearchInput] 자동 채움 매핑 설정:", {
autoFillMappingsProp,
configAutoFillMappings: config.autoFillMappings,
effectiveAutoFillMappings: autoFillMappings,
isInteractive,
hasOnFormDataChange: !!onFormDataChange,
});
// 연쇄관계 설정 추출 // 연쇄관계 설정 추출
const effectiveCascadingRelationCode = cascadingRelationCode || config.cascadingRelationCode; const effectiveCascadingRelationCode = cascadingRelationCode || config.cascadingRelationCode;
// cascadingParentField: ConfigPanel에서 저장되는 필드명 // cascadingParentField: ConfigPanel에서 저장되는 필드명
@ -309,6 +325,23 @@ export function EntitySearchInputComponent({
console.log("📤 EntitySearchInput -> onFormDataChange:", component.columnName, newValue); console.log("📤 EntitySearchInput -> onFormDataChange:", component.columnName, newValue);
} }
} }
// 🆕 자동 채움 매핑 적용
if (autoFillMappings.length > 0 && isInteractive && onFormDataChange && fullData) {
console.log("🔄 자동 채움 매핑 적용:", { mappings: autoFillMappings, fullData });
for (const mapping of autoFillMappings) {
if (mapping.sourceField && mapping.targetField) {
const sourceValue = fullData[mapping.sourceField];
if (sourceValue !== undefined) {
onFormDataChange(mapping.targetField, sourceValue);
console.log(`${mapping.sourceField}${mapping.targetField}:`, sourceValue);
} else {
console.log(` ⚠️ ${mapping.sourceField} 값이 없음`);
}
}
}
}
}; };
// 다중선택 모드에서 개별 항목 제거 // 다중선택 모드에서 개별 항목 제거
@ -436,7 +469,7 @@ export function EntitySearchInputComponent({
const isSelected = selectedValues.includes(String(option[valueField])); const isSelected = selectedValues.includes(String(option[valueField]));
return ( return (
<CommandItem <CommandItem
key={option[valueField] || index} key={option[valueField] ?? `option-${index}`}
value={`${option[displayField] || ""}-${option[valueField] || ""}`} value={`${option[displayField] || ""}-${option[valueField] || ""}`}
onSelect={() => handleSelectOption(option)} onSelect={() => handleSelectOption(option)}
className="text-xs sm:text-sm" className="text-xs sm:text-sm"
@ -509,7 +542,7 @@ export function EntitySearchInputComponent({
<CommandGroup> <CommandGroup>
{effectiveOptions.map((option, index) => ( {effectiveOptions.map((option, index) => (
<CommandItem <CommandItem
key={option[valueField] || index} key={option[valueField] ?? `select-option-${index}`}
value={`${option[displayField] || ""}-${option[valueField] || ""}`} value={`${option[displayField] || ""}-${option[valueField] || ""}`}
onSelect={() => handleSelectOption(option)} onSelect={() => handleSelectOption(option)}
className="text-xs sm:text-sm" className="text-xs sm:text-sm"

View File

@ -10,7 +10,7 @@ import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Plus, X, Check, ChevronsUpDown, Database, Info, Link2, ExternalLink } from "lucide-react"; import { Plus, X, Check, ChevronsUpDown, Database, Info, Link2, ExternalLink } from "lucide-react";
// allComponents는 현재 사용되지 않지만 향후 확장을 위해 props에 유지 // allComponents는 현재 사용되지 않지만 향후 확장을 위해 props에 유지
import { EntitySearchInputConfig } from "./config"; import { EntitySearchInputConfig, AutoFillMapping } from "./config";
import { tableManagementApi } from "@/lib/api/tableManagement"; import { tableManagementApi } from "@/lib/api/tableManagement";
import { tableTypeApi } from "@/lib/api/screen"; import { tableTypeApi } from "@/lib/api/screen";
import { cascadingRelationApi, CascadingRelation } from "@/lib/api/cascadingRelation"; import { cascadingRelationApi, CascadingRelation } from "@/lib/api/cascadingRelation";
@ -236,6 +236,7 @@ export function EntitySearchInputConfigPanel({
const newConfig = { ...localConfig, ...updates }; const newConfig = { ...localConfig, ...updates };
setLocalConfig(newConfig); setLocalConfig(newConfig);
onConfigChange(newConfig); onConfigChange(newConfig);
console.log("📝 [EntitySearchInput] 설정 업데이트:", { updates, newConfig });
}; };
// 연쇄 드롭다운 활성화/비활성화 // 연쇄 드롭다운 활성화/비활성화
@ -636,9 +637,9 @@ export function EntitySearchInputConfigPanel({
<CommandList> <CommandList>
<CommandEmpty className="text-xs sm:text-sm"> .</CommandEmpty> <CommandEmpty className="text-xs sm:text-sm"> .</CommandEmpty>
<CommandGroup> <CommandGroup>
{tableColumns.map((column) => ( {tableColumns.map((column, idx) => (
<CommandItem <CommandItem
key={column.columnName} key={column.columnName || `display-col-${idx}`}
value={`${column.displayName || column.columnName}-${column.columnName}`} value={`${column.displayName || column.columnName}-${column.columnName}`}
onSelect={() => { onSelect={() => {
updateConfig({ displayField: column.columnName }); updateConfig({ displayField: column.columnName });
@ -690,9 +691,9 @@ export function EntitySearchInputConfigPanel({
<CommandList> <CommandList>
<CommandEmpty className="text-xs sm:text-sm"> .</CommandEmpty> <CommandEmpty className="text-xs sm:text-sm"> .</CommandEmpty>
<CommandGroup> <CommandGroup>
{tableColumns.map((column) => ( {tableColumns.map((column, idx) => (
<CommandItem <CommandItem
key={column.columnName} key={column.columnName || `value-col-${idx}`}
value={`${column.displayName || column.columnName}-${column.columnName}`} value={`${column.displayName || column.columnName}-${column.columnName}`}
onSelect={() => { onSelect={() => {
updateConfig({ valueField: column.columnName }); updateConfig({ valueField: column.columnName });
@ -812,8 +813,8 @@ export function EntitySearchInputConfigPanel({
<SelectValue placeholder="컬럼 선택" /> <SelectValue placeholder="컬럼 선택" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{tableColumns.map((col) => ( {tableColumns.map((col, colIdx) => (
<SelectItem key={col.columnName} value={col.columnName}> <SelectItem key={col.columnName || `modal-col-${colIdx}`} value={col.columnName}>
{col.displayName || col.columnName} {col.displayName || col.columnName}
</SelectItem> </SelectItem>
))} ))}
@ -860,8 +861,8 @@ export function EntitySearchInputConfigPanel({
<SelectValue placeholder="필드 선택" /> <SelectValue placeholder="필드 선택" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{tableColumns.map((col) => ( {tableColumns.map((col, colIdx) => (
<SelectItem key={col.columnName} value={col.columnName}> <SelectItem key={col.columnName || `search-col-${colIdx}`} value={col.columnName}>
{col.displayName || col.columnName} {col.displayName || col.columnName}
</SelectItem> </SelectItem>
))} ))}
@ -919,8 +920,8 @@ export function EntitySearchInputConfigPanel({
<SelectValue placeholder="필드 선택" /> <SelectValue placeholder="필드 선택" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{tableColumns.map((col) => ( {tableColumns.map((col, colIdx) => (
<SelectItem key={col.columnName} value={col.columnName}> <SelectItem key={col.columnName || `additional-col-${colIdx}`} value={col.columnName}>
{col.displayName || col.columnName} {col.displayName || col.columnName}
</SelectItem> </SelectItem>
))} ))}
@ -939,6 +940,105 @@ export function EntitySearchInputConfigPanel({
</div> </div>
</div> </div>
)} )}
{/* 자동 채움 매핑 설정 */}
<div className="border-t pt-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Link2 className="h-4 w-4" />
<h4 className="text-sm font-medium"> </h4>
</div>
<Button
size="sm"
variant="outline"
onClick={() => {
const mappings = localConfig.autoFillMappings || [];
updateConfig({ autoFillMappings: [...mappings, { sourceField: "", targetField: "" }] });
}}
className="h-7 text-xs"
disabled={!localConfig.tableName || isLoadingColumns}
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<p className="text-muted-foreground text-xs">
.
</p>
{(localConfig.autoFillMappings || []).length > 0 && (
<div className="space-y-2">
{(localConfig.autoFillMappings || []).map((mapping, index) => (
<div key={`autofill-mapping-${index}`} className="flex items-center gap-2 rounded-md border p-2 bg-muted/30">
{/* 소스 필드 (선택된 엔티티) */}
<div className="flex-1">
<Label className="text-[10px] text-muted-foreground mb-1 block"> ()</Label>
<Select
value={mapping.sourceField || ""}
onValueChange={(value) => {
const mappings = [...(localConfig.autoFillMappings || [])];
mappings[index] = { ...mappings[index], sourceField: value };
updateConfig({ autoFillMappings: mappings });
}}
disabled={!localConfig.tableName || isLoadingColumns}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{tableColumns.map((col, colIdx) => (
<SelectItem key={col.columnName || `col-${colIdx}`} value={col.columnName}>
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 화살표 */}
<div className="flex items-center justify-center pt-4">
<span className="text-muted-foreground text-sm"></span>
</div>
{/* 대상 필드 (폼) */}
<div className="flex-1">
<Label className="text-[10px] text-muted-foreground mb-1 block"> ()</Label>
<Input
value={mapping.targetField || ""}
onChange={(e) => {
const mappings = [...(localConfig.autoFillMappings || [])];
mappings[index] = { ...mappings[index], targetField: e.target.value };
updateConfig({ autoFillMappings: mappings });
}}
placeholder="폼 필드명"
className="h-8 text-xs"
/>
</div>
{/* 삭제 버튼 */}
<Button
size="sm"
variant="ghost"
onClick={() => {
const mappings = [...(localConfig.autoFillMappings || [])];
mappings.splice(index, 1);
updateConfig({ autoFillMappings: mappings });
}}
className="h-8 w-8 p-0 mt-4"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
{(localConfig.autoFillMappings || []).length === 0 && (
<div className="text-muted-foreground text-xs text-center py-3 rounded-md border border-dashed">
. + .
</div>
)}
</div>
</div> </div>
); );
} }

View File

@ -38,12 +38,16 @@ export const EntitySearchInputWrapper: React.FC<WebTypeComponentProps> = ({
// placeholder // placeholder
const placeholder = config.placeholder || widget?.placeholder || "항목을 선택하세요"; const placeholder = config.placeholder || widget?.placeholder || "항목을 선택하세요";
// 자동 채움 매핑 설정
const autoFillMappings = config.autoFillMappings || [];
console.log("🏢 EntitySearchInputWrapper 렌더링:", { console.log("🏢 EntitySearchInputWrapper 렌더링:", {
tableName, tableName,
displayField, displayField,
valueField, valueField,
uiMode, uiMode,
multiple, multiple,
autoFillMappings,
value, value,
config, config,
}); });
@ -68,6 +72,7 @@ export const EntitySearchInputWrapper: React.FC<WebTypeComponentProps> = ({
value={value} value={value}
onChange={onChange} onChange={onChange}
multiple={multiple} multiple={multiple}
autoFillMappings={autoFillMappings}
component={component} component={component}
isInteractive={props.isInteractive} isInteractive={props.isInteractive}
onFormDataChange={props.onFormDataChange} onFormDataChange={props.onFormDataChange}

View File

@ -148,9 +148,9 @@ export function EntitySearchModal({
</th> </th>
)} )}
{displayColumns.map((col) => ( {displayColumns.map((col, colIdx) => (
<th <th
key={col} key={col || `header-${colIdx}`}
className="px-4 py-2 text-left font-medium text-muted-foreground" className="px-4 py-2 text-left font-medium text-muted-foreground"
> >
{col} {col}
@ -179,7 +179,8 @@ export function EntitySearchModal({
</tr> </tr>
) : ( ) : (
results.map((item, index) => { results.map((item, index) => {
const uniqueKey = item[valueField] !== undefined ? `${item[valueField]}` : `row-${index}`; // null과 undefined 모두 체크하여 유니크 키 생성
const uniqueKey = item[valueField] != null ? `${item[valueField]}` : `row-${index}`;
const isSelected = isItemSelected(item); const isSelected = isItemSelected(item);
return ( return (
<tr <tr
@ -200,8 +201,8 @@ export function EntitySearchModal({
/> />
</td> </td>
)} )}
{displayColumns.map((col) => ( {displayColumns.map((col, colIdx) => (
<td key={`${uniqueKey}-${col}`} className="px-4 py-2"> <td key={`${uniqueKey}-${col || colIdx}`} className="px-4 py-2">
{item[col] || "-"} {item[col] || "-"}
</td> </td>
))} ))}

View File

@ -1,3 +1,9 @@
// 자동 채움 매핑 타입
export interface AutoFillMapping {
sourceField: string; // 선택된 엔티티의 필드 (예: customer_name)
targetField: string; // 폼의 필드 (예: partner_name)
}
export interface EntitySearchInputConfig { export interface EntitySearchInputConfig {
tableName: string; tableName: string;
displayField: string; displayField: string;
@ -18,5 +24,8 @@ export interface EntitySearchInputConfig {
cascadingRelationCode?: string; // 연쇄관계 코드 (WAREHOUSE_LOCATION 등) cascadingRelationCode?: string; // 연쇄관계 코드 (WAREHOUSE_LOCATION 등)
cascadingRole?: "parent" | "child"; // 역할 (부모/자식) cascadingRole?: "parent" | "child"; // 역할 (부모/자식)
cascadingParentField?: string; // 부모 필드의 컬럼명 (자식 역할일 때만 사용) cascadingParentField?: string; // 부모 필드의 컬럼명 (자식 역할일 때만 사용)
// 자동 채움 매핑 설정
autoFillMappings?: AutoFillMapping[]; // 엔티티 선택 시 다른 필드에 자동으로 값 채우기
} }

View File

@ -287,12 +287,18 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
if (onChange && items.length > 0) { if (onChange && items.length > 0) {
// 🆕 RepeaterFieldGroup이 관리하는 필드 목록 추출 // 🆕 RepeaterFieldGroup이 관리하는 필드 목록 추출
const repeaterFieldNames = (configRef.current.fields || []).map((f: any) => f.name); const repeaterFieldNames = (configRef.current.fields || []).map((f: any) => f.name);
// 🆕 subDataSource 설정이 있는 필드 목록 (하위 데이터 조회 연동)
const fieldsConfig = (configRef.current.fields || []).map((f: any) => ({
name: f.name,
subDataSource: f.subDataSource,
}));
const dataWithMeta = items.map((item: any) => ({ const dataWithMeta = items.map((item: any) => ({
...item, ...item,
_targetTable: targetTable, _targetTable: targetTable,
_originalItemIds: itemIds, // 🆕 원본 ID 목록도 함께 전달 _originalItemIds: itemIds, // 🆕 원본 ID 목록도 함께 전달
_existingRecord: !!item.id, // 🆕 기존 레코드 플래그 (id가 있으면 기존 레코드) _existingRecord: !!item.id, // 🆕 기존 레코드 플래그 (id가 있으면 기존 레코드)
_repeaterFields: repeaterFieldNames, // 🆕 품목 고유 필드 목록 _repeaterFields: repeaterFieldNames, // 🆕 품목 고유 필드 목록
_repeaterFieldsConfig: fieldsConfig, // 🆕 필드 설정 (subDataSource 등)
})); }));
onChange(dataWithMeta); onChange(dataWithMeta);
} }
@ -393,11 +399,17 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
if (items.length > 0) { if (items.length > 0) {
// 🆕 RepeaterFieldGroup이 관리하는 필드 목록 추출 // 🆕 RepeaterFieldGroup이 관리하는 필드 목록 추출
const repeaterFieldNames = (configRef.current.fields || []).map((f: any) => f.name); const repeaterFieldNames = (configRef.current.fields || []).map((f: any) => f.name);
// 🆕 subDataSource 설정이 있는 필드 목록
const fieldsConfig = (configRef.current.fields || []).map((f: any) => ({
name: f.name,
subDataSource: f.subDataSource,
}));
const dataWithMeta = items.map((item: any) => ({ const dataWithMeta = items.map((item: any) => ({
...item, ...item,
_targetTable: effectiveTargetTable, _targetTable: effectiveTargetTable,
_existingRecord: !!item.id, _existingRecord: !!item.id,
_repeaterFields: repeaterFieldNames, // 🆕 품목 고유 필드 목록 _repeaterFields: repeaterFieldNames, // 🆕 품목 고유 필드 목록
_repeaterFieldsConfig: fieldsConfig, // 🆕 필드 설정 (subDataSource 등)
})); }));
onChange(dataWithMeta); onChange(dataWithMeta);
} else { } else {
@ -681,6 +693,11 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
(newValue: any[]) => { (newValue: any[]) => {
// 🆕 RepeaterFieldGroup이 관리하는 필드 목록 추출 // 🆕 RepeaterFieldGroup이 관리하는 필드 목록 추출
const repeaterFieldNames = (configRef.current.fields || []).map((f: any) => f.name); const repeaterFieldNames = (configRef.current.fields || []).map((f: any) => f.name);
// 🆕 subDataSource 설정이 있는 필드 목록
const fieldsConfig = (configRef.current.fields || []).map((f: any) => ({
name: f.name,
subDataSource: f.subDataSource,
}));
// 🆕 모든 항목에 메타데이터 추가 // 🆕 모든 항목에 메타데이터 추가
let valueWithMeta = newValue.map((item: any) => ({ let valueWithMeta = newValue.map((item: any) => ({
@ -688,6 +705,7 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
_targetTable: effectiveTargetTable || targetTable, _targetTable: effectiveTargetTable || targetTable,
_existingRecord: !!item.id, _existingRecord: !!item.id,
_repeaterFields: repeaterFieldNames, // 🆕 품목 고유 필드 목록 _repeaterFields: repeaterFieldNames, // 🆕 품목 고유 필드 목록
_repeaterFieldsConfig: fieldsConfig, // 🆕 필드 설정 (subDataSource 등)
})); }));
// 🆕 분할 패널에서 우측인 경우, FK 값 추가 // 🆕 분할 패널에서 우측인 경우, FK 값 추가

View File

@ -78,8 +78,20 @@ export const SubDataLookupPanel: React.FC<SubDataLookupPanelProps> = ({
return config.lookup.columnLabels?.[columnName] || columnName; return config.lookup.columnLabels?.[columnName] || columnName;
}; };
// 표시할 컬럼 목록 // 표시할 컬럼 목록 (columnOrder가 있으면 순서 적용)
const displayColumns = config.lookup.displayColumns || []; const displayColumns = useMemo(() => {
const columns = config.lookup.displayColumns || [];
const columnOrder = config.lookup.columnOrder;
if (columnOrder && columnOrder.length > 0) {
// columnOrder 순서대로 정렬 (displayColumns에 있는 것만)
const orderedCols = columnOrder.filter(col => columns.includes(col));
// columnOrder에 없지만 displayColumns에 있는 컬럼 추가
const remainingCols = columns.filter(col => !columnOrder.includes(col));
return [...orderedCols, ...remainingCols];
}
return columns;
}, [config.lookup.displayColumns, config.lookup.columnOrder]);
// 요약 정보 표시용 선택 상태 // 요약 정보 표시용 선택 상태
const summaryText = useMemo(() => { const summaryText = useMemo(() => {

View File

@ -197,10 +197,18 @@ export function useSubDataLookup(props: UseSubDataLookupProps): UseSubDataLookup
return "선택 안됨"; return "선택 안됨";
} }
const { displayColumns, columnLabels } = config.lookup; const { displayColumns, columnLabels, columnOrder } = config.lookup;
const parts: string[] = []; const parts: string[] = [];
displayColumns.forEach((col) => { // columnOrder가 있으면 순서 적용, 없으면 displayColumns 순서
let orderedColumns = displayColumns;
if (columnOrder && columnOrder.length > 0) {
const orderedCols = columnOrder.filter(col => displayColumns.includes(col));
const remainingCols = displayColumns.filter(col => !columnOrder.includes(col));
orderedColumns = [...orderedCols, ...remainingCols];
}
orderedColumns.forEach((col) => {
const value = selectedItem[col]; const value = selectedItem[col];
if (value !== undefined && value !== null && value !== "") { if (value !== undefined && value !== null && value !== "") {
const label = columnLabels?.[col] || col; const label = columnLabels?.[col] || col;

View File

@ -33,6 +33,7 @@ import {
DialogDescription, DialogDescription,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
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";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
@ -171,6 +172,12 @@ 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>>({}); // 좌측 컬럼 라벨
@ -917,11 +924,11 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const { entityJoinApi } = await import("@/lib/api/entityJoin"); const { entityJoinApi } = await import("@/lib/api/entityJoin");
// 복합키 조건 생성 // 복합키 조건 생성
// 🔧 관계 필터링은 정확한 값 매칭이 필요하므로 equals 연산자 사용 // 🔧 entity 타입 컬럼은 코드 값으로 정확히 매칭해야 하므로 operator: 'equals' 사용
// (entity 타입 컬럼의 경우 기본 contains 연산자가 참조 테이블의 표시 컬럼으로 검색하여 실패함)
const searchConditions: Record<string, any> = {}; const searchConditions: Record<string, any> = {};
keys.forEach((key) => { keys.forEach((key) => {
if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) { if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) {
// 연결 필터는 정확한 값 매칭이 필요하므로 equals 연산자 사용
searchConditions[key.rightColumn] = { searchConditions[key.rightColumn] = {
value: leftItem[key.leftColumn], value: leftItem[key.leftColumn],
operator: "equals", operator: "equals",
@ -1006,12 +1013,145 @@ 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) {
// 복합키
// 🔧 entity 타입 컬럼은 코드 값으로 정확히 매칭해야 하므로 operator: 'equals' 사용
keys.forEach((key) => {
if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) {
searchConditions[key.rightColumn] = {
value: leftItem[key.leftColumn],
operator: "equals",
};
}
});
} else {
// 단일키
// 🔧 entity 타입 컬럼은 코드 값으로 정확히 매칭해야 하므로 operator: 'equals' 사용
const leftValue = leftItem[leftColumn];
if (leftValue !== undefined) {
searchConditions[rightColumn] = {
value: leftValue,
operator: "equals",
};
}
}
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()); // 좌측 항목 변경 시 우측 확장 초기화
setTabsData({}); // 🆕 모든 탭 데이터 초기화
// 🆕 현재 활성 탭에 따라 데이터 로드
if (activeTabIndex === 0) {
loadRightData(item); loadRightData(item);
} else {
loadTabData(activeTabIndex, item);
}
// 🆕 modalDataStore에 선택된 좌측 항목 저장 (단일 선택) // 🆕 modalDataStore에 선택된 좌측 항목 저장 (단일 선택)
const leftTableName = componentConfig.leftPanel?.tableName; const leftTableName = componentConfig.leftPanel?.tableName;
@ -1022,7 +1162,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],
); );
// 우측 항목 확장/축소 토글 // 우측 항목 확장/축소 토글
@ -1427,21 +1590,40 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 커스텀 모달 화면 열기 // 커스텀 모달 화면 열기
const rightTableName = componentConfig.rightPanel?.tableName || ""; const rightTableName = componentConfig.rightPanel?.tableName || "";
// Primary Key 찾기 (우선순위: id > ID > 첫 번째 필드) // Primary Key 찾기 (우선순위: 설정값 > id > ID > non-null 필드)
// 🔧 설정에서 primaryKeyColumn 지정 가능
const configuredPrimaryKey = componentConfig.rightPanel?.editButton?.primaryKeyColumn;
let primaryKeyName = "id"; let primaryKeyName = "id";
let primaryKeyValue: any; let primaryKeyValue: any;
if (item.id !== undefined && item.id !== null) { if (configuredPrimaryKey && item[configuredPrimaryKey] !== undefined && item[configuredPrimaryKey] !== null) {
// 설정된 Primary Key 사용
primaryKeyName = configuredPrimaryKey;
primaryKeyValue = item[configuredPrimaryKey];
} else if (item.id !== undefined && item.id !== null) {
primaryKeyName = "id"; primaryKeyName = "id";
primaryKeyValue = item.id; primaryKeyValue = item.id;
} else if (item.ID !== undefined && item.ID !== null) { } else if (item.ID !== undefined && item.ID !== null) {
primaryKeyName = "ID"; primaryKeyName = "ID";
primaryKeyValue = item.ID; primaryKeyValue = item.ID;
} else { } else {
// 첫 번째 필드를 Primary Key로 간주 // 🔧 첫 번째 non-null 필드를 Primary Key로 간주
const firstKey = Object.keys(item)[0]; const keys = Object.keys(item);
primaryKeyName = firstKey; let found = false;
primaryKeyValue = item[firstKey]; for (const key of keys) {
if (item[key] !== undefined && item[key] !== null) {
primaryKeyName = key;
primaryKeyValue = item[key];
found = true;
break;
}
}
// 모든 필드가 null이면 첫 번째 필드 사용
if (!found && keys.length > 0) {
primaryKeyName = keys[0];
primaryKeyValue = item[keys[0]];
}
} }
console.log("✅ 수정 모달 열기:", { console.log("✅ 수정 모달 열기:", {
@ -1466,7 +1648,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
hasGroupByColumns: groupByColumns.length > 0, hasGroupByColumns: groupByColumns.length > 0,
}); });
// ScreenModal 열기 이벤트 발생 (URL 파라미터로 ID + groupByColumns 전달) // ScreenModal 열기 이벤트 발생 (URL 파라미터로 ID + groupByColumns + primaryKeyColumn 전달)
window.dispatchEvent( window.dispatchEvent(
new CustomEvent("openScreenModal", { new CustomEvent("openScreenModal", {
detail: { detail: {
@ -1475,6 +1657,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
mode: "edit", mode: "edit",
editId: primaryKeyValue, editId: primaryKeyValue,
tableName: rightTableName, tableName: rightTableName,
primaryKeyColumn: primaryKeyName, // 🆕 Primary Key 컬럼명 전달
...(groupByColumns.length > 0 && { ...(groupByColumns.length > 0 && {
groupByColumns: JSON.stringify(groupByColumns), groupByColumns: JSON.stringify(groupByColumns),
}), }),
@ -1487,6 +1670,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
screenId: modalScreenId, screenId: modalScreenId,
editId: primaryKeyValue, editId: primaryKeyValue,
tableName: rightTableName, tableName: rightTableName,
primaryKeyColumn: primaryKeyName,
groupByColumns: groupByColumns.length > 0 ? JSON.stringify(groupByColumns) : "없음", groupByColumns: groupByColumns.length > 0 ? JSON.stringify(groupByColumns) : "없음",
}); });
@ -2540,6 +2724,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={{
@ -2552,11 +2764,23 @@ 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 && ( {/* 🆕 현재 활성 탭에 따른 추가 버튼 */}
{activeTabIndex === 0
? componentConfig.rightPanel?.showAdd && (
<Button size="sm" variant="outline" onClick={() => handleAddClick("right")}>
<Plus className="mr-1 h-4 w-4" />
</Button>
)
: componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]?.showAdd && (
<Button size="sm" variant="outline" onClick={() => handleAddClick("right")}> <Button size="sm" variant="outline" onClick={() => handleAddClick("right")}>
<Plus className="mr-1 h-4 w-4" /> <Plus className="mr-1 h-4 w-4" />
@ -2581,7 +2805,218 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</div> </div>
)} )}
<CardContent className="flex-1 overflow-auto p-4"> <CardContent className="flex-1 overflow-auto p-4">
{/* 우측 데이터 */} {/* 🆕 추가 탭 데이터 렌더링 */}
{activeTabIndex > 0 ? (
// 추가 탭 컨텐츠
(() => {
const currentTabConfig = componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1];
const currentTabData = tabsData[activeTabIndex] || [];
const isTabLoading = tabsLoading[activeTabIndex];
if (isTabLoading) {
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">
{formatCellValue(col.name, 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" : ""}>
{formatCellValue(col.name, 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">{formatCellValue(col.name, item[col.name], {}, col.format)}</span>
</div>
))}
</div>
</div>
)}
</div>
);
})}
</div>
);
}
})()
) : (
/* 기본 탭 (우측 패널) 데이터 */
<>
{isLoadingRight ? ( {isLoadingRight ? (
// 로딩 중 // 로딩 중
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
@ -3033,6 +3468,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</div> </div>
</div> </div>
)} )}
</>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@ -2721,19 +2721,41 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const value = row[mappedColumnName]; const value = row[mappedColumnName];
// 카테고리 매핑된 값 처리 // 카테고리 매핑된 값 처리
if (categoryMappings[col.columnName] && value !== null && value !== undefined) { if (value !== null && value !== undefined) {
const mapping = categoryMappings[col.columnName][String(value)]; const valueStr = String(value);
// 디버그 로그 (카테고리 값인 경우만)
if (valueStr.startsWith("CATEGORY_")) {
console.log("🔍 [엑셀다운로드] 카테고리 변환 시도:", {
columnName: col.columnName,
value: valueStr,
hasMappings: !!categoryMappings[col.columnName],
mappingsKeys: categoryMappings[col.columnName] ? Object.keys(categoryMappings[col.columnName]).slice(0, 5) : [],
});
}
if (categoryMappings[col.columnName]) {
// 쉼표로 구분된 중복 값 처리
if (valueStr.includes(",")) {
const values = valueStr.split(",").map((v) => v.trim()).filter((v) => v);
const labels = values.map((v) => {
const mapping = categoryMappings[col.columnName][v];
return mapping ? mapping.label : v;
});
return labels.join(", ");
}
// 단일 값 처리
const mapping = categoryMappings[col.columnName][valueStr];
if (mapping) { if (mapping) {
return mapping.label; return mapping.label;
} }
} }
// null/undefined 처리 return value;
if (value === null || value === undefined) {
return "";
} }
return value; // null/undefined 처리
return "";
}); });
}); });

View File

@ -714,7 +714,14 @@ export class ButtonActionExecutor {
console.log("🔄 [handleSave] RepeaterFieldGroup JSON 문자열 감지:", repeaterJsonKeys); console.log("🔄 [handleSave] RepeaterFieldGroup JSON 문자열 감지:", repeaterJsonKeys);
// 🎯 채번 규칙 할당 처리 (RepeaterFieldGroup 저장 전에 실행) // 🎯 채번 규칙 할당 처리 (RepeaterFieldGroup 저장 전에 실행)
console.log("🔍 [handleSave-RepeaterFieldGroup] 채번 규칙 할당 체크 시작"); // 🔧 수정 모드 체크: formData.id가 존재하면 UPDATE 모드이므로 채번 코드 재할당 금지
const isEditModeRepeater =
context.formData.id !== undefined && context.formData.id !== null && context.formData.id !== "";
console.log("🔍 [handleSave-RepeaterFieldGroup] 채번 규칙 할당 체크 시작", {
isEditMode: isEditModeRepeater,
formDataId: context.formData.id,
});
const fieldsWithNumberingRepeater: Record<string, string> = {}; const fieldsWithNumberingRepeater: Record<string, string> = {};
@ -729,9 +736,12 @@ export class ButtonActionExecutor {
console.log("📋 [handleSave-RepeaterFieldGroup] 채번 규칙이 설정된 필드:", fieldsWithNumberingRepeater); console.log("📋 [handleSave-RepeaterFieldGroup] 채번 규칙이 설정된 필드:", fieldsWithNumberingRepeater);
// 채번 규칙이 있는 필드에 대해 allocateCode 호출 // 🔧 수정 모드에서는 채번 코드 할당 건너뛰기 (기존 번호 유지)
if (Object.keys(fieldsWithNumberingRepeater).length > 0) { // 신규 등록 모드에서만 allocateCode 호출하여 새 번호 할당
console.log("🎯 [handleSave-RepeaterFieldGroup] 채번 규칙 할당 시작 (allocateCode 호출)"); if (Object.keys(fieldsWithNumberingRepeater).length > 0 && !isEditModeRepeater) {
console.log(
"🎯 [handleSave-RepeaterFieldGroup] 신규 등록 모드 - 채번 규칙 할당 시작 (allocateCode 호출)",
);
const { allocateNumberingCode } = await import("@/lib/api/numberingRule"); const { allocateNumberingCode } = await import("@/lib/api/numberingRule");
for (const [fieldName, ruleId] of Object.entries(fieldsWithNumberingRepeater)) { for (const [fieldName, ruleId] of Object.entries(fieldsWithNumberingRepeater)) {
@ -741,7 +751,9 @@ export class ButtonActionExecutor {
if (allocateResult.success && allocateResult.data?.generatedCode) { if (allocateResult.success && allocateResult.data?.generatedCode) {
const newCode = allocateResult.data.generatedCode; const newCode = allocateResult.data.generatedCode;
console.log(`✅ [handleSave-RepeaterFieldGroup] ${fieldName} 새 코드 할당: ${context.formData[fieldName]}${newCode}`); console.log(
`✅ [handleSave-RepeaterFieldGroup] ${fieldName} 새 코드 할당: ${context.formData[fieldName]}${newCode}`,
);
context.formData[fieldName] = newCode; context.formData[fieldName] = newCode;
} else { } else {
console.warn(`⚠️ [handleSave-RepeaterFieldGroup] ${fieldName} 코드 할당 실패:`, allocateResult.error); console.warn(`⚠️ [handleSave-RepeaterFieldGroup] ${fieldName} 코드 할당 실패:`, allocateResult.error);
@ -750,9 +762,11 @@ export class ButtonActionExecutor {
console.error(`❌ [handleSave-RepeaterFieldGroup] ${fieldName} 코드 할당 오류:`, allocateError); console.error(`❌ [handleSave-RepeaterFieldGroup] ${fieldName} 코드 할당 오류:`, allocateError);
} }
} }
} else if (isEditModeRepeater) {
console.log("⏭️ [handleSave-RepeaterFieldGroup] 수정 모드 - 채번 코드 할당 건너뜀 (기존 번호 유지)");
} }
console.log("✅ [handleSave-RepeaterFieldGroup] 채번 규칙 할당 완료"); console.log("✅ [handleSave-RepeaterFieldGroup] 채번 규칙 할당 처리 완료");
// 🆕 상단 폼 데이터(마스터 정보) 추출 // 🆕 상단 폼 데이터(마스터 정보) 추출
// RepeaterFieldGroup JSON과 컴포넌트 키를 제외한 나머지가 마스터 정보 // RepeaterFieldGroup JSON과 컴포넌트 키를 제외한 나머지가 마스터 정보
@ -808,7 +822,7 @@ export class ButtonActionExecutor {
for (const item of parsedData) { for (const item of parsedData) {
// 메타 필드 제거 // 메타 필드 제거
const { _targetTable, _isNewItem, _existingRecord, _originalItemIds, _deletedItemIds, _repeaterFields, ...itemData } = item; const { _targetTable, _isNewItem, _existingRecord, _originalItemIds, _deletedItemIds, _repeaterFields, _subDataSelection, _subDataMaxValue, ...itemData } = item;
// 🔧 품목 고유 필드만 추출 (RepeaterFieldGroup 설정 기반) // 🔧 품목 고유 필드만 추출 (RepeaterFieldGroup 설정 기반)
const itemOnlyData: Record<string, any> = {}; const itemOnlyData: Record<string, any> = {};
@ -818,6 +832,42 @@ export class ButtonActionExecutor {
} }
}); });
// 🆕 하위 데이터 선택에서 값 추출 (subDataSource 설정 기반)
// 필드 정의에서 subDataSource.enabled가 true이고 sourceColumn이 설정된 필드만 처리
if (_subDataSelection && typeof _subDataSelection === 'object') {
// _repeaterFieldsConfig에서 subDataSource 설정 확인
const fieldsConfig = item._repeaterFieldsConfig as Array<{
name: string;
subDataSource?: { enabled: boolean; sourceColumn: string };
}> | undefined;
if (fieldsConfig && Array.isArray(fieldsConfig)) {
fieldsConfig.forEach((fieldConfig) => {
if (fieldConfig.subDataSource?.enabled && fieldConfig.subDataSource?.sourceColumn) {
const targetField = fieldConfig.name; // 필드명 = 저장할 컬럼명
const sourceColumn = fieldConfig.subDataSource.sourceColumn;
const sourceValue = _subDataSelection[sourceColumn];
if (sourceValue !== undefined && sourceValue !== null) {
itemOnlyData[targetField] = sourceValue;
console.log(`📋 [handleSave] 하위 데이터 값 매핑: ${sourceColumn}${targetField} = ${sourceValue}`);
}
}
});
} else {
// 하위 호환성: fieldsConfig가 없으면 기존 방식 사용
Object.keys(_subDataSelection).forEach((subDataKey) => {
if (itemOnlyData[subDataKey] === undefined || itemOnlyData[subDataKey] === null || itemOnlyData[subDataKey] === '') {
const subDataValue = _subDataSelection[subDataKey];
if (subDataValue !== undefined && subDataValue !== null) {
itemOnlyData[subDataKey] = subDataValue;
console.log(`📋 [handleSave] 하위 데이터 선택 값 추가 (레거시): ${subDataKey} = ${subDataValue}`);
}
}
});
}
}
// 🔧 마스터 정보 + 품목 고유 정보 병합 // 🔧 마스터 정보 + 품목 고유 정보 병합
// masterFields: 상단 폼에서 수정한 최신 마스터 정보 // masterFields: 상단 폼에서 수정한 최신 마스터 정보
// itemOnlyData: 품목 고유 필드만 (품번, 품명, 수량 등) // itemOnlyData: 품목 고유 필드만 (품번, 품명, 수량 등)
@ -1967,7 +2017,16 @@ export class ButtonActionExecutor {
} }
// 🎯 채번 규칙 할당 처리 (저장 시점에 실제 순번 증가) // 🎯 채번 규칙 할당 처리 (저장 시점에 실제 순번 증가)
console.log("🔍 [handleUniversalFormModalTableSectionSave] 채번 규칙 할당 체크 시작"); // 🔧 수정 모드 체크: formData.id 또는 originalGroupedData가 있으면 UPDATE 모드
const isEditModeUniversal =
(formData.id !== undefined && formData.id !== null && formData.id !== "") ||
originalGroupedData.length > 0;
console.log("🔍 [handleUniversalFormModalTableSectionSave] 채번 규칙 할당 체크 시작", {
isEditMode: isEditModeUniversal,
formDataId: formData.id,
originalGroupedDataCount: originalGroupedData.length,
});
const fieldsWithNumbering: Record<string, string> = {}; const fieldsWithNumbering: Record<string, string> = {};
@ -1993,9 +2052,12 @@ export class ButtonActionExecutor {
console.log("📋 [handleUniversalFormModalTableSectionSave] 채번 규칙이 설정된 필드:", fieldsWithNumbering); console.log("📋 [handleUniversalFormModalTableSectionSave] 채번 규칙이 설정된 필드:", fieldsWithNumbering);
// 🔥 저장 시점에 allocateCode 호출하여 실제 순번 증가 // 🔧 수정 모드에서는 채번 코드 할당 건너뛰기 (기존 번호 유지)
if (Object.keys(fieldsWithNumbering).length > 0) { // 신규 등록 모드에서만 allocateCode 호출하여 새 번호 할당
console.log("🎯 [handleUniversalFormModalTableSectionSave] 채번 규칙 할당 시작 (allocateCode 호출)"); if (Object.keys(fieldsWithNumbering).length > 0 && !isEditModeUniversal) {
console.log(
"🎯 [handleUniversalFormModalTableSectionSave] 신규 등록 모드 - 채번 규칙 할당 시작 (allocateCode 호출)",
);
const { allocateNumberingCode } = await import("@/lib/api/numberingRule"); const { allocateNumberingCode } = await import("@/lib/api/numberingRule");
for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) { for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) {
@ -2022,6 +2084,8 @@ export class ButtonActionExecutor {
// 오류 시 기존 값 유지 // 오류 시 기존 값 유지
} }
} }
} else if (isEditModeUniversal) {
console.log("⏭️ [handleUniversalFormModalTableSectionSave] 수정 모드 - 채번 코드 할당 건너뜀 (기존 번호 유지)");
} }
console.log("✅ [handleUniversalFormModalTableSectionSave] 채번 규칙 할당 완료"); console.log("✅ [handleUniversalFormModalTableSectionSave] 채번 규칙 할당 완료");
@ -4949,7 +5013,24 @@ export class ButtonActionExecutor {
const filteredRow: Record<string, any> = {}; const filteredRow: Record<string, any> = {};
visibleColumns!.forEach((columnName: string) => { visibleColumns!.forEach((columnName: string) => {
const label = columnLabels?.[columnName] || columnName; const label = columnLabels?.[columnName] || columnName;
filteredRow[label] = row[columnName]; let value = row[columnName];
// 카테고리 코드를 라벨로 변환 (CATEGORY_로 시작하는 값)
if (value && typeof value === "string" && value.includes("CATEGORY_")) {
// 먼저 _label 필드 확인 (API에서 제공하는 경우)
const labelFieldName = `${columnName}_label`;
if (row[labelFieldName]) {
value = row[labelFieldName];
} else {
// _value_label 필드 확인
const valueLabelFieldName = `${columnName}_value_label`;
if (row[valueLabelFieldName]) {
value = row[valueLabelFieldName];
}
}
}
filteredRow[label] = value;
}); });
return filteredRow; return filteredRow;
}); });
@ -5222,9 +5303,16 @@ export class ButtonActionExecutor {
value = row[`${columnName}_name`]; value = row[`${columnName}_name`];
} }
// 카테고리 타입 필드는 라벨로 변환 (백엔드에서 정의된 컬럼만) // 카테고리 타입 필드는 라벨로 변환 (백엔드에서 정의된 컬럼만)
else if (categoryMap[columnName] && typeof value === "string" && categoryMap[columnName][value]) { else if (categoryMap[columnName] && typeof value === "string") {
// 쉼표로 구분된 다중 값 처리
if (value.includes(",")) {
const values = value.split(",").map((v) => v.trim()).filter((v) => v);
const labels = values.map((v) => categoryMap[columnName][v] || v);
value = labels.join(", ");
} else if (categoryMap[columnName][value]) {
value = categoryMap[columnName][value]; value = categoryMap[columnName][value];
} }
}
filteredRow[label] = value; filteredRow[label] = value;
} }

View File

@ -116,8 +116,10 @@ export async function importFromExcel(
return; return;
} }
// JSON으로 변환 // JSON으로 변환 (빈 셀도 포함하여 모든 컬럼 키 유지)
const jsonData = XLSX.utils.sheet_to_json(worksheet); const jsonData = XLSX.utils.sheet_to_json(worksheet, {
defval: "", // 빈 셀에 빈 문자열 할당
});
console.log("✅ 엑셀 가져오기 완료:", { console.log("✅ 엑셀 가져오기 완료:", {
sheetName: targetSheetName, sheetName: targetSheetName,

View File

@ -43,9 +43,19 @@ export interface CalculationFormula {
* *
* - input: 입력 ( ) * - input: 입력 ( )
* - readonly: * - readonly:
* - hidden: 숨김 (UI에 )
* - ( ) * - ( )
*/ */
export type RepeaterFieldDisplayMode = "input" | "readonly"; export type RepeaterFieldDisplayMode = "input" | "readonly" | "hidden";
/**
*
*
*/
export interface SubDataSourceConfig {
enabled: boolean; // 활성화 여부
sourceColumn: string; // 하위 데이터 조회 테이블의 소스 컬럼 (예: lot_number)
}
/** /**
* *
@ -60,6 +70,8 @@ export interface RepeaterFieldDefinition {
options?: Array<{ label: string; value: string }>; // select용 options?: Array<{ label: string; value: string }>; // select용
width?: string; // 필드 너비 (예: "200px", "50%") width?: string; // 필드 너비 (예: "200px", "50%")
displayMode?: RepeaterFieldDisplayMode; // 표시 모드: input(입력), readonly(읽기전용) displayMode?: RepeaterFieldDisplayMode; // 표시 모드: input(입력), readonly(읽기전용)
isHidden?: boolean; // 숨김 여부 (true면 테이블에 표시 안 함, 데이터는 저장)
subDataSource?: SubDataSourceConfig; // 하위 데이터 조회에서 값 가져오기 설정
categoryCode?: string; // category 타입일 때 사용할 카테고리 코드 categoryCode?: string; // category 타입일 때 사용할 카테고리 코드
formula?: CalculationFormula; // 계산식 (type이 "calculated"일 때 사용) formula?: CalculationFormula; // 계산식 (type이 "calculated"일 때 사용)
numberFormat?: { numberFormat?: {
@ -113,6 +125,14 @@ export type RepeaterData = RepeaterItemData[];
// 품목 선택 시 재고/단가 등 관련 데이터를 조회하고 선택하는 기능 // 품목 선택 시 재고/단가 등 관련 데이터를 조회하고 선택하는 기능
// ============================================================ // ============================================================
/**
*
*/
export interface SubDataFieldMapping {
sourceColumn: string; // 조회 테이블 컬럼 (예: lot_number)
targetField: string; // 저장 테이블 컬럼 (예: lot_number) 또는 "" (선택안함)
}
/** /**
* *
*/ */
@ -121,6 +141,8 @@ export interface SubDataLookupSettings {
linkColumn: string; // 상위 데이터와 연결할 컬럼 (예: item_code) linkColumn: string; // 상위 데이터와 연결할 컬럼 (예: item_code)
displayColumns: string[]; // 표시할 컬럼들 (예: ["warehouse_code", "location_code", "quantity"]) displayColumns: string[]; // 표시할 컬럼들 (예: ["warehouse_code", "location_code", "quantity"])
columnLabels?: Record<string, string>; // 컬럼 라벨 (예: { warehouse_code: "창고" }) columnLabels?: Record<string, string>; // 컬럼 라벨 (예: { warehouse_code: "창고" })
columnOrder?: string[]; // 컬럼 표시 순서 (없으면 displayColumns 순서 사용)
fieldMappings?: SubDataFieldMapping[]; // 선택 데이터 저장 매핑 (조회 컬럼 → 저장 컬럼)
additionalFilters?: Record<string, any>; // 추가 필터 조건 additionalFilters?: Record<string, any>; // 추가 필터 조건
} }