Merge remote-tracking branch 'upstream/main'

This commit is contained in:
kjs 2026-01-22 10:32:34 +09:00
commit a4e90fd10f
30 changed files with 1999 additions and 342 deletions

View File

@ -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) {

View File

@ -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,7 +500,11 @@ class DataService {
return validation.error!;
}
// Primary Key 컬럼 찾기
// 🆕 클라이언트에서 전달한 Primary Key 컬럼이 있으면 우선 사용
let pkColumn = primaryKeyColumn || "";
// Primary Key 컬럼이 없으면 자동 감지
if (!pkColumn) {
const pkResult = await query<{ attname: string }>(
`SELECT a.attname
FROM pg_index i
@ -508,10 +513,16 @@ class DataService {
[tableName]
);
let pkColumn = "id"; // 기본값
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) {

View File

@ -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 형식으로 추가
// 🔧 중복 방지: 같은 sourceColumn에서 _label은 첫 번째만 생성
const labelAlias = `${config.sourceColumn}_label`;
if (!generatedLabelAliases.has(labelAlias)) {
resultColumns.push(
`COALESCE(${alias}.${col}::TEXT, '') AS ${config.sourceColumn}_label`
`COALESCE(${alias}.${col}::TEXT, '') AS ${labelAlias}`
);
generatedLabelAliases.add(labelAlias);
}
// 🆕 referenceColumn (PK)도 항상 SELECT (parentDataMapping용)
// 예: customer_code, item_number 등
// col과 동일해도 별도의 alias로 추가 (customer_code as customer_code)
// 🔧 중복 방지: referenceColumn도 한 번만 추가
const refColAlias = config.referenceColumn;
if (!generatedLabelAliases.has(refColAlias)) {
resultColumns.push(
`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.referenceColumn}`
`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);
}
}

View File

@ -921,11 +921,12 @@ export class TableManagementService {
...layout.properties,
widgetType: inputType,
inputType: inputType,
// componentConfig 내부의 type 업데이트
// componentConfig 내부의 type, inputType, webType 모두 업데이트
componentConfig: {
...layout.properties?.componentConfig,
type: newComponentType,
inputType: inputType,
webType: inputType, // 프론트엔드 SelectBasicComponent에서 카테고리 로딩 여부 판단에 사용
},
};
@ -941,7 +942,7 @@ export class TableManagementService {
);
logger.info(
`화면 레이아웃 업데이트: screen_id=${layout.screen_id}, component_id=${layout.component_id}, widgetType=${inputType}, componentType=${newComponentType}`
`화면 레이아웃 업데이트: screen_id=${layout.screen_id}, component_id=${layout.component_id}, widgetType=${inputType}, webType=${inputType}, componentType=${newComponentType}`
);
}

View File

@ -928,12 +928,39 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
{field.inputType === "entity" ? (
<Select
value={masterFieldValues[field.columnName]?.toString() || ""}
onValueChange={(value) =>
setMasterFieldValues((prev) => ({
onValueChange={(value) => {
// 선택한 item 찾기
const selectedItem = entitySearchData[field.columnName]?.find(
(item: any) => item[field.referenceColumn || "id"]?.toString() === value
);
// displayColumn에서 name 값도 가져오기
const displayColName =
field.displayColumn ||
entityDisplayColumns[field.columnName] ||
field.referenceColumn ||
"id";
const displayValue = selectedItem?.[displayColName];
// code와 name 컬럼명 추출 (예: supplier_code → supplier_name)
const codeColName = field.columnName; // supplier_code
const nameColName = codeColName.replace(/_code$/, "_name"); // supplier_name
setMasterFieldValues((prev) => {
const newValues = {
...prev,
[field.columnName]: value,
}))
[codeColName]: value,
};
// _code로 끝나는 컬럼이면 _name도 함께 저장
if (codeColName.endsWith("_code") && displayValue) {
newValues[nameColName] = displayValue;
console.log(`🔗 엔티티 연동: ${codeColName}=${value}, ${nameColName}=${displayValue}`);
}
return newValues;
});
}}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue placeholder={`${field.columnLabel} 선택`} />

View File

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

View File

@ -33,6 +33,7 @@ interface EditModalState {
dataflowConfig?: any;
dataflowTiming?: string;
}; // 🆕 모달 내부 저장 버튼의 제어로직 설정
menuObjid?: number; // 🆕 메뉴 OBJID (카테고리 스코프용)
}
interface EditModalProps {
@ -91,6 +92,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
buttonConfig: undefined,
buttonContext: undefined,
saveButtonConfig: undefined,
menuObjid: undefined,
});
const [screenData, setScreenData] = useState<{
@ -234,7 +236,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
// 전역 모달 이벤트 리스너
useEffect(() => {
const handleOpenEditModal = async (event: CustomEvent) => {
const { screenId, title, description, modalSize, editData, onSave, groupByColumns, tableName, isCreateMode, buttonConfig, buttonContext } = event.detail;
const { screenId, title, description, modalSize, editData, onSave, groupByColumns, tableName, isCreateMode, buttonConfig, buttonContext, menuObjid } = event.detail;
// 🆕 모달 내부 저장 버튼의 제어로직 설정 조회
let saveButtonConfig: EditModalState["saveButtonConfig"] = undefined;
@ -258,6 +260,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
buttonConfig, // 🆕 버튼 설정
buttonContext, // 🆕 버튼 컨텍스트
saveButtonConfig, // 🆕 모달 내부 저장 버튼의 제어로직 설정
menuObjid, // 🆕 메뉴 OBJID (카테고리 스코프용)
});
// 편집 데이터로 폼 데이터 초기화
@ -1079,6 +1082,8 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
id: modalState.screenId!,
tableName: screenData.screenInfo?.tableName,
}}
// 🆕 메뉴 OBJID 전달 (카테고리 스코프용)
menuObjid={modalState.menuObjid}
// 🆕 그룹 데이터가 있거나 UniversalFormModal이 없으면 EditModal.handleSave 사용
// groupData가 있으면 일괄 저장을 위해 반드시 EditModal.handleSave 사용
onSave={shouldUseEditModalSave ? handleSave : undefined}

View File

@ -1213,13 +1213,14 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
description: editModalDescription,
modalSize: "lg",
editData: initialData,
menuObjid, // 🆕 메뉴 OBJID 전달 (카테고리 스코프용)
onSave: () => {
loadData(); // 테이블 데이터 새로고침
},
},
});
window.dispatchEvent(event);
}, [selectedRows, data, getDisplayColumns, component.addModalConfig, component.editModalConfig, loadData]);
}, [selectedRows, data, getDisplayColumns, component.addModalConfig, component.editModalConfig, loadData, menuObjid]);
// 수정 폼 데이터 변경 핸들러
const handleEditFormChange = useCallback((columnName: string, value: any) => {
@ -2720,6 +2721,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
screenId={saveModalScreenId}
modalSize={component.addModalConfig?.modalSize || "lg"}
initialData={saveModalData}
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 (카테고리 스코프용)
onSaveSuccess={() => {
// 저장 성공 시 테이블 새로고침
loadData(currentPage, searchValues); // 현재 페이지로 다시 로드

View File

@ -19,6 +19,7 @@ interface SaveModalProps {
modalSize?: "sm" | "md" | "lg" | "xl" | "full";
initialData?: any; // 수정 모드일 때 기존 데이터
onSaveSuccess?: () => void; // 저장 성공 시 콜백 (테이블 새로고침용)
menuObjid?: number; // 🆕 메뉴 OBJID (카테고리 스코프용)
}
/**
@ -33,6 +34,7 @@ export const SaveModal: React.FC<SaveModalProps> = ({
modalSize = "lg",
initialData,
onSaveSuccess,
menuObjid,
}) => {
const { user, userName } = useAuth(); // 현재 사용자 정보 가져오기
const [formData, setFormData] = useState<Record<string, any>>(initialData || {});
@ -373,6 +375,7 @@ export const SaveModal: React.FC<SaveModalProps> = ({
}));
}}
hideLabel={false}
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 (카테고리 스코프용)
/>
) : (
<DynamicComponentRenderer
@ -385,6 +388,7 @@ export const SaveModal: React.FC<SaveModalProps> = ({
}}
screenId={screenId}
tableName={screenData.tableName}
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 (카테고리 스코프용)
userId={user?.userId} // ✅ 사용자 ID 전달
userName={user?.userName} // ✅ 사용자 이름 전달
companyCode={user?.companyCode} // ✅ 회사 코드 전달

View File

@ -309,6 +309,19 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
_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에 복사 (설정된 경우)
// 예: warehouse_code, location_code 등
if (subDataLookup.lookup.displayColumns) {
@ -322,6 +335,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
}
});
}
}
setItems(newItems);
@ -894,6 +908,10 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
// 하위 데이터 조회 설정이 있으면 연결 컬럼 찾기
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">
<Table>
@ -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"
>
@ -1003,6 +1021,10 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
// 하위 데이터 조회 설정이 있으면 연결 컬럼 찾기
const linkColumn = subDataLookup?.lookup?.linkColumn;
// hidden이 아닌 필드만 표시
// isHidden이 true이거나 displayMode가 hidden인 필드는 제외 (하위 호환성 유지)
const visibleFields = fields.filter((f) => !f.isHidden && f.displayMode !== "hidden");
return (
<>
{items.map((item, itemIndex) => {
@ -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}

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 (
<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,6 +1491,7 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
{/* 카테고리 타입이 아닐 때만 표시 모드 선택 */}
{field.type !== "category" && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs"> </Label>
@ -1381,6 +1522,76 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
</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 { 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";
@ -55,6 +59,18 @@ export function EntitySearchInputComponent({
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;
// cascadingParentField: ConfigPanel에서 저장되는 필드명
@ -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"

View File

@ -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>
);
}

View File

@ -38,12 +38,16 @@ export const EntitySearchInputWrapper: React.FC<WebTypeComponentProps> = ({
// placeholder
const placeholder = config.placeholder || widget?.placeholder || "항목을 선택하세요";
// 자동 채움 매핑 설정
const autoFillMappings = config.autoFillMappings || [];
console.log("🏢 EntitySearchInputWrapper 렌더링:", {
tableName,
displayField,
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}

View File

@ -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>
))}

View File

@ -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[]; // 엔티티 선택 시 다른 필드에 자동으로 값 채우기
}

View File

@ -7,6 +7,8 @@
import React, { useState, useMemo, useCallback, useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import {
PivotGridProps,
PivotResult,
@ -50,6 +52,10 @@ import {
} from "lucide-react";
import { Button } from "@/components/ui/button";
// ==================== 상수 ====================
const PIVOT_STATE_VERSION = "1.0"; // 상태 저장 버전 (호환성 체크용)
// ==================== 유틸리티 함수 ====================
// 셀 병합 정보 계산
@ -128,7 +134,10 @@ const RowHeaderCell: React.FC<RowHeaderCellProps> = ({
<div className="flex items-center gap-1">
{row.hasChildren && (
<button
onClick={() => onToggleExpand(row.path)}
onClick={(e) => {
e.stopPropagation();
onToggleExpand(row.path);
}}
className="p-0.5 hover:bg-accent rounded"
>
{row.isExpanded ? (
@ -299,6 +308,8 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
// ==================== 상태 ====================
const [fields, setFields] = useState<PivotFieldConfig[]>(initialFields);
// 초기 필드 설정 저장 (초기화용)
const initialFieldsRef = useRef<PivotFieldConfig[]>(initialFields);
const [pivotState, setPivotState] = useState<PivotGridState>({
expandedRowPaths: [],
expandedColumnPaths: [],
@ -344,41 +355,44 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
const [resizeStartX, setResizeStartX] = useState<number>(0);
const [resizeStartWidth, setResizeStartWidth] = useState<number>(0);
// 외부 fields 변경 시 동기화
useEffect(() => {
if (initialFields.length > 0) {
setFields(initialFields);
}
}, [initialFields]);
// 상태 저장 키
const stateStorageKey = `pivot-state-${title || "default"}`;
const persistSettingKey = `pivot-persist-${title || "default"}`;
// 상태 저장 (localStorage)
const saveStateToStorage = useCallback(() => {
if (typeof window === "undefined") return;
const stateToSave = {
fields,
pivotState,
sortConfig,
columnWidths,
};
localStorage.setItem(stateStorageKey, JSON.stringify(stateToSave));
}, [fields, pivotState, sortConfig, columnWidths, stateStorageKey]);
// 상태 유지 설정 (체크박스용)
const [persistState, setPersistState] = useState<boolean>(() => {
if (typeof window === "undefined") return true;
const saved = localStorage.getItem(persistSettingKey);
return saved !== null ? saved === "true" : true; // 기본값 true
});
// 상태 복원 (localStorage) - 프로덕션 안전성 강화
// 복원 완료 여부 (initialFields 덮어쓰기 방지)
const [isStateRestored, setIsStateRestored] = useState(false);
// 상태 복원 (localStorage) - 마운트 시 한 번만 실행
useEffect(() => {
if (typeof window === "undefined") return;
// 상태 유지가 꺼져 있으면 복원하지 않음
if (!persistState) {
localStorage.removeItem(stateStorageKey);
setIsStateRestored(true);
return;
}
try {
const savedState = localStorage.getItem(stateStorageKey);
if (!savedState) return;
if (!savedState) {
setIsStateRestored(true);
return;
}
const parsed = JSON.parse(savedState);
// 버전 체크 - 버전이 다르면 이전 상태 무시
if (parsed.version !== PIVOT_STATE_VERSION) {
localStorage.removeItem(stateStorageKey);
setIsStateRestored(true);
return;
}
@ -424,7 +438,72 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
// 손상된 상태는 제거
localStorage.removeItem(stateStorageKey);
}
}, [stateStorageKey]);
setIsStateRestored(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 마운트 시 한 번만 실행
// 외부 fields 변경 시 동기화 (복원이 완료된 후에만, 저장된 상태가 없을 때만)
useEffect(() => {
if (!isStateRestored) return; // 복원 완료 전에는 무시
// 저장된 상태가 있으면 initialFields로 덮어쓰지 않음
if (typeof window !== "undefined") {
const savedState = localStorage.getItem(stateStorageKey);
if (savedState) return; // 이미 저장된 상태가 있으면 무시
}
if (initialFields.length > 0) {
setFields(initialFields);
}
// persistState는 의존성에서 제외 - 체크박스 변경 시 현재 상태 유지
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialFields, isStateRestored, stateStorageKey]);
// 상태 유지 설정 저장 + 켜질 때 현재 상태 즉시 저장
useEffect(() => {
if (typeof window === "undefined") return;
localStorage.setItem(persistSettingKey, String(persistState));
// 상태 유지를 켜면 현재 상태를 즉시 저장
if (persistState && isStateRestored && fields.length > 0) {
const stateToSave = {
version: PIVOT_STATE_VERSION,
fields,
pivotState,
sortConfig,
columnWidths,
};
localStorage.setItem(stateStorageKey, JSON.stringify(stateToSave));
}
// 상태 유지를 끄면 저장된 상태 삭제
if (!persistState) {
localStorage.removeItem(stateStorageKey);
}
}, [persistState, persistSettingKey, isStateRestored, fields, pivotState, sortConfig, columnWidths, stateStorageKey]);
// 상태 저장 (localStorage)
const saveStateToStorage = useCallback(() => {
if (typeof window === "undefined" || !persistState) return;
const stateToSave = {
version: PIVOT_STATE_VERSION,
fields,
pivotState,
sortConfig,
columnWidths,
};
localStorage.setItem(stateStorageKey, JSON.stringify(stateToSave));
}, [fields, pivotState, sortConfig, columnWidths, stateStorageKey, persistState]);
// 상태 변경 시 자동 저장 (복원 완료 후에만)
useEffect(() => {
if (!persistState || !isStateRestored) return;
// 초기 로드 후에만 저장 (빈 필드일 때는 저장 안 함)
if (fields.length > 0) {
saveStateToStorage();
}
}, [fields, pivotState, sortConfig, columnWidths, persistState, isStateRestored, saveStateToStorage]);
// 데이터
const data = externalData || [];
@ -500,9 +579,9 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
const filteredData = useMemo(() => {
if (!data || data.length === 0) return data;
// 필터 영역의 필드들로 데이터 필터링
// 모든 영역(행/열/필터)의 필터 값이 있는 필드로 데이터 필터링
const activeFilters = fields.filter(
(f) => f.area === "filter" && f.filterValues && f.filterValues.length > 0
(f) => f.filterValues && f.filterValues.length > 0
);
if (activeFilters.length === 0) return data;
@ -1129,6 +1208,7 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
onFieldsChange={handleFieldsChange}
collapsed={!showFieldPanel}
onToggleCollapse={() => setShowFieldPanel(!showFieldPanel)}
initialFields={initialFieldsRef.current}
/>
{/* 안내 메시지 */}
@ -1170,7 +1250,7 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
);
}
const { flatColumns, dataMatrix, grandTotals } = pivotResult;
const { flatColumns, dataMatrix, grandTotals, columnHeaderLevels } = pivotResult;
// ==================== 키보드 네비게이션 ====================
@ -1405,6 +1485,7 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
onFieldsChange={handleFieldsChange}
collapsed={!showFieldPanel}
onToggleCollapse={() => setShowFieldPanel(!showFieldPanel)}
initialFields={initialFieldsRef.current}
/>
{/* 헤더 툴바 */}
@ -1467,6 +1548,22 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
</>
)}
{/* 상태 유지 체크박스 */}
<div className="flex items-center gap-1.5 ml-2 pl-2 border-l">
<Checkbox
id="persist-state"
checked={persistState}
onCheckedChange={(checked) => setPersistState(checked === true)}
className="h-3.5 w-3.5"
/>
<Label
htmlFor="persist-state"
className="text-xs text-muted-foreground cursor-pointer whitespace-nowrap"
>
</Label>
</div>
{/* 차트 토글 */}
{chartConfig && (
<Button
@ -1674,23 +1771,155 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
className="flex-1 overflow-auto focus:outline-none"
style={{
maxHeight: enableVirtualScroll && containerHeight > 0 ? containerHeight : undefined,
minHeight: 100 // 최소 높이 보장 - 블라인드 효과 방지
// 최소 200px 보장 + 데이터에 맞게 조정 (최대 400px)
minHeight: Math.max(
200, // 절대 최소값 - 블라인드 효과 방지
Math.min(400, (sortedFlatRows.length + 3) * ROW_HEIGHT + 50)
)
}}
tabIndex={0}
onKeyDown={handleKeyDown}
>
<table ref={tableRef} className="w-full border-collapse">
<thead>
{/* 열 헤더 */}
<tr className="bg-background">
{/* 좌상단 코너 (행 필드 라벨 + 필터) */}
{/* 다중 행 열 헤더 */}
{columnHeaderLevels.length > 0 ? (
// 열 필드가 있는 경우: 각 레벨별로 행 생성
columnHeaderLevels.map((levelCells, levelIdx) => (
<tr key={`col-level-${levelIdx}`} className="bg-background">
{/* 좌상단 코너 (첫 번째 레벨에만 표시) */}
{levelIdx === 0 && (
<th
className={cn(
"border-r border-b border-border",
"px-2 py-1 text-left text-xs font-medium",
"bg-background sticky left-0 top-0 z-20"
)}
rowSpan={columnFields.length > 0 ? 2 : 1}
rowSpan={columnHeaderLevels.length + (dataFields.length > 1 ? 1 : 0)}
>
<div className="flex items-center gap-1 flex-wrap">
{rowFields.map((f, idx) => (
<div key={f.field} className="flex items-center gap-0.5 group">
<span>{f.caption}</span>
<FilterPopup
field={f}
data={data}
onFilterChange={(field, values, type) => {
const newFields = fields.map((fld) =>
fld.field === field.field && fld.area === "row"
? { ...fld, filterValues: values, filterType: type }
: fld
);
handleFieldsChange(newFields);
}}
trigger={
<button
className={cn(
"p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity",
"hover:bg-accent",
f.filterValues && f.filterValues.length > 0 && "opacity-100 text-primary"
)}
>
<Filter className="h-3 w-3" />
</button>
}
/>
{idx < rowFields.length - 1 && <span className="mx-0.5 text-muted-foreground">/</span>}
</div>
))}
{rowFields.length === 0 && <span></span>}
</div>
</th>
)}
{/* 열 헤더 셀 - 해당 레벨 */}
{levelCells.map((cell, cellIdx) => (
<th
key={`${levelIdx}-${cellIdx}`}
className={cn(
"border-r border-b border-border relative",
"px-2 py-1 text-center text-xs font-medium",
"bg-background sticky top-0 z-10",
levelIdx === columnHeaderLevels.length - 1 && dataFields.length === 1 && "cursor-pointer hover:bg-accent/50"
)}
colSpan={cell.colSpan * (dataFields.length || 1)}
>
<div className="flex items-center justify-center gap-1">
<span>{cell.caption || "(전체)"}</span>
{levelIdx === columnHeaderLevels.length - 1 && dataFields.length === 1 && (
<SortIcon field={dataFields[0].field} />
)}
</div>
</th>
))}
{/* 행 총계 헤더 (첫 번째 레벨에만 표시) */}
{levelIdx === 0 && totals?.showRowGrandTotals && (
<th
className={cn(
"border-b border-border",
"px-2 py-1 text-center text-xs font-medium",
"bg-background sticky top-0 z-10"
)}
colSpan={dataFields.length || 1}
rowSpan={columnHeaderLevels.length + (dataFields.length > 1 ? 1 : 0)}
>
</th>
)}
{/* 열 필드 필터 (첫 번째 레벨에만 표시) */}
{levelIdx === 0 && columnFields.length > 0 && (
<th
className={cn(
"border-b border-border",
"px-1 py-1 text-center text-xs",
"bg-background sticky top-0 z-10"
)}
rowSpan={columnHeaderLevels.length + (dataFields.length > 1 ? 1 : 0)}
>
<div className="flex flex-col gap-0.5">
{columnFields.map((f) => (
<FilterPopup
key={f.field}
field={f}
data={data}
onFilterChange={(field, values, type) => {
const newFields = fields.map((fld) =>
fld.field === field.field && fld.area === "column"
? { ...fld, filterValues: values, filterType: type }
: fld
);
handleFieldsChange(newFields);
}}
trigger={
<button
className={cn(
"p-0.5 rounded hover:bg-accent",
f.filterValues && f.filterValues.length > 0 && "text-primary"
)}
title={`${f.caption} 필터`}
>
<Filter className="h-3 w-3" />
</button>
}
/>
))}
</div>
</th>
)}
</tr>
))
) : (
// 열 필드가 없는 경우: 단일 행
<tr className="bg-background">
<th
className={cn(
"border-r border-b border-border",
"px-2 py-1 text-left text-xs font-medium",
"bg-background sticky left-0 top-0 z-20"
)}
rowSpan={dataFields.length > 1 ? 2 : 1}
>
<div className="flex items-center gap-1 flex-wrap">
{rowFields.map((f, idx) => (
@ -1726,7 +1955,7 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
</div>
</th>
{/* 열 헤더 셀 */}
{/* 열 헤더 셀 (열 필드 없을 때) */}
{flatColumns.map((col, idx) => (
<th
key={idx}
@ -1744,7 +1973,6 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
<span>{col.caption || "(전체)"}</span>
{dataFields.length === 1 && <SortIcon field={dataFields[0].field} />}
</div>
{/* 열 리사이즈 핸들 */}
<div
className={cn(
"absolute right-0 top-0 bottom-0 w-1 cursor-col-resize",
@ -1770,48 +1998,8 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
</th>
)}
{/* 열 필드 필터 (헤더 오른쪽 끝에 표시) */}
{columnFields.length > 0 && (
<th
className={cn(
"border-b border-border",
"px-1 py-1 text-center text-xs",
"bg-background sticky top-0 z-10"
)}
rowSpan={dataFields.length > 1 ? 2 : 1}
>
<div className="flex flex-col gap-0.5">
{columnFields.map((f) => (
<FilterPopup
key={f.field}
field={f}
data={data}
onFilterChange={(field, values, type) => {
const newFields = fields.map((fld) =>
fld.field === field.field && fld.area === "column"
? { ...fld, filterValues: values, filterType: type }
: fld
);
handleFieldsChange(newFields);
}}
trigger={
<button
className={cn(
"p-0.5 rounded hover:bg-accent",
f.filterValues && f.filterValues.length > 0 && "text-primary"
)}
title={`${f.caption} 필터`}
>
<Filter className="h-3 w-3" />
</button>
}
/>
))}
</div>
</th>
)}
</tr>
)}
{/* 데이터 필드 라벨 (다중 데이터 필드인 경우) */}
{dataFields.length > 1 && (

View File

@ -16,6 +16,7 @@ import {
PivotAreaType,
AggregationType,
FieldDataType,
DateGroupInterval,
} from "./types";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
@ -202,6 +203,28 @@ const AreaDropZone: React.FC<AreaDropZoneProps> = ({
</Select>
)}
{/* 행/열 영역에서 날짜 타입일 때 그룹화 옵션 */}
{(area === "row" || area === "column") && field.dataType === "date" && (
<Select
value={field.groupInterval || "__none__"}
onValueChange={(v) => onUpdateField(idx, {
groupInterval: v === "__none__" ? undefined : v as DateGroupInterval
})}
>
<SelectTrigger className="h-6 w-16 text-xs">
<SelectValue placeholder="그룹" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"></SelectItem>
<SelectItem value="year"></SelectItem>
<SelectItem value="quarter"></SelectItem>
<SelectItem value="month"></SelectItem>
<SelectItem value="week"></SelectItem>
<SelectItem value="day"></SelectItem>
</SelectContent>
</Select>
)}
<Button
variant="ghost"
size="icon"
@ -295,7 +318,8 @@ export const PivotGridConfigPanel: React.FC<PivotGridConfigPanelProps> = ({
const mappedColumns: ColumnInfo[] = columnList.map((c: any) => ({
column_name: c.columnName || c.column_name,
data_type: c.dataType || c.data_type || "text",
column_comment: c.columnLabel || c.column_label || c.columnName || c.column_name,
// 라벨 우선순위: displayName > comment > columnLabel > columnName
column_comment: c.displayName || c.comment || c.columnLabel || c.column_label || c.columnName || c.column_name,
}));
setColumns(mappedColumns);
} catch (error) {

View File

@ -37,6 +37,12 @@ import {
BarChart3,
GripVertical,
ChevronDown,
RotateCcw,
FilterX,
LayoutGrid,
Trash2,
Calendar,
Check,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@ -56,6 +62,8 @@ interface FieldPanelProps {
onFieldSettingsChange?: (field: PivotFieldConfig) => void;
collapsed?: boolean;
onToggleCollapse?: () => void;
/** 초기 필드 설정 (필드 배치 초기화용) */
initialFields?: PivotFieldConfig[];
}
interface FieldChipProps {
@ -123,15 +131,33 @@ const SortableFieldChip: React.FC<FieldChipProps> = ({
transition,
};
// 필터 적용 여부 확인
const hasFilter = field.filterValues && field.filterValues.length > 0;
const filterCount = field.filterValues?.length || 0;
// 그룹화 상태 확인
const hasGrouping = field.groupInterval && field.dataType === "date";
const groupLabels: Record<string, string> = {
year: "연도",
quarter: "분기",
month: "월",
week: "주",
day: "일",
};
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs",
"bg-background border border-border shadow-sm",
"bg-background border shadow-sm",
"hover:bg-accent/50 transition-colors",
isDragging && "opacity-50 shadow-lg"
isDragging && "opacity-50 shadow-lg",
// 필터 적용 시 강조 표시
hasFilter
? "border-primary bg-primary/5"
: "border-border"
)}
>
{/* 드래그 핸들 */}
@ -143,11 +169,30 @@ const SortableFieldChip: React.FC<FieldChipProps> = ({
<GripVertical className="h-3 w-3" />
</button>
{/* 필터 아이콘 (필터 적용 시) */}
{hasFilter && (
<Filter className="h-3 w-3 text-primary" />
)}
{/* 필드 라벨 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-1 hover:text-primary">
<span className="font-medium">{field.caption}</span>
<span className={cn("font-medium", hasFilter && "text-primary")}>
{field.caption}
</span>
{/* 그룹화 적용 표시 */}
{hasGrouping && (
<span className="bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300 text-[10px] px-1 rounded">
{groupLabels[field.groupInterval!]}
</span>
)}
{/* 필터 적용 개수 배지 */}
{hasFilter && (
<span className="bg-primary text-primary-foreground text-[10px] px-1 rounded">
{filterCount}
</span>
)}
{field.area === "data" && field.summaryType && (
<span className="text-muted-foreground">
({getSummaryLabel(field.summaryType)})
@ -197,6 +242,59 @@ const SortableFieldChip: React.FC<FieldChipProps> = ({
<DropdownMenuSeparator />
</>
)}
{/* 날짜 그룹화 옵션 (행/열 영역의 날짜 타입 필드만) */}
{(field.area === "row" || field.area === "column") &&
field.dataType === "date" && (
<>
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground flex items-center gap-1">
<Calendar className="h-3 w-3" />
</div>
<DropdownMenuItem
onClick={() => onSettingsChange?.({ ...field, groupInterval: undefined })}
className="pl-6"
>
{!field.groupInterval && <Check className="h-3 w-3 mr-2" />}
<span className={!field.groupInterval ? "font-medium" : ""}> </span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onSettingsChange?.({ ...field, groupInterval: "year" })}
className="pl-6"
>
{field.groupInterval === "year" && <Check className="h-3 w-3 mr-2" />}
<span className={field.groupInterval === "year" ? "font-medium" : ""}></span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onSettingsChange?.({ ...field, groupInterval: "quarter" })}
className="pl-6"
>
{field.groupInterval === "quarter" && <Check className="h-3 w-3 mr-2" />}
<span className={field.groupInterval === "quarter" ? "font-medium" : ""}></span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onSettingsChange?.({ ...field, groupInterval: "month" })}
className="pl-6"
>
{field.groupInterval === "month" && <Check className="h-3 w-3 mr-2" />}
<span className={field.groupInterval === "month" ? "font-medium" : ""}></span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onSettingsChange?.({ ...field, groupInterval: "week" })}
className="pl-6"
>
{field.groupInterval === "week" && <Check className="h-3 w-3 mr-2" />}
<span className={field.groupInterval === "week" ? "font-medium" : ""}></span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onSettingsChange?.({ ...field, groupInterval: "day" })}
className="pl-6"
>
{field.groupInterval === "day" && <Check className="h-3 w-3 mr-2" />}
<span className={field.groupInterval === "day" ? "font-medium" : ""}></span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
onClick={() =>
onSettingsChange?.({
@ -208,6 +306,19 @@ const SortableFieldChip: React.FC<FieldChipProps> = ({
{field.sortOrder === "asc" ? "내림차순 정렬" : "오름차순 정렬"}
</DropdownMenuItem>
<DropdownMenuSeparator />
{/* 필터 초기화 (필터가 적용된 경우에만 표시) */}
{hasFilter && (
<>
<DropdownMenuItem
onClick={() => onSettingsChange?.({ ...field, filterValues: [] })}
className="text-orange-600"
>
<Filter className="h-3 w-3 mr-2" />
({filterCount} )
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
onClick={() => onSettingsChange?.({ ...field, visible: false })}
>
@ -326,10 +437,73 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
onFieldSettingsChange,
collapsed = false,
onToggleCollapse,
initialFields,
}) => {
const [activeId, setActiveId] = useState<string | null>(null);
const [overArea, setOverArea] = useState<PivotAreaType | null>(null);
// 필터만 초기화
const handleResetFilters = () => {
const newFields = fields.map((f) => ({
...f,
filterValues: [],
filterType: "include" as const,
}));
onFieldsChange(newFields);
};
// 필드 배치 초기화 (initialFields가 있으면 사용, 없으면 모든 필드를 row로)
const handleResetLayout = () => {
if (initialFields && initialFields.length > 0) {
// initialFields의 영역 배치를 복원하되 현재 필터 값은 유지
const newFields = fields.map((f) => {
const initial = initialFields.find((i) => i.field === f.field);
if (initial) {
return {
...f,
area: initial.area,
areaIndex: initial.areaIndex,
};
}
return f;
});
onFieldsChange(newFields);
} else {
// 기본값: 숫자는 data, 나머지는 row로
const newFields = fields.map((f, idx) => ({
...f,
area: f.dataType === "number" ? "data" : "row" as PivotAreaType,
areaIndex: idx,
visible: true,
}));
onFieldsChange(newFields);
}
};
// 전체 초기화 (필드 배치 + 필터)
const handleResetAll = () => {
if (initialFields && initialFields.length > 0) {
// initialFields로 완전히 복원
onFieldsChange([...initialFields]);
} else {
// 기본값으로 초기화
const newFields = fields.map((f, idx) => ({
...f,
area: f.dataType === "number" ? "data" : "row" as PivotAreaType,
areaIndex: idx,
visible: true,
filterValues: [],
filterType: "include" as const,
}));
onFieldsChange(newFields);
}
};
// 필터가 적용된 필드 개수
const filteredFieldCount = fields.filter(
(f) => f.filterValues && f.filterValues.length > 0
).length;
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
@ -576,20 +750,61 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
/>
</div>
{/* 하단 버튼 영역 */}
<div className="flex items-center justify-between mt-1.5">
{/* 초기화 드롭다운 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="text-xs h-6 px-2 text-muted-foreground hover:text-foreground"
>
<RotateCcw className="h-3 w-3 mr-1" />
{filteredFieldCount > 0 && (
<span className="ml-1 bg-orange-500 text-white text-[10px] px-1 rounded">
{filteredFieldCount}
</span>
)}
<ChevronDown className="h-3 w-3 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
<DropdownMenuItem onClick={handleResetFilters}>
<FilterX className="h-3.5 w-3.5 mr-2 text-orange-500" />
{filteredFieldCount > 0 && (
<span className="ml-auto text-xs text-muted-foreground">
({filteredFieldCount})
</span>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleResetLayout}>
<LayoutGrid className="h-3.5 w-3.5 mr-2 text-blue-500" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleResetAll} className="text-destructive">
<Trash2 className="h-3.5 w-3.5 mr-2" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* 접기 버튼 */}
{onToggleCollapse && (
<div className="flex justify-center mt-1.5">
<Button
variant="ghost"
size="sm"
onClick={onToggleCollapse}
className="text-xs h-5 px-2"
className="text-xs h-6 px-2"
>
</Button>
</div>
)}
</div>
</div>
{/* 드래그 오버레이 */}
<DragOverlay>

View File

@ -304,6 +304,7 @@ export interface PivotHeaderNode {
level: number; // 깊이
children?: PivotHeaderNode[]; // 자식 노드
isExpanded: boolean; // 확장 상태
hasChildren: boolean; // 자식 존재 가능 여부 (다음 레벨 필드 있음)
path: string[]; // 경로 (드릴다운용)
subtotal?: PivotCellValue[]; // 소계
span?: number; // colspan/rowspan
@ -330,9 +331,12 @@ export interface PivotResult {
// 플랫 행 목록 (렌더링용)
flatRows: PivotFlatRow[];
// 플랫 열 목록 (렌더링용)
// 플랫 열 목록 (렌더링용) - 리프 노드만
flatColumns: PivotFlatColumn[];
// 열 헤더 레벨별 (다중 행 헤더용)
columnHeaderLevels: PivotColumnHeaderCell[][];
// 총합계
grandTotals: {
row: Map<string, PivotCellValue[]>; // 행별 총합
@ -360,6 +364,14 @@ export interface PivotFlatColumn {
isTotal?: boolean;
}
// 열 헤더 셀 (다중 행 헤더용)
export interface PivotColumnHeaderCell {
caption: string; // 표시 텍스트
colSpan: number; // 병합할 열 수
path: string[]; // 전체 경로
level: number; // 레벨 (0부터 시작)
}
// ==================== 상태 관리 ====================
export interface PivotGridState {

View File

@ -10,6 +10,7 @@ import {
PivotFlatRow,
PivotFlatColumn,
PivotCellValue,
PivotColumnHeaderCell,
DateGroupInterval,
AggregationType,
SummaryDisplayMode,
@ -76,6 +77,31 @@ export function pathToKey(path: string[]): string {
return path.join("||");
}
/**
* ( )
*/
function generateAllPaths(
data: Record<string, any>[],
fields: PivotFieldConfig[]
): string[] {
const allPaths: string[] = [];
// 각 레벨까지의 고유 경로 수집
for (let depth = 1; depth <= fields.length; depth++) {
const fieldsAtDepth = fields.slice(0, depth);
const pathSet = new Set<string>();
data.forEach((row) => {
const path = fieldsAtDepth.map((f) => getFieldValue(row, f));
pathSet.add(pathToKey(path));
});
pathSet.forEach((pathKey) => allPaths.push(pathKey));
}
return allPaths;
}
/**
*
*/
@ -129,6 +155,7 @@ function buildHeaderTree(
caption: key,
level: 0,
isExpanded: expandedPaths.has(pathKey),
hasChildren: remainingFields.length > 0, // 다음 레벨 필드가 있으면 자식 있음
path: path,
span: 1,
};
@ -195,6 +222,7 @@ function buildChildNodes(
caption: key,
level: level,
isExpanded: expandedPaths.has(pathKey),
hasChildren: remainingFields.length > 0, // 다음 레벨 필드가 있으면 자식 있음
path: path,
span: 1,
};
@ -238,7 +266,7 @@ function flattenRows(nodes: PivotHeaderNode[]): PivotFlatRow[] {
level: node.level,
caption: node.caption,
isExpanded: node.isExpanded,
hasChildren: !!(node.children && node.children.length > 0),
hasChildren: node.hasChildren, // 노드에서 직접 가져옴 (다음 레벨 필드 존재 여부 기준)
});
if (node.isExpanded && node.children) {
@ -324,6 +352,66 @@ function getMaxColumnLevel(
return Math.min(maxLevel, totalFields - 1);
}
/**
*
* colSpan
*/
function buildColumnHeaderLevels(
nodes: PivotHeaderNode[],
totalLevels: number
): PivotColumnHeaderCell[][] {
if (totalLevels === 0 || nodes.length === 0) {
return [];
}
const levels: PivotColumnHeaderCell[][] = Array.from(
{ length: totalLevels },
() => []
);
// 리프 노드 수 계산 (colSpan 계산용)
function countLeaves(node: PivotHeaderNode): number {
if (!node.children || node.children.length === 0 || !node.isExpanded) {
return 1;
}
return node.children.reduce((sum, child) => sum + countLeaves(child), 0);
}
// 트리 순회하며 각 레벨에 셀 추가
function traverse(node: PivotHeaderNode, level: number) {
const colSpan = countLeaves(node);
levels[level].push({
caption: node.caption,
colSpan,
path: node.path,
level,
});
if (node.children && node.isExpanded) {
for (const child of node.children) {
traverse(child, level + 1);
}
} else if (level < totalLevels - 1) {
// 확장되지 않은 노드는 다음 레벨들에 빈 셀로 채움
for (let i = level + 1; i < totalLevels; i++) {
levels[i].push({
caption: "",
colSpan,
path: node.path,
level: i,
});
}
}
}
for (const node of nodes) {
traverse(node, 0);
}
return levels;
}
// ==================== 데이터 매트릭스 생성 ====================
/**
@ -733,12 +821,11 @@ export function processPivotData(
uniqueValues.forEach((val) => expandedRowSet.add(val));
}
if (expandedColumnPaths.length === 0 && columnFields.length > 0) {
const firstField = columnFields[0];
const uniqueValues = new Set(
filteredData.map((row) => getFieldValue(row, firstField))
);
uniqueValues.forEach((val) => expandedColSet.add(val));
// 열은 항상 전체 확장 (열 헤더는 확장/축소 UI가 없음)
// 모든 가능한 열 경로를 확장 상태로 설정
if (columnFields.length > 0) {
const allColumnPaths = generateAllPaths(filteredData, columnFields);
allColumnPaths.forEach((pathKey) => expandedColSet.add(pathKey));
}
// 헤더 트리 생성
@ -786,6 +873,12 @@ export function processPivotData(
grandTotals.grand
);
// 다중 행 열 헤더 생성
const columnHeaderLevels = buildColumnHeaderLevels(
columnHeaders,
columnFields.length
);
return {
rowHeaders,
columnHeaders,
@ -797,6 +890,7 @@ export function processPivotData(
caption: path[path.length - 1] || "",
span: 1,
})),
columnHeaderLevels,
grandTotals,
};
}

View File

@ -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 값 추가

View File

@ -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(() => {

View File

@ -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;

View File

@ -299,12 +299,14 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
tableName: component.tableName,
columnName: component.columnName,
webType,
menuObjid, // 🆕 menuObjid 로깅 추가
});
setIsLoadingCategories(true);
import("@/lib/api/tableCategoryValue").then(({ getCategoryValues }) => {
getCategoryValues(component.tableName!, component.columnName!)
// 🆕 menuObjid를 4번째 파라미터로 전달 (카테고리 스코프 적용)
getCategoryValues(component.tableName!, component.columnName!, false, menuObjid)
.then((response) => {
console.log("🔍 [SelectBasic] 카테고리 API 응답:", response);
@ -324,6 +326,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
activeValuesCount: activeValues.length,
options,
sampleOption: options[0],
menuObjid, // 🆕 menuObjid 로깅 추가
});
setCategoryOptions(options);
@ -339,7 +342,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
});
});
}
}, [webType, component.tableName, component.columnName]);
}, [webType, component.tableName, component.columnName, menuObjid]); // 🆕 menuObjid 의존성 추가
// 디버깅: menuObjid가 제대로 전달되는지 확인
useEffect(() => {

View File

@ -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()); // 좌측 항목 변경 시 우측 확장 초기화
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) : "없음",
});
@ -2539,6 +2723,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={{
@ -2551,11 +2763,23 @@ 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 && (
{/* 🆕 현재 활성 탭에 따른 추가 버튼 */}
{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" />
@ -2580,7 +2804,218 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</div>
)}
<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 ? (
// 로딩 중
<div className="flex h-full items-center justify-center">
@ -3032,6 +3467,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</div>
</div>
)}
</>
)}
</CardContent>
</Card>
</div>

View File

@ -41,7 +41,7 @@ import {
Lock,
} from "lucide-react";
import * as XLSX from "xlsx";
import { FileText, ChevronRightIcon } from "lucide-react";
import { FileText, ChevronRightIcon, Search } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
@ -455,6 +455,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 🆕 컬럼 헤더 필터 상태 (상단에서 선언)
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
const [headerLikeFilters, setHeaderLikeFilters] = useState<Record<string, string>>({}); // LIKE 검색용
const [openFilterColumn, setOpenFilterColumn] = useState<string | null>(null);
// 🆕 Filter Builder (고급 필터) 관련 상태 - filteredData보다 먼저 정의해야 함
@ -488,6 +489,22 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
});
}
// 2-1. 🆕 LIKE 검색 필터 적용
if (Object.keys(headerLikeFilters).length > 0) {
result = result.filter((row) => {
return Object.entries(headerLikeFilters).every(([columnName, searchText]) => {
if (!searchText || searchText.trim() === "") return true;
// 여러 가능한 컬럼명 시도
const cellValue = row[columnName] ?? row[columnName.toLowerCase()] ?? row[columnName.toUpperCase()];
const cellStr = cellValue !== null && cellValue !== undefined ? String(cellValue).toLowerCase() : "";
// LIKE 검색 (대소문자 무시)
return cellStr.includes(searchText.toLowerCase());
});
});
}
// 3. 🆕 Filter Builder 적용
if (filterGroups.length > 0) {
result = result.filter((row) => {
@ -541,7 +558,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}
return result;
}, [data, splitPanelPosition, splitPanelContext?.addedItemIds, headerFilters, filterGroups]);
}, [data, splitPanelPosition, splitPanelContext?.addedItemIds, headerFilters, headerLikeFilters, filterGroups]);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
@ -2671,19 +2688,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 (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;
}
}
// null/undefined 처리
if (value === null || value === undefined) {
return "";
return value;
}
return value;
// null/undefined 처리
return "";
});
});
@ -2935,6 +2974,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
headerFilters: Object.fromEntries(
Object.entries(headerFilters).map(([key, set]) => [key, Array.from(set as Set<string>)]),
),
headerLikeFilters, // LIKE 검색 필터 저장
pageSize: localPageSize,
timestamp: Date.now(),
};
@ -2955,6 +2995,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
frozenColumnCount,
showGridLines,
headerFilters,
headerLikeFilters,
localPageSize,
]);
@ -2991,6 +3032,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
});
setHeaderFilters(filters);
}
if (state.headerLikeFilters) {
setHeaderLikeFilters(state.headerLikeFilters);
}
} catch (error) {
console.error("❌ 테이블 상태 복원 실패:", error);
}
@ -5737,7 +5781,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}}
className={cn(
"hover:bg-primary/20 ml-1 rounded p-0.5 transition-colors",
headerFilters[column.columnName]?.size > 0 && "text-primary bg-primary/10",
(headerFilters[column.columnName]?.size > 0 || headerLikeFilters[column.columnName]) && "text-primary bg-primary/10",
)}
title="필터"
>
@ -5745,7 +5789,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
</button>
</PopoverTrigger>
<PopoverContent
className="w-48 p-2"
className="w-56 p-2"
align="start"
onClick={(e) => e.stopPropagation()}
>
@ -5754,16 +5798,42 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<span className="text-xs font-medium">
: {columnLabels[column.columnName] || column.displayName}
</span>
{headerFilters[column.columnName]?.size > 0 && (
{(headerFilters[column.columnName]?.size > 0 || headerLikeFilters[column.columnName]) && (
<button
onClick={() => clearHeaderFilter(column.columnName)}
onClick={() => {
clearHeaderFilter(column.columnName);
setHeaderLikeFilters((prev) => {
const newFilters = { ...prev };
delete newFilters[column.columnName];
return newFilters;
});
}}
className="text-destructive text-xs hover:underline"
>
</button>
)}
</div>
<div className="max-h-48 space-y-1 overflow-y-auto">
{/* LIKE 검색 입력 필드 */}
<div className="relative">
<Search className="text-muted-foreground absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2" />
<input
type="text"
placeholder="검색어 입력 (포함)"
value={headerLikeFilters[column.columnName] || ""}
onChange={(e) => {
setHeaderLikeFilters((prev) => ({
...prev,
[column.columnName]: e.target.value,
}));
}}
className="border-input bg-background placeholder:text-muted-foreground h-7 w-full rounded-md border pl-7 pr-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary"
onClick={(e) => e.stopPropagation()}
/>
</div>
{/* 구분선 */}
<div className="text-muted-foreground border-t pt-2 text-[10px]"> :</div>
<div className="max-h-40 space-y-1 overflow-y-auto">
{columnUniqueValues[column.columnName]?.slice(0, 50).map((val) => {
const isSelected = headerFilters[column.columnName]?.has(val);
return (

View File

@ -709,7 +709,14 @@ export class ButtonActionExecutor {
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> = {};
@ -724,9 +731,12 @@ export class ButtonActionExecutor {
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)) {
@ -736,7 +746,9 @@ export class ButtonActionExecutor {
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);
@ -745,9 +757,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과 컴포넌트 키를 제외한 나머지가 마스터 정보
@ -803,7 +817,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> = {};
@ -813,6 +827,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: 상단 폼에서 수정한 최신 마스터 정보
// itemOnlyData: 품목 고유 필드만 (품번, 품명, 수량 등)
@ -1915,7 +1965,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> = {};
@ -1941,9 +2000,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)) {
@ -1970,6 +2032,8 @@ export class ButtonActionExecutor {
// 오류 시 기존 값 유지
}
}
} else if (isEditModeUniversal) {
console.log("⏭️ [handleUniversalFormModalTableSectionSave] 수정 모드 - 채번 코드 할당 건너뜀 (기존 번호 유지)");
}
console.log("✅ [handleUniversalFormModalTableSectionSave] 채번 규칙 할당 완료");
@ -4737,7 +4801,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;
});
@ -5010,9 +5091,16 @@ export class ButtonActionExecutor {
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];
}
}
filteredRow[label] = value;
}

View File

@ -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,

View File

@ -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>; // 추가 필터 조건
}