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

View File

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

View File

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

View File

@ -921,11 +921,12 @@ export class TableManagementService {
...layout.properties, ...layout.properties,
widgetType: inputType, widgetType: inputType,
inputType: inputType, inputType: inputType,
// componentConfig 내부의 type 업데이트 // componentConfig 내부의 type, inputType, webType 모두 업데이트
componentConfig: { componentConfig: {
...layout.properties?.componentConfig, ...layout.properties?.componentConfig,
type: newComponentType, type: newComponentType,
inputType: inputType, inputType: inputType,
webType: inputType, // 프론트엔드 SelectBasicComponent에서 카테고리 로딩 여부 판단에 사용
}, },
}; };
@ -941,7 +942,7 @@ export class TableManagementService {
); );
logger.info( 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" ? ( {field.inputType === "entity" ? (
<Select <Select
value={masterFieldValues[field.columnName]?.toString() || ""} value={masterFieldValues[field.columnName]?.toString() || ""}
onValueChange={(value) => onValueChange={(value) => {
setMasterFieldValues((prev) => ({ // 선택한 item 찾기
...prev, const selectedItem = entitySearchData[field.columnName]?.find(
[field.columnName]: value, (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,
[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"> <SelectTrigger className="h-9 text-xs">
<SelectValue placeholder={`${field.columnLabel} 선택`} /> <SelectValue placeholder={`${field.columnLabel} 선택`} />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,6 +7,8 @@
import React, { useState, useMemo, useCallback, useEffect, useRef } from "react"; import React, { useState, useMemo, useCallback, useEffect, useRef } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { import {
PivotGridProps, PivotGridProps,
PivotResult, PivotResult,
@ -50,6 +52,10 @@ import {
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; 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"> <div className="flex items-center gap-1">
{row.hasChildren && ( {row.hasChildren && (
<button <button
onClick={() => onToggleExpand(row.path)} onClick={(e) => {
e.stopPropagation();
onToggleExpand(row.path);
}}
className="p-0.5 hover:bg-accent rounded" className="p-0.5 hover:bg-accent rounded"
> >
{row.isExpanded ? ( {row.isExpanded ? (
@ -299,6 +308,8 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
// ==================== 상태 ==================== // ==================== 상태 ====================
const [fields, setFields] = useState<PivotFieldConfig[]>(initialFields); const [fields, setFields] = useState<PivotFieldConfig[]>(initialFields);
// 초기 필드 설정 저장 (초기화용)
const initialFieldsRef = useRef<PivotFieldConfig[]>(initialFields);
const [pivotState, setPivotState] = useState<PivotGridState>({ const [pivotState, setPivotState] = useState<PivotGridState>({
expandedRowPaths: [], expandedRowPaths: [],
expandedColumnPaths: [], expandedColumnPaths: [],
@ -344,41 +355,44 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
const [resizeStartX, setResizeStartX] = useState<number>(0); const [resizeStartX, setResizeStartX] = useState<number>(0);
const [resizeStartWidth, setResizeStartWidth] = 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 stateStorageKey = `pivot-state-${title || "default"}`;
const persistSettingKey = `pivot-persist-${title || "default"}`;
// 상태 저장 (localStorage) // 상태 유지 설정 (체크박스용)
const saveStateToStorage = useCallback(() => { const [persistState, setPersistState] = useState<boolean>(() => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return true;
const stateToSave = { const saved = localStorage.getItem(persistSettingKey);
fields, return saved !== null ? saved === "true" : true; // 기본값 true
pivotState, });
sortConfig,
columnWidths,
};
localStorage.setItem(stateStorageKey, JSON.stringify(stateToSave));
}, [fields, pivotState, sortConfig, columnWidths, stateStorageKey]);
// 상태 복원 (localStorage) - 프로덕션 안전성 강화 // 복원 완료 여부 (initialFields 덮어쓰기 방지)
const [isStateRestored, setIsStateRestored] = useState(false);
// 상태 복원 (localStorage) - 마운트 시 한 번만 실행
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
// 상태 유지가 꺼져 있으면 복원하지 않음
if (!persistState) {
localStorage.removeItem(stateStorageKey);
setIsStateRestored(true);
return;
}
try { try {
const savedState = localStorage.getItem(stateStorageKey); const savedState = localStorage.getItem(stateStorageKey);
if (!savedState) return; if (!savedState) {
setIsStateRestored(true);
return;
}
const parsed = JSON.parse(savedState); const parsed = JSON.parse(savedState);
// 버전 체크 - 버전이 다르면 이전 상태 무시 // 버전 체크 - 버전이 다르면 이전 상태 무시
if (parsed.version !== PIVOT_STATE_VERSION) { if (parsed.version !== PIVOT_STATE_VERSION) {
localStorage.removeItem(stateStorageKey); localStorage.removeItem(stateStorageKey);
setIsStateRestored(true);
return; return;
} }
@ -424,7 +438,72 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
// 손상된 상태는 제거 // 손상된 상태는 제거
localStorage.removeItem(stateStorageKey); 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 || []; const data = externalData || [];
@ -500,9 +579,9 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
if (!data || data.length === 0) return data; if (!data || data.length === 0) return data;
// 필터 영역의 필드들로 데이터 필터링 // 모든 영역(행/열/필터)의 필터 값이 있는 필드로 데이터 필터링
const activeFilters = fields.filter( 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; if (activeFilters.length === 0) return data;
@ -1129,6 +1208,7 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
onFieldsChange={handleFieldsChange} onFieldsChange={handleFieldsChange}
collapsed={!showFieldPanel} collapsed={!showFieldPanel}
onToggleCollapse={() => setShowFieldPanel(!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} onFieldsChange={handleFieldsChange}
collapsed={!showFieldPanel} collapsed={!showFieldPanel}
onToggleCollapse={() => setShowFieldPanel(!showFieldPanel)} onToggleCollapse={() => setShowFieldPanel(!showFieldPanel)}
initialFields={initialFieldsRef.current}
/> />
{/* 헤더 툴바 */} {/* 헤더 툴바 */}
@ -1466,6 +1547,22 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
</Button> </Button>
</> </>
)} )}
{/* 상태 유지 체크박스 */}
<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 && ( {chartConfig && (
@ -1674,144 +1771,235 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
className="flex-1 overflow-auto focus:outline-none" className="flex-1 overflow-auto focus:outline-none"
style={{ style={{
maxHeight: enableVirtualScroll && containerHeight > 0 ? containerHeight : undefined, 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} tabIndex={0}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
> >
<table ref={tableRef} className="w-full border-collapse"> <table ref={tableRef} className="w-full border-collapse">
<thead> <thead>
{/* 열 헤더 */} {/* 다중 행 열 헤더 */}
<tr className="bg-background"> {columnHeaderLevels.length > 0 ? (
{/* 좌상단 코너 (행 필드 라벨 + 필터) */} // 열 필드가 있는 경우: 각 레벨별로 행 생성
<th columnHeaderLevels.map((levelCells, levelIdx) => (
className={cn( <tr key={`col-level-${levelIdx}`} className="bg-background">
"border-r border-b border-border", {/* 좌상단 코너 (첫 번째 레벨에만 표시) */}
"px-2 py-1 text-left text-xs font-medium", {levelIdx === 0 && (
"bg-background sticky left-0 top-0 z-20" <th
)} className={cn(
rowSpan={columnFields.length > 0 ? 2 : 1} "border-r border-b border-border",
> "px-2 py-1 text-left text-xs font-medium",
<div className="flex items-center gap-1 flex-wrap"> "bg-background sticky left-0 top-0 z-20"
{rowFields.map((f, idx) => ( )}
<div key={f.field} className="flex items-center gap-0.5 group"> rowSpan={columnHeaderLevels.length + (dataFields.length > 1 ? 1 : 0)}
<span>{f.caption}</span> >
<FilterPopup <div className="flex items-center gap-1 flex-wrap">
field={f} {rowFields.map((f, idx) => (
data={data} <div key={f.field} className="flex items-center gap-0.5 group">
onFilterChange={(field, values, type) => { <span>{f.caption}</span>
const newFields = fields.map((fld) => <FilterPopup
fld.field === field.field && fld.area === "row" field={f}
? { ...fld, filterValues: values, filterType: type } data={data}
: fld onFilterChange={(field, values, type) => {
); const newFields = fields.map((fld) =>
handleFieldsChange(newFields); fld.field === field.field && fld.area === "row"
}} ? { ...fld, filterValues: values, filterType: type }
trigger={ : fld
<button );
className={cn( handleFieldsChange(newFields);
"p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity", }}
"hover:bg-accent", trigger={
f.filterValues && f.filterValues.length > 0 && "opacity-100 text-primary" <button
)} className={cn(
> "p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity",
<Filter className="h-3 w-3" /> "hover:bg-accent",
</button> f.filterValues && f.filterValues.length > 0 && "opacity-100 text-primary"
} )}
/> >
{idx < rowFields.length - 1 && <span className="mx-0.5 text-muted-foreground">/</span>} <Filter className="h-3 w-3" />
</div> </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>
))} ))}
{rowFields.length === 0 && <span></span>}
</div>
</th>
{/* 열 헤더 셀 */} {/* 행 총계 헤더 (첫 번째 레벨에만 표시) */}
{flatColumns.map((col, idx) => ( {levelIdx === 0 && totals?.showRowGrandTotals && (
<th <th
key={idx} className={cn(
className={cn( "border-b border-border",
"border-r border-b border-border relative group", "px-2 py-1 text-center text-xs font-medium",
"px-2 py-1 text-center text-xs font-medium", "bg-background sticky top-0 z-10"
"bg-background sticky top-0 z-10", )}
dataFields.length === 1 && "cursor-pointer hover:bg-accent/50" colSpan={dataFields.length || 1}
rowSpan={columnHeaderLevels.length + (dataFields.length > 1 ? 1 : 0)}
>
</th>
)} )}
colSpan={dataFields.length || 1}
style={{ width: columnWidths[idx] || "auto", minWidth: 50 }} {/* 열 필드 필터 (첫 번째 레벨에만 표시) */}
onClick={dataFields.length === 1 ? () => handleSort(dataFields[0].field) : undefined} {levelIdx === 0 && columnFields.length > 0 && (
> <th
<div className="flex items-center justify-center gap-1"> className={cn(
<span>{col.caption || "(전체)"}</span> "border-b border-border",
{dataFields.length === 1 && <SortIcon field={dataFields[0].field} />} "px-1 py-1 text-center text-xs",
</div> "bg-background sticky top-0 z-10"
{/* 열 리사이즈 핸들 */} )}
<div rowSpan={columnHeaderLevels.length + (dataFields.length > 1 ? 1 : 0)}
className={cn( >
"absolute right-0 top-0 bottom-0 w-1 cursor-col-resize", <div className="flex flex-col gap-0.5">
"hover:bg-primary/50 transition-colors", {columnFields.map((f) => (
resizingColumn === idx && "bg-primary" <FilterPopup
)} key={f.field}
onMouseDown={(e) => handleResizeStart(idx, e)} field={f}
/> data={data}
</th> onFilterChange={(field, values, type) => {
))} const newFields = fields.map((fld) =>
fld.field === field.field && fld.area === "column"
{/* 행 총계 헤더 */} ? { ...fld, filterValues: values, filterType: type }
{totals?.showRowGrandTotals && ( : fld
<th );
className={cn( handleFieldsChange(newFields);
"border-b border-border", }}
"px-2 py-1 text-center text-xs font-medium", trigger={
"bg-background sticky top-0 z-10" <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>
)} )}
colSpan={dataFields.length || 1} </tr>
rowSpan={dataFields.length > 1 ? 2 : 1} ))
> ) : (
// 열 필드가 없는 경우: 단일 행
</th> <tr className="bg-background">
)}
{/* 열 필드 필터 (헤더 오른쪽 끝에 표시) */}
{columnFields.length > 0 && (
<th <th
className={cn( className={cn(
"border-b border-border", "border-r border-b border-border",
"px-1 py-1 text-center text-xs", "px-2 py-1 text-left text-xs font-medium",
"bg-background sticky top-0 z-10" "bg-background sticky left-0 top-0 z-20"
)} )}
rowSpan={dataFields.length > 1 ? 2 : 1} rowSpan={dataFields.length > 1 ? 2 : 1}
> >
<div className="flex flex-col gap-0.5"> <div className="flex items-center gap-1 flex-wrap">
{columnFields.map((f) => ( {rowFields.map((f, idx) => (
<FilterPopup <div key={f.field} className="flex items-center gap-0.5 group">
key={f.field} <span>{f.caption}</span>
field={f} <FilterPopup
data={data} field={f}
onFilterChange={(field, values, type) => { data={data}
const newFields = fields.map((fld) => onFilterChange={(field, values, type) => {
fld.field === field.field && fld.area === "column" const newFields = fields.map((fld) =>
? { ...fld, filterValues: values, filterType: type } fld.field === field.field && fld.area === "row"
: fld ? { ...fld, filterValues: values, filterType: type }
); : fld
handleFieldsChange(newFields); );
}} handleFieldsChange(newFields);
trigger={ }}
<button trigger={
className={cn( <button
"p-0.5 rounded hover:bg-accent", className={cn(
f.filterValues && f.filterValues.length > 0 && "text-primary" "p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity",
)} "hover:bg-accent",
title={`${f.caption} 필터`} f.filterValues && f.filterValues.length > 0 && "opacity-100 text-primary"
> )}
<Filter className="h-3 w-3" /> >
</button> <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> </div>
</th> </th>
)}
</tr> {/* 열 헤더 셀 (열 필드 없을 때) */}
{flatColumns.map((col, idx) => (
<th
key={idx}
className={cn(
"border-r border-b border-border relative group",
"px-2 py-1 text-center text-xs font-medium",
"bg-background sticky top-0 z-10",
dataFields.length === 1 && "cursor-pointer hover:bg-accent/50"
)}
colSpan={dataFields.length || 1}
style={{ width: columnWidths[idx] || "auto", minWidth: 50 }}
onClick={dataFields.length === 1 ? () => handleSort(dataFields[0].field) : undefined}
>
<div className="flex items-center justify-center gap-1">
<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",
"hover:bg-primary/50 transition-colors",
resizingColumn === idx && "bg-primary"
)}
onMouseDown={(e) => handleResizeStart(idx, e)}
/>
</th>
))}
{/* 행 총계 헤더 */}
{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={dataFields.length > 1 ? 2 : 1}
>
</th>
)}
</tr>
)}
{/* 데이터 필드 라벨 (다중 데이터 필드인 경우) */} {/* 데이터 필드 라벨 (다중 데이터 필드인 경우) */}
{dataFields.length > 1 && ( {dataFields.length > 1 && (

View File

@ -16,6 +16,7 @@ import {
PivotAreaType, PivotAreaType,
AggregationType, AggregationType,
FieldDataType, FieldDataType,
DateGroupInterval,
} from "./types"; } from "./types";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -202,6 +203,28 @@ const AreaDropZone: React.FC<AreaDropZoneProps> = ({
</Select> </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 <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@ -295,7 +318,8 @@ export const PivotGridConfigPanel: React.FC<PivotGridConfigPanelProps> = ({
const mappedColumns: ColumnInfo[] = columnList.map((c: any) => ({ const mappedColumns: ColumnInfo[] = columnList.map((c: any) => ({
column_name: c.columnName || c.column_name, column_name: c.columnName || c.column_name,
data_type: c.dataType || c.data_type || "text", 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); setColumns(mappedColumns);
} catch (error) { } catch (error) {

View File

@ -37,6 +37,12 @@ import {
BarChart3, BarChart3,
GripVertical, GripVertical,
ChevronDown, ChevronDown,
RotateCcw,
FilterX,
LayoutGrid,
Trash2,
Calendar,
Check,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -56,6 +62,8 @@ interface FieldPanelProps {
onFieldSettingsChange?: (field: PivotFieldConfig) => void; onFieldSettingsChange?: (field: PivotFieldConfig) => void;
collapsed?: boolean; collapsed?: boolean;
onToggleCollapse?: () => void; onToggleCollapse?: () => void;
/** 초기 필드 설정 (필드 배치 초기화용) */
initialFields?: PivotFieldConfig[];
} }
interface FieldChipProps { interface FieldChipProps {
@ -123,15 +131,33 @@ const SortableFieldChip: React.FC<FieldChipProps> = ({
transition, 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 ( return (
<div <div
ref={setNodeRef} ref={setNodeRef}
style={style} style={style}
className={cn( className={cn(
"inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs", "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", "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" /> <GripVertical className="h-3 w-3" />
</button> </button>
{/* 필터 아이콘 (필터 적용 시) */}
{hasFilter && (
<Filter className="h-3 w-3 text-primary" />
)}
{/* 필드 라벨 */} {/* 필드 라벨 */}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button className="flex items-center gap-1 hover:text-primary"> <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 && ( {field.area === "data" && field.summaryType && (
<span className="text-muted-foreground"> <span className="text-muted-foreground">
({getSummaryLabel(field.summaryType)}) ({getSummaryLabel(field.summaryType)})
@ -197,6 +242,59 @@ const SortableFieldChip: React.FC<FieldChipProps> = ({
<DropdownMenuSeparator /> <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 <DropdownMenuItem
onClick={() => onClick={() =>
onSettingsChange?.({ onSettingsChange?.({
@ -208,6 +306,19 @@ const SortableFieldChip: React.FC<FieldChipProps> = ({
{field.sortOrder === "asc" ? "내림차순 정렬" : "오름차순 정렬"} {field.sortOrder === "asc" ? "내림차순 정렬" : "오름차순 정렬"}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{/* 필터 초기화 (필터가 적용된 경우에만 표시) */}
{hasFilter && (
<>
<DropdownMenuItem
onClick={() => onSettingsChange?.({ ...field, filterValues: [] })}
className="text-orange-600"
>
<Filter className="h-3 w-3 mr-2" />
({filterCount} )
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem <DropdownMenuItem
onClick={() => onSettingsChange?.({ ...field, visible: false })} onClick={() => onSettingsChange?.({ ...field, visible: false })}
> >
@ -326,10 +437,73 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
onFieldSettingsChange, onFieldSettingsChange,
collapsed = false, collapsed = false,
onToggleCollapse, onToggleCollapse,
initialFields,
}) => { }) => {
const [activeId, setActiveId] = useState<string | null>(null); const [activeId, setActiveId] = useState<string | null>(null);
const [overArea, setOverArea] = useState<PivotAreaType | 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( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
activationConstraint: { activationConstraint: {
@ -576,19 +750,60 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
/> />
</div> </div>
{/* 접기 버튼 */} {/* 하단 버튼 영역 */}
{onToggleCollapse && ( <div className="flex items-center justify-between mt-1.5">
<div className="flex justify-center 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 && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={onToggleCollapse} onClick={onToggleCollapse}
className="text-xs h-5 px-2" className="text-xs h-6 px-2"
> >
</Button> </Button>
</div> )}
)} </div>
</div> </div>
{/* 드래그 오버레이 */} {/* 드래그 오버레이 */}

View File

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

View File

@ -10,6 +10,7 @@ import {
PivotFlatRow, PivotFlatRow,
PivotFlatColumn, PivotFlatColumn,
PivotCellValue, PivotCellValue,
PivotColumnHeaderCell,
DateGroupInterval, DateGroupInterval,
AggregationType, AggregationType,
SummaryDisplayMode, SummaryDisplayMode,
@ -76,6 +77,31 @@ export function pathToKey(path: string[]): string {
return path.join("||"); 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, caption: key,
level: 0, level: 0,
isExpanded: expandedPaths.has(pathKey), isExpanded: expandedPaths.has(pathKey),
hasChildren: remainingFields.length > 0, // 다음 레벨 필드가 있으면 자식 있음
path: path, path: path,
span: 1, span: 1,
}; };
@ -195,6 +222,7 @@ function buildChildNodes(
caption: key, caption: key,
level: level, level: level,
isExpanded: expandedPaths.has(pathKey), isExpanded: expandedPaths.has(pathKey),
hasChildren: remainingFields.length > 0, // 다음 레벨 필드가 있으면 자식 있음
path: path, path: path,
span: 1, span: 1,
}; };
@ -238,7 +266,7 @@ function flattenRows(nodes: PivotHeaderNode[]): PivotFlatRow[] {
level: node.level, level: node.level,
caption: node.caption, caption: node.caption,
isExpanded: node.isExpanded, isExpanded: node.isExpanded,
hasChildren: !!(node.children && node.children.length > 0), hasChildren: node.hasChildren, // 노드에서 직접 가져옴 (다음 레벨 필드 존재 여부 기준)
}); });
if (node.isExpanded && node.children) { if (node.isExpanded && node.children) {
@ -324,6 +352,66 @@ function getMaxColumnLevel(
return Math.min(maxLevel, totalFields - 1); 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)); uniqueValues.forEach((val) => expandedRowSet.add(val));
} }
if (expandedColumnPaths.length === 0 && columnFields.length > 0) { // 열은 항상 전체 확장 (열 헤더는 확장/축소 UI가 없음)
const firstField = columnFields[0]; // 모든 가능한 열 경로를 확장 상태로 설정
const uniqueValues = new Set( if (columnFields.length > 0) {
filteredData.map((row) => getFieldValue(row, firstField)) const allColumnPaths = generateAllPaths(filteredData, columnFields);
); allColumnPaths.forEach((pathKey) => expandedColSet.add(pathKey));
uniqueValues.forEach((val) => expandedColSet.add(val));
} }
// 헤더 트리 생성 // 헤더 트리 생성
@ -786,6 +873,12 @@ export function processPivotData(
grandTotals.grand grandTotals.grand
); );
// 다중 행 열 헤더 생성
const columnHeaderLevels = buildColumnHeaderLevels(
columnHeaders,
columnFields.length
);
return { return {
rowHeaders, rowHeaders,
columnHeaders, columnHeaders,
@ -797,6 +890,7 @@ export function processPivotData(
caption: path[path.length - 1] || "", caption: path[path.length - 1] || "",
span: 1, span: 1,
})), })),
columnHeaderLevels,
grandTotals, grandTotals,
}; };
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -33,6 +33,7 @@ import {
DialogDescription, DialogDescription,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useTableOptions } from "@/contexts/TableOptionsContext"; import { useTableOptions } from "@/contexts/TableOptionsContext";
import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-options"; import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-options";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
@ -171,6 +172,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const [rightSearchQuery, setRightSearchQuery] = useState(""); const [rightSearchQuery, setRightSearchQuery] = useState("");
const [isLoadingLeft, setIsLoadingLeft] = useState(false); const [isLoadingLeft, setIsLoadingLeft] = useState(false);
const [isLoadingRight, setIsLoadingRight] = useState(false); const [isLoadingRight, setIsLoadingRight] = useState(false);
// 🆕 추가 탭 관련 상태
const [activeTabIndex, setActiveTabIndex] = useState(0); // 0 = 기본 탭 (우측 패널), 1+ = 추가 탭
const [tabsData, setTabsData] = useState<Record<number, any[]>>({}); // 탭별 데이터 캐시
const [tabsLoading, setTabsLoading] = useState<Record<number, boolean>>({}); // 탭별 로딩 상태
const [rightTableColumns, setRightTableColumns] = useState<any[]>([]); // 우측 테이블 컬럼 정보 const [rightTableColumns, setRightTableColumns] = useState<any[]>([]); // 우측 테이블 컬럼 정보
const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들 const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들
const [leftColumnLabels, setLeftColumnLabels] = useState<Record<string, string>>({}); // 좌측 컬럼 라벨 const [leftColumnLabels, setLeftColumnLabels] = useState<Record<string, string>>({}); // 좌측 컬럼 라벨
@ -917,11 +924,11 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const { entityJoinApi } = await import("@/lib/api/entityJoin"); const { entityJoinApi } = await import("@/lib/api/entityJoin");
// 복합키 조건 생성 // 복합키 조건 생성
// 🔧 관계 필터링은 정확한 값 매칭이 필요하므로 equals 연산자 사용 // 🔧 entity 타입 컬럼은 코드 값으로 정확히 매칭해야 하므로 operator: 'equals' 사용
// (entity 타입 컬럼의 경우 기본 contains 연산자가 참조 테이블의 표시 컬럼으로 검색하여 실패함)
const searchConditions: Record<string, any> = {}; const searchConditions: Record<string, any> = {};
keys.forEach((key) => { keys.forEach((key) => {
if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) { if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) {
// 연결 필터는 정확한 값 매칭이 필요하므로 equals 연산자 사용
searchConditions[key.rightColumn] = { searchConditions[key.rightColumn] = {
value: leftItem[key.leftColumn], value: leftItem[key.leftColumn],
operator: "equals", operator: "equals",
@ -1006,12 +1013,145 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
], ],
); );
// 🆕 추가 탭 데이터 로딩 함수
const loadTabData = useCallback(
async (tabIndex: number, leftItem: any) => {
const tabConfig = componentConfig.rightPanel?.additionalTabs?.[tabIndex - 1];
if (!tabConfig || !leftItem || isDesignMode) return;
const tabTableName = tabConfig.tableName;
if (!tabTableName) return;
setTabsLoading((prev) => ({ ...prev, [tabIndex]: true }));
try {
// 조인 키 확인
const keys = tabConfig.relation?.keys;
const leftColumn = tabConfig.relation?.leftColumn || keys?.[0]?.leftColumn;
const rightColumn = tabConfig.relation?.foreignKey || keys?.[0]?.rightColumn;
let resultData: any[] = [];
if (leftColumn && rightColumn) {
// 조인 조건이 있는 경우
const { entityJoinApi } = await import("@/lib/api/entityJoin");
const searchConditions: Record<string, any> = {};
if (keys && keys.length > 0) {
// 복합키
// 🔧 entity 타입 컬럼은 코드 값으로 정확히 매칭해야 하므로 operator: 'equals' 사용
keys.forEach((key) => {
if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) {
searchConditions[key.rightColumn] = {
value: leftItem[key.leftColumn],
operator: "equals",
};
}
});
} else {
// 단일키
// 🔧 entity 타입 컬럼은 코드 값으로 정확히 매칭해야 하므로 operator: 'equals' 사용
const leftValue = leftItem[leftColumn];
if (leftValue !== undefined) {
searchConditions[rightColumn] = {
value: leftValue,
operator: "equals",
};
}
}
console.log(`🔗 [추가탭 ${tabIndex}] 조회 조건:`, searchConditions);
const result = await entityJoinApi.getTableDataWithJoins(tabTableName, {
search: searchConditions,
enableEntityJoin: true,
size: 1000,
});
resultData = result.data || [];
} else {
// 조인 조건이 없는 경우: 전체 데이터 조회 (독립 탭)
const { entityJoinApi } = await import("@/lib/api/entityJoin");
const result = await entityJoinApi.getTableDataWithJoins(tabTableName, {
enableEntityJoin: true,
size: 1000,
});
resultData = result.data || [];
}
// 데이터 필터 적용
const dataFilter = tabConfig.dataFilter;
if (dataFilter?.enabled && dataFilter.conditions?.length > 0) {
resultData = resultData.filter((item: any) => {
return dataFilter.conditions.every((cond: any) => {
const value = item[cond.column];
const condValue = cond.value;
switch (cond.operator) {
case "equals":
return value === condValue;
case "notEquals":
return value !== condValue;
case "contains":
return String(value).includes(String(condValue));
default:
return true;
}
});
});
}
// 중복 제거 적용
const deduplication = tabConfig.deduplication;
if (deduplication?.enabled && deduplication.groupByColumn) {
const groupedMap = new Map<string, any>();
resultData.forEach((item) => {
const key = String(item[deduplication.groupByColumn] || "");
const existing = groupedMap.get(key);
if (!existing) {
groupedMap.set(key, item);
} else {
// keepStrategy에 따라 유지할 항목 결정
const sortCol = deduplication.sortColumn || "start_date";
const existingVal = existing[sortCol];
const newVal = item[sortCol];
if (deduplication.keepStrategy === "latest" && newVal > existingVal) {
groupedMap.set(key, item);
} else if (deduplication.keepStrategy === "earliest" && newVal < existingVal) {
groupedMap.set(key, item);
}
}
});
resultData = Array.from(groupedMap.values());
}
console.log(`🔗 [추가탭 ${tabIndex}] 결과 데이터:`, resultData.length);
setTabsData((prev) => ({ ...prev, [tabIndex]: resultData }));
} catch (error) {
console.error(`추가탭 ${tabIndex} 데이터 로드 실패:`, error);
toast({
title: "데이터 로드 실패",
description: `탭 데이터를 불러올 수 없습니다.`,
variant: "destructive",
});
} finally {
setTabsLoading((prev) => ({ ...prev, [tabIndex]: false }));
}
},
[componentConfig.rightPanel?.additionalTabs, isDesignMode, toast],
);
// 좌측 항목 선택 핸들러 // 좌측 항목 선택 핸들러
const handleLeftItemSelect = useCallback( const handleLeftItemSelect = useCallback(
(item: any) => { (item: any) => {
setSelectedLeftItem(item); setSelectedLeftItem(item);
setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화 setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화
loadRightData(item); setTabsData({}); // 🆕 모든 탭 데이터 초기화
// 🆕 현재 활성 탭에 따라 데이터 로드
if (activeTabIndex === 0) {
loadRightData(item);
} else {
loadTabData(activeTabIndex, item);
}
// 🆕 modalDataStore에 선택된 좌측 항목 저장 (단일 선택) // 🆕 modalDataStore에 선택된 좌측 항목 저장 (단일 선택)
const leftTableName = componentConfig.leftPanel?.tableName; const leftTableName = componentConfig.leftPanel?.tableName;
@ -1022,7 +1162,30 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
}); });
} }
}, },
[loadRightData, componentConfig.leftPanel?.tableName, isDesignMode], [loadRightData, loadTabData, activeTabIndex, componentConfig.leftPanel?.tableName, isDesignMode],
);
// 🆕 탭 변경 핸들러
const handleTabChange = useCallback(
(newTabIndex: number) => {
setActiveTabIndex(newTabIndex);
// 선택된 좌측 항목이 있으면 해당 탭의 데이터 로드
if (selectedLeftItem) {
if (newTabIndex === 0) {
// 기본 탭: 우측 패널 데이터가 없으면 로드
if (!rightData || (Array.isArray(rightData) && rightData.length === 0)) {
loadRightData(selectedLeftItem);
}
} else {
// 추가 탭: 해당 탭 데이터가 없으면 로드
if (!tabsData[newTabIndex]) {
loadTabData(newTabIndex, selectedLeftItem);
}
}
}
},
[selectedLeftItem, rightData, tabsData, loadRightData, loadTabData],
); );
// 우측 항목 확장/축소 토글 // 우측 항목 확장/축소 토글
@ -1427,21 +1590,40 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 커스텀 모달 화면 열기 // 커스텀 모달 화면 열기
const rightTableName = componentConfig.rightPanel?.tableName || ""; const rightTableName = componentConfig.rightPanel?.tableName || "";
// Primary Key 찾기 (우선순위: id > ID > 첫 번째 필드) // Primary Key 찾기 (우선순위: 설정값 > id > ID > non-null 필드)
// 🔧 설정에서 primaryKeyColumn 지정 가능
const configuredPrimaryKey = componentConfig.rightPanel?.editButton?.primaryKeyColumn;
let primaryKeyName = "id"; let primaryKeyName = "id";
let primaryKeyValue: any; let primaryKeyValue: any;
if (item.id !== undefined && item.id !== null) { if (configuredPrimaryKey && item[configuredPrimaryKey] !== undefined && item[configuredPrimaryKey] !== null) {
// 설정된 Primary Key 사용
primaryKeyName = configuredPrimaryKey;
primaryKeyValue = item[configuredPrimaryKey];
} else if (item.id !== undefined && item.id !== null) {
primaryKeyName = "id"; primaryKeyName = "id";
primaryKeyValue = item.id; primaryKeyValue = item.id;
} else if (item.ID !== undefined && item.ID !== null) { } else if (item.ID !== undefined && item.ID !== null) {
primaryKeyName = "ID"; primaryKeyName = "ID";
primaryKeyValue = item.ID; primaryKeyValue = item.ID;
} else { } else {
// 첫 번째 필드를 Primary Key로 간주 // 🔧 첫 번째 non-null 필드를 Primary Key로 간주
const firstKey = Object.keys(item)[0]; const keys = Object.keys(item);
primaryKeyName = firstKey; let found = false;
primaryKeyValue = item[firstKey]; for (const key of keys) {
if (item[key] !== undefined && item[key] !== null) {
primaryKeyName = key;
primaryKeyValue = item[key];
found = true;
break;
}
}
// 모든 필드가 null이면 첫 번째 필드 사용
if (!found && keys.length > 0) {
primaryKeyName = keys[0];
primaryKeyValue = item[keys[0]];
}
} }
console.log("✅ 수정 모달 열기:", { console.log("✅ 수정 모달 열기:", {
@ -1466,7 +1648,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
hasGroupByColumns: groupByColumns.length > 0, hasGroupByColumns: groupByColumns.length > 0,
}); });
// ScreenModal 열기 이벤트 발생 (URL 파라미터로 ID + groupByColumns 전달) // ScreenModal 열기 이벤트 발생 (URL 파라미터로 ID + groupByColumns + primaryKeyColumn 전달)
window.dispatchEvent( window.dispatchEvent(
new CustomEvent("openScreenModal", { new CustomEvent("openScreenModal", {
detail: { detail: {
@ -1475,6 +1657,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
mode: "edit", mode: "edit",
editId: primaryKeyValue, editId: primaryKeyValue,
tableName: rightTableName, tableName: rightTableName,
primaryKeyColumn: primaryKeyName, // 🆕 Primary Key 컬럼명 전달
...(groupByColumns.length > 0 && { ...(groupByColumns.length > 0 && {
groupByColumns: JSON.stringify(groupByColumns), groupByColumns: JSON.stringify(groupByColumns),
}), }),
@ -1487,6 +1670,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
screenId: modalScreenId, screenId: modalScreenId,
editId: primaryKeyValue, editId: primaryKeyValue,
tableName: rightTableName, tableName: rightTableName,
primaryKeyColumn: primaryKeyName,
groupByColumns: groupByColumns.length > 0 ? JSON.stringify(groupByColumns) : "없음", groupByColumns: groupByColumns.length > 0 ? JSON.stringify(groupByColumns) : "없음",
}); });
@ -2539,6 +2723,34 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
className="flex flex-shrink-0 flex-col" className="flex flex-shrink-0 flex-col"
> >
<Card className="flex flex-col border-0 shadow-none" style={{ height: "100%" }}> <Card className="flex flex-col border-0 shadow-none" style={{ height: "100%" }}>
{/* 🆕 탭 바 (추가 탭이 있을 때만 표시) */}
{(componentConfig.rightPanel?.additionalTabs?.length || 0) > 0 && (
<div className="flex-shrink-0 border-b">
<Tabs
value={String(activeTabIndex)}
onValueChange={(value) => handleTabChange(Number(value))}
className="w-full"
>
<TabsList className="h-9 w-full justify-start rounded-none border-b-0 bg-transparent p-0 px-2">
<TabsTrigger
value="0"
className="h-8 rounded-none border-b-2 border-transparent px-4 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
>
{componentConfig.rightPanel?.title || "기본"}
</TabsTrigger>
{componentConfig.rightPanel?.additionalTabs?.map((tab, index) => (
<TabsTrigger
key={tab.tabId}
value={String(index + 1)}
className="h-8 rounded-none border-b-2 border-transparent px-4 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
>
{tab.label || `${index + 1}`}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
)}
<CardHeader <CardHeader
className="flex-shrink-0 border-b" className="flex-shrink-0 border-b"
style={{ style={{
@ -2551,16 +2763,28 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
> >
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
<CardTitle className="text-base font-semibold"> <CardTitle className="text-base font-semibold">
{componentConfig.rightPanel?.title || "우측 패널"} {activeTabIndex === 0
? componentConfig.rightPanel?.title || "우측 패널"
: componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]?.title ||
componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]?.label ||
"우측 패널"}
</CardTitle> </CardTitle>
{!isDesignMode && ( {!isDesignMode && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{componentConfig.rightPanel?.showAdd && ( {/* 🆕 현재 활성 탭에 따른 추가 버튼 */}
<Button size="sm" variant="outline" onClick={() => handleAddClick("right")}> {activeTabIndex === 0
<Plus className="mr-1 h-4 w-4" /> ? componentConfig.rightPanel?.showAdd && (
<Button size="sm" variant="outline" onClick={() => handleAddClick("right")}>
</Button> <Plus className="mr-1 h-4 w-4" />
)}
</Button>
)
: componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]?.showAdd && (
<Button size="sm" variant="outline" onClick={() => handleAddClick("right")}>
<Plus className="mr-1 h-4 w-4" />
</Button>
)}
{/* 우측 패널 수정/삭제는 각 카드에서 처리 */} {/* 우측 패널 수정/삭제는 각 카드에서 처리 */}
</div> </div>
)} )}
@ -2580,20 +2804,231 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</div> </div>
)} )}
<CardContent className="flex-1 overflow-auto p-4"> <CardContent className="flex-1 overflow-auto p-4">
{/* 우측 데이터 */} {/* 🆕 추가 탭 데이터 렌더링 */}
{isLoadingRight ? ( {activeTabIndex > 0 ? (
// 로딩 중 // 추가 탭 컨텐츠
<div className="flex h-full items-center justify-center"> (() => {
<div className="text-center"> const currentTabConfig = componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1];
<Loader2 className="text-primary mx-auto h-8 w-8 animate-spin" /> const currentTabData = tabsData[activeTabIndex] || [];
<p className="text-muted-foreground mt-2 text-sm"> ...</p> const isTabLoading = tabsLoading[activeTabIndex];
</div>
</div> if (isTabLoading) {
) : rightData ? ( return (
// 실제 데이터 표시 <div className="flex h-full items-center justify-center">
Array.isArray(rightData) ? ( <div className="text-center">
// 조인 모드: 여러 데이터를 테이블/리스트로 표시 <Loader2 className="text-primary mx-auto h-8 w-8 animate-spin" />
(() => { <p className="text-muted-foreground mt-2 text-sm"> ...</p>
</div>
</div>
);
}
if (!selectedLeftItem) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm"> </p>
</div>
);
}
if (currentTabData.length === 0) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm"> </p>
</div>
);
}
// 탭 데이터 렌더링 (목록/테이블 모드)
const isTableMode = currentTabConfig?.displayMode === "table";
if (isTableMode) {
// 테이블 모드
const displayColumns = currentTabConfig?.columns || [];
const columnsToShow =
displayColumns.length > 0
? displayColumns.map((col) => ({
...col,
label: col.label || col.name,
}))
: Object.keys(currentTabData[0] || {})
.filter(shouldShowField)
.slice(0, 8)
.map((key) => ({ name: key, label: key }));
return (
<div className="overflow-auto rounded-lg border">
<table className="w-full text-sm">
<thead className="bg-muted/50 sticky top-0">
<tr>
{columnsToShow.map((col: any) => (
<th
key={col.name}
className="px-3 py-2 text-left font-medium"
style={{ width: col.width ? `${col.width}px` : "auto" }}
>
{col.label}
</th>
))}
{(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && (
<th className="w-20 px-3 py-2 text-center font-medium"></th>
)}
</tr>
</thead>
<tbody>
{currentTabData.map((item: any, idx: number) => (
<tr key={item.id || idx} className="hover:bg-muted/30 border-t">
{columnsToShow.map((col: any) => (
<td key={col.name} className="px-3 py-2">
{formatCellValue(col.name, item[col.name], {}, col.format)}
</td>
))}
{(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && (
<td className="px-3 py-2 text-center">
<div className="flex items-center justify-center gap-1">
{currentTabConfig?.showEdit && (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => handleEditClick("right", item)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
)}
{currentTabConfig?.showDelete && (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-red-500 hover:text-red-600"
onClick={() => handleDeleteClick("right", item)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
);
} else {
// 목록 (카드) 모드
const displayColumns = currentTabConfig?.columns || [];
const summaryCount = currentTabConfig?.summaryColumnCount ?? 3;
const showLabel = currentTabConfig?.summaryShowLabel ?? true;
return (
<div className="space-y-2">
{currentTabData.map((item: any, idx: number) => {
const itemId = item.id || idx;
const isExpanded = expandedRightItems.has(itemId);
// 표시할 컬럼 결정
const columnsToShow =
displayColumns.length > 0
? displayColumns
: Object.keys(item)
.filter(shouldShowField)
.slice(0, 8)
.map((key) => ({ name: key, label: key }));
const summaryColumns = columnsToShow.slice(0, summaryCount);
const detailColumns = columnsToShow.slice(summaryCount);
return (
<div key={itemId} className="rounded-lg border bg-white p-3">
<div
className="flex cursor-pointer items-start justify-between"
onClick={() => toggleRightItemExpansion(itemId)}
>
<div className="flex-1">
<div className="flex flex-wrap gap-x-4 gap-y-1">
{summaryColumns.map((col: any) => (
<div key={col.name} className="text-sm">
{showLabel && (
<span className="text-muted-foreground mr-1">{col.label}:</span>
)}
<span className={col.bold ? "font-semibold" : ""}>
{formatCellValue(col.name, item[col.name], {}, col.format)}
</span>
</div>
))}
</div>
</div>
<div className="ml-2 flex items-center gap-1">
{currentTabConfig?.showEdit && (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={(e) => {
e.stopPropagation();
handleEditClick("right", item);
}}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
)}
{currentTabConfig?.showDelete && (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-red-500 hover:text-red-600"
onClick={(e) => {
e.stopPropagation();
handleDeleteClick("right", item);
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
{detailColumns.length > 0 &&
(isExpanded ? (
<ChevronUp className="h-4 w-4 text-gray-400" />
) : (
<ChevronDown className="h-4 w-4 text-gray-400" />
))}
</div>
</div>
{isExpanded && detailColumns.length > 0 && (
<div className="mt-2 border-t pt-2">
<div className="grid grid-cols-2 gap-2">
{detailColumns.map((col: any) => (
<div key={col.name} className="text-sm">
<span className="text-muted-foreground">{col.label}:</span>
<span className="ml-1">{formatCellValue(col.name, item[col.name], {}, col.format)}</span>
</div>
))}
</div>
</div>
)}
</div>
);
})}
</div>
);
}
})()
) : (
/* 기본 탭 (우측 패널) 데이터 */
<>
{isLoadingRight ? (
// 로딩 중
<div className="flex h-full items-center justify-center">
<div className="text-center">
<Loader2 className="text-primary mx-auto h-8 w-8 animate-spin" />
<p className="text-muted-foreground mt-2 text-sm"> ...</p>
</div>
</div>
) : rightData ? (
// 실제 데이터 표시
Array.isArray(rightData) ? (
// 조인 모드: 여러 데이터를 테이블/리스트로 표시
(() => {
// 검색 필터링 // 검색 필터링
const filteredData = rightSearchQuery const filteredData = rightSearchQuery
? rightData.filter((item) => { ? rightData.filter((item) => {
@ -3023,14 +3458,16 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</div> </div>
</div> </div>
</div> </div>
) : ( ) : (
// 선택 없음 // 선택 없음
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<div className="text-muted-foreground text-center text-sm"> <div className="text-muted-foreground text-center text-sm">
<p className="mb-2"> </p> <p className="mb-2"> </p>
<p className="text-xs"> </p> <p className="text-xs"> </p>
</div> </div>
</div> </div>
)}
</>
)} )}
</CardContent> </CardContent>
</Card> </Card>

View File

@ -41,7 +41,7 @@ import {
Lock, Lock,
} from "lucide-react"; } from "lucide-react";
import * as XLSX from "xlsx"; 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 { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { toast } from "sonner"; import { toast } from "sonner";
@ -455,6 +455,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 🆕 컬럼 헤더 필터 상태 (상단에서 선언) // 🆕 컬럼 헤더 필터 상태 (상단에서 선언)
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({}); const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
const [headerLikeFilters, setHeaderLikeFilters] = useState<Record<string, string>>({}); // LIKE 검색용
const [openFilterColumn, setOpenFilterColumn] = useState<string | null>(null); const [openFilterColumn, setOpenFilterColumn] = useState<string | null>(null);
// 🆕 Filter Builder (고급 필터) 관련 상태 - filteredData보다 먼저 정의해야 함 // 🆕 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 적용 // 3. 🆕 Filter Builder 적용
if (filterGroups.length > 0) { if (filterGroups.length > 0) {
result = result.filter((row) => { result = result.filter((row) => {
@ -541,7 +558,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
} }
return result; return result;
}, [data, splitPanelPosition, splitPanelContext?.addedItemIds, headerFilters, filterGroups]); }, [data, splitPanelPosition, splitPanelContext?.addedItemIds, headerFilters, headerLikeFilters, filterGroups]);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(0); const [totalPages, setTotalPages] = useState(0);
@ -2671,19 +2688,41 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const value = row[mappedColumnName]; const value = row[mappedColumnName];
// 카테고리 매핑된 값 처리 // 카테고리 매핑된 값 처리
if (categoryMappings[col.columnName] && value !== null && value !== undefined) { if (value !== null && value !== undefined) {
const mapping = categoryMappings[col.columnName][String(value)]; const valueStr = String(value);
if (mapping) {
return mapping.label; // 디버그 로그 (카테고리 값인 경우만)
if (valueStr.startsWith("CATEGORY_")) {
console.log("🔍 [엑셀다운로드] 카테고리 변환 시도:", {
columnName: col.columnName,
value: valueStr,
hasMappings: !!categoryMappings[col.columnName],
mappingsKeys: categoryMappings[col.columnName] ? Object.keys(categoryMappings[col.columnName]).slice(0, 5) : [],
});
} }
if (categoryMappings[col.columnName]) {
// 쉼표로 구분된 중복 값 처리
if (valueStr.includes(",")) {
const values = valueStr.split(",").map((v) => v.trim()).filter((v) => v);
const labels = values.map((v) => {
const mapping = categoryMappings[col.columnName][v];
return mapping ? mapping.label : v;
});
return labels.join(", ");
}
// 단일 값 처리
const mapping = categoryMappings[col.columnName][valueStr];
if (mapping) {
return mapping.label;
}
}
return value;
} }
// null/undefined 처리 // null/undefined 처리
if (value === null || value === undefined) { return "";
return "";
}
return value;
}); });
}); });
@ -2935,6 +2974,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
headerFilters: Object.fromEntries( headerFilters: Object.fromEntries(
Object.entries(headerFilters).map(([key, set]) => [key, Array.from(set as Set<string>)]), Object.entries(headerFilters).map(([key, set]) => [key, Array.from(set as Set<string>)]),
), ),
headerLikeFilters, // LIKE 검색 필터 저장
pageSize: localPageSize, pageSize: localPageSize,
timestamp: Date.now(), timestamp: Date.now(),
}; };
@ -2955,6 +2995,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
frozenColumnCount, frozenColumnCount,
showGridLines, showGridLines,
headerFilters, headerFilters,
headerLikeFilters,
localPageSize, localPageSize,
]); ]);
@ -2991,6 +3032,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}); });
setHeaderFilters(filters); setHeaderFilters(filters);
} }
if (state.headerLikeFilters) {
setHeaderLikeFilters(state.headerLikeFilters);
}
} catch (error) { } catch (error) {
console.error("❌ 테이블 상태 복원 실패:", error); console.error("❌ 테이블 상태 복원 실패:", error);
} }
@ -5737,7 +5781,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}} }}
className={cn( className={cn(
"hover:bg-primary/20 ml-1 rounded p-0.5 transition-colors", "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="필터" title="필터"
> >
@ -5745,7 +5789,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
</button> </button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
className="w-48 p-2" className="w-56 p-2"
align="start" align="start"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
@ -5754,16 +5798,42 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<span className="text-xs font-medium"> <span className="text-xs font-medium">
: {columnLabels[column.columnName] || column.displayName} : {columnLabels[column.columnName] || column.displayName}
</span> </span>
{headerFilters[column.columnName]?.size > 0 && ( {(headerFilters[column.columnName]?.size > 0 || headerLikeFilters[column.columnName]) && (
<button <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" className="text-destructive text-xs hover:underline"
> >
</button> </button>
)} )}
</div> </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) => { {columnUniqueValues[column.columnName]?.slice(0, 50).map((val) => {
const isSelected = headerFilters[column.columnName]?.has(val); const isSelected = headerFilters[column.columnName]?.has(val);
return ( return (

View File

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

View File

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

View File

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