Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/v2-unified-renewal
This commit is contained in:
commit
e46d216aae
|
|
@ -606,7 +606,7 @@ router.get(
|
|||
});
|
||||
}
|
||||
|
||||
const { enableEntityJoin, groupByColumns } = req.query;
|
||||
const { enableEntityJoin, groupByColumns, primaryKeyColumn } = req.query;
|
||||
const enableEntityJoinFlag =
|
||||
enableEntityJoin === "true" ||
|
||||
(typeof enableEntityJoin === "boolean" && enableEntityJoin);
|
||||
|
|
@ -626,17 +626,22 @@ router.get(
|
|||
}
|
||||
}
|
||||
|
||||
// 🆕 primaryKeyColumn 파싱
|
||||
const primaryKeyColumnStr = typeof primaryKeyColumn === "string" ? primaryKeyColumn : undefined;
|
||||
|
||||
console.log(`🔍 레코드 상세 조회: ${tableName}/${id}`, {
|
||||
enableEntityJoin: enableEntityJoinFlag,
|
||||
groupByColumns: groupByColumnsArray,
|
||||
primaryKeyColumn: primaryKeyColumnStr,
|
||||
});
|
||||
|
||||
// 레코드 상세 조회 (Entity Join 옵션 + 그룹핑 옵션 포함)
|
||||
// 레코드 상세 조회 (Entity Join 옵션 + 그룹핑 옵션 + Primary Key 컬럼 포함)
|
||||
const result = await dataService.getRecordDetail(
|
||||
tableName,
|
||||
id,
|
||||
enableEntityJoinFlag,
|
||||
groupByColumnsArray
|
||||
groupByColumnsArray,
|
||||
primaryKeyColumnStr // 🆕 Primary Key 컬럼명 전달
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
|
|
|
|||
|
|
@ -490,7 +490,8 @@ class DataService {
|
|||
tableName: string,
|
||||
id: string | number,
|
||||
enableEntityJoin: boolean = false,
|
||||
groupByColumns: string[] = []
|
||||
groupByColumns: string[] = [],
|
||||
primaryKeyColumn?: string // 🆕 클라이언트에서 전달한 Primary Key 컬럼명
|
||||
): Promise<ServiceResponse<any>> {
|
||||
try {
|
||||
// 테이블 접근 검증
|
||||
|
|
@ -499,20 +500,30 @@ class DataService {
|
|||
return validation.error!;
|
||||
}
|
||||
|
||||
// Primary Key 컬럼 찾기
|
||||
const pkResult = await query<{ attname: string }>(
|
||||
`SELECT a.attname
|
||||
FROM pg_index i
|
||||
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
||||
WHERE i.indrelid = $1::regclass AND i.indisprimary`,
|
||||
[tableName]
|
||||
);
|
||||
// 🆕 클라이언트에서 전달한 Primary Key 컬럼이 있으면 우선 사용
|
||||
let pkColumn = primaryKeyColumn || "";
|
||||
|
||||
// Primary Key 컬럼이 없으면 자동 감지
|
||||
if (!pkColumn) {
|
||||
const pkResult = await query<{ attname: string }>(
|
||||
`SELECT a.attname
|
||||
FROM pg_index i
|
||||
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
||||
WHERE i.indrelid = $1::regclass AND i.indisprimary`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
let pkColumn = "id"; // 기본값
|
||||
if (pkResult.length > 0) {
|
||||
pkColumn = pkResult[0].attname;
|
||||
pkColumn = "id"; // 기본값
|
||||
if (pkResult.length > 0) {
|
||||
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이 활성화된 경우
|
||||
if (enableEntityJoin) {
|
||||
const { EntityJoinService } = await import("./entityJoinService");
|
||||
|
|
|
|||
|
|
@ -334,6 +334,10 @@ export class EntityJoinService {
|
|||
);
|
||||
});
|
||||
|
||||
// 🔧 _label 별칭 중복 방지를 위한 Set
|
||||
// 같은 sourceColumn에서 여러 조인 설정이 있을 때 _label은 첫 번째만 생성
|
||||
const generatedLabelAliases = new Set<string>();
|
||||
|
||||
const joinColumns = joinConfigs
|
||||
.map((config) => {
|
||||
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
|
||||
|
|
@ -368,16 +372,26 @@ export class EntityJoinService {
|
|||
|
||||
// _label 필드도 함께 SELECT (프론트엔드 getColumnUniqueValues용)
|
||||
// sourceColumn_label 형식으로 추가
|
||||
resultColumns.push(
|
||||
`COALESCE(${alias}.${col}::TEXT, '') AS ${config.sourceColumn}_label`
|
||||
);
|
||||
// 🔧 중복 방지: 같은 sourceColumn에서 _label은 첫 번째만 생성
|
||||
const labelAlias = `${config.sourceColumn}_label`;
|
||||
if (!generatedLabelAliases.has(labelAlias)) {
|
||||
resultColumns.push(
|
||||
`COALESCE(${alias}.${col}::TEXT, '') AS ${labelAlias}`
|
||||
);
|
||||
generatedLabelAliases.add(labelAlias);
|
||||
}
|
||||
|
||||
// 🆕 referenceColumn (PK)도 항상 SELECT (parentDataMapping용)
|
||||
// 예: customer_code, item_number 등
|
||||
// col과 동일해도 별도의 alias로 추가 (customer_code as customer_code)
|
||||
resultColumns.push(
|
||||
`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.referenceColumn}`
|
||||
);
|
||||
// 🔧 중복 방지: referenceColumn도 한 번만 추가
|
||||
const refColAlias = config.referenceColumn;
|
||||
if (!generatedLabelAliases.has(refColAlias)) {
|
||||
resultColumns.push(
|
||||
`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${refColAlias}`
|
||||
);
|
||||
generatedLabelAliases.add(refColAlias);
|
||||
}
|
||||
} else {
|
||||
resultColumns.push(
|
||||
`COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}`
|
||||
|
|
@ -392,6 +406,11 @@ export class EntityJoinService {
|
|||
|
||||
const individualAlias = `${config.sourceColumn}_${col}`;
|
||||
|
||||
// 🔧 중복 방지: 같은 alias가 이미 생성되었으면 스킵
|
||||
if (generatedLabelAliases.has(individualAlias)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isJoinTableColumn) {
|
||||
// 조인 테이블 컬럼은 조인 별칭 사용
|
||||
resultColumns.push(
|
||||
|
|
@ -403,6 +422,7 @@ export class EntityJoinService {
|
|||
`COALESCE(main.${col}::TEXT, '') AS ${individualAlias}`
|
||||
);
|
||||
}
|
||||
generatedLabelAliases.add(individualAlias);
|
||||
});
|
||||
|
||||
// 🆕 referenceColumn (PK)도 함께 SELECT (parentDataMapping용)
|
||||
|
|
@ -410,11 +430,13 @@ export class EntityJoinService {
|
|||
config.referenceTable && config.referenceTable !== tableName;
|
||||
if (
|
||||
isJoinTableColumn &&
|
||||
!displayColumns.includes(config.referenceColumn)
|
||||
!displayColumns.includes(config.referenceColumn) &&
|
||||
!generatedLabelAliases.has(config.referenceColumn) // 🔧 중복 방지
|
||||
) {
|
||||
resultColumns.push(
|
||||
`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.referenceColumn}`
|
||||
);
|
||||
generatedLabelAliases.add(config.referenceColumn);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -335,6 +335,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
const editId = urlParams.get("editId");
|
||||
const tableName = urlParams.get("tableName") || screenInfo.tableName;
|
||||
const groupByColumnsParam = urlParams.get("groupByColumns");
|
||||
const primaryKeyColumn = urlParams.get("primaryKeyColumn"); // 🆕 Primary Key 컬럼명
|
||||
|
||||
console.log("📋 URL 파라미터 확인:", { mode, editId, tableName, groupByColumnsParam, primaryKeyColumn });
|
||||
|
||||
// 수정 모드이고 editId가 있으면 해당 레코드 조회
|
||||
if (mode === "edit" && editId && tableName) {
|
||||
|
|
@ -357,6 +360,16 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
if (groupByColumns.length > 0) {
|
||||
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 response = apiResponse.data;
|
||||
|
|
@ -582,66 +595,66 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
</div>
|
||||
) : screenData ? (
|
||||
<ActiveTabProvider>
|
||||
<TableOptionsProvider>
|
||||
<div
|
||||
className="relative mx-auto bg-white"
|
||||
style={{
|
||||
width: `${screenDimensions?.width || 800}px`,
|
||||
height: `${screenDimensions?.height || 600}px`,
|
||||
transformOrigin: "center center",
|
||||
}}
|
||||
>
|
||||
{screenData.components.map((component) => {
|
||||
// 화면 관리 해상도를 사용하는 경우 offset 조정 불필요
|
||||
const offsetX = screenDimensions?.offsetX || 0;
|
||||
const offsetY = screenDimensions?.offsetY || 0;
|
||||
<TableOptionsProvider>
|
||||
<div
|
||||
className="relative mx-auto bg-white"
|
||||
style={{
|
||||
width: `${screenDimensions?.width || 800}px`,
|
||||
height: `${screenDimensions?.height || 600}px`,
|
||||
transformOrigin: "center center",
|
||||
}}
|
||||
>
|
||||
{screenData.components.map((component) => {
|
||||
// 화면 관리 해상도를 사용하는 경우 offset 조정 불필요
|
||||
const offsetX = screenDimensions?.offsetX || 0;
|
||||
const offsetY = screenDimensions?.offsetY || 0;
|
||||
|
||||
// offset이 0이면 원본 위치 사용 (화면 관리 해상도 사용 시)
|
||||
const adjustedComponent =
|
||||
offsetX === 0 && offsetY === 0
|
||||
? component
|
||||
: {
|
||||
...component,
|
||||
position: {
|
||||
...component.position,
|
||||
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
|
||||
y: parseFloat(component.position?.y?.toString() || "0") - offsetY,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<InteractiveScreenViewerDynamic
|
||||
key={`${component.id}-${resetKey}`}
|
||||
component={adjustedComponent}
|
||||
allComponents={screenData.components}
|
||||
formData={formData}
|
||||
originalData={originalData} // 🆕 원본 데이터 전달 (UPDATE 판단용)
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
setFormData((prev) => {
|
||||
const newFormData = {
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
// offset이 0이면 원본 위치 사용 (화면 관리 해상도 사용 시)
|
||||
const adjustedComponent =
|
||||
offsetX === 0 && offsetY === 0
|
||||
? component
|
||||
: {
|
||||
...component,
|
||||
position: {
|
||||
...component.position,
|
||||
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
|
||||
y: parseFloat(component.position?.y?.toString() || "0") - offsetY,
|
||||
},
|
||||
};
|
||||
return newFormData;
|
||||
});
|
||||
}}
|
||||
onRefresh={() => {
|
||||
// 부모 화면의 테이블 새로고침 이벤트 발송
|
||||
window.dispatchEvent(new CustomEvent("refreshTable"));
|
||||
}}
|
||||
screenInfo={{
|
||||
id: modalState.screenId!,
|
||||
tableName: screenData.screenInfo?.tableName,
|
||||
}}
|
||||
groupedData={selectedData}
|
||||
userId={userId}
|
||||
userName={userName}
|
||||
companyCode={user?.companyCode}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TableOptionsProvider>
|
||||
|
||||
return (
|
||||
<InteractiveScreenViewerDynamic
|
||||
key={`${component.id}-${resetKey}`}
|
||||
component={adjustedComponent}
|
||||
allComponents={screenData.components}
|
||||
formData={formData}
|
||||
originalData={originalData} // 🆕 원본 데이터 전달 (UPDATE 판단용)
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
setFormData((prev) => {
|
||||
const newFormData = {
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
};
|
||||
return newFormData;
|
||||
});
|
||||
}}
|
||||
onRefresh={() => {
|
||||
// 부모 화면의 테이블 새로고침 이벤트 발송
|
||||
window.dispatchEvent(new CustomEvent("refreshTable"));
|
||||
}}
|
||||
screenInfo={{
|
||||
id: modalState.screenId!,
|
||||
tableName: screenData.screenInfo?.tableName,
|
||||
}}
|
||||
groupedData={selectedData}
|
||||
userId={userId}
|
||||
userName={userName}
|
||||
companyCode={user?.companyCode}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TableOptionsProvider>
|
||||
</ActiveTabProvider>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
|
|
|
|||
|
|
@ -309,18 +309,32 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
_subDataMaxValue: maxValue,
|
||||
};
|
||||
|
||||
// 선택된 하위 데이터의 필드 값을 상위 item에 복사 (설정된 경우)
|
||||
// 예: warehouse_code, location_code 등
|
||||
if (subDataLookup.lookup.displayColumns) {
|
||||
subDataLookup.lookup.displayColumns.forEach((col) => {
|
||||
if (selectedItem[col] !== undefined) {
|
||||
// 필드가 정의되어 있으면 복사
|
||||
const fieldDef = fields.find((f) => f.name === col);
|
||||
if (fieldDef || col.includes("_code") || col.includes("_id")) {
|
||||
newItems[itemIndex][col] = selectedItem[col];
|
||||
// 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에 복사 (설정된 경우)
|
||||
// 예: warehouse_code, location_code 등
|
||||
if (subDataLookup.lookup.displayColumns) {
|
||||
subDataLookup.lookup.displayColumns.forEach((col) => {
|
||||
if (selectedItem[col] !== undefined) {
|
||||
// 필드가 정의되어 있으면 복사
|
||||
const fieldDef = fields.find((f) => f.name === col);
|
||||
if (fieldDef || col.includes("_code") || col.includes("_id")) {
|
||||
newItems[itemIndex][col] = selectedItem[col];
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setItems(newItems);
|
||||
|
|
@ -893,6 +907,10 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
const renderGridLayout = () => {
|
||||
// 하위 데이터 조회 설정이 있으면 연결 컬럼 찾기
|
||||
const linkColumn = subDataLookup?.lookup?.linkColumn;
|
||||
|
||||
// hidden이 아닌 필드만 표시
|
||||
// isHidden이 true이거나 displayMode가 hidden인 필드는 제외 (하위 호환성 유지)
|
||||
const visibleFields = fields.filter((f) => !f.isHidden && f.displayMode !== "hidden");
|
||||
|
||||
return (
|
||||
<div className="bg-card">
|
||||
|
|
@ -905,7 +923,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
{allowReorder && (
|
||||
<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">
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
|
|
@ -944,8 +962,8 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
</TableCell>
|
||||
)}
|
||||
|
||||
{/* 필드들 */}
|
||||
{fields.map((field) => (
|
||||
{/* 필드들 (hidden 제외) */}
|
||||
{visibleFields.map((field) => (
|
||||
<TableCell key={field.name} className="h-12 px-2.5 py-2">
|
||||
{renderField(field, itemIndex, item[field.name])}
|
||||
</TableCell>
|
||||
|
|
@ -973,7 +991,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
<TableRow className="bg-gray-50/50">
|
||||
<TableCell
|
||||
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"
|
||||
>
|
||||
|
|
@ -1002,6 +1020,10 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
const renderCardLayout = () => {
|
||||
// 하위 데이터 조회 설정이 있으면 연결 컬럼 찾기
|
||||
const linkColumn = subDataLookup?.lookup?.linkColumn;
|
||||
|
||||
// hidden이 아닌 필드만 표시
|
||||
// isHidden이 true이거나 displayMode가 hidden인 필드는 제외 (하위 호환성 유지)
|
||||
const visibleFields = fields.filter((f) => !f.isHidden && f.displayMode !== "hidden");
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -1070,7 +1092,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
{!isCollapsed && (
|
||||
<CardContent>
|
||||
<div className={getFieldsLayoutClass()}>
|
||||
{fields.map((field) => (
|
||||
{visibleFields.map((field) => (
|
||||
<div key={field.name} className="space-y-1" style={{ width: field.width }}>
|
||||
<label className="text-foreground text-sm font-medium">
|
||||
{field.label}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="space-y-4">
|
||||
{/* 대상 테이블 선택 */}
|
||||
|
|
@ -588,7 +656,7 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
<Checkbox
|
||||
id={`display-col-${col.columnName}`}
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) => handleDisplayColumnToggle(col.columnName, checked as boolean)}
|
||||
onCheckedChange={(checked) => handleDisplayColumnToggleWithOrder(col.columnName, checked as boolean)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`display-col-${col.columnName}`}
|
||||
|
|
@ -605,6 +673,78 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
</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 && (
|
||||
<div className="space-y-3 border-t border-purple-200 pt-3">
|
||||
|
|
@ -1351,35 +1491,106 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
|
||||
{/* 카테고리 타입이 아닐 때만 표시 모드 선택 */}
|
||||
{field.type !== "category" && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">표시 모드</Label>
|
||||
<Select
|
||||
value={field.displayMode || "input"}
|
||||
onValueChange={(value) => updateField(index, { displayMode: value as any })}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="input">입력 (편집 가능)</SelectItem>
|
||||
<SelectItem value="readonly">읽기전용 (텍스트)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">표시 모드</Label>
|
||||
<Select
|
||||
value={field.displayMode || "input"}
|
||||
onValueChange={(value) => updateField(index, { displayMode: value as any })}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="input">입력 (편집 가능)</SelectItem>
|
||||
<SelectItem value="readonly">읽기전용 (텍스트)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4 pt-5">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`required-${index}`}
|
||||
checked={field.required ?? false}
|
||||
onCheckedChange={(checked) => updateField(index, { required: checked as boolean })}
|
||||
/>
|
||||
<Label htmlFor={`required-${index}`} className="cursor-pointer text-xs font-normal">
|
||||
필수
|
||||
</Label>
|
||||
<div className="flex items-center space-x-4 pt-5">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`required-${index}`}
|
||||
checked={field.required ?? false}
|
||||
onCheckedChange={(checked) => updateField(index, { required: checked as boolean })}
|
||||
/>
|
||||
<Label htmlFor={`required-${index}`} className="cursor-pointer text-xs font-normal">
|
||||
필수
|
||||
</Label>
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
|
|||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { dynamicFormApi } from "@/lib/api/dynamicForm";
|
||||
import { cascadingRelationApi } from "@/lib/api/cascadingRelation";
|
||||
import { AutoFillMapping } from "./config";
|
||||
|
||||
export function EntitySearchInputComponent({
|
||||
tableName,
|
||||
|
|
@ -37,6 +38,8 @@ export function EntitySearchInputComponent({
|
|||
formData,
|
||||
// 다중선택 props
|
||||
multiple: multipleProp,
|
||||
// 자동 채움 매핑 props
|
||||
autoFillMappings: autoFillMappingsProp,
|
||||
// 추가 props
|
||||
component,
|
||||
isInteractive,
|
||||
|
|
@ -47,6 +50,7 @@ export function EntitySearchInputComponent({
|
|||
isInteractive?: boolean;
|
||||
onFormDataChange?: (fieldName: string, value: any) => void;
|
||||
webTypeConfig?: any; // 웹타입 설정 (연쇄관계 등)
|
||||
autoFillMappings?: AutoFillMapping[]; // 자동 채움 매핑
|
||||
}) {
|
||||
// uiMode가 있으면 우선 사용, 없으면 modeProp 사용, 기본값 "combo"
|
||||
const mode = (uiMode || modeProp || "combo") as "select" | "modal" | "combo" | "autocomplete";
|
||||
|
|
@ -54,6 +58,18 @@ export function EntitySearchInputComponent({
|
|||
// 다중선택 및 연쇄관계 설정 (props > webTypeConfig > componentConfig 순서)
|
||||
const config = component?.componentConfig || component?.webTypeConfig || {};
|
||||
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;
|
||||
|
|
@ -309,6 +325,23 @@ export function EntitySearchInputComponent({
|
|||
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]));
|
||||
return (
|
||||
<CommandItem
|
||||
key={option[valueField] || index}
|
||||
key={option[valueField] ?? `option-${index}`}
|
||||
value={`${option[displayField] || ""}-${option[valueField] || ""}`}
|
||||
onSelect={() => handleSelectOption(option)}
|
||||
className="text-xs sm:text-sm"
|
||||
|
|
@ -509,7 +542,7 @@ export function EntitySearchInputComponent({
|
|||
<CommandGroup>
|
||||
{effectiveOptions.map((option, index) => (
|
||||
<CommandItem
|
||||
key={option[valueField] || index}
|
||||
key={option[valueField] ?? `select-option-${index}`}
|
||||
value={`${option[displayField] || ""}-${option[valueField] || ""}`}
|
||||
onSelect={() => handleSelectOption(option)}
|
||||
className="text-xs sm:text-sm"
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { Switch } from "@/components/ui/switch";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, X, Check, ChevronsUpDown, Database, Info, Link2, ExternalLink } from "lucide-react";
|
||||
// allComponents는 현재 사용되지 않지만 향후 확장을 위해 props에 유지
|
||||
import { EntitySearchInputConfig } from "./config";
|
||||
import { EntitySearchInputConfig, AutoFillMapping } from "./config";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import { cascadingRelationApi, CascadingRelation } from "@/lib/api/cascadingRelation";
|
||||
|
|
@ -236,6 +236,7 @@ export function EntitySearchInputConfigPanel({
|
|||
const newConfig = { ...localConfig, ...updates };
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
console.log("📝 [EntitySearchInput] 설정 업데이트:", { updates, newConfig });
|
||||
};
|
||||
|
||||
// 연쇄 드롭다운 활성화/비활성화
|
||||
|
|
@ -636,9 +637,9 @@ export function EntitySearchInputConfigPanel({
|
|||
<CommandList>
|
||||
<CommandEmpty className="text-xs sm:text-sm">필드를 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tableColumns.map((column) => (
|
||||
{tableColumns.map((column, idx) => (
|
||||
<CommandItem
|
||||
key={column.columnName}
|
||||
key={column.columnName || `display-col-${idx}`}
|
||||
value={`${column.displayName || column.columnName}-${column.columnName}`}
|
||||
onSelect={() => {
|
||||
updateConfig({ displayField: column.columnName });
|
||||
|
|
@ -690,9 +691,9 @@ export function EntitySearchInputConfigPanel({
|
|||
<CommandList>
|
||||
<CommandEmpty className="text-xs sm:text-sm">필드를 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tableColumns.map((column) => (
|
||||
{tableColumns.map((column, idx) => (
|
||||
<CommandItem
|
||||
key={column.columnName}
|
||||
key={column.columnName || `value-col-${idx}`}
|
||||
value={`${column.displayName || column.columnName}-${column.columnName}`}
|
||||
onSelect={() => {
|
||||
updateConfig({ valueField: column.columnName });
|
||||
|
|
@ -812,8 +813,8 @@ export function EntitySearchInputConfigPanel({
|
|||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{tableColumns.map((col, colIdx) => (
|
||||
<SelectItem key={col.columnName || `modal-col-${colIdx}`} value={col.columnName}>
|
||||
{col.displayName || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
|
@ -860,8 +861,8 @@ export function EntitySearchInputConfigPanel({
|
|||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{tableColumns.map((col, colIdx) => (
|
||||
<SelectItem key={col.columnName || `search-col-${colIdx}`} value={col.columnName}>
|
||||
{col.displayName || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
|
@ -919,8 +920,8 @@ export function EntitySearchInputConfigPanel({
|
|||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{tableColumns.map((col, colIdx) => (
|
||||
<SelectItem key={col.columnName || `additional-col-${colIdx}`} value={col.columnName}>
|
||||
{col.displayName || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
|
@ -939,6 +940,105 @@ export function EntitySearchInputConfigPanel({
|
|||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,9 @@ export const EntitySearchInputWrapper: React.FC<WebTypeComponentProps> = ({
|
|||
|
||||
// placeholder
|
||||
const placeholder = config.placeholder || widget?.placeholder || "항목을 선택하세요";
|
||||
|
||||
// 자동 채움 매핑 설정
|
||||
const autoFillMappings = config.autoFillMappings || [];
|
||||
|
||||
console.log("🏢 EntitySearchInputWrapper 렌더링:", {
|
||||
tableName,
|
||||
|
|
@ -44,6 +47,7 @@ export const EntitySearchInputWrapper: React.FC<WebTypeComponentProps> = ({
|
|||
valueField,
|
||||
uiMode,
|
||||
multiple,
|
||||
autoFillMappings,
|
||||
value,
|
||||
config,
|
||||
});
|
||||
|
|
@ -68,6 +72,7 @@ export const EntitySearchInputWrapper: React.FC<WebTypeComponentProps> = ({
|
|||
value={value}
|
||||
onChange={onChange}
|
||||
multiple={multiple}
|
||||
autoFillMappings={autoFillMappings}
|
||||
component={component}
|
||||
isInteractive={props.isInteractive}
|
||||
onFormDataChange={props.onFormDataChange}
|
||||
|
|
|
|||
|
|
@ -148,9 +148,9 @@ export function EntitySearchModal({
|
|||
선택
|
||||
</th>
|
||||
)}
|
||||
{displayColumns.map((col) => (
|
||||
{displayColumns.map((col, colIdx) => (
|
||||
<th
|
||||
key={col}
|
||||
key={col || `header-${colIdx}`}
|
||||
className="px-4 py-2 text-left font-medium text-muted-foreground"
|
||||
>
|
||||
{col}
|
||||
|
|
@ -179,7 +179,8 @@ export function EntitySearchModal({
|
|||
</tr>
|
||||
) : (
|
||||
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);
|
||||
return (
|
||||
<tr
|
||||
|
|
@ -200,8 +201,8 @@ export function EntitySearchModal({
|
|||
/>
|
||||
</td>
|
||||
)}
|
||||
{displayColumns.map((col) => (
|
||||
<td key={`${uniqueKey}-${col}`} className="px-4 py-2">
|
||||
{displayColumns.map((col, colIdx) => (
|
||||
<td key={`${uniqueKey}-${col || colIdx}`} className="px-4 py-2">
|
||||
{item[col] || "-"}
|
||||
</td>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
// 자동 채움 매핑 타입
|
||||
export interface AutoFillMapping {
|
||||
sourceField: string; // 선택된 엔티티의 필드 (예: customer_name)
|
||||
targetField: string; // 폼의 필드 (예: partner_name)
|
||||
}
|
||||
|
||||
export interface EntitySearchInputConfig {
|
||||
tableName: string;
|
||||
displayField: string;
|
||||
|
|
@ -18,5 +24,8 @@ export interface EntitySearchInputConfig {
|
|||
cascadingRelationCode?: string; // 연쇄관계 코드 (WAREHOUSE_LOCATION 등)
|
||||
cascadingRole?: "parent" | "child"; // 역할 (부모/자식)
|
||||
cascadingParentField?: string; // 부모 필드의 컬럼명 (자식 역할일 때만 사용)
|
||||
|
||||
// 자동 채움 매핑 설정
|
||||
autoFillMappings?: AutoFillMapping[]; // 엔티티 선택 시 다른 필드에 자동으로 값 채우기
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -287,12 +287,18 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
if (onChange && items.length > 0) {
|
||||
// 🆕 RepeaterFieldGroup이 관리하는 필드 목록 추출
|
||||
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) => ({
|
||||
...item,
|
||||
_targetTable: targetTable,
|
||||
_originalItemIds: itemIds, // 🆕 원본 ID 목록도 함께 전달
|
||||
_existingRecord: !!item.id, // 🆕 기존 레코드 플래그 (id가 있으면 기존 레코드)
|
||||
_repeaterFields: repeaterFieldNames, // 🆕 품목 고유 필드 목록
|
||||
_repeaterFieldsConfig: fieldsConfig, // 🆕 필드 설정 (subDataSource 등)
|
||||
}));
|
||||
onChange(dataWithMeta);
|
||||
}
|
||||
|
|
@ -393,11 +399,17 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
if (items.length > 0) {
|
||||
// 🆕 RepeaterFieldGroup이 관리하는 필드 목록 추출
|
||||
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) => ({
|
||||
...item,
|
||||
_targetTable: effectiveTargetTable,
|
||||
_existingRecord: !!item.id,
|
||||
_repeaterFields: repeaterFieldNames, // 🆕 품목 고유 필드 목록
|
||||
_repeaterFieldsConfig: fieldsConfig, // 🆕 필드 설정 (subDataSource 등)
|
||||
}));
|
||||
onChange(dataWithMeta);
|
||||
} else {
|
||||
|
|
@ -681,6 +693,11 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
(newValue: any[]) => {
|
||||
// 🆕 RepeaterFieldGroup이 관리하는 필드 목록 추출
|
||||
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) => ({
|
||||
|
|
@ -688,6 +705,7 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
_targetTable: effectiveTargetTable || targetTable,
|
||||
_existingRecord: !!item.id,
|
||||
_repeaterFields: repeaterFieldNames, // 🆕 품목 고유 필드 목록
|
||||
_repeaterFieldsConfig: fieldsConfig, // 🆕 필드 설정 (subDataSource 등)
|
||||
}));
|
||||
|
||||
// 🆕 분할 패널에서 우측인 경우, FK 값 추가
|
||||
|
|
|
|||
|
|
@ -78,8 +78,20 @@ export const SubDataLookupPanel: React.FC<SubDataLookupPanelProps> = ({
|
|||
return config.lookup.columnLabels?.[columnName] || columnName;
|
||||
};
|
||||
|
||||
// 표시할 컬럼 목록
|
||||
const displayColumns = config.lookup.displayColumns || [];
|
||||
// 표시할 컬럼 목록 (columnOrder가 있으면 순서 적용)
|
||||
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(() => {
|
||||
|
|
|
|||
|
|
@ -197,10 +197,18 @@ export function useSubDataLookup(props: UseSubDataLookupProps): UseSubDataLookup
|
|||
return "선택 안됨";
|
||||
}
|
||||
|
||||
const { displayColumns, columnLabels } = config.lookup;
|
||||
const { displayColumns, columnLabels, columnOrder } = config.lookup;
|
||||
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];
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
const label = columnLabels?.[col] || col;
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import {
|
|||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
||||
import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-options";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
|
@ -171,6 +172,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
const [rightSearchQuery, setRightSearchQuery] = useState("");
|
||||
const [isLoadingLeft, setIsLoadingLeft] = 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 [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들
|
||||
const [leftColumnLabels, setLeftColumnLabels] = useState<Record<string, string>>({}); // 좌측 컬럼 라벨
|
||||
|
|
@ -917,11 +924,11 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
const { entityJoinApi } = await import("@/lib/api/entityJoin");
|
||||
|
||||
// 복합키 조건 생성
|
||||
// 🔧 관계 필터링은 정확한 값 매칭이 필요하므로 equals 연산자 사용
|
||||
// (entity 타입 컬럼의 경우 기본 contains 연산자가 참조 테이블의 표시 컬럼으로 검색하여 실패함)
|
||||
// 🔧 entity 타입 컬럼은 코드 값으로 정확히 매칭해야 하므로 operator: 'equals' 사용
|
||||
const searchConditions: Record<string, any> = {};
|
||||
keys.forEach((key) => {
|
||||
if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) {
|
||||
// 연결 필터는 정확한 값 매칭이 필요하므로 equals 연산자 사용
|
||||
searchConditions[key.rightColumn] = {
|
||||
value: leftItem[key.leftColumn],
|
||||
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(
|
||||
(item: any) => {
|
||||
setSelectedLeftItem(item);
|
||||
setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화
|
||||
loadRightData(item);
|
||||
setTabsData({}); // 🆕 모든 탭 데이터 초기화
|
||||
|
||||
// 🆕 현재 활성 탭에 따라 데이터 로드
|
||||
if (activeTabIndex === 0) {
|
||||
loadRightData(item);
|
||||
} else {
|
||||
loadTabData(activeTabIndex, item);
|
||||
}
|
||||
|
||||
// 🆕 modalDataStore에 선택된 좌측 항목 저장 (단일 선택)
|
||||
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 || "";
|
||||
|
||||
// Primary Key 찾기 (우선순위: id > ID > 첫 번째 필드)
|
||||
// Primary Key 찾기 (우선순위: 설정값 > id > ID > non-null 필드)
|
||||
// 🔧 설정에서 primaryKeyColumn 지정 가능
|
||||
const configuredPrimaryKey = componentConfig.rightPanel?.editButton?.primaryKeyColumn;
|
||||
|
||||
let primaryKeyName = "id";
|
||||
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";
|
||||
primaryKeyValue = item.id;
|
||||
} else if (item.ID !== undefined && item.ID !== null) {
|
||||
primaryKeyName = "ID";
|
||||
primaryKeyValue = item.ID;
|
||||
} else {
|
||||
// 첫 번째 필드를 Primary Key로 간주
|
||||
const firstKey = Object.keys(item)[0];
|
||||
primaryKeyName = firstKey;
|
||||
primaryKeyValue = item[firstKey];
|
||||
// 🔧 첫 번째 non-null 필드를 Primary Key로 간주
|
||||
const keys = Object.keys(item);
|
||||
let found = false;
|
||||
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("✅ 수정 모달 열기:", {
|
||||
|
|
@ -1466,7 +1648,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
hasGroupByColumns: groupByColumns.length > 0,
|
||||
});
|
||||
|
||||
// ScreenModal 열기 이벤트 발생 (URL 파라미터로 ID + groupByColumns 전달)
|
||||
// ScreenModal 열기 이벤트 발생 (URL 파라미터로 ID + groupByColumns + primaryKeyColumn 전달)
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("openScreenModal", {
|
||||
detail: {
|
||||
|
|
@ -1475,6 +1657,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
mode: "edit",
|
||||
editId: primaryKeyValue,
|
||||
tableName: rightTableName,
|
||||
primaryKeyColumn: primaryKeyName, // 🆕 Primary Key 컬럼명 전달
|
||||
...(groupByColumns.length > 0 && {
|
||||
groupByColumns: JSON.stringify(groupByColumns),
|
||||
}),
|
||||
|
|
@ -1487,6 +1670,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
screenId: modalScreenId,
|
||||
editId: primaryKeyValue,
|
||||
tableName: rightTableName,
|
||||
primaryKeyColumn: primaryKeyName,
|
||||
groupByColumns: groupByColumns.length > 0 ? JSON.stringify(groupByColumns) : "없음",
|
||||
});
|
||||
|
||||
|
|
@ -2540,6 +2724,34 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
className="flex flex-shrink-0 flex-col"
|
||||
>
|
||||
<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
|
||||
className="flex-shrink-0 border-b"
|
||||
style={{
|
||||
|
|
@ -2552,16 +2764,28 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<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>
|
||||
{!isDesignMode && (
|
||||
<div className="flex items-center gap-2">
|
||||
{componentConfig.rightPanel?.showAdd && (
|
||||
<Button size="sm" variant="outline" onClick={() => handleAddClick("right")}>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
추가
|
||||
</Button>
|
||||
)}
|
||||
{/* 🆕 현재 활성 탭에 따른 추가 버튼 */}
|
||||
{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")}>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
추가
|
||||
</Button>
|
||||
)}
|
||||
{/* 우측 패널 수정/삭제는 각 카드에서 처리 */}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -2581,20 +2805,231 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
</div>
|
||||
)}
|
||||
<CardContent className="flex-1 overflow-auto p-4">
|
||||
{/* 우측 데이터 */}
|
||||
{isLoadingRight ? (
|
||||
// 로딩 중
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="text-primary mx-auto h-8 w-8 animate-spin" />
|
||||
<p className="text-muted-foreground mt-2 text-sm">데이터를 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : rightData ? (
|
||||
// 실제 데이터 표시
|
||||
Array.isArray(rightData) ? (
|
||||
// 조인 모드: 여러 데이터를 테이블/리스트로 표시
|
||||
(() => {
|
||||
{/* 🆕 추가 탭 데이터 렌더링 */}
|
||||
{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 ? (
|
||||
// 로딩 중
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="text-primary mx-auto h-8 w-8 animate-spin" />
|
||||
<p className="text-muted-foreground mt-2 text-sm">데이터를 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : rightData ? (
|
||||
// 실제 데이터 표시
|
||||
Array.isArray(rightData) ? (
|
||||
// 조인 모드: 여러 데이터를 테이블/리스트로 표시
|
||||
(() => {
|
||||
// 검색 필터링
|
||||
const filteredData = rightSearchQuery
|
||||
? rightData.filter((item) => {
|
||||
|
|
@ -3024,14 +3459,16 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 선택 없음
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-muted-foreground text-center text-sm">
|
||||
<p className="mb-2">좌측에서 항목을 선택하세요</p>
|
||||
<p className="text-xs">선택한 항목의 상세 정보가 여기에 표시됩니다</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 선택 없음
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-muted-foreground text-center text-sm">
|
||||
<p className="mb-2">좌측에서 항목을 선택하세요</p>
|
||||
<p className="text-xs">선택한 항목의 상세 정보가 여기에 표시됩니다</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -2721,19 +2721,41 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const value = row[mappedColumnName];
|
||||
|
||||
// 카테고리 매핑된 값 처리
|
||||
if (categoryMappings[col.columnName] && value !== null && value !== undefined) {
|
||||
const mapping = categoryMappings[col.columnName][String(value)];
|
||||
if (mapping) {
|
||||
return mapping.label;
|
||||
if (value !== null && value !== undefined) {
|
||||
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) {
|
||||
return mapping.label;
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
// null/undefined 처리
|
||||
if (value === null || value === undefined) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return value;
|
||||
return "";
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -712,12 +712,19 @@ export class ButtonActionExecutor {
|
|||
|
||||
if (repeaterJsonKeys.length > 0) {
|
||||
console.log("🔄 [handleSave] RepeaterFieldGroup JSON 문자열 감지:", repeaterJsonKeys);
|
||||
|
||||
|
||||
// 🎯 채번 규칙 할당 처리 (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> = {};
|
||||
|
||||
|
||||
// formData에서 채번 규칙이 설정된 필드 찾기
|
||||
for (const [key, value] of Object.entries(context.formData)) {
|
||||
if (key.endsWith("_numberingRuleId") && value) {
|
||||
|
|
@ -726,22 +733,27 @@ export class ButtonActionExecutor {
|
|||
console.log(`🎯 [handleSave-RepeaterFieldGroup] 채번 필드 발견: ${fieldName} → 규칙 ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
console.log("📋 [handleSave-RepeaterFieldGroup] 채번 규칙이 설정된 필드:", fieldsWithNumberingRepeater);
|
||||
|
||||
// 채번 규칙이 있는 필드에 대해 allocateCode 호출
|
||||
if (Object.keys(fieldsWithNumberingRepeater).length > 0) {
|
||||
console.log("🎯 [handleSave-RepeaterFieldGroup] 채번 규칙 할당 시작 (allocateCode 호출)");
|
||||
|
||||
// 🔧 수정 모드에서는 채번 코드 할당 건너뛰기 (기존 번호 유지)
|
||||
// 신규 등록 모드에서만 allocateCode 호출하여 새 번호 할당
|
||||
if (Object.keys(fieldsWithNumberingRepeater).length > 0 && !isEditModeRepeater) {
|
||||
console.log(
|
||||
"🎯 [handleSave-RepeaterFieldGroup] 신규 등록 모드 - 채번 규칙 할당 시작 (allocateCode 호출)",
|
||||
);
|
||||
const { allocateNumberingCode } = await import("@/lib/api/numberingRule");
|
||||
|
||||
|
||||
for (const [fieldName, ruleId] of Object.entries(fieldsWithNumberingRepeater)) {
|
||||
try {
|
||||
console.log(`🔄 [handleSave-RepeaterFieldGroup] ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}`);
|
||||
const allocateResult = await allocateNumberingCode(ruleId);
|
||||
|
||||
|
||||
if (allocateResult.success && 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;
|
||||
} else {
|
||||
console.warn(`⚠️ [handleSave-RepeaterFieldGroup] ${fieldName} 코드 할당 실패:`, allocateResult.error);
|
||||
|
|
@ -750,9 +762,11 @@ export class ButtonActionExecutor {
|
|||
console.error(`❌ [handleSave-RepeaterFieldGroup] ${fieldName} 코드 할당 오류:`, allocateError);
|
||||
}
|
||||
}
|
||||
} else if (isEditModeRepeater) {
|
||||
console.log("⏭️ [handleSave-RepeaterFieldGroup] 수정 모드 - 채번 코드 할당 건너뜀 (기존 번호 유지)");
|
||||
}
|
||||
|
||||
console.log("✅ [handleSave-RepeaterFieldGroup] 채번 규칙 할당 완료");
|
||||
|
||||
console.log("✅ [handleSave-RepeaterFieldGroup] 채번 규칙 할당 처리 완료");
|
||||
|
||||
// 🆕 상단 폼 데이터(마스터 정보) 추출
|
||||
// RepeaterFieldGroup JSON과 컴포넌트 키를 제외한 나머지가 마스터 정보
|
||||
|
|
@ -808,7 +822,7 @@ export class ButtonActionExecutor {
|
|||
|
||||
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 설정 기반)
|
||||
const itemOnlyData: Record<string, any> = {};
|
||||
|
|
@ -817,6 +831,42 @@ export class ButtonActionExecutor {
|
|||
itemOnlyData[field] = itemData[field];
|
||||
}
|
||||
});
|
||||
|
||||
// 🆕 하위 데이터 선택에서 값 추출 (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: 상단 폼에서 수정한 최신 마스터 정보
|
||||
|
|
@ -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> = {};
|
||||
|
||||
|
|
@ -1993,9 +2052,12 @@ export class ButtonActionExecutor {
|
|||
|
||||
console.log("📋 [handleUniversalFormModalTableSectionSave] 채번 규칙이 설정된 필드:", fieldsWithNumbering);
|
||||
|
||||
// 🔥 저장 시점에 allocateCode 호출하여 실제 순번 증가
|
||||
if (Object.keys(fieldsWithNumbering).length > 0) {
|
||||
console.log("🎯 [handleUniversalFormModalTableSectionSave] 채번 규칙 할당 시작 (allocateCode 호출)");
|
||||
// 🔧 수정 모드에서는 채번 코드 할당 건너뛰기 (기존 번호 유지)
|
||||
// 신규 등록 모드에서만 allocateCode 호출하여 새 번호 할당
|
||||
if (Object.keys(fieldsWithNumbering).length > 0 && !isEditModeUniversal) {
|
||||
console.log(
|
||||
"🎯 [handleUniversalFormModalTableSectionSave] 신규 등록 모드 - 채번 규칙 할당 시작 (allocateCode 호출)",
|
||||
);
|
||||
const { allocateNumberingCode } = await import("@/lib/api/numberingRule");
|
||||
|
||||
for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) {
|
||||
|
|
@ -2022,6 +2084,8 @@ export class ButtonActionExecutor {
|
|||
// 오류 시 기존 값 유지
|
||||
}
|
||||
}
|
||||
} else if (isEditModeUniversal) {
|
||||
console.log("⏭️ [handleUniversalFormModalTableSectionSave] 수정 모드 - 채번 코드 할당 건너뜀 (기존 번호 유지)");
|
||||
}
|
||||
|
||||
console.log("✅ [handleUniversalFormModalTableSectionSave] 채번 규칙 할당 완료");
|
||||
|
|
@ -4949,7 +5013,24 @@ export class ButtonActionExecutor {
|
|||
const filteredRow: Record<string, any> = {};
|
||||
visibleColumns!.forEach((columnName: string) => {
|
||||
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;
|
||||
});
|
||||
|
|
@ -5222,8 +5303,15 @@ export class ButtonActionExecutor {
|
|||
value = row[`${columnName}_name`];
|
||||
}
|
||||
// 카테고리 타입 필드는 라벨로 변환 (백엔드에서 정의된 컬럼만)
|
||||
else if (categoryMap[columnName] && typeof value === "string" && categoryMap[columnName][value]) {
|
||||
value = 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];
|
||||
}
|
||||
}
|
||||
|
||||
filteredRow[label] = value;
|
||||
|
|
|
|||
|
|
@ -116,8 +116,10 @@ export async function importFromExcel(
|
|||
return;
|
||||
}
|
||||
|
||||
// JSON으로 변환
|
||||
const jsonData = XLSX.utils.sheet_to_json(worksheet);
|
||||
// JSON으로 변환 (빈 셀도 포함하여 모든 컬럼 키 유지)
|
||||
const jsonData = XLSX.utils.sheet_to_json(worksheet, {
|
||||
defval: "", // 빈 셀에 빈 문자열 할당
|
||||
});
|
||||
|
||||
console.log("✅ 엑셀 가져오기 완료:", {
|
||||
sheetName: targetSheetName,
|
||||
|
|
|
|||
|
|
@ -43,9 +43,19 @@ export interface CalculationFormula {
|
|||
* 필드 표시 모드
|
||||
* - input: 입력 필드로 표시 (편집 가능)
|
||||
* - 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용
|
||||
width?: string; // 필드 너비 (예: "200px", "50%")
|
||||
displayMode?: RepeaterFieldDisplayMode; // 표시 모드: input(입력), readonly(읽기전용)
|
||||
isHidden?: boolean; // 숨김 여부 (true면 테이블에 표시 안 함, 데이터는 저장)
|
||||
subDataSource?: SubDataSourceConfig; // 하위 데이터 조회에서 값 가져오기 설정
|
||||
categoryCode?: string; // category 타입일 때 사용할 카테고리 코드
|
||||
formula?: CalculationFormula; // 계산식 (type이 "calculated"일 때 사용)
|
||||
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)
|
||||
displayColumns: string[]; // 표시할 컬럼들 (예: ["warehouse_code", "location_code", "quantity"])
|
||||
columnLabels?: Record<string, string>; // 컬럼 라벨 (예: { warehouse_code: "창고" })
|
||||
columnOrder?: string[]; // 컬럼 표시 순서 (없으면 displayColumns 순서 사용)
|
||||
fieldMappings?: SubDataFieldMapping[]; // 선택 데이터 저장 매핑 (조회 컬럼 → 저장 컬럼)
|
||||
additionalFilters?: Record<string, any>; // 추가 필터 조건
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue